@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 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 for phone, D-A for desktop). */
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 for phone, D-A for desktop). */
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[doc.picture.imageId] === void 0) {
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[doc.logo.imageId] === void 0) {
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 */]: [0.6, 0.42],
352
- ["picture-left" /* PictureLeft */]: [0.6, 0.42],
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 g = side(w, h, PICTURE_SIDE[layout] ?? [0.6, 0.42]);
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, anchor, alignX, scale) {
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?.(b.title, 250), b.script ? l.script?.(b.script, 120) : void 0, b.titleBottom ? l.display?.(b.titleBottom, 250) : void 0];
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?.(item.date, 67)];
452
- item.schedule.forEach((row) => lines.push(l.strong?.(`${row.label} ${row.time}`, 42)));
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?.(item.price, 42));
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
- return { lines, gapAfter: 48 * u };
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 eventGroups(doc, l, u, withPrices) {
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?.forEach((row) => groups.push({ lines: [l.strong?.(row.label, 66), l.accent?.(row.time, 60)], gapAfter: 30 * u }));
462
- const prices = (withPrices ? b.prices : void 0) ?? [];
463
- 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 }));
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?.(`${row.amount} ${row.label}`, 58)) ?? [];
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: 30 * u });
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?.(row.amount, 110), l.accent?.(row.label, 48)], gapAfter: 30 * u }));
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, withPrices = true) {
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
- 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 } });
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, withPrices));
552
+ groups.push(...isList ? listGroups(doc, l, u) : eventGroups(doc, l, u, options));
487
553
  if (b.includes) {
488
- const lines = [l.accent?.(b.includes.heading, 38), ...b.includes.items.map((i) => l.item?.(i, 38))];
489
- groups.push({ lines, gapAfter: 24 * u, uniform: true });
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: [l.soft?.(b.footer, 38)], gapAfter: 0 });
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.bullet ? line.size * k * BULLET_INDENT : 0;
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, d, x, textTop, textBottom, out) {
594
+ function placeIcon(group, box, out) {
595
+ const { d, x, top, bottom: bottom2 } = box;
527
596
  if (!group.icon || d <= 0) {
528
- return textBottom;
597
+ return bottom2;
529
598
  }
530
- const textH = textBottom - textTop;
531
- out?.icons.push({ rect: { x, y: textTop + Math.max(0, (textH - d) / 2), w: d, h: d }, color: group.icon.color, glyph: group.icon.glyph });
532
- return textTop + Math.max(textH, d);
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, k, col, y0, out) {
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.bullet ? line.size * k * BULLET_INDENT : 0) + iconIndent;
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, k, col, out, spread = 0) {
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 * 0.04);
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, out, c.groups.length > 1 && columns.length > 1 ? (end - (heights[i] ?? end)) / (c.groups.length - 1) : 0));
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, u, frame) {
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, u) {
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 geometryWithGive(layout, width, height, give) {
753
- return layout === "picture-bottom" /* PictureBottom */ ? geometry(layout, width, height, give) : yieldToText(geometry(layout, width, height), give);
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 fitToFloor(ctx, input) {
756
- const { doc, width, height } = input;
757
- const u = Math.min(width, height) / BASE;
758
- const floor = FLOOR_PX * u;
759
- let fitted = flowAll(ctx, input, geometryWithGive(doc.layout, width, height, 0), u);
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, geometryWithGive(doc.layout, width, height, step * GIVE_STEP), u);
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, u, { height });
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
  }