@dloizides/poster-render 0.2.3 → 0.2.5

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