@dloizides/poster-render 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -291,73 +291,6 @@ function missingAssets(doc, assets) {
291
291
  return errors;
292
292
  }
293
293
 
294
- // src/layout/blockGroups.ts
295
- var BOLD = 700;
296
- var SEMI = 600;
297
- var MEDIUM = 500;
298
- var SCRIPT = 400;
299
- var makers = (p, u) => ({
300
- display: (text2, size) => ({ text: text2, role: "display", weight: BOLD, size: size * u, color: p.title }),
301
- venue: (text2, size) => ({ text: text2, role: "display", weight: MEDIUM, size: size * u, color: p.ink }),
302
- script: (text2, size) => ({ text: text2, role: "script", weight: SCRIPT, size: size * u, color: p.accent }),
303
- strong: (text2, size) => ({ text: text2, role: "body", weight: BOLD, size: size * u, color: p.ink }),
304
- accent: (text2, size) => ({ text: text2, role: "body", weight: SEMI, size: size * u, color: p.accent }),
305
- item: (text2, size) => ({ text: text2, role: "body", weight: SEMI, size: size * u, color: p.ink, bullet: p.accent }),
306
- soft: (text2, size) => ({ text: text2, role: "body", weight: MEDIUM, size: size * u, color: p.soft })
307
- });
308
- function headerGroups(doc, p, u) {
309
- const l = makers(p, u);
310
- const b = doc.blocks;
311
- const lines = [l.display?.(b.title, 250), b.script ? l.script?.(b.script, 120) : void 0, b.titleBottom ? l.display?.(b.titleBottom, 250) : void 0];
312
- return [{ lines: lines.filter((x) => x !== void 0), gapAfter: 0 }];
313
- }
314
- function itemGroup(item, l, u) {
315
- const lines = [l.script?.(item.date, 67)];
316
- item.schedule.forEach((row) => lines.push(l.strong?.(`${row.label} ${row.time}`, 42)));
317
- if (item.price) {
318
- lines.push(l.accent?.(item.price, 42));
319
- }
320
- return { lines, gapAfter: 48 * u };
321
- }
322
- function eventGroups(doc, l, u, withPrices) {
323
- const b = doc.blocks;
324
- const groups = [];
325
- b.schedule?.forEach((row) => groups.push({ lines: [l.strong?.(row.label, 66), l.accent?.(row.time, 60)], gapAfter: 30 * u }));
326
- const prices = (withPrices ? b.prices : void 0) ?? [];
327
- 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 }));
328
- return groups;
329
- }
330
- function listGroups(doc, l, u) {
331
- const groups = (doc.items ?? []).map((item) => itemGroup(item, l, u));
332
- const prices = doc.blocks.prices?.map((row) => l.strong?.(`${row.amount} ${row.label}`, 58)) ?? [];
333
- if (prices.length > 0) {
334
- groups.push({ lines: prices, gapAfter: 30 * u });
335
- }
336
- return groups;
337
- }
338
- function flankPriceGroups(doc, p, u) {
339
- const l = makers(p, u);
340
- return (doc.blocks.prices ?? []).map((row) => ({ lines: [l.strong?.(row.amount, 110), l.accent?.(row.label, 48)], gapAfter: 30 * u }));
341
- }
342
- function bodyGroups(doc, p, u, withPrices = true) {
343
- const l = makers(p, u);
344
- const b = doc.blocks;
345
- const groups = [];
346
- const isList = doc.layout === "list" /* List */;
347
- if (b.venue) {
348
- 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 } });
349
- }
350
- groups.push(...isList ? listGroups(doc, l, u) : eventGroups(doc, l, u, withPrices));
351
- if (b.includes) {
352
- const lines = [l.accent?.(b.includes.heading, 38), ...b.includes.items.map((i) => l.item?.(i, 38))];
353
- groups.push({ lines, gapAfter: 24 * u, uniform: true });
354
- }
355
- if (b.footer) {
356
- groups.push({ lines: [l.soft?.(b.footer, 38)], gapAfter: 0 });
357
- }
358
- return groups;
359
- }
360
-
361
294
  // src/layout/layoutSpecs.ts
