@dloizides/poster-render 0.2.3 → 0.2.5
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 +13 -0
- package/README.md +40 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +225 -128
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +225 -128
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.5
|
|
4
|
+
|
|
5
|
+
- Own-key checks: the schema validator and the asset lookup use `Object.hasOwn`, so a `__proto__` key is rejected and an imageId such as `constructor` is reported missing.
|
|
6
|
+
- `placeContain(image, region, { anchor, alignX, scale })` takes an options object (was 5 positional parameters).
|
|
7
|
+
- Named size and gap constants; ESLint now lints tests and `src/testing`.
|
|
8
|
+
- README: install, usage, canvas injection, fonts, type floor, schema version policy.
|
|
9
|
+
|
|
10
|
+
## 0.2.4
|
|
11
|
+
|
|
12
|
+
- Type floor: every text run is at least 30px at a 1080 short edge (scaled per format by short edge). When a layout cannot fit its text at the floor, the picture yields space to the body (and a header stacked above it) in 2.5% steps.
|
|
13
|
+
- picture-bottom sizes the picture to the space left once the body holds the floor (about 0.56w for the real tall couple on a story); it stays whole on the bottom edge.
|
|
14
|
+
- Text zones pick two columns when that sets the type at least 15% larger.
|
|
15
|
+
|
|
3
16
|
## 0.2.3
|
|
4
17
|
|
|
5
18
|
- `picture-bottom`: the picture is sized for a tall cut-out (design aspect 0.57): 80% of the width, standing on the bottom edge; the header and body share the top (at least 25% of the height) and shrink to fit. The picture is never cropped.
|
package/README.md
CHANGED
|
@@ -7,7 +7,46 @@ One JSON document (`FlierDoc`) in, one poster out. Used by Flier Studio (FLIER-1
|
|
|
7
7
|
- `render(doc, assets, canvas)`: validates first (throws `FlierDocError` before touching the canvas), resizes the canvas to the format, draws, returns a `RenderReport` (picture rect, text and card boxes).
|
|
8
8
|
- `exportPng(canvas)`: PNG `Blob` of the canvas size.
|
|
9
9
|
- Formats: story 1080x1920, post 1080x1350, square 1080x1080, phone 1170x2532, desktop 2560x1440.
|
|
10
|
-
- Layouts: `picture-right` (E-A), `picture-left` (E-B), `arch` (E-C), `picture-bottom`, `list`, `phone-clock-safe` (P-A, top kept clear for the lock-screen clock), `phone-arch` (P-B), `desktop-safe` (D-A, text clear of desktop icons and the taskbar), `desktop-arch` (D-B). `LAYOUT_CATALOGUE` lists id, design id, label, description and designed formats (for the app picker and the LLM prompt); `defaultLayoutFor(format)` gives E-A / P-A / D-A.
|
|
10
|
+
- Layouts: `picture-right` (E-A "Picture right"), `picture-left` (E-B "Picture left"), `arch` (E-C "Arch"), `picture-bottom`, `list`, `phone-clock-safe` (P-A "Phone, clock clear": top kept clear for the lock-screen clock), `phone-arch` (P-B "Phone arch"), `desktop-safe` (D-A "Desktop, icons clear": text clear of desktop icons and the taskbar), `desktop-arch` (D-B "Desktop arch"). `LAYOUT_CATALOGUE` lists id, design id, label, description and designed formats (for the app picker and the LLM prompt); `defaultLayoutFor(format)` gives E-A "Picture right" / P-A "Phone, clock clear" / D-A "Desktop, icons clear" (D4 "Layouts: keep all").
|
|
11
11
|
- Arch layouts paint a soft arch (palette `arch`, about 4% off the ground) behind the picture only, inside the picture region; prices sit large on either side. The includes block is set at one size for every line. Palettes: `sand`, `night`, `sea`, `rose`. Font sets: `classic`, `modern`, `elegant` (the app loads the web fonts).
|
|
12
12
|
- The picture is placed contain + anchor in its own region: never cropped, never faded, drawn last, and no text or card is laid out in its region. Text shrinks to fit, never overflows.
|
|
13
13
|
- Example: `kizombaWorkshopDocs` (3 workshops + list flier), `kizombaWorkshop` (the list flier).
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install @dloizides/poster-render
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { exportPng, kizombaWorkshop, render, validateFlierDoc } from '@dloizides/poster-render';
|
|
25
|
+
|
|
26
|
+
const checked = validateFlierDoc(kizombaWorkshop);
|
|
27
|
+
if (!checked.ok) {
|
|
28
|
+
throw new Error(checked.errors.join('\n'));
|
|
29
|
+
}
|
|
30
|
+
const canvas = document.createElement('canvas');
|
|
31
|
+
const report = render(checked.doc, { dancers: pictureImage, logo: logoImage }, canvas);
|
|
32
|
+
const png = await exportPng(canvas);
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Canvas injection
|
|
36
|
+
|
|
37
|
+
`render` takes any canvas with a 2d context; the package has no canvas dependency.
|
|
38
|
+
|
|
39
|
+
- Browser: an `HTMLCanvasElement` (or `OffscreenCanvas`); assets are loaded `HTMLImageElement` / `ImageBitmap`.
|
|
40
|
+
- Node: `@napi-rs/canvas` (`createCanvas(1, 1)`, `loadImage(path)`); `render` resizes the canvas to the format.
|
|
41
|
+
|
|
42
|
+
## Fonts
|
|
43
|
+
|
|
44
|
+
The font sets name CSS families (`FONT_SETS`). The host loads them before `render`: in a browser with `document.fonts.load(...)` / `@font-face`, in Node with `GlobalFonts.registerFromPath` from `@napi-rs/canvas`. A missing font falls back to the default family and the text still fits.
|
|
45
|
+
|
|
46
|
+
## Type floor
|
|
47
|
+
|
|
48
|
+
Every text run is at least 30px at a 1080 short edge (40px on desktop 2560x1440). When a layout cannot fit its text at the floor, the picture gives space to the text.
|
|
49
|
+
|
|
50
|
+
## Schema version policy
|
|
51
|
+
|
|
52
|
+
`FlierDoc.version` is `1`. A field added as optional keeps version 1; a removed, renamed or retyped field raises the version and `validateFlierDoc` rejects the other version with `version: must be 1 (got N)`. A package major release goes with a new doc version.
|
package/dist/index.d.mts
CHANGED
|
@@ -217,7 +217,7 @@ interface LayoutCatalogueEntry {
|
|
|
217
217
|
formats: readonly FormatId[];
|
|
218
218
|
}
|
|
219
219
|
declare const LAYOUT_CATALOGUE: readonly LayoutCatalogueEntry[];
|
|
220
|
-
/** The default layout for a format (D4: E-A for posters, P-A
|
|
220
|
+
/** The default layout for a format (D4 "Layouts: keep all"): E-A "Picture right" for posters, P-A "Phone, clock clear", D-A "Desktop, icons clear". */
|
|
221
221
|
declare function defaultLayoutFor(format: FormatId): LayoutId;
|
|
222
222
|
|
|
223
223
|
declare const kizombaWorkshop: FlierDoc;
|
package/dist/index.d.ts
CHANGED
|
@@ -217,7 +217,7 @@ interface LayoutCatalogueEntry {
|
|
|
217
217
|
formats: readonly FormatId[];
|
|
218
218
|
}
|
|
219
219
|
declare const LAYOUT_CATALOGUE: readonly LayoutCatalogueEntry[];
|
|
220
|
-
/** The default layout for a format (D4: E-A for posters, P-A
|
|
220
|
+
/** The default layout for a format (D4 "Layouts: keep all"): E-A "Picture right" for posters, P-A "Phone, clock clear", D-A "Desktop, icons clear". */
|
|
221
221
|
declare function defaultLayoutFor(format: FormatId): LayoutId;
|
|
222
222
|
|
|
223
223
|
declare const kizombaWorkshop: FlierDoc;
|
package/dist/index.js
CHANGED
|
@@ -226,7 +226,7 @@ function checkObject(schema, value, path, errors) {
|
|
|
226
226
|
}
|
|
227
227
|
}
|
|
228
228
|
for (const [key, child] of Object.entries(value)) {
|
|
229
|
-
const childSchema = props[key];
|
|
229
|
+
const childSchema = Object.hasOwn(props, key) ? props[key] : void 0;
|
|
230
230
|
if (childSchema === void 0) {
|
|
231
231
|
if (schema.additionalProperties === false) {
|
|
232
232
|
errors.push(`${join(path, key)}: is not allowed`);
|
|
@@ -284,82 +284,15 @@ function validateFlierDoc(input) {
|
|
|
284
284
|
}
|
|
285
285
|
function missingAssets(doc, assets) {
|
|
286
286
|
const errors = [];
|
|
287
|
-
if (doc.picture && assets
|
|
287
|
+
if (doc.picture && !Object.hasOwn(assets, doc.picture.imageId)) {
|
|
288
288
|
errors.push(`picture.imageId: no asset named "${doc.picture.imageId}"`);
|
|
289
289
|
}
|
|
290
|
-
if (doc.logo && assets
|
|
290
|
+
if (doc.logo && !Object.hasOwn(assets, doc.logo.imageId)) {
|
|
291
291
|
errors.push(`logo.imageId: no asset named "${doc.logo.imageId}"`);
|
|
292
292
|
}
|
|
293
293
|
return errors;
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
-
// src/layout/blockGroups.ts
|
|
297
|
-
var BOLD = 700;
|
|
298
|
-
var SEMI = 600;
|
|
299
|
-
var MEDIUM = 500;
|
|
300
|
-
var SCRIPT = 400;
|
|
301
|
-
var makers = (p, u) => ({
|
|
302
|
-
display: (text2, size) => ({ text: text2, role: "display", weight: BOLD, size: size * u, color: p.title }),
|
|
303
|
-
venue: (text2, size) => ({ text: text2, role: "display", weight: MEDIUM, size: size * u, color: p.ink }),
|
|
304
|
-
script: (text2, size) => ({ text: text2, role: "script", weight: SCRIPT, size: size * u, color: p.accent }),
|
|
305
|
-
strong: (text2, size) => ({ text: text2, role: "body", weight: BOLD, size: size * u, color: p.ink }),
|
|
306
|
-
accent: (text2, size) => ({ text: text2, role: "body", weight: SEMI, size: size * u, color: p.accent }),
|
|
307
|
-
item: (text2, size) => ({ text: text2, role: "body", weight: SEMI, size: size * u, color: p.ink, bullet: p.accent }),
|
|
308
|
-
soft: (text2, size) => ({ text: text2, role: "body", weight: MEDIUM, size: size * u, color: p.soft })
|
|
309
|
-
});
|
|
310
|
-
function headerGroups(doc, p, u) {
|
|
311
|
-
const l = makers(p, u);
|
|
312
|
-
const b = doc.blocks;
|
|
313
|
-
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];
|
|
314
|
-
return [{ lines: lines.filter((x) => x !== void 0), gapAfter: 0 }];
|
|
315
|
-
}
|
|
316
|
-
function itemGroup(item, l, u) {
|
|
317
|
-
const lines = [l.script?.(item.date, 67)];
|
|
318
|
-
item.schedule.forEach((row) => lines.push(l.strong?.(`${row.label} ${row.time}`, 42)));
|
|
319
|
-
if (item.price) {
|
|
320
|
-
lines.push(l.accent?.(item.price, 42));
|
|
321
|
-
}
|
|
322
|
-
return { lines, gapAfter: 48 * u };
|
|
323
|
-
}
|
|
324
|
-
function eventGroups(doc, l, u, withPrices) {
|
|
325
|
-
const b = doc.blocks;
|
|
326
|
-
const groups = [];
|
|
327
|
-
b.schedule?.forEach((row) => groups.push({ lines: [l.strong?.(row.label, 66), l.accent?.(row.time, 60)], gapAfter: 30 * u }));
|
|
328
|
-
const prices = (withPrices ? b.prices : void 0) ?? [];
|
|
329
|
-
prices.forEach((row, i) => groups.push({ lines: [l.strong?.(row.amount, 66), l.accent?.(row.label, 52)], gapAfter: 30 * u, keepWithNext: i < prices.length - 1 }));
|
|
330
|
-
return groups;
|
|
331
|
-
}
|
|
332
|
-
function listGroups(doc, l, u) {
|
|
333
|
-
const groups = (doc.items ?? []).map((item) => itemGroup(item, l, u));
|
|
334
|
-
const prices = doc.blocks.prices?.map((row) => l.strong?.(`${row.amount} ${row.label}`, 58)) ?? [];
|
|
335
|
-
if (prices.length > 0) {
|
|
336
|
-
groups.push({ lines: prices, gapAfter: 30 * u });
|
|
337
|
-
}
|
|
338
|
-
return groups;
|
|
339
|
-
}
|
|
340
|
-
function flankPriceGroups(doc, p, u) {
|
|
341
|
-
const l = makers(p, u);
|
|
342
|
-
return (doc.blocks.prices ?? []).map((row) => ({ lines: [l.strong?.(row.amount, 110), l.accent?.(row.label, 48)], gapAfter: 30 * u }));
|
|
343
|
-
}
|
|
344
|
-
function bodyGroups(doc, p, u, withPrices = true) {
|
|
345
|
-
const l = makers(p, u);
|
|
346
|
-
const b = doc.blocks;
|
|
347
|
-
const groups = [];
|
|
348
|
-
const isList = doc.layout === "list" /* List */;
|
|
349
|
-
if (b.venue) {
|
|
350
|
-
groups.push({ lines: b.venue.map((v) => l.venue?.(v, isList ? 48 : 58)), gapAfter: 40 * u, rule: { color: p.accent, height: 8 * u }, ruleBelow: true, icon: { color: p.accent, glyph: p.background } });
|
|
351
|
-
}
|
|
352
|
-
groups.push(...isList ? listGroups(doc, l, u) : eventGroups(doc, l, u, withPrices));
|
|
353
|
-
if (b.includes) {
|
|
354
|
-
const lines = [l.accent?.(b.includes.heading, 38), ...b.includes.items.map((i) => l.item?.(i, 38))];
|
|
355
|
-
groups.push({ lines, gapAfter: 24 * u, uniform: true });
|
|
356
|
-
}
|
|
357
|
-
if (b.footer) {
|
|
358
|
-
groups.push({ lines: [l.soft?.(b.footer, 38)], gapAfter: 0 });
|
|
359
|
-
}
|
|
360
|
-
return groups;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
296
|
// src/layout/layoutSpecs.ts
|
|
364
297
|
var flanksBeside = (pic, top, bottom2, m) => [
|
|
365
298
|
[m, top, pic[0] - 2 * m, bottom2 - top],
|
|
@@ -411,12 +344,13 @@ var HEADER_PORTRAIT = 0.3;
|
|
|
411
344
|
var HEADER_LANDSCAPE = 0.36;
|
|
412
345
|
var BOTTOM_PICTURE_W = 0.8;
|
|
413
346
|
var BOTTOM_MIN_TOP = 0.25;
|
|
414
|
-
var BOTTOM_HEADER_SHARE = 0.
|
|
347
|
+
var BOTTOM_HEADER_SHARE = 0.3;
|
|
415
348
|
var BOTTOM_GAP = 40 / 1080;
|
|
416
349
|
var BOTTOM_ASPECT = 0.57;
|
|
350
|
+
var DEFAULT_SIDE = [0.6, 0.42];
|
|
417
351
|
var PICTURE_SIDE = {
|
|
418
|
-
["picture-right" /* PictureRight */]:
|
|
419
|
-
["picture-left" /* PictureLeft */]:
|
|
352
|
+
["picture-right" /* PictureRight */]: DEFAULT_SIDE,
|
|
353
|
+
["picture-left" /* PictureLeft */]: DEFAULT_SIDE,
|
|
420
354
|
["list" /* List */]: [0.5, 0.36]
|
|
421
355
|
};
|
|
422
356
|
var ANCHOR_Y = { ["top" /* Top */]: 0, ["center" /* Center */]: 0.5, ["bottom" /* Bottom */]: 1 };
|
|
@@ -442,11 +376,11 @@ function side(w, h, fraction) {
|
|
|
442
376
|
alignX: 1
|
|
443
377
|
};
|
|
444
378
|
}
|
|
445
|
-
function bottom(w, h) {
|
|
379
|
+
function bottom(w, h, share) {
|
|
446
380
|
const m = r2(Math.min(w, h) * MARGIN);
|
|
447
381
|
const gap = r2(Math.min(w, h) * BOTTOM_GAP);
|
|
448
|
-
const picW = r2(w *
|
|
449
|
-
const picH = Math.min(r2(picW / BOTTOM_ASPECT), h - r2(h * BOTTOM_MIN_TOP));
|
|
382
|
+
const picW = r2(w * share);
|
|
383
|
+
const picH = Math.min(r2(picW / BOTTOM_ASPECT), r2((h - r2(h * BOTTOM_MIN_TOP)) * share / BOTTOM_PICTURE_W));
|
|
450
384
|
const pic = h - picH;
|
|
451
385
|
const top = m + r2((pic - m) * BOTTOM_HEADER_SHARE);
|
|
452
386
|
return {
|
|
@@ -468,21 +402,22 @@ function fromSpec(spec, w, h) {
|
|
|
468
402
|
};
|
|
469
403
|
}
|
|
470
404
|
var mirror = (rect, w) => ({ ...rect, x: w - rect.x - rect.w });
|
|
471
|
-
function geometry(layout, w, h) {
|
|
405
|
+
function geometry(layout, w, h, give = 0) {
|
|
472
406
|
const oriented = LAYOUT_SPECS[layout];
|
|
473
407
|
if (oriented) {
|
|
474
408
|
return fromSpec(h >= w ? oriented.portrait : oriented.landscape, w, h);
|
|
475
409
|
}
|
|
476
410
|
if (layout === "picture-bottom" /* PictureBottom */) {
|
|
477
|
-
return bottom(w, h);
|
|
411
|
+
return bottom(w, h, BOTTOM_PICTURE_W - give);
|
|
478
412
|
}
|
|
479
|
-
const g = side(w, h, PICTURE_SIDE[layout] ??
|
|
413
|
+
const g = side(w, h, PICTURE_SIDE[layout] ?? DEFAULT_SIDE);
|
|
480
414
|
if (layout !== "picture-left" /* PictureLeft */) {
|
|
481
415
|
return g;
|
|
482
416
|
}
|
|
483
417
|
return { header: mirror(g.header, w), body: mirror(g.body, w), picture: mirror(g.picture, w), alignX: 1 - g.alignX };
|
|
484
418
|
}
|
|
485
|
-
function placeContain(image, region,
|
|
419
|
+
function placeContain(image, region, fit) {
|
|
420
|
+
const { anchor, alignX, scale } = fit;
|
|
486
421
|
const s = Math.min(region.w / image.width, region.h / image.height) * scale;
|
|
487
422
|
const w = Math.floor(image.width * s);
|
|
488
423
|
const h = Math.floor(image.height * s);
|
|
@@ -494,6 +429,97 @@ function placeContain(image, region, anchor, alignX, scale) {
|
|
|
494
429
|
};
|
|
495
430
|
}
|
|
496
431
|
|
|
432
|
+
// src/layout/blockGroups.ts
|
|
433
|
+
var BOLD = 700;
|
|
434
|
+
var SEMI = 600;
|
|
435
|
+
var MEDIUM = 500;
|
|
436
|
+
var SCRIPT = 400;
|
|
437
|
+
var SIZE = {
|
|
438
|
+
title: 250,
|
|
439
|
+
headerScript: 120,
|
|
440
|
+
itemDate: 67,
|
|
441
|
+
itemRow: 42,
|
|
442
|
+
scheduleLabel: 66,
|
|
443
|
+
scheduleTime: 60,
|
|
444
|
+
priceAmount: 66,
|
|
445
|
+
priceLabel: 52,
|
|
446
|
+
listPrice: 58,
|
|
447
|
+
flankAmount: 110,
|
|
448
|
+
flankLabel: 48,
|
|
449
|
+
venue: 58,
|
|
450
|
+
listVenue: 48,
|
|
451
|
+
small: 38
|
|
452
|
+
};
|
|
453
|
+
var GAP = {
|
|
454
|
+
item: 48,
|
|
455
|
+
row: 30,
|
|
456
|
+
venue: 40,
|
|
457
|
+
includes: 24,
|
|
458
|
+
rule: 8
|
|
459
|
+
};
|
|
460
|
+
var makers = (p, u) => ({
|
|
461
|
+
display: (text2, size) => ({ text: text2, role: "display", weight: BOLD, size: size * u, color: p.title }),
|
|
462
|
+
venue: (text2, size) => ({ text: text2, role: "display", weight: MEDIUM, size: size * u, color: p.ink }),
|
|
463
|
+
script: (text2, size) => ({ text: text2, role: "script", weight: SCRIPT, size: size * u, color: p.accent }),
|
|
464
|
+
strong: (text2, size) => ({ text: text2, role: "body", weight: BOLD, size: size * u, color: p.ink }),
|
|
465
|
+
accent: (text2, size) => ({ text: text2, role: "body", weight: SEMI, size: size * u, color: p.accent }),
|
|
466
|
+
item: (text2, size) => ({ text: text2, role: "body", weight: SEMI, size: size * u, color: p.ink, bullet: p.accent }),
|
|
467
|
+
soft: (text2, size) => ({ text: text2, role: "body", weight: MEDIUM, size: size * u, color: p.soft })
|
|
468
|
+
});
|
|
469
|
+
function headerGroups(doc, p, u) {
|
|
470
|
+
const l = makers(p, u);
|
|
471
|
+
const b = doc.blocks;
|
|
472
|
+
const lines = [l.display(b.title, SIZE.title), b.script ? l.script(b.script, SIZE.headerScript) : void 0, b.titleBottom ? l.display(b.titleBottom, SIZE.title) : void 0];
|
|
473
|
+
return [{ lines: lines.filter((x) => x !== void 0), gapAfter: 0 }];
|
|
474
|
+
}
|
|
475
|
+
function itemGroup(item, l, u) {
|
|
476
|
+
const lines = [l.script(item.date, SIZE.itemDate)];
|
|
477
|
+
item.schedule.forEach((row) => lines.push(l.strong(`${row.label} ${row.time}`, SIZE.itemRow)));
|
|
478
|
+
if (item.price) {
|
|
479
|
+
lines.push(l.accent(item.price, SIZE.itemRow));
|
|
480
|
+
}
|
|
481
|
+
return { lines, gapAfter: GAP.item * u };
|
|
482
|
+
}
|
|
483
|
+
function eventGroups(doc, l, u, withPrices) {
|
|
484
|
+
const b = doc.blocks;
|
|
485
|
+
const groups = [];
|
|
486
|
+
b.schedule?.forEach((row) => groups.push({ lines: [l.strong(row.label, SIZE.scheduleLabel), l.accent(row.time, SIZE.scheduleTime)], gapAfter: GAP.row * u }));
|
|
487
|
+
const prices = (withPrices ? b.prices : void 0) ?? [];
|
|
488
|
+
prices.forEach((row, i) => groups.push({ lines: [l.strong(row.amount, SIZE.priceAmount), l.accent(row.label, SIZE.priceLabel)], gapAfter: GAP.row * u, keepWithNext: i < prices.length - 1 }));
|
|
489
|
+
return groups;
|
|
490
|
+
}
|
|
491
|
+
function listGroups(doc, l, u) {
|
|
492
|
+
const groups = (doc.items ?? []).map((item) => itemGroup(item, l, u));
|
|
493
|
+
const prices = doc.blocks.prices?.map((row) => l.strong(`${row.amount} ${row.label}`, SIZE.listPrice)) ?? [];
|
|
494
|
+
if (prices.length > 0) {
|
|
495
|
+
groups.push({ lines: prices, gapAfter: GAP.row * u });
|
|
496
|
+
}
|
|
497
|
+
return groups;
|
|
498
|
+
}
|
|
499
|
+
function flankPriceGroups(doc, p, u) {
|
|
500
|
+
const l = makers(p, u);
|
|
501
|
+
return (doc.blocks.prices ?? []).map((row) => ({ lines: [l.strong(row.amount, SIZE.flankAmount), l.accent(row.label, SIZE.flankLabel)], gapAfter: GAP.row * u }));
|
|
502
|
+
}
|
|
503
|
+
function bodyGroups(doc, p, u, withPrices = true) {
|
|
504
|
+
const l = makers(p, u);
|
|
505
|
+
const b = doc.blocks;
|
|
506
|
+
const groups = [];
|
|
507
|
+
const isList = doc.layout === "list" /* List */;
|
|
508
|
+
if (b.venue) {
|
|
509
|
+
const size = isList ? SIZE.listVenue : SIZE.venue;
|
|
510
|
+
groups.push({ lines: b.venue.map((v) => l.venue(v, size)), gapAfter: GAP.venue * u, rule: { color: p.accent, height: GAP.rule * u }, ruleBelow: true, icon: { color: p.accent, glyph: p.background } });
|
|
511
|
+
}
|
|
512
|
+
groups.push(...isList ? listGroups(doc, l, u) : eventGroups(doc, l, u, withPrices));
|
|
513
|
+
if (b.includes) {
|
|
514
|
+
const lines = [l.accent(b.includes.heading, SIZE.small), ...b.includes.items.map((i) => l.item(i, SIZE.small))];
|
|
515
|
+
groups.push({ lines, gapAfter: GAP.includes * u, uniform: true });
|
|
516
|
+
}
|
|
517
|
+
if (b.footer) {
|
|
518
|
+
groups.push({ lines: [l.soft(b.footer, SIZE.small)], gapAfter: 0 });
|
|
519
|
+
}
|
|
520
|
+
return groups;
|
|
521
|
+
}
|
|
522
|
+
|
|
497
523
|
// src/layout/textFlow.ts
|
|
498
524
|
var LINE_HEIGHT = 1.2;
|
|
499
525
|
var BULLET_INDENT = 1.1;
|
|
@@ -501,6 +527,9 @@ var BULLET_RADIUS = 0.2;
|
|
|
501
527
|
var RULE_GAP = 2.5;
|
|
502
528
|
var RULE_WIDTH = 0.9;
|
|
503
529
|
var WIDE = 1.6;
|
|
530
|
+
var COLUMN_GAP = 0.04;
|
|
531
|
+
var TWO_COLUMN_MIN = 0.75;
|
|
532
|
+
var TWO_COLUMN_GAIN = 1.15;
|
|
504
533
|
var MIN_K = 0.02;
|
|
505
534
|
var STEPS = 22;
|
|
506
535
|
var ICON_SCALE = 1.6;
|
|
@@ -521,15 +550,17 @@ function groupRatio(m, k, avail) {
|
|
|
521
550
|
}
|
|
522
551
|
return Math.min(...m.group.lines.map((line, i) => lineSize(line, m.widths[i] ?? 1, k, avail) / line.size));
|
|
523
552
|
}
|
|
524
|
-
function placeIcon(group,
|
|
553
|
+
function placeIcon(group, box, out) {
|
|
554
|
+
const { d, x, top, bottom: bottom2 } = box;
|
|
525
555
|
if (!group.icon || d <= 0) {
|
|
526
|
-
return
|
|
556
|
+
return bottom2;
|
|
527
557
|
}
|
|
528
|
-
const textH =
|
|
529
|
-
out?.icons.push({ rect: { x, y:
|
|
530
|
-
return
|
|
558
|
+
const textH = bottom2 - top;
|
|
559
|
+
out?.icons.push({ rect: { x, y: top + Math.max(0, (textH - d) / 2), w: d, h: d }, color: group.icon.color, glyph: group.icon.glyph });
|
|
560
|
+
return top + Math.max(textH, d);
|
|
531
561
|
}
|
|
532
|
-
function placeGroup(m,
|
|
562
|
+
function placeGroup(m, at, out) {
|
|
563
|
+
const { k, col, y0 } = at;
|
|
533
564
|
const { group } = m;
|
|
534
565
|
let y = y0;
|
|
535
566
|
if (group.rule) {
|
|
@@ -554,7 +585,7 @@ function placeGroup(m, k, col, y0, out) {
|
|
|
554
585
|
}
|
|
555
586
|
y += size * LINE_HEIGHT;
|
|
556
587
|
});
|
|
557
|
-
y = placeIcon(group, d, col.x + pad, top + pad, y, out);
|
|
588
|
+
y = placeIcon(group, { d, x: col.x + pad, top: top + pad, bottom: y }, out);
|
|
558
589
|
y += pad;
|
|
559
590
|
if (group.rule && group.ruleBelow) {
|
|
560
591
|
y += group.rule.height * k * (RULE_GAP - 1);
|
|
@@ -566,10 +597,11 @@ function placeGroup(m, k, col, y0, out) {
|
|
|
566
597
|
}
|
|
567
598
|
return y + group.gapAfter * k - y0;
|
|
568
599
|
}
|
|
569
|
-
function placeColumn(groups,
|
|
600
|
+
function placeColumn(groups, at, out) {
|
|
601
|
+
const { k, col, spread = 0 } = at;
|
|
570
602
|
let h = 0;
|
|
571
603
|
for (const g of groups) {
|
|
572
|
-
h += placeGroup(g, k, col, col.y + h, out) + spread;
|
|
604
|
+
h += placeGroup(g, { k, col, y0: col.y + h }, out) + spread;
|
|
573
605
|
}
|
|
574
606
|
return h - spread;
|
|
575
607
|
}
|
|
@@ -596,12 +628,9 @@ function keepTogether(groups, heights, cut) {
|
|
|
596
628
|
return candidates.reduce((best, c) => tallest(heights, c) < tallest(heights, best) ? c : best);
|
|
597
629
|
}
|
|
598
630
|
function splitColumns(groups, zone) {
|
|
599
|
-
|
|
600
|
-
return [{ col: zone, groups }];
|
|
601
|
-
}
|
|
602
|
-
const gap = Math.round(zone.w * 0.04);
|
|
631
|
+
const gap = Math.round(zone.w * COLUMN_GAP);
|
|
603
632
|
const w = (zone.w - gap) / 2;
|
|
604
|
-
const heights = groups.map((g) => placeGroup(g, 1, { ...zone, w }, 0, null));
|
|
633
|
+
const heights = groups.map((g) => placeGroup(g, { k: 1, col: { ...zone, w }, y0: 0 }, null));
|
|
605
634
|
const total = columnSum(heights, 0, heights.length);
|
|
606
635
|
let cut = 1;
|
|
607
636
|
for (let c = 2; c < groups.length; c++) {
|
|
@@ -615,16 +644,8 @@ function splitColumns(groups, zone) {
|
|
|
615
644
|
{ col: { ...zone, x: zone.x + w + gap, w }, groups: groups.slice(cut) }
|
|
616
645
|
];
|
|
617
646
|
}
|
|
618
|
-
function
|
|
619
|
-
const
|
|
620
|
-
group,
|
|
621
|
-
widths: group.lines.map((line) => {
|
|
622
|
-
ctx.font = fontOf(line, line.size, fonts);
|
|
623
|
-
return ctx.measureText(line.text).width;
|
|
624
|
-
})
|
|
625
|
-
}));
|
|
626
|
-
const columns = splitColumns(measured, zone);
|
|
627
|
-
const fits = (k) => columns.every((c) => placeColumn(c.groups, k, c.col, null) <= zone.h);
|
|
647
|
+
function largestK(columns, zoneH) {
|
|
648
|
+
const fits = (k) => columns.every((c) => placeColumn(c.groups, { k, col: c.col }, null) <= zoneH);
|
|
628
649
|
let lo = MIN_K;
|
|
629
650
|
let hi = 1;
|
|
630
651
|
if (fits(1)) {
|
|
@@ -638,10 +659,32 @@ function flow(ctx, groups, zone, fonts) {
|
|
|
638
659
|
hi = mid;
|
|
639
660
|
}
|
|
640
661
|
}
|
|
662
|
+
return lo;
|
|
663
|
+
}
|
|
664
|
+
function bestColumns(measured, zone) {
|
|
665
|
+
const single = [{ col: zone, groups: measured }];
|
|
666
|
+
const k1 = largestK(single, zone.h);
|
|
667
|
+
if (measured.length < 2 || zone.w / Math.max(zone.h, 1) < TWO_COLUMN_MIN) {
|
|
668
|
+
return { columns: single, k: k1 };
|
|
669
|
+
}
|
|
670
|
+
const split = splitColumns(measured, zone);
|
|
671
|
+
const k2 = largestK(split, zone.h);
|
|
672
|
+
const forced = zone.w / Math.max(zone.h, 1) > WIDE;
|
|
673
|
+
return forced || k2 > k1 * TWO_COLUMN_GAIN ? { columns: split, k: k2 } : { columns: single, k: k1 };
|
|
674
|
+
}
|
|
675
|
+
function flow(ctx, groups, zone, fonts) {
|
|
676
|
+
const measured = groups.map((group) => ({
|
|
677
|
+
group,
|
|
678
|
+
widths: group.lines.map((line) => {
|
|
679
|
+
ctx.font = fontOf(line, line.size, fonts);
|
|
680
|
+
return ctx.measureText(line.text).width;
|
|
681
|
+
})
|
|
682
|
+
}));
|
|
683
|
+
const { columns, k: lo } = bestColumns(measured, zone);
|
|
641
684
|
const out = emptyFlow();
|
|
642
|
-
const heights = columns.map((c) => placeColumn(c.groups, lo, c.col, null));
|
|
685
|
+
const heights = columns.map((c) => placeColumn(c.groups, { k: lo, col: c.col }, null));
|
|
643
686
|
const end = Math.max(...heights);
|
|
644
|
-
columns.forEach((c, i) => placeColumn(c.groups, lo, c.col,
|
|
687
|
+
columns.forEach((c, i) => placeColumn(c.groups, { k: lo, col: c.col, spread: c.groups.length > 1 && columns.length > 1 ? (end - (heights[i] ?? end)) / (c.groups.length - 1) : 0 }, out));
|
|
645
688
|
const lines = groups.flatMap((g) => g.lines);
|
|
646
689
|
out.texts.forEach((t, i) => {
|
|
647
690
|
const line = lines[i];
|
|
@@ -659,36 +702,99 @@ function flowFlanks(ctx, groups, flanks, fonts) {
|
|
|
659
702
|
}
|
|
660
703
|
function smallestPerLine(groups, results) {
|
|
661
704
|
const sizes = results.flatMap((r3) => r3.texts.map((t) => t.box.h / LINE_HEIGHT2));
|
|
662
|
-
const
|
|
705
|
+
const smallest2 = [];
|
|
663
706
|
let i = 0;
|
|
664
707
|
groups.forEach(
|
|
665
708
|
(g) => g.lines.forEach((_line, j) => {
|
|
666
|
-
|
|
709
|
+
smallest2[j] = Math.min(smallest2[j] ?? Infinity, sizes[i] ?? Infinity);
|
|
667
710
|
i++;
|
|
668
711
|
})
|
|
669
712
|
);
|
|
670
|
-
return
|
|
713
|
+
return smallest2;
|
|
671
714
|
}
|
|
672
715
|
function equalFlanks(ctx, groups, flanks, fonts) {
|
|
673
|
-
const
|
|
674
|
-
const sized = groups.map((g) => ({ ...g, lines: g.lines.map((line, j) => ({ ...line, size: Math.min(line.size,
|
|
716
|
+
const smallest2 = smallestPerLine(groups, flowFlanks(ctx, groups, flanks, fonts));
|
|
717
|
+
const sized = groups.map((g) => ({ ...g, lines: g.lines.map((line, j) => ({ ...line, size: Math.min(line.size, smallest2[j] ?? line.size) })) }));
|
|
675
718
|
return flowFlanks(ctx, sized, flanks, fonts);
|
|
676
719
|
}
|
|
677
720
|
function measuredEnd(f, fallback) {
|
|
678
721
|
const bottoms = [...f.texts.map((t) => t.box), ...f.rules.map((r3) => r3.rect), ...f.icons.map((i) => i.rect)].map((r3) => r3.y + r3.h);
|
|
679
722
|
return bottoms.length > 0 ? Math.max(...bottoms) : fallback;
|
|
680
723
|
}
|
|
681
|
-
function pictureRegion(layout, geo, body,
|
|
724
|
+
function pictureRegion(layout, geo, body, frame) {
|
|
682
725
|
if (layout !== "picture-bottom" /* PictureBottom */) {
|
|
683
726
|
return geo.picture;
|
|
684
727
|
}
|
|
685
|
-
const y = Math.min(geo.picture.y, Math.ceil(measuredEnd(body, geo.body.y) + BODY_GAP * u));
|
|
728
|
+
const y = Math.min(geo.picture.y, Math.ceil(measuredEnd(body, geo.body.y) + BODY_GAP * frame.u));
|
|
686
729
|
return { x: geo.picture.x, y, w: geo.picture.w, h: frame.height - y };
|
|
687
730
|
}
|
|
688
731
|
|
|
689
|
-
// src/render/
|
|
732
|
+
// src/render/yieldToText.ts
|
|
733
|
+
function yieldToText(geo, give) {
|
|
734
|
+
const { picture: p, body: b } = geo;
|
|
735
|
+
if (give <= 0) {
|
|
736
|
+
return geo;
|
|
737
|
+
}
|
|
738
|
+
if (p.x >= b.x + b.w) {
|
|
739
|
+
const d = Math.round(p.w * give);
|
|
740
|
+
return { ...geo, picture: { ...p, x: p.x + d, w: p.w - d }, body: { ...b, w: b.w + d } };
|
|
741
|
+
}
|
|
742
|
+
if (p.x + p.w <= b.x) {
|
|
743
|
+
const d = Math.round(p.w * give);
|
|
744
|
+
return { ...geo, picture: { ...p, w: p.w - d }, body: { ...b, x: b.x - d, w: b.w + d } };
|
|
745
|
+
}
|
|
746
|
+
if (p.y >= b.y + b.h) {
|
|
747
|
+
const d = Math.round(p.h * give);
|
|
748
|
+
const stacked = geo.header.y + geo.header.h <= b.y;
|
|
749
|
+
const toHeader = stacked ? Math.round(d / 2) : 0;
|
|
750
|
+
return {
|
|
751
|
+
...geo,
|
|
752
|
+
header: { ...geo.header, h: geo.header.h + toHeader },
|
|
753
|
+
picture: { ...p, y: p.y + d, h: p.h - d },
|
|
754
|
+
body: { ...b, y: b.y + toHeader, h: b.h + d - toHeader },
|
|
755
|
+
flanks: geo.flanks?.map((f) => ({ ...f, y: f.y + d, h: f.h - d }))
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
return geo;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// src/render/fitToFloor.ts
|
|
762
|
+
var FLOOR_PX = 30;
|
|
690
763
|
var BASE = 1080;
|
|
764
|
+
var LINE_HEIGHT3 = 1.2;
|
|
691
765
|
var LOGO_SHARE = 0.26;
|
|
766
|
+
var GIVE_STEP = 0.025;
|
|
767
|
+
var GIVE_STEPS = 32;
|
|
768
|
+
function splitHeader(header, hasLogo) {
|
|
769
|
+
const logoW = hasLogo ? Math.round(header.w * LOGO_SHARE) : 0;
|
|
770
|
+
return { text: { ...header, w: header.w - logoW * 1.1 }, logo: { ...header, x: header.x + header.w - logoW, w: logoW } };
|
|
771
|
+
}
|
|
772
|
+
function flowAll(ctx, input, geo, u) {
|
|
773
|
+
const { doc, palette, fonts } = input;
|
|
774
|
+
const zones = geo.logo ? { text: geo.header, logo: geo.logo } : splitHeader(geo.header, doc.logo !== void 0);
|
|
775
|
+
const head = flow(ctx, headerGroups(doc, palette, u), zones.text, fonts);
|
|
776
|
+
const flanks = geo.flanks ?? [];
|
|
777
|
+
const body = flow(ctx, bodyGroups(doc, palette, u, flanks.length === 0), geo.body, fonts);
|
|
778
|
+
const sides = flanks.length > 0 ? equalFlanks(ctx, flankPriceGroups(doc, palette, u), flanks, fonts) : [emptyFlow()];
|
|
779
|
+
return { geo, zones, head, body, sides };
|
|
780
|
+
}
|
|
781
|
+
var smallest = (t) => Math.min(Infinity, ...[t.head, t.body, ...t.sides].flatMap((f) => f.texts.map((x) => x.box.h / LINE_HEIGHT3)));
|
|
782
|
+
function geometryWithGive(layout, width, height, give) {
|
|
783
|
+
return layout === "picture-bottom" /* PictureBottom */ ? geometry(layout, width, height, give) : yieldToText(geometry(layout, width, height), give);
|
|
784
|
+
}
|
|
785
|
+
function fitToFloor(ctx, input) {
|
|
786
|
+
const { doc, width, height } = input;
|
|
787
|
+
const u = Math.min(width, height) / BASE;
|
|
788
|
+
const floor = FLOOR_PX * u;
|
|
789
|
+
let fitted = flowAll(ctx, input, geometryWithGive(doc.layout, width, height, 0), u);
|
|
790
|
+
for (let step = 1; step <= GIVE_STEPS && smallest(fitted) < floor; step++) {
|
|
791
|
+
fitted = flowAll(ctx, input, geometryWithGive(doc.layout, width, height, step * GIVE_STEP), u);
|
|
792
|
+
}
|
|
793
|
+
return fitted;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// src/render/render.ts
|
|
797
|
+
var BASE2 = 1080;
|
|
692
798
|
var CARD_RADIUS = 0.035;
|
|
693
799
|
var CAP_TOP = 0.15;
|
|
694
800
|
var PIN_HEAD_Y = 0.08;
|
|
@@ -783,10 +889,6 @@ function capAligned(zone, head) {
|
|
|
783
889
|
const shift = first ? Math.min(Math.max(0, Math.round(first.y + first.h * CAP_TOP) - zone.y), Math.round(zone.h / 2)) : 0;
|
|
784
890
|
return { ...zone, y: zone.y + shift, h: zone.h - shift };
|
|
785
891
|
}
|
|
786
|
-
function splitHeader(header, hasLogo) {
|
|
787
|
-
const logoW = hasLogo ? Math.round(header.w * LOGO_SHARE) : 0;
|
|
788
|
-
return { text: { ...header, w: header.w - logoW * 1.1 }, logo: { ...header, x: header.x + header.w - logoW, w: logoW } };
|
|
789
|
-
}
|
|
790
892
|
function render(input, assets, canvas) {
|
|
791
893
|
const doc = checked(input, assets);
|
|
792
894
|
const { width, height } = FORMAT_SIZES[doc.format];
|
|
@@ -798,8 +900,8 @@ function render(input, assets, canvas) {
|
|
|
798
900
|
}
|
|
799
901
|
const palette = PALETTES[doc.palette];
|
|
800
902
|
const fonts = FONT_SETS[doc.fonts];
|
|
801
|
-
const
|
|
802
|
-
const
|
|
903
|
+
const u = Math.min(width, height) / BASE2;
|
|
904
|
+
const { geo, zones, head, body, sides } = fitToFloor(ctx, { doc, palette, fonts, width, height });
|
|
803
905
|
ctx.globalAlpha = 1;
|
|
804
906
|
ctx.fillStyle = palette.background;
|
|
805
907
|
ctx.fillRect(0, 0, width, height);
|
|
@@ -807,15 +909,10 @@ function render(input, assets, canvas) {
|
|
|
807
909
|
ctx.fillStyle = palette.tint;
|
|
808
910
|
[geo.header, geo.body].forEach((r3) => ctx.fillRect(r3.x, r3.y, r3.w, r3.h));
|
|
809
911
|
}
|
|
810
|
-
const zones = geo.logo ? { text: geo.header, logo: geo.logo } : splitHeader(geo.header, doc.logo !== void 0);
|
|
811
|
-
const head = flow(ctx, headerGroups(doc, palette, u), zones.text, fonts);
|
|
812
|
-
const flanks = geo.flanks ?? [];
|
|
813
|
-
const body = flow(ctx, bodyGroups(doc, palette, u, flanks.length === 0), geo.body, fonts);
|
|
814
|
-
const sides = flanks.length > 0 ? equalFlanks(ctx, flankPriceGroups(doc, palette, u), flanks, fonts) : [emptyFlow()];
|
|
815
912
|
const radius = Math.min(width, height) * CARD_RADIUS;
|
|
816
913
|
[head, body, ...sides].forEach((f) => drawFlow(ctx, f, radius));
|
|
817
914
|
const logoImage = doc.logo ? assets[doc.logo.imageId] : void 0;
|
|
818
|
-
const logo = logoImage ? placeContain(logoImage, geo.logo ? zones.logo : capAligned(zones.logo, head), "top" /* Top */, 1, 1) : null;
|
|
915
|
+
const logo = logoImage ? placeContain(logoImage, geo.logo ? zones.logo : capAligned(zones.logo, head), { anchor: "top" /* Top */, alignX: 1, scale: 1 }) : null;
|
|
819
916
|
if (logoImage && logo) {
|
|
820
917
|
ctx.drawImage(logoImage, logo.x, logo.y, logo.w, logo.h);
|
|
821
918
|
}
|
|
@@ -824,8 +921,8 @@ function render(input, assets, canvas) {
|
|
|
824
921
|
if (backdrop) {
|
|
825
922
|
drawArch(ctx, backdrop, palette.arch);
|
|
826
923
|
}
|
|
827
|
-
const region = pictureRegion(doc.layout, geo, body,
|
|
828
|
-
const picture = image && doc.picture ? placeContain(image, region, doc.picture.anchor, geo.alignX, doc.picture.scale) : null;
|
|
924
|
+
const region = pictureRegion(doc.layout, geo, body, { height, u });
|
|
925
|
+
const picture = image && doc.picture ? placeContain(image, region, { anchor: doc.picture.anchor, alignX: geo.alignX, scale: doc.picture.scale }) : null;
|
|
829
926
|
if (image && picture) {
|
|
830
927
|
ctx.drawImage(image, picture.x, picture.y, picture.w, picture.h);
|
|
831
928
|
}
|