@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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.4
4
+
5
+ - 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.
6
+ - 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.
7
+ - Text zones pick two columns when that sets the type at least 15% larger.
8
+
3
9
  ## 0.2.3
4
10
 
5
11
  - `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/dist/index.js CHANGED
@@ -293,73 +293,6 @@ function missingAssets(doc, assets) {
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,7 +344,7 @@ 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.45;
347
+ var BOTTOM_HEADER_SHARE = 0.3;
415
348
  var BOTTOM_GAP = 40 / 1080;
416
349
  var BOTTOM_ASPECT = 0.57;
417
350
  var PICTURE_SIDE = {
@@ -442,11 +375,11 @@ function side(w, h, fraction) {
442
375
  alignX: 1
443
376
  };
444
377
  }
445
- function bottom(w, h) {
378
+ function bottom(w, h, share) {
446
379
  const m = r2(Math.min(w, h) * MARGIN);
447
380
  const gap = r2(Math.min(w, h) * BOTTOM_GAP);
448
- const picW = r2(w * BOTTOM_PICTURE_W);
449
- const picH = Math.min(r2(picW / BOTTOM_ASPECT), h - r2(h * BOTTOM_MIN_TOP));
381
+ const picW = r2(w * share);
382
+ const picH = Math.min(r2(picW / BOTTOM_ASPECT), r2((h - r2(h * BOTTOM_MIN_TOP)) * share / BOTTOM_PICTURE_W));
450
383
  const pic = h - picH;
451
384
  const top = m + r2((pic - m) * BOTTOM_HEADER_SHARE);
452
385
  return {
@@ -468,13 +401,13 @@ function fromSpec(spec, w, h) {
468
401
  };
469
402
  }
470
403
  var mirror = (rect, w) => ({ ...rect, x: w - rect.x - rect.w });
471
- function geometry(layout, w, h) {
404
+ function geometry(layout, w, h, give = 0) {
472
405
  const oriented = LAYOUT_SPECS[layout];
473
406
  if (oriented) {
474
407
  return fromSpec(h >= w ? oriented.portrait : oriented.landscape, w, h);
475
408
  }
476
409
  if (layout === "picture-bottom" /* PictureBottom */) {
477
- return bottom(w, h);
410
+ return bottom(w, h, BOTTOM_PICTURE_W - give);
478
411
  }
479
412
  const g = side(w, h, PICTURE_SIDE[layout] ?? [0.6, 0.42]);
480
413
  if (layout !== "picture-left" /* PictureLeft */) {
@@ -494,6 +427,73 @@ function placeContain(image, region, anchor, alignX, scale) {
494
427
  };
495
428
  }
496
429
 
430
+ // src/layout/blockGroups.ts
431
+ var BOLD = 700;
432
+ var SEMI = 600;
433
+ var MEDIUM = 500;
434
+ var SCRIPT = 400;
435
+ var makers = (p, u) => ({
436
+ display: (text2, size) => ({ text: text2, role: "display", weight: BOLD, size: size * u, color: p.title }),
437
+ venue: (text2, size) => ({ text: text2, role: "display", weight: MEDIUM, size: size * u, color: p.ink }),
438
+ script: (text2, size) => ({ text: text2, role: "script", weight: SCRIPT, size: size * u, color: p.accent }),
439
+ strong: (text2, size) => ({ text: text2, role: "body", weight: BOLD, size: size * u, color: p.ink }),
440
+ accent: (text2, size) => ({ text: text2, role: "body", weight: SEMI, size: size * u, color: p.accent }),
441
+ item: (text2, size) => ({ text: text2, role: "body", weight: SEMI, size: size * u, color: p.ink, bullet: p.accent }),
442
+ soft: (text2, size) => ({ text: text2, role: "body", weight: MEDIUM, size: size * u, color: p.soft })
443
+ });
444
+ function headerGroups(doc, p, u) {
445
+ const l = makers(p, u);
446
+ 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];
448
+ return [{ lines: lines.filter((x) => x !== void 0), gapAfter: 0 }];
449
+ }
450
+ 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)));
453
+ if (item.price) {
454
+ lines.push(l.accent?.(item.price, 42));
455
+ }
456
+ return { lines, gapAfter: 48 * u };
457
+ }
458
+ function eventGroups(doc, l, u, withPrices) {
459
+ const b = doc.blocks;
460
+ 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 }));
464
+ return groups;
465
+ }
466
+ function listGroups(doc, l, u) {
467
+ 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)) ?? [];
469
+ if (prices.length > 0) {
470
+ groups.push({ lines: prices, gapAfter: 30 * u });
471
+ }
472
+ return groups;
473
+ }
474
+ function flankPriceGroups(doc, p, u) {
475
+ 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 }));
477
+ }
478
+ function bodyGroups(doc, p, u, withPrices = true) {
479
+ const l = makers(p, u);
480
+ const b = doc.blocks;
481
+ const groups = [];
482
+ const isList = doc.layout === "list" /* List */;
483
+ 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 } });
485
+ }
486
+ groups.push(...isList ? listGroups(doc, l, u) : eventGroups(doc, l, u, withPrices));
487
+ 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 });
490
+ }
491
+ if (b.footer) {
492
+ groups.push({ lines: [l.soft?.(b.footer, 38)], gapAfter: 0 });
493
+ }
494
+ return groups;
495
+ }
496
+
497
497
  // src/layout/textFlow.ts
498
498
  var LINE_HEIGHT = 1.2;
499
499
  var BULLET_INDENT = 1.1;
@@ -501,6 +501,8 @@ var BULLET_RADIUS = 0.2;
501
501
  var RULE_GAP = 2.5;
502
502
  var RULE_WIDTH = 0.9;
503
503
  var WIDE = 1.6;
504
+ var TWO_COLUMN_MIN = 0.75;
505
+ var TWO_COLUMN_GAIN = 1.15;
504
506
  var MIN_K = 0.02;
505
507
  var STEPS = 22;
506
508
  var ICON_SCALE = 1.6;
@@ -596,9 +598,6 @@ function keepTogether(groups, heights, cut) {
596
598
  return candidates.reduce((best, c) => tallest(heights, c) < tallest(heights, best) ? c : best);
597
599
  }
598
600
  function splitColumns(groups, zone) {
599
- if (zone.w / Math.max(zone.h, 1) <= WIDE || groups.length < 2) {
600
- return [{ col: zone, groups }];
601
- }
602
601
  const gap = Math.round(zone.w * 0.04);
603
602
  const w = (zone.w - gap) / 2;
604
603
  const heights = groups.map((g) => placeGroup(g, 1, { ...zone, w }, 0, null));
@@ -615,16 +614,8 @@ function splitColumns(groups, zone) {
615
614
  { col: { ...zone, x: zone.x + w + gap, w }, groups: groups.slice(cut) }
616
615
  ];
617
616
  }
618
- function flow(ctx, groups, zone, fonts) {
619
- const measured = groups.map((group) => ({
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);
617
+ function largestK(columns, zoneH) {
618
+ const fits = (k) => columns.every((c) => placeColumn(c.groups, k, c.col, null) <= zoneH);
628
619
  let lo = MIN_K;
629
620
  let hi = 1;
630
621
  if (fits(1)) {
@@ -638,6 +629,28 @@ function flow(ctx, groups, zone, fonts) {
638
629
  hi = mid;
639
630
  }
640
631
  }
632
+ return lo;
633
+ }
634
+ function bestColumns(measured, zone) {
635
+ const single = [{ col: zone, groups: measured }];
636
+ const k1 = largestK(single, zone.h);
637
+ if (measured.length < 2 || zone.w / Math.max(zone.h, 1) < TWO_COLUMN_MIN) {
638
+ return { columns: single, k: k1 };
639
+ }
640
+ const split = splitColumns(measured, zone);
641
+ const k2 = largestK(split, zone.h);
642
+ const forced = zone.w / Math.max(zone.h, 1) > WIDE;
643
+ return forced || k2 > k1 * TWO_COLUMN_GAIN ? { columns: split, k: k2 } : { columns: single, k: k1 };
644
+ }
645
+ function flow(ctx, groups, zone, fonts) {
646
+ const measured = groups.map((group) => ({
647
+ group,
648
+ widths: group.lines.map((line) => {
649
+ ctx.font = fontOf(line, line.size, fonts);
650
+ return ctx.measureText(line.text).width;
651
+ })
652
+ }));
653
+ const { columns, k: lo } = bestColumns(measured, zone);
641
654
  const out = emptyFlow();
642
655
  const heights = columns.map((c) => placeColumn(c.groups, lo, c.col, null));
643
656
  const end = Math.max(...heights);
@@ -659,19 +672,19 @@ function flowFlanks(ctx, groups, flanks, fonts) {
659
672
  }
660
673
  function smallestPerLine(groups, results) {
661
674
  const sizes = results.flatMap((r3) => r3.texts.map((t) => t.box.h / LINE_HEIGHT2));
662
- const smallest = [];
675
+ const smallest2 = [];
663
676
  let i = 0;
664
677
  groups.forEach(
665
678
  (g) => g.lines.forEach((_line, j) => {
666
- smallest[j] = Math.min(smallest[j] ?? Infinity, sizes[i] ?? Infinity);
679
+ smallest2[j] = Math.min(smallest2[j] ?? Infinity, sizes[i] ?? Infinity);
667
680
  i++;
668
681
  })
669
682
  );
670
- return smallest;
683
+ return smallest2;
671
684
  }
672
685
  function equalFlanks(ctx, groups, flanks, fonts) {
673
- const smallest = smallestPerLine(groups, flowFlanks(ctx, groups, flanks, fonts));
674
- const sized = groups.map((g) => ({ ...g, lines: g.lines.map((line, j) => ({ ...line, size: Math.min(line.size, smallest[j] ?? line.size) })) }));
686
+ const smallest2 = smallestPerLine(groups, flowFlanks(ctx, groups, flanks, fonts));
687
+ const sized = groups.map((g) => ({ ...g, lines: g.lines.map((line, j) => ({ ...line, size: Math.min(line.size, smallest2[j] ?? line.size) })) }));
675
688
  return flowFlanks(ctx, sized, flanks, fonts);
676
689
  }
677
690
  function measuredEnd(f, fallback) {
@@ -686,9 +699,72 @@ function pictureRegion(layout, geo, body, u, frame) {
686
699
  return { x: geo.picture.x, y, w: geo.picture.w, h: frame.height - y };
687
700
  }
688
701
 
689
- // src/render/render.ts
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
+ // src/render/fitToFloor.ts
732
+ var FLOOR_PX = 30;
690
733
  var BASE = 1080;
734
+ var LINE_HEIGHT3 = 1.2;
691
735
  var LOGO_SHARE = 0.26;
736
+ var GIVE_STEP = 0.025;
737
+ var GIVE_STEPS = 32;
738
+ function splitHeader(header, hasLogo) {
739
+ const logoW = hasLogo ? Math.round(header.w * LOGO_SHARE) : 0;
740
+ return { text: { ...header, w: header.w - logoW * 1.1 }, logo: { ...header, x: header.x + header.w - logoW, w: logoW } };
741
+ }
742
+ function flowAll(ctx, input, geo, u) {
743
+ const { doc, palette, fonts } = input;
744
+ const zones = geo.logo ? { text: geo.header, logo: geo.logo } : splitHeader(geo.header, doc.logo !== void 0);
745
+ const head = flow(ctx, headerGroups(doc, palette, u), zones.text, fonts);
746
+ const flanks = geo.flanks ?? [];
747
+ const body = flow(ctx, bodyGroups(doc, palette, u, flanks.length === 0), geo.body, fonts);
748
+ const sides = flanks.length > 0 ? equalFlanks(ctx, flankPriceGroups(doc, palette, u), flanks, fonts) : [emptyFlow()];
749
+ return { geo, zones, head, body, sides };
750
+ }
751
+ 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);
754
+ }
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);
760
+ 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);
762
+ }
763
+ return fitted;
764
+ }
765
+
766
+ // src/render/render.ts
767
+ var BASE2 = 1080;
692
768
  var CARD_RADIUS = 0.035;
693
769
  var CAP_TOP = 0.15;
694
770
  var PIN_HEAD_Y = 0.08;
@@ -783,10 +859,6 @@ function capAligned(zone, head) {
783
859
  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
860
  return { ...zone, y: zone.y + shift, h: zone.h - shift };
785
861
  }
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
862
  function render(input, assets, canvas) {
791
863
  const doc = checked(input, assets);
792
864
  const { width, height } = FORMAT_SIZES[doc.format];
@@ -798,8 +870,8 @@ function render(input, assets, canvas) {
798
870
  }
799
871
  const palette = PALETTES[doc.palette];
800
872
  const fonts = FONT_SETS[doc.fonts];
801
- const geo = geometry(doc.layout, width, height);
802
- const u = Math.min(width, height) / BASE;
873
+ const u = Math.min(width, height) / BASE2;
874
+ const { geo, zones, head, body, sides } = fitToFloor(ctx, { doc, palette, fonts, width, height });
803
875
  ctx.globalAlpha = 1;
804
876
  ctx.fillStyle = palette.background;
805
877
  ctx.fillRect(0, 0, width, height);
@@ -807,11 +879,6 @@ function render(input, assets, canvas) {
807
879
  ctx.fillStyle = palette.tint;
808
880
  [geo.header, geo.body].forEach((r3) => ctx.fillRect(r3.x, r3.y, r3.w, r3.h));
809
881
  }
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
882
  const radius = Math.min(width, height) * CARD_RADIUS;
816
883
  [head, body, ...sides].forEach((f) => drawFlow(ctx, f, radius));
817
884
  const logoImage = doc.logo ? assets[doc.logo.imageId] : void 0;