362
295
  var flanksBeside = (pic, top, bottom2, m) => [
363
296
  [m, top, pic[0] - 2 * m, bottom2 - top],
@@ -409,7 +342,7 @@ var HEADER_PORTRAIT = 0.3;
409
342
  var HEADER_LANDSCAPE = 0.36;
410
343
  var BOTTOM_PICTURE_W = 0.8;
411
344
  var BOTTOM_MIN_TOP = 0.25;
412
- var BOTTOM_HEADER_SHARE = 0.45;
345
+ var BOTTOM_HEADER_SHARE = 0.3;
413
346
  var BOTTOM_GAP = 40 / 1080;
414
347
  var BOTTOM_ASPECT = 0.57;
415
348
  var PICTURE_SIDE = {
@@ -440,11 +373,11 @@ function side(w, h, fraction) {
440
373
  alignX: 1
441
374
  };
442
375
  }
443
- function bottom(w, h) {
376
+ function bottom(w, h, share) {
444
377
  const m = r2(Math.min(w, h) * MARGIN);
445
378
  const gap = r2(Math.min(w, h) * BOTTOM_GAP);
446
- const picW = r2(w * BOTTOM_PICTURE_W);
447
- const picH = Math.min(r2(picW / BOTTOM_ASPECT), h - r2(h * BOTTOM_MIN_TOP));
379
+ const picW = r2(w * share);
380
+ const picH = Math.min(r2(picW / BOTTOM_ASPECT), r2((h - r2(h * BOTTOM_MIN_TOP)) * share / BOTTOM_PICTURE_W));
448
381
  const pic = h - picH;
449
382
  const top = m + r2((pic - m) * BOTTOM_HEADER_SHARE);
450
383
  return {
@@ -466,13 +399,13 @@ function fromSpec(spec, w, h) {
466
399
  };
467
400
  }
468
401
  var mirror = (rect, w) => ({ ...rect, x: w - rect.x - rect.w });
469
- function geometry(layout, w, h) {
402
+ function geometry(layout, w, h, give = 0) {
470
403
  const oriented = LAYOUT_SPECS[layout];
471
404
  if (oriented) {
472
405
  return fromSpec(h >= w ? oriented.portrait : oriented.landscape, w, h);
473
406
  }
474
407
  if (layout === "picture-bottom" /* PictureBottom */) {
475
- return bottom(w, h);
408
+ return bottom(w, h, BOTTOM_PICTURE_W - give);
476
409
  }
477
410
  const g = side(w, h, PICTURE_SIDE[layout] ?? [0.6, 0.42]);
478
411
  if (layout !== "picture-left" /* PictureLeft */) {
@@ -492,6 +425,73 @@ function placeContain(image, region, anchor, alignX, scale) {
492
425
  };
493
426
  }
494
427
 
428
+ // src/layout/blockGroups.ts
429
+ var BOLD = 700;
430
+ var SEMI = 600;
431
+ var MEDIUM = 500;
432
+ var SCRIPT = 400;
433
+ var makers = (p, u) => ({
434
+ display: (text2, size) => ({ text: text2, role: "display", weight: BOLD, size: size * u, color: p.title }),
435
+ venue: (text2, size) => ({ text: text2, role: "display", weight: MEDIUM, size: size * u, color: p.ink }),
436
+ script: (text2, size) => ({ text: text2, role: "script", weight: SCRIPT, size: size * u, color: p.accent }),
437
+ strong: (text2, size) => ({ text: text2, role: "body", weight: BOLD, size: size * u, color: p.ink }),
438
+ accent: (text2, size) => ({ text: text2, role: "body", weight: SEMI, size: size * u, color: p.accent }),
439
+ item: (text2, size) => ({ text: text2, role: "body", weight: SEMI, size: size * u, color: p.ink, bullet: p.accent }),
440
+ soft: (text2, size) => ({ text: text2, role: "body", weight: MEDIUM, size: size * u, color: p.soft })
441
+ });
442
+ function headerGroups(doc, p, u) {
443
+ const l = makers(p, u);
444
+ const b = doc.blocks;
445
+ 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];
446
+ return [{ lines: lines.filter((x) => x !== void 0), gapAfter: 0 }];
447
+ }
448
+ function itemGroup(item, l, u) {
449
+ const lines = [l.script?.(item.date, 67)];
450
+ item.schedule.forEach((row) => lines.push(l.strong?.(`${row.label} ${row.time}`, 42)));
451
+ if (item.price) {
452
+ lines.push(l.accent?.(item.price, 42));
453
+ }
454
+ return { lines, gapAfter: 48 * u };
455
+ }
456
+ function eventGroups(doc, l, u, withPrices) {
457
+ const b = doc.blocks;
458
+ const groups = [];
459
+ b.schedule?.forEach((row) => groups.push({ lines: [l.strong?.(row.label, 66), l.accent?.(row.time, 60)], gapAfter: 30 * u }));
460
+ const prices = (withPrices ? b.prices : void 0) ?? [];
461
+ 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 }));
462
+ return groups;
463
+ }
464
+ function listGroups(doc, l, u) {
465
+ const groups = (doc.items ?? []).map((item) => itemGroup(item, l, u));
466
+ const prices = doc.blocks.prices?.map((row) => l.strong?.(`${row.amount} ${row.label}`, 58)) ?? [];
467
+ if (prices.length > 0) {
468
+ groups.push({ lines: prices, gapAfter: 30 * u });
469
+ }
470
+ return groups;
471
+ }
472
+ function flankPriceGroups(doc, p, u) {
473
+ const l = makers(p, u);
474
+ return (doc.blocks.prices ?? []).map((row) => ({ lines: [l.strong?.(row.amount, 110), l.accent?.(row.label, 48)], gapAfter: 30 * u }));
475
+ }
476
+ function bodyGroups(doc, p, u, withPrices = true) {
477
+ const l = makers(p, u);
478
+ const b = doc.blocks;
479
+ const groups = [];
480
+ const isList = doc.layout === "list" /* List */;
481
+ if (b.venue) {
482
+ 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 } });
483
+ }
484
+ groups.push(...isList ? listGroups(doc, l, u) : eventGroups(doc, l, u, withPrices));
485
+ if (b.includes) {
486
+ const lines = [l.accent?.(b.includes.heading, 38), ...b.includes.items.map((i) => l.item?.(i, 38))];
487
+ groups.push({ lines, gapAfter: 24 * u, uniform: true });
488
+ }
489
+ if (b.footer) {
490
+ groups.push({ lines: [l.soft?.(b.footer, 38)], gapAfter: 0 });
491
+ }
492
+ return groups;
493
+ }
494
+
495
495
  // src/layout/textFlow.ts
496
496
  var LINE_HEIGHT = 1.2;
497
497
  var BULLET_INDENT = 1.1;
@@ -499,6 +499,8 @@ var BULLET_RADIUS = 0.2;
499
499
  var RULE_GAP = 2.5;
500
500
  var RULE_WIDTH = 0.9;
501
501
  var WIDE = 1.6;
502
+ var TWO_COLUMN_MIN = 0.75;
503
+ var TWO_COLUMN_GAIN = 1.15;
502
504
  var MIN_K = 0.02;
503
505
  var STEPS = 22;
504
506
  var ICON_SCALE = 1.6;
@@ -594,9 +596,6 @@ function keepTogether(groups, heights, cut) {
594
596
  return candidates.reduce((best, c) => tallest(heights, c) < tallest(heights, best) ? c : best);
595
597
  }
596
598
  function splitColumns(groups, zone) {
597
- if (zone.w / Math.max(zone.h, 1) <= WIDE || groups.length < 2) {
598
- return [{ col: zone, groups }];
599
- }
600
599
  const gap = Math.round(zone.w * 0.04);
601
600
  const w = (zone.w - gap) / 2;
602
601
  const heights = groups.map((g) => placeGroup(g, 1, { ...zone, w }, 0, null));
@@ -613,16 +612,8 @@ function splitColumns(groups, zone) {
613
612
  { col: { ...zone, x: zone.x + w + gap, w }, groups: groups.slice(cut) }
614
613
  ];
615
614
  }
616
- function flow(ctx, groups, zone, fonts) {
617
- const measured = groups.map((group) => ({
618
- group,
619
- widths: group.lines.map((line) => {
620
- ctx.font = fontOf(line, line.size, fonts);
621
- return ctx.measureText(line.text).width;
622
- })
623
- }));
624
- const columns = splitColumns(measured, zone);
625
- const fits = (k) => columns.every((c) => placeColumn(c.groups, k, c.col, null) <= zone.h);
615
+ function largestK(columns, zoneH) {
616
+ const fits = (k) => columns.every((c) => placeColumn(c.groups, k, c.col, null) <= zoneH);
626
617
  let lo = MIN_K;
627
618
  let hi = 1;
628
619
  if (fits(1)) {
@@ -636,6 +627,28 @@ function flow(ctx, groups, zone, fonts) {
636
627
  hi = mid;
637
628
  }
638
629
  }
630
+ return lo;
631
+ }
632
+ function bestColumns(measured, zone) {
633
+ const single = [{ col: zone, groups: measured }];
634
+ const k1 = largestK(single, zone.h);
635
+ if (measured.length < 2 || zone.w / Math.max(zone.h, 1) < TWO_COLUMN_MIN) {
636
+ return { columns: single, k: k1 };
637
+ }
638
+ const split = splitColumns(measured, zone);
639
+ const k2 = largestK(split, zone.h);
640
+ const forced = zone.w / Math.max(zone.h, 1) > WIDE;
641
+ return forced || k2 > k1 * TWO_COLUMN_GAIN ? { columns: split, k: k2 } : { columns: single, k: k1 };
642
+ }
643
+ function flow(ctx, groups, zone, fonts) {
644
+ const measured = groups.map((group) => ({
645
+ group,
646
+ widths: group.lines.map((line) => {
647
+ ctx.font = fontOf(line, line.size, fonts);
648
+ return ctx.measureText(line.text).width;
649
+ })
650
+ }));
651
+ const { columns, k: lo } = bestColumns(measured, zone);
639
652
  const out = emptyFlow();
640
653
  const heights = columns.map((c) => placeColumn(c.groups, lo, c.col, null));
641
654
  const end = Math.max(...heights);
@@ -657,19 +670,19 @@ function flowFlanks(ctx, groups, flanks, fonts) {
657
670
  }
658
671
  function smallestPerLine(groups, results) {
659
672
  const sizes = results.flatMap((r3) => r3.texts.map((t) => t.box.h / LINE_HEIGHT2));
660
- const smallest = [];
673
+ const smallest2 = [];
661
674
  let i = 0;
662
675
  groups.forEach(
663
676
  (g) => g.lines.forEach((_line, j) => {
664
- smallest[j] = Math.min(smallest[j] ?? Infinity, sizes[i] ?? Infinity);
677
+ smallest2[j] = Math.min(smallest2[j] ?? Infinity, sizes[i] ?? Infinity);
665
678
  i++;
666
679
  })
667
680
  );
668
- return smallest;
681
+ return smallest2;
669
682
  }
670
683
  function equalFlanks(ctx, groups, flanks, fonts) {
671
- const smallest = smallestPerLine(groups, flowFlanks(ctx, groups, flanks, fonts));
672
- const sized = groups.map((g) => ({ ...g, lines: g.lines.map((line, j) => ({ ...line, size: Math.min(line.size, smallest[j] ?? line.size) })) }));
684
+ const smallest2 = smallestPerLine(groups, flowFlanks(ctx, groups, flanks, fonts));
685
+ const sized = groups.map((g) => ({ ...g, lines: g.lines.map((line, j) => ({ ...line, size: Math.min(line.size, smallest2[j] ?? line.size) })) }));
673
686
  return flowFlanks(ctx, sized, flanks, fonts);
674
687
  }
675
688
  function measuredEnd(f, fallback) {
@@ -684,9 +697,72 @@ function pictureRegion(layout, geo, body, u, frame) {
684
697
  return { x: geo.picture.x, y, w: geo.picture.w, h: frame.height - y };
685
698
  }
686
699
 
687
- // src/render/render.ts
700
+ // src/render/yieldToText.ts
701
+ function yieldToText(geo, give) {
702
+ const { picture: p, body: b } = geo;
703
+ if (give <= 0) {
704
+ return geo;
705
+ }
706
+ if (p.x >= b.x + b.w) {
707
+ const d = Math.round(p.w * give);
708
+ return { ...geo, picture: { ...p, x: p.x + d, w: p.w - d }, body: { ...b, w: b.w + d } };
709
+ }
710
+ if (p.x + p.w <= b.x) {
711
+ const d = Math.round(p.w * give);
712
+ return { ...geo, picture: { ...p, w: p.w - d }, body: { ...b, x: b.x - d, w: b.w + d } };
713
+ }
714
+ if (p.y >= b.y + b.h) {
715
+ const d = Math.round(p.h * give);
716
+ const stacked = geo.header.y + geo.header.h <= b.y;
717
+ const toHeader = stacked ? Math.round(d / 2) : 0;
718
+ return {
719
+ ...geo,
720
+ header: { ...geo.header, h: geo.header.h + toHeader },
721
+ picture: { ...p, y: p.y + d, h: p.h - d },
722
+ body: { ...b, y: b.y + toHeader, h: b.h + d - toHeader },
723
+ flanks: geo.flanks?.map((f) => ({ ...f, y: f.y + d, h: f.h - d }))
724
+ };
725
+ }
726
+ return geo;
727
+ }
728
+
729
+ // src/render/fitToFloor.ts
730
+ var FLOOR_PX = 30;
688
731
  var BASE = 1080;
732
+ var LINE_HEIGHT3 = 1.2;
689
733
  var LOGO_SHARE = 0.26;
734
+ var GIVE_STEP = 0.025;
735
+ var GIVE_STEPS = 32;
736
+ function splitHeader(header, hasLogo) {
737
+ const logoW = hasLogo ? Math.round(header.w * LOGO_SHARE) : 0;
738
+ return { text: { ...header, w: header.w - logoW * 1.1 }, logo: { ...header, x: header.x + header.w - logoW, w: logoW } };
739
+ }
740
+ function flowAll(ctx, input, geo, u) {
741
+ const { doc, palette, fonts } = input;
742
+ const zones = geo.logo ? { text: geo.header, logo: geo.logo } : splitHeader(geo.header, doc.logo !== void 0);
743
+ const head = flow(ctx, headerGroups(doc, palette, u), zones.text, fonts);
744
+ const flanks = geo.flanks ?? [];
745
+ const body = flow(ctx, bodyGroups(doc, palette, u, flanks.length === 0), geo.body, fonts);
746
+ const sides = flanks.length > 0 ? equalFlanks(ctx, flankPriceGroups(doc, palette, u), flanks, fonts) : [emptyFlow()];
747
+ return { geo, zones, head, body, sides };
748
+ }
749
+ var smallest = (t) => Math.min(Infinity, ...[t.head, t.body, ...t.sides].flatMap((f) => f.texts.map((x) => x.box.h / LINE_HEIGHT3)));
750
+ function geometryWithGive(layout, width, height, give) {
751
+ return layout === "picture-bottom" /* PictureBottom */ ? geometry(layout, width, height, give) : yieldToText(geometry(layout, width, height), give);
752
+ }
753
+ function fitToFloor(ctx, input) {
754
+ const { doc, width, height } = input;
755
+ const u = Math.min(width, height) / BASE;
756
+ const floor = FLOOR_PX * u;
757
+ let fitted = flowAll(ctx, input, geometryWithGive(doc.layout, width, height, 0), u);
758
+ for (let step = 1; step <= GIVE_STEPS && smallest(fitted) < floor; step++) {
759
+ fitted = flowAll(ctx, input, geometryWithGive(doc.layout, width, height, step * GIVE_STEP), u);
760
+ }
761
+ return fitted;
762
+ }
763
+
764
+ // src/render/render.ts
765
+ var BASE2 = 1080;
690
766
  var CARD_RADIUS = 0.035;
691
767
  var CAP_TOP = 0.15;
692
768
  var PIN_HEAD_Y = 0.08;
@@ -781,10 +857,6 @@ function capAligned(zone, head) {
781
857
  const shift = first ? Math.min(Math.max(0, Math.round(first.y + first.h * CAP_TOP) - zone.y), Math.round(zone.h / 2)) : 0;
782
858
  return { ...zone, y: zone.y + shift, h: zone.h - shift };
783
859
  }
784
- function splitHeader(header, hasLogo) {
785
- const logoW = hasLogo ? Math.round(header.w * LOGO_SHARE) : 0;
786
- return { text: { ...header, w: header.w - logoW * 1.1 }, logo: { ...header, x: header.x + header.w - logoW, w: logoW } };
787
- }
788
860
  function render(input, assets, canvas) {
789
861
  const doc = checked(input, assets);
790
862
  const { width, height } = FORMAT_SIZES[doc.format];
@@ -796,8 +868,8 @@ function render(input, assets, canvas) {
796
868
  }
797
869
  const palette = PALETTES[doc.palette];
798
870
  const fonts = FONT_SETS[doc.fonts];
799
- const geo = geometry(doc.layout, width, height);
800
- const u = Math.min(width, height) / BASE;
871
+ const u = Math.min(width, height) / BASE2;
872
+ const { geo, zones, head, body, sides } = fitToFloor(ctx, { doc, palette, fonts, width, height });
801
873
  ctx.globalAlpha = 1;
802
874
  ctx.fillStyle = palette.background;
803
875
  ctx.fillRect(0, 0, width, height);
@@ -805,11 +877,6 @@ function render(input, assets, canvas) {
805
877
  ctx.fillStyle = palette.tint;
806
878
  [geo.header, geo.body].forEach((r3) => ctx.fillRect(r3.x, r3.y, r3.w, r3.h));
807
879
  }
808
- const zones = geo.logo ? { text: geo.header, logo: geo.logo } : splitHeader(geo.header, doc.logo !== void 0);
809
- const head = flow(ctx, headerGroups(doc, palette, u), zones.text, fonts);
810
- const flanks = geo.flanks ?? [];
811
- const body = flow(ctx, bodyGroups(doc, palette, u, flanks.length === 0), geo.body, fonts);
812
- const sides = flanks.length > 0 ? equalFlanks(ctx, flankPriceGroups(doc, palette, u), flanks, fonts) : [emptyFlow()];
813
880
  const radius = Math.min(width, height) * CARD_RADIUS;
814
881
  [head, body, ...sides].forEach((f) => drawFlow(ctx, f, radius));
815
882
  const logoImage = doc.logo ? assets[doc.logo.imageId] : void 0;