@dloizides/poster-render 0.2.4 → 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,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,9 +347,10 @@ 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
  };
355
356
  var ANCHOR_Y = { ["top" /* Top */]: 0, ["center" /* Center */]: 0.5, ["bottom" /* Bottom */]: 1 };
@@ -409,13 +410,14 @@ function geometry(layout, w, h, give = 0) {
409
410
  if (layout === "picture-bottom" /* PictureBottom */) {
410
411
  return bottom(w, h, BOTTOM_PICTURE_W - give);
411
412
  }
412
- const g = side(w, h, PICTURE_SIDE[layout] ?? [0.6, 0.42]);
413
+ const g = side(w, h, PICTURE_SIDE[layout] ?? DEFAULT_SIDE);
413
414
  if (layout !== "picture-left" /* PictureLeft */) {
414
415
  return g;
415
416
  }
416
417
  return { header: mirror(g.header, w), body: mirror(g.body, w), picture: mirror(g.picture, w), alignX: 1 - g.alignX };
417
418
  }
418
- function placeContain(image, region, anchor, alignX, scale) {
419
+ function placeContain(image, region, fit) {
420
+ const { anchor, alignX, scale } = fit;
419
421
  const s = Math.min(region.w / image.width, region.h / image.height) * scale;
420
422
  const w = Math.floor(image.width * s);
421
423
  const h = Math.floor(image.height * s);
@@ -432,6 +434,29 @@ var BOLD = 700;
432
434
  var SEMI = 600;
433
435
  var MEDIUM = 500;
434
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
+ };
435
460
  var makers = (p, u) => ({
436
461
  display: (text2, size) => ({ text: text2, role: "display", weight: BOLD, size: size * u, color: p.title }),
437
462
  venue: (text2, size) => ({ text: text2, role: "display", weight: MEDIUM, size: size * u, color: p.ink }),
@@ -444,36 +469,36 @@ var makers = (p, u) => ({
444
469
  function headerGroups(doc, p, u) {
445
470
  const l = makers(p, u);
446
471
  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];
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];
448
473
  return [{ lines: lines.filter((x) => x !== void 0), gapAfter: 0 }];
449
474
  }
450
475
  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)));
476
+ const lines = [l.script(item.date, SIZE.itemDate)];
477
+ item.schedule.forEach((row) => lines.push(l.strong(`${row.label} ${row.time}`, SIZE.itemRow)));
453
478
  if (item.price) {
454
- lines.push(l.accent?.(item.price, 42));
479
+ lines.push(l.accent(item.price, SIZE.itemRow));
455
480
  }
456
- return { lines, gapAfter: 48 * u };
481
+ return { lines, gapAfter: GAP.item * u };
457
482
  }
458
483
  function eventGroups(doc, l, u, withPrices) {
459
484
  const b = doc.blocks;
460
485
  const groups = [];
461
- b.schedule?.forEach((row) => groups.push({ lines: [l.strong?.(row.label, 66), l.accent?.(row.time, 60)], gapAfter: 30 * u }));
486
+ b.schedule?.forEach((row) => groups.push({ lines: [l.strong(row.label, SIZE.scheduleLabel), l.accent(row.time, SIZE.scheduleTime)], gapAfter: GAP.row * u }));
462
487
  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 }));
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 }));
464
489
  return groups;
465
490
  }
466
491
  function listGroups(doc, l, u) {
467
492
  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)) ?? [];
493
+ const prices = doc.blocks.prices?.map((row) => l.strong(`${row.amount} ${row.label}`, SIZE.listPrice)) ?? [];
469
494
  if (prices.length > 0) {
470
- groups.push({ lines: prices, gapAfter: 30 * u });
495
+ groups.push({ lines: prices, gapAfter: GAP.row * u });
471
496
  }
472
497
  return groups;
473
498
  }
474
499
  function flankPriceGroups(doc, p, u) {
475
500
  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 }));
501
+ return (doc.blocks.prices ?? []).map((row) => ({ lines: [l.strong(row.amount, SIZE.flankAmount), l.accent(row.label, SIZE.flankLabel)], gapAfter: GAP.row * u }));
477
502
  }
478
503
  function bodyGroups(doc, p, u, withPrices = true) {
479
504
  const l = makers(p, u);
@@ -481,15 +506,16 @@ function bodyGroups(doc, p, u, withPrices = true) {
481
506
  const groups = [];
482
507
  const isList = doc.layout === "list" /* List */;
483
508
  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 } });
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 } });
485
511
  }
486
512
  groups.push(...isList ? listGroups(doc, l, u) : eventGroups(doc, l, u, withPrices));
487
513
  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 });
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 });
490
516
  }
491
517
  if (b.footer) {
492
- groups.push({ lines: [l.soft?.(b.footer, 38)], gapAfter: 0 });
518
+ groups.push({ lines: [l.soft(b.footer, SIZE.small)], gapAfter: 0 });
493
519
  }
494
520
  return groups;
495
521
  }
@@ -501,6 +527,7 @@ 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;
504
531
  var TWO_COLUMN_MIN = 0.75;
505
532
  var TWO_COLUMN_GAIN = 1.15;
506
533
  var MIN_K = 0.02;
@@ -523,15 +550,17 @@ function groupRatio(m, k, avail) {
523
550
  }
524
551
  return Math.min(...m.group.lines.map((line, i) => lineSize(line, m.widths[i] ?? 1, k, avail) / line.size));
525
552
  }
526
- function placeIcon(group, d, x, textTop, textBottom, out) {
553
+ function placeIcon(group, box, out) {
554
+ const { d, x, top, bottom: bottom2 } = box;
527
555
  if (!group.icon || d <= 0) {
528
- return textBottom;
556
+ return bottom2;
529
557
  }
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);
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);
533
561
  }
534
- function placeGroup(m, k, col, y0, out) {
562
+ function placeGroup(m, at, out) {
563
+ const { k, col, y0 } = at;
535
564
  const { group } = m;
536
565
  let y = y0;
537
566
  if (group.rule) {
@@ -556,7 +585,7 @@ function placeGroup(m, k, col, y0, out) {
556
585
  }
557
586
  y += size * LINE_HEIGHT;
558
587
  });
559
- 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);
560
589
  y += pad;
561
590
  if (group.rule && group.ruleBelow) {
562
591
  y += group.rule.height * k * (RULE_GAP - 1);
@@ -568,10 +597,11 @@ function placeGroup(m, k, col, y0, out) {
568
597
  }
569
598
  return y + group.gapAfter * k - y0;
570
599
  }
571
- function placeColumn(groups, k, col, out, spread = 0) {
600
+ function placeColumn(groups, at, out) {
601
+ const { k, col, spread = 0 } = at;
572
602
  let h = 0;
573
603
  for (const g of groups) {
574
- h += placeGroup(g, k, col, col.y + h, out) + spread;
604
+ h += placeGroup(g, { k, col, y0: col.y + h }, out) + spread;
575
605
  }
576
606
  return h - spread;
577
607
  }
@@ -598,9 +628,9 @@ function keepTogether(groups, heights, cut) {
598
628
  return candidates.reduce((best, c) => tallest(heights, c) < tallest(heights, best) ? c : best);
599
629
  }
600
630
  function splitColumns(groups, zone) {
601
- const gap = Math.round(zone.w * 0.04);
631
+ const gap = Math.round(zone.w * COLUMN_GAP);
602
632
  const w = (zone.w - gap) / 2;
603
- 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));
604
634
  const total = columnSum(heights, 0, heights.length);
605
635
  let cut = 1;
606
636
  for (let c = 2; c < groups.length; c++) {
@@ -615,7 +645,7 @@ function splitColumns(groups, zone) {
615
645
  ];
616
646
  }
617
647
  function largestK(columns, zoneH) {
618
- const fits = (k) => columns.every((c) => placeColumn(c.groups, k, c.col, null) <= zoneH);
648
+ const fits = (k) => columns.every((c) => placeColumn(c.groups, { k, col: c.col }, null) <= zoneH);
619
649
  let lo = MIN_K;
620
650
  let hi = 1;
621
651
  if (fits(1)) {
@@ -652,9 +682,9 @@ function flow(ctx, groups, zone, fonts) {
652
682
  }));
653
683
  const { columns, k: lo } = bestColumns(measured, zone);
654
684
  const out = emptyFlow();
655
- 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));
656
686
  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));
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));
658
688
  const lines = groups.flatMap((g) => g.lines);
659
689
  out.texts.forEach((t, i) => {
660
690
  const line = lines[i];
@@ -691,11 +721,11 @@ function measuredEnd(f, fallback) {
691
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);
692
722
  return bottoms.length > 0 ? Math.max(...bottoms) : fallback;
693
723
  }
694
- function pictureRegion(layout, geo, body, u, frame) {
724
+ function pictureRegion(layout, geo, body, frame) {
695
725
  if (layout !== "picture-bottom" /* PictureBottom */) {
696
726
  return geo.picture;
697
727
  }
698
- 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));
699
729
  return { x: geo.picture.x, y, w: geo.picture.w, h: frame.height - y };
700
730
  }
701
731
 
@@ -882,7 +912,7 @@ function render(input, assets, canvas) {
882
912
  const radius = Math.min(width, height) * CARD_RADIUS;
883
913
  [head, body, ...sides].forEach((f) => drawFlow(ctx, f, radius));
884
914
  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;
915
+ const logo = logoImage ? placeContain(logoImage, geo.logo ? zones.logo : capAligned(zones.logo, head), { anchor: "top" /* Top */, alignX: 1, scale: 1 }) : null;
886
916
  if (logoImage && logo) {
887
917
  ctx.drawImage(logoImage, logo.x, logo.y, logo.w, logo.h);
888
918
  }
@@ -891,8 +921,8 @@ function render(input, assets, canvas) {
891
921
  if (backdrop) {
892
922
  drawArch(ctx, backdrop, palette.arch);
893
923
  }
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;
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;
896
926
  if (image && picture) {
897
927
  ctx.drawImage(image, picture.x, picture.y, picture.w, picture.h);
898
928
  }