@dloizides/poster-render 0.2.4 → 0.2.6
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 +7 -0
- package/README.md +40 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +183 -86
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +183 -86
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
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
|
+
|
|
3
10
|
## 0.2.4
|
|
4
11
|
|
|
5
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.
|
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,10 +284,10 @@ 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;
|
|
@@ -347,11 +347,15 @@ var BOTTOM_MIN_TOP = 0.25;
|
|
|
347
347
|
var BOTTOM_HEADER_SHARE = 0.3;
|
|
348
348
|
var BOTTOM_GAP = 40 / 1080;
|
|
349
349
|
var BOTTOM_ASPECT = 0.57;
|
|
350
|
+
var DEFAULT_SIDE = [0.6, 0.42];
|
|
350
351
|
var PICTURE_SIDE = {
|
|
351
|
-
["picture-right" /* PictureRight */]:
|
|
352
|
-
["picture-left" /* PictureLeft */]:
|
|
352
|
+
["picture-right" /* PictureRight */]: DEFAULT_SIDE,
|
|
353
|
+
["picture-left" /* PictureLeft */]: DEFAULT_SIDE,
|
|
353
354
|
["list" /* List */]: [0.5, 0.36]
|
|
354
355
|
};
|
|
356
|
+
var SHORT_PORTRAIT = 1.5;
|
|
357
|
+
var SHORT_PICTURE_W = 0.5;
|
|
358
|
+
var SHORT_PICTURE_H = 0.75;
|
|
355
359
|
var ANCHOR_Y = { ["top" /* Top */]: 0, ["center" /* Center */]: 0.5, ["bottom" /* Bottom */]: 1 };
|
|
356
360
|
var r2 = Math.round;
|
|
357
361
|
function side(w, h, fraction) {
|
|
@@ -375,6 +379,20 @@ function side(w, h, fraction) {
|
|
|
375
379
|
alignX: 1
|
|
376
380
|
};
|
|
377
381
|
}
|
|
382
|
+
function shortSide(w, h) {
|
|
383
|
+
const m = r2(Math.min(w, h) * MARGIN);
|
|
384
|
+
const picW = r2(w * SHORT_PICTURE_W);
|
|
385
|
+
const picH = r2(h * SHORT_PICTURE_H);
|
|
386
|
+
const top = r2(h * HEADER_PORTRAIT);
|
|
387
|
+
const textW = w - picW - 2 * m;
|
|
388
|
+
return {
|
|
389
|
+
header: { x: m, y: m, w: textW, h: top - 2 * m },
|
|
390
|
+
body: { x: m, y: top, w: textW, h: h - top - m },
|
|
391
|
+
picture: { x: w - picW, y: h - picH, w: picW, h: picH },
|
|
392
|
+
logo: { x: w - picW + m, y: m, w: picW - 2 * m, h: h - picH - 2 * m },
|
|
393
|
+
alignX: 1
|
|
394
|
+
};
|
|
395
|
+
}
|
|
378
396
|
function bottom(w, h, share) {
|
|
379
397
|
const m = r2(Math.min(w, h) * MARGIN);
|
|
380
398
|
const gap = r2(Math.min(w, h) * BOTTOM_GAP);
|
|
@@ -409,13 +427,15 @@ function geometry(layout, w, h, give = 0) {
|
|
|
409
427
|
if (layout === "picture-bottom" /* PictureBottom */) {
|
|
410
428
|
return bottom(w, h, BOTTOM_PICTURE_W - give);
|
|
411
429
|
}
|
|
412
|
-
const
|
|
430
|
+
const isSide = layout === "picture-right" /* PictureRight */ || layout === "picture-left" /* PictureLeft */;
|
|
431
|
+
const g = isSide && h >= w && h < w * SHORT_PORTRAIT ? shortSide(w, h) : side(w, h, PICTURE_SIDE[layout] ?? DEFAULT_SIDE);
|
|
413
432
|
if (layout !== "picture-left" /* PictureLeft */) {
|
|
414
433
|
return g;
|
|
415
434
|
}
|
|
416
|
-
return { header: mirror(g.header, w), body: mirror(g.body, w), picture: mirror(g.picture, w), alignX: 1 - g.alignX };
|
|
435
|
+
return { header: mirror(g.header, w), body: mirror(g.body, w), picture: mirror(g.picture, w), logo: g.logo ? mirror(g.logo, w) : void 0, alignX: 1 - g.alignX };
|
|
417
436
|
}
|
|
418
|
-
function placeContain(image, region,
|
|
437
|
+
function placeContain(image, region, fit) {
|
|
438
|
+
const { anchor, alignX, scale } = fit;
|
|
419
439
|
const s = Math.min(region.w / image.width, region.h / image.height) * scale;
|
|
420
440
|
const w = Math.floor(image.width * s);
|
|
421
441
|
const h = Math.floor(image.height * s);
|
|
@@ -432,6 +452,29 @@ var BOLD = 700;
|
|
|
432
452
|
var SEMI = 600;
|
|
433
453
|
var MEDIUM = 500;
|
|
434
454
|
var SCRIPT = 400;
|
|
455
|
+
var SIZE = {
|
|
456
|
+
title: 250,
|
|
457
|
+
headerScript: 120,
|
|
458
|
+
itemDate: 67,
|
|
459
|
+
itemRow: 42,
|
|
460
|
+
scheduleLabel: 66,
|
|
461
|
+
scheduleTime: 60,
|
|
462
|
+
priceAmount: 66,
|
|
463
|
+
priceLabel: 52,
|
|
464
|
+
listPrice: 58,
|
|
465
|
+
flankAmount: 110,
|
|
466
|
+
flankLabel: 48,
|
|
467
|
+
venue: 58,
|
|
468
|
+
listVenue: 48,
|
|
469
|
+
small: 38
|
|
470
|
+
};
|
|
471
|
+
var GAP = {
|
|
472
|
+
item: 48,
|
|
473
|
+
row: 30,
|
|
474
|
+
venue: 40,
|
|
475
|
+
includes: 24,
|
|
476
|
+
rule: 8
|
|
477
|
+
};
|
|
435
478
|
var makers = (p, u) => ({
|
|
436
479
|
display: (text2, size) => ({ text: text2, role: "display", weight: BOLD, size: size * u, color: p.title }),
|
|
437
480
|
venue: (text2, size) => ({ text: text2, role: "display", weight: MEDIUM, size: size * u, color: p.ink }),
|
|
@@ -444,52 +487,75 @@ var makers = (p, u) => ({
|
|
|
444
487
|
function headerGroups(doc, p, u) {
|
|
445
488
|
const l = makers(p, u);
|
|
446
489
|
const b = doc.blocks;
|
|
447
|
-
const lines = [l.display
|
|
490
|
+
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];
|
|
448
491
|
return [{ lines: lines.filter((x) => x !== void 0), gapAfter: 0 }];
|
|
449
492
|
}
|
|
450
493
|
function itemGroup(item, l, u) {
|
|
451
|
-
const lines = [l.script
|
|
452
|
-
item.schedule.forEach((row) => lines.push(l.strong
|
|
494
|
+
const lines = [l.script(item.date, SIZE.itemDate)];
|
|
495
|
+
item.schedule.forEach((row) => lines.push(l.strong(`${row.label} ${row.time}`, SIZE.itemRow)));
|
|
453
496
|
if (item.price) {
|
|
454
|
-
lines.push(l.accent
|
|
497
|
+
lines.push(l.accent(item.price, SIZE.itemRow));
|
|
498
|
+
}
|
|
499
|
+
return { lines, gapAfter: GAP.item * u };
|
|
500
|
+
}
|
|
501
|
+
var WRAP_FROM = 14;
|
|
502
|
+
function halves(text2) {
|
|
503
|
+
const mid = text2.length / 2;
|
|
504
|
+
const spaces = [...text2].flatMap((c, i) => c === " " ? [i] : []);
|
|
505
|
+
if (text2.length < WRAP_FROM || spaces.length === 0) {
|
|
506
|
+
return [text2];
|
|
455
507
|
}
|
|
456
|
-
|
|
508
|
+
const cut = spaces.reduce((best, i) => Math.abs(i - mid) < Math.abs(best - mid) ? i : best);
|
|
509
|
+
return [text2.slice(0, cut), text2.slice(cut + 1)];
|
|
457
510
|
}
|
|
458
|
-
function
|
|
511
|
+
function wrapped(line, wrap) {
|
|
512
|
+
const parts = wrap ? halves(line.text) : [line.text];
|
|
513
|
+
return parts.map((text2, i) => i === 0 ? { ...line, text: text2 } : { ...line, text: text2, bullet: void 0, hang: line.bullet !== void 0 });
|
|
514
|
+
}
|
|
515
|
+
function eventGroups(doc, l, u, options) {
|
|
459
516
|
const b = doc.blocks;
|
|
460
517
|
const groups = [];
|
|
461
|
-
b.schedule
|
|
462
|
-
|
|
463
|
-
|
|
518
|
+
const schedule2 = b.schedule ?? [];
|
|
519
|
+
schedule2.forEach((row, i) => {
|
|
520
|
+
const lines = options.compact ? [l.strong(`${row.label} ${row.time}`, SIZE.scheduleLabel)] : [l.strong(row.label, SIZE.scheduleLabel), l.accent(row.time, SIZE.scheduleTime)];
|
|
521
|
+
groups.push({ lines, gapAfter: GAP.row * u, keepWithNext: i < schedule2.length - 1 });
|
|
522
|
+
});
|
|
523
|
+
const prices = (options.withPrices === false ? void 0 : b.prices) ?? [];
|
|
524
|
+
prices.forEach((row, i) => {
|
|
525
|
+
const lines = options.compact ? [l.strong(`${row.amount} ${row.label}`, SIZE.priceAmount)] : [l.strong(row.amount, SIZE.priceAmount), l.accent(row.label, SIZE.priceLabel)];
|
|
526
|
+
groups.push({ lines, gapAfter: GAP.row * u, keepWithNext: i < prices.length - 1 });
|
|
527
|
+
});
|
|
464
528
|
return groups;
|
|
465
529
|
}
|
|
466
530
|
function listGroups(doc, l, u) {
|
|
467
531
|
const groups = (doc.items ?? []).map((item) => itemGroup(item, l, u));
|
|
468
|
-
const prices = doc.blocks.prices?.map((row) => l.strong
|
|
532
|
+
const prices = doc.blocks.prices?.map((row) => l.strong(`${row.amount} ${row.label}`, SIZE.listPrice)) ?? [];
|
|
469
533
|
if (prices.length > 0) {
|
|
470
|
-
groups.push({ lines: prices, gapAfter:
|
|
534
|
+
groups.push({ lines: prices, gapAfter: GAP.row * u });
|
|
471
535
|
}
|
|
472
536
|
return groups;
|
|
473
537
|
}
|
|
474
538
|
function flankPriceGroups(doc, p, u) {
|
|
475
539
|
const l = makers(p, u);
|
|
476
|
-
return (doc.blocks.prices ?? []).map((row) => ({ lines: [l.strong
|
|
540
|
+
return (doc.blocks.prices ?? []).map((row) => ({ lines: [l.strong(row.amount, SIZE.flankAmount), l.accent(row.label, SIZE.flankLabel)], gapAfter: GAP.row * u }));
|
|
477
541
|
}
|
|
478
|
-
function bodyGroups(doc, p, u,
|
|
542
|
+
function bodyGroups(doc, p, u, options = {}) {
|
|
479
543
|
const l = makers(p, u);
|
|
480
544
|
const b = doc.blocks;
|
|
481
545
|
const groups = [];
|
|
482
546
|
const isList = doc.layout === "list" /* List */;
|
|
483
547
|
if (b.venue) {
|
|
484
|
-
|
|
548
|
+
const size = isList ? SIZE.listVenue : SIZE.venue;
|
|
549
|
+
const keepWithNext = !isList && (b.schedule?.length ?? 0) > 0;
|
|
550
|
+
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 }, keepWithNext });
|
|
485
551
|
}
|
|
486
|
-
groups.push(...isList ? listGroups(doc, l, u) : eventGroups(doc, l, u,
|
|
552
|
+
groups.push(...isList ? listGroups(doc, l, u) : eventGroups(doc, l, u, options));
|
|
487
553
|
if (b.includes) {
|
|
488
|
-
const lines = [l.accent
|
|
489
|
-
groups.push({ lines, gapAfter:
|
|
554
|
+
const lines = [l.accent(b.includes.heading, SIZE.small), ...b.includes.items.map((i) => l.item(i, SIZE.small))].flatMap((line) => wrapped(line, options.wrap));
|
|
555
|
+
groups.push({ lines, gapAfter: GAP.includes * u, uniform: true });
|
|
490
556
|
}
|
|
491
557
|
if (b.footer) {
|
|
492
|
-
groups.push({ lines:
|
|
558
|
+
groups.push({ lines: wrapped(l.soft(b.footer, SIZE.small), options.wrap), gapAfter: 0 });
|
|
493
559
|
}
|
|
494
560
|
return groups;
|
|
495
561
|
}
|
|
@@ -501,6 +567,7 @@ var BULLET_RADIUS = 0.2;
|
|
|
501
567
|
var RULE_GAP = 2.5;
|
|
502
568
|
var RULE_WIDTH = 0.9;
|
|
503
569
|
var WIDE = 1.6;
|
|
570
|
+
var COLUMN_GAP = 0.04;
|
|
504
571
|
var TWO_COLUMN_MIN = 0.75;
|
|
505
572
|
var TWO_COLUMN_GAIN = 1.15;
|
|
506
573
|
var MIN_K = 0.02;
|
|
@@ -509,12 +576,13 @@ var ICON_SCALE = 1.6;
|
|
|
509
576
|
var ICON_GAP = 0.35;
|
|
510
577
|
var fontOf = (line, size, fonts) => `${line.weight} ${size}px ${fonts[line.role]}`;
|
|
511
578
|
var emptyFlow = () => ({ texts: [], cards: [], rules: [], bullets: [], icons: [] });
|
|
579
|
+
var indented = (line) => line.bullet !== void 0 || line.hang === true;
|
|
512
580
|
var iconSize = (group, k) => {
|
|
513
581
|
const first = group.lines[0];
|
|
514
582
|
return group.icon && first ? first.size * k * LINE_HEIGHT * ICON_SCALE : 0;
|
|
515
583
|
};
|
|
516
584
|
function lineSize(line, width, k, avail) {
|
|
517
|
-
const indent = line
|
|
585
|
+
const indent = indented(line) ? line.size * k * BULLET_INDENT : 0;
|
|
518
586
|
return Math.max(0, Math.min(line.size * k, line.size * (avail - indent) / Math.max(width, 1)));
|
|
519
587
|
}
|
|
520
588
|
function groupRatio(m, k, avail) {
|
|
@@ -523,15 +591,17 @@ function groupRatio(m, k, avail) {
|
|
|
523
591
|
}
|
|
524
592
|
return Math.min(...m.group.lines.map((line, i) => lineSize(line, m.widths[i] ?? 1, k, avail) / line.size));
|
|
525
593
|
}
|
|
526
|
-
function placeIcon(group,
|
|
594
|
+
function placeIcon(group, box, out) {
|
|
595
|
+
const { d, x, top, bottom: bottom2 } = box;
|
|
527
596
|
if (!group.icon || d <= 0) {
|
|
528
|
-
return
|
|
597
|
+
return bottom2;
|
|
529
598
|
}
|
|
530
|
-
const textH =
|
|
531
|
-
out?.icons.push({ rect: { x, y:
|
|
532
|
-
return
|
|
599
|
+
const textH = bottom2 - top;
|
|
600
|
+
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 });
|
|
601
|
+
return top + Math.max(textH, d);
|
|
533
602
|
}
|
|
534
|
-
function placeGroup(m,
|
|
603
|
+
function placeGroup(m, at, out) {
|
|
604
|
+
const { k, col, y0 } = at;
|
|
535
605
|
const { group } = m;
|
|
536
606
|
let y = y0;
|
|
537
607
|
if (group.rule) {
|
|
@@ -547,7 +617,7 @@ function placeGroup(m, k, col, y0, out) {
|
|
|
547
617
|
const ratio = groupRatio(m, k, avail);
|
|
548
618
|
group.lines.forEach((line, i) => {
|
|
549
619
|
const size = ratio === null ? lineSize(line, m.widths[i] ?? 1, k, avail) : line.size * ratio;
|
|
550
|
-
const indent = (line
|
|
620
|
+
const indent = (indented(line) ? line.size * k * BULLET_INDENT : 0) + iconIndent;
|
|
551
621
|
const w = (m.widths[i] ?? 0) * size / line.size;
|
|
552
622
|
const x = col.right ? col.x + col.w - pad - w : col.x + pad + indent;
|
|
553
623
|
out?.texts.push({ text: line.text, font: "", color: line.color, box: { x, y, w, h: size * LINE_HEIGHT } });
|
|
@@ -556,7 +626,7 @@ function placeGroup(m, k, col, y0, out) {
|
|
|
556
626
|
}
|
|
557
627
|
y += size * LINE_HEIGHT;
|
|
558
628
|
});
|
|
559
|
-
y = placeIcon(group, d, col.x + pad, top + pad, y, out);
|
|
629
|
+
y = placeIcon(group, { d, x: col.x + pad, top: top + pad, bottom: y }, out);
|
|
560
630
|
y += pad;
|
|
561
631
|
if (group.rule && group.ruleBelow) {
|
|
562
632
|
y += group.rule.height * k * (RULE_GAP - 1);
|
|
@@ -568,10 +638,11 @@ function placeGroup(m, k, col, y0, out) {
|
|
|
568
638
|
}
|
|
569
639
|
return y + group.gapAfter * k - y0;
|
|
570
640
|
}
|
|
571
|
-
function placeColumn(groups,
|
|
641
|
+
function placeColumn(groups, at, out) {
|
|
642
|
+
const { k, col, spread = 0 } = at;
|
|
572
643
|
let h = 0;
|
|
573
644
|
for (const g of groups) {
|
|
574
|
-
h += placeGroup(g, k, col, col.y + h, out) + spread;
|
|
645
|
+
h += placeGroup(g, { k, col, y0: col.y + h }, out) + spread;
|
|
575
646
|
}
|
|
576
647
|
return h - spread;
|
|
577
648
|
}
|
|
@@ -598,9 +669,9 @@ function keepTogether(groups, heights, cut) {
|
|
|
598
669
|
return candidates.reduce((best, c) => tallest(heights, c) < tallest(heights, best) ? c : best);
|
|
599
670
|
}
|
|
600
671
|
function splitColumns(groups, zone) {
|
|
601
|
-
const gap = Math.round(zone.w *
|
|
672
|
+
const gap = Math.round(zone.w * COLUMN_GAP);
|
|
602
673
|
const w = (zone.w - gap) / 2;
|
|
603
|
-
const heights = groups.map((g) => placeGroup(g, 1, { ...zone, w }, 0, null));
|
|
674
|
+
const heights = groups.map((g) => placeGroup(g, { k: 1, col: { ...zone, w }, y0: 0 }, null));
|
|
604
675
|
const total = columnSum(heights, 0, heights.length);
|
|
605
676
|
let cut = 1;
|
|
606
677
|
for (let c = 2; c < groups.length; c++) {
|
|
@@ -615,7 +686,7 @@ function splitColumns(groups, zone) {
|
|
|
615
686
|
];
|
|
616
687
|
}
|
|
617
688
|
function largestK(columns, zoneH) {
|
|
618
|
-
const fits = (k) => columns.every((c) => placeColumn(c.groups, k, c.col, null) <= zoneH);
|
|
689
|
+
const fits = (k) => columns.every((c) => placeColumn(c.groups, { k, col: c.col }, null) <= zoneH);
|
|
619
690
|
let lo = MIN_K;
|
|
620
691
|
let hi = 1;
|
|
621
692
|
if (fits(1)) {
|
|
@@ -652,9 +723,9 @@ function flow(ctx, groups, zone, fonts) {
|
|
|
652
723
|
}));
|
|
653
724
|
const { columns, k: lo } = bestColumns(measured, zone);
|
|
654
725
|
const out = emptyFlow();
|
|
655
|
-
const heights = columns.map((c) => placeColumn(c.groups, lo, c.col, null));
|
|
726
|
+
const heights = columns.map((c) => placeColumn(c.groups, { k: lo, col: c.col }, null));
|
|
656
727
|
const end = Math.max(...heights);
|
|
657
|
-
columns.forEach((c, i) => placeColumn(c.groups, lo, c.col,
|
|
728
|
+
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));
|
|
658
729
|
const lines = groups.flatMap((g) => g.lines);
|
|
659
730
|
out.texts.forEach((t, i) => {
|
|
660
731
|
const line = lines[i];
|
|
@@ -691,43 +762,14 @@ function measuredEnd(f, fallback) {
|
|
|
691
762
|
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);
|
|
692
763
|
return bottoms.length > 0 ? Math.max(...bottoms) : fallback;
|
|
693
764
|
}
|
|
694
|
-
function pictureRegion(layout, geo, body,
|
|
765
|
+
function pictureRegion(layout, geo, body, frame) {
|
|
695
766
|
if (layout !== "picture-bottom" /* PictureBottom */) {
|
|
696
767
|
return geo.picture;
|
|
697
768
|
}
|
|
698
|
-
const y = Math.min(geo.picture.y, Math.ceil(measuredEnd(body, geo.body.y) + BODY_GAP * u));
|
|
769
|
+
const y = Math.min(geo.picture.y, Math.ceil(measuredEnd(body, geo.body.y) + BODY_GAP * frame.u));
|
|
699
770
|
return { x: geo.picture.x, y, w: geo.picture.w, h: frame.height - y };
|
|
700
771
|
}
|
|
701
772
|
|
|
702
|
-
// src/render/yieldToText.ts
|
|
703
|
-
function yieldToText(geo, give) {
|
|
704
|
-
const { picture: p, body: b } = geo;
|
|
705
|
-
if (give <= 0) {
|
|
706
|
-
return geo;
|
|
707
|
-
}
|
|
708
|
-
if (p.x >= b.x + b.w) {
|
|
709
|
-
const d = Math.round(p.w * give);
|
|
710
|
-
return { ...geo, picture: { ...p, x: p.x + d, w: p.w - d }, body: { ...b, w: b.w + d } };
|
|
711
|
-
}
|
|
712
|
-
if (p.x + p.w <= b.x) {
|
|
713
|
-
const d = Math.round(p.w * give);
|
|
714
|
-
return { ...geo, picture: { ...p, w: p.w - d }, body: { ...b, x: b.x - d, w: b.w + d } };
|
|
715
|
-
}
|
|
716
|
-
if (p.y >= b.y + b.h) {
|
|
717
|
-
const d = Math.round(p.h * give);
|
|
718
|
-
const stacked = geo.header.y + geo.header.h <= b.y;
|
|
719
|
-
const toHeader = stacked ? Math.round(d / 2) : 0;
|
|
720
|
-
return {
|
|
721
|
-
...geo,
|
|
722
|
-
header: { ...geo.header, h: geo.header.h + toHeader },
|
|
723
|
-
picture: { ...p, y: p.y + d, h: p.h - d },
|
|
724
|
-
body: { ...b, y: b.y + toHeader, h: b.h + d - toHeader },
|
|
725
|
-
flanks: geo.flanks?.map((f) => ({ ...f, y: f.y + d, h: f.h - d }))
|
|
726
|
-
};
|
|
727
|
-
}
|
|
728
|
-
return geo;
|
|
729
|
-
}
|
|
730
|
-
|
|
731
773
|
// src/render/fitToFloor.ts
|
|
732
774
|
var FLOOR_PX = 30;
|
|
733
775
|
var BASE = 1080;
|
|
@@ -735,33 +777,88 @@ var LINE_HEIGHT3 = 1.2;
|
|
|
735
777
|
var LOGO_SHARE = 0.26;
|
|
736
778
|
var GIVE_STEP = 0.025;
|
|
737
779
|
var GIVE_STEPS = 32;
|
|
780
|
+
var SHIFT_STEP = 0.06;
|
|
781
|
+
var SHIFT_STEPS = 8;
|
|
782
|
+
var MARGIN2 = 0.045;
|
|
738
783
|
function splitHeader(header, hasLogo) {
|
|
739
784
|
const logoW = hasLogo ? Math.round(header.w * LOGO_SHARE) : 0;
|
|
740
785
|
return { text: { ...header, w: header.w - logoW * 1.1 }, logo: { ...header, x: header.x + header.w - logoW, w: logoW } };
|
|
741
786
|
}
|
|
742
|
-
function flowAll(ctx, input, geo,
|
|
787
|
+
function flowAll(ctx, input, geo, reflow) {
|
|
743
788
|
const { doc, palette, fonts } = input;
|
|
789
|
+
const u = Math.min(input.width, input.height) / BASE;
|
|
744
790
|
const zones = geo.logo ? { text: geo.header, logo: geo.logo } : splitHeader(geo.header, doc.logo !== void 0);
|
|
745
791
|
const head = flow(ctx, headerGroups(doc, palette, u), zones.text, fonts);
|
|
746
792
|
const flanks = geo.flanks ?? [];
|
|
747
|
-
const body = flow(ctx, bodyGroups(doc, palette, u, flanks.length === 0), geo.body, fonts);
|
|
793
|
+
const body = flow(ctx, bodyGroups(doc, palette, u, { withPrices: flanks.length === 0, compact: reflow.compact, wrap: reflow.wrap }), geo.body, fonts);
|
|
748
794
|
const sides = flanks.length > 0 ? equalFlanks(ctx, flankPriceGroups(doc, palette, u), flanks, fonts) : [emptyFlow()];
|
|
749
795
|
return { geo, zones, head, body, sides };
|
|
750
796
|
}
|
|
751
797
|
var smallest = (t) => Math.min(Infinity, ...[t.head, t.body, ...t.sides].flatMap((f) => f.texts.map((x) => x.box.h / LINE_HEIGHT3)));
|
|
752
|
-
function
|
|
753
|
-
|
|
798
|
+
function shiftHeader(geo, shift) {
|
|
799
|
+
const { header, body } = geo;
|
|
800
|
+
const stacked = header.y + header.h <= body.y && header.x < body.x + body.w && body.x < header.x + header.w;
|
|
801
|
+
const d = Math.round(header.h * shift);
|
|
802
|
+
if (!stacked || d === 0 || body.h - d <= 0) {
|
|
803
|
+
return geo;
|
|
804
|
+
}
|
|
805
|
+
return { ...geo, header: { ...header, h: header.h - d }, body: { ...body, y: body.y - d, h: body.h + d } };
|
|
754
806
|
}
|
|
755
|
-
function
|
|
756
|
-
const {
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
807
|
+
function liftHeader(geo, top) {
|
|
808
|
+
const { header } = geo;
|
|
809
|
+
return header.y > top ? { ...geo, header: { ...header, y: top, h: header.h + header.y - top } } : geo;
|
|
810
|
+
}
|
|
811
|
+
function fitBottom(ctx, input, floor) {
|
|
812
|
+
const { width, height } = input;
|
|
813
|
+
const none = { compact: false, wrap: false};
|
|
814
|
+
let fitted = flowAll(ctx, input, geometry("picture-bottom" /* PictureBottom */, width, height), none);
|
|
815
|
+
for (let step = 1; step <= GIVE_STEPS && smallest(fitted) < floor; step++) {
|
|
816
|
+
fitted = flowAll(ctx, input, geometry("picture-bottom" /* PictureBottom */, width, height, step * GIVE_STEP), none);
|
|
817
|
+
}
|
|
818
|
+
return fitted;
|
|
819
|
+
}
|
|
820
|
+
function yieldPicture(geo, give) {
|
|
821
|
+
const { picture: p, body: b } = geo;
|
|
822
|
+
const d = Math.round(p.w * give);
|
|
823
|
+
return { ...geo, picture: { ...p, x: p.x + d, w: p.w - d }, body: { ...b, w: b.w + d } };
|
|
824
|
+
}
|
|
825
|
+
function fitList(ctx, input, floor, base2) {
|
|
826
|
+
const last = { compact: true, wrap: true};
|
|
827
|
+
let fitted = flowAll(ctx, input, base2, last);
|
|
760
828
|
for (let step = 1; step <= GIVE_STEPS && smallest(fitted) < floor; step++) {
|
|
761
|
-
fitted = flowAll(ctx, input,
|
|
829
|
+
fitted = flowAll(ctx, input, yieldPicture(base2, step * GIVE_STEP), last);
|
|
762
830
|
}
|
|
763
831
|
return fitted;
|
|
764
832
|
}
|
|
833
|
+
var SHIFTS = [0, ...Array.from({ length: SHIFT_STEPS }, (_v, i) => [(i + 1) * SHIFT_STEP, -(i + 1) * SHIFT_STEP]).flat()];
|
|
834
|
+
var TIERS = [
|
|
835
|
+
{ compact: false, wrap: false, lift: false },
|
|
836
|
+
{ compact: true, wrap: false, lift: false },
|
|
837
|
+
{ compact: true, wrap: true, lift: false },
|
|
838
|
+
{ compact: true, wrap: true, lift: true }
|
|
839
|
+
];
|
|
840
|
+
var REFLOWS = TIERS.flatMap((tier) => SHIFTS.map((shift) => ({ ...tier, shift })));
|
|
841
|
+
function fitToFloor(ctx, input) {
|
|
842
|
+
const { doc, width, height } = input;
|
|
843
|
+
const floor = FLOOR_PX * Math.min(width, height) / BASE;
|
|
844
|
+
if (doc.layout === "picture-bottom" /* PictureBottom */) {
|
|
845
|
+
return fitBottom(ctx, input, floor);
|
|
846
|
+
}
|
|
847
|
+
const base2 = geometry(doc.layout, width, height);
|
|
848
|
+
const top = Math.round(Math.min(width, height) * MARGIN2);
|
|
849
|
+
let best = null;
|
|
850
|
+
for (const reflow of REFLOWS) {
|
|
851
|
+
const fitted = flowAll(ctx, input, shiftHeader(reflow.lift ? liftHeader(base2, top) : base2, reflow.shift), reflow);
|
|
852
|
+
if (smallest(fitted) >= floor) {
|
|
853
|
+
return fitted;
|
|
854
|
+
}
|
|
855
|
+
best = best === null || smallest(fitted) > smallest(best) ? fitted : best;
|
|
856
|
+
}
|
|
857
|
+
if (doc.layout === "list" /* List */) {
|
|
858
|
+
return fitList(ctx, input, floor, base2);
|
|
859
|
+
}
|
|
860
|
+
return best ?? flowAll(ctx, input, base2, { compact: false, wrap: false});
|
|
861
|
+
}
|
|
765
862
|
|
|
766
863
|
// src/render/render.ts
|
|
767
864
|
var BASE2 = 1080;
|
|
@@ -882,7 +979,7 @@ function render(input, assets, canvas) {
|
|
|
882
979
|
const radius = Math.min(width, height) * CARD_RADIUS;
|
|
883
980
|
[head, body, ...sides].forEach((f) => drawFlow(ctx, f, radius));
|
|
884
981
|
const logoImage = doc.logo ? assets[doc.logo.imageId] : void 0;
|
|
885
|
-
const logo = logoImage ? placeContain(logoImage, geo.logo ? zones.logo : capAligned(zones.logo, head), "top" /* Top */, 1, 1) : null;
|
|
982
|
+
const logo = logoImage ? placeContain(logoImage, geo.logo ? zones.logo : capAligned(zones.logo, head), { anchor: "top" /* Top */, alignX: 1, scale: 1 }) : null;
|
|
886
983
|
if (logoImage && logo) {
|
|
887
984
|
ctx.drawImage(logoImage, logo.x, logo.y, logo.w, logo.h);
|
|
888
985
|
}
|
|
@@ -891,8 +988,8 @@ function render(input, assets, canvas) {
|
|
|
891
988
|
if (backdrop) {
|
|
892
989
|
drawArch(ctx, backdrop, palette.arch);
|
|
893
990
|
}
|
|
894
|
-
const region = pictureRegion(doc.layout, geo, body,
|
|
895
|
-
const picture = image && doc.picture ? placeContain(image, region, doc.picture.anchor, geo.alignX, doc.picture.scale) : null;
|
|
991
|
+
const region = pictureRegion(doc.layout, geo, body, { height, u });
|
|
992
|
+
const picture = image && doc.picture ? placeContain(image, region, { anchor: doc.picture.anchor, alignX: geo.alignX, scale: doc.picture.scale }) : null;
|
|
896
993
|
if (image && picture) {
|
|
897
994
|
ctx.drawImage(image, picture.x, picture.y, picture.w, picture.h);
|
|
898
995
|
}
|