@realnation/builder-shared-sdk 2.2.0 → 2.4.0

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.
@@ -1 +1 @@
1
- {"version":3,"file":"vue.d.ts","sourceRoot":"","sources":["../../src/interaction/vue.ts"],"names":[],"mappings":"AA0BA,OAAO,KAAK,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAEtF,wFAAwF;AACxF,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,mBAAmB,GAAG,IAAI,GAAG,SAAS,CAAC;CAClD;AAED,MAAM,WAAW,0BAA0B;IACzC,gFAAgF;IAChF,QAAQ,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC;IACnD,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IACvC,8EAA8E;IAC9E,SAAS,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,IAAI,KAAK,IAAI,CAAC;IACrC;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,YAAY,CAAC;CACrB;AAED,MAAM,WAAW,sBAAsB;IACrC;;;;;;;OAOG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,mBAAmB;IAClC;;;;;OAKG;IACH,KAAK,CAAC,QAAQ,EAAE,mBAAmB,GAAG,IAAI,GAAG,SAAS,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,GAAG,gBAAgB,CAAC;IACzG,oFAAoF;IACpF,IAAI,CACF,QAAQ,EAAE,mBAAmB,GAAG,IAAI,GAAG,SAAS,EAChD,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,sBAAsB,GAC/B,MAAM,CAAC,MAAM,EAAE,MAAM,IAAI,CAAC,CAAC;IAC9B;;;;;;OAMG;IACH,IAAI,CAAC,OAAO,EAAE,gBAAgB,EAAE,GAAG,IAAI,CAAC;IACxC,oEAAoE;IACpE,IAAI,IAAI,IAAI,CAAC;IACb,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;IAC/B,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,OAAO,GAAG,IAAI,CAAC;CAC3C;AAUD,wBAAgB,mBAAmB,CAAC,OAAO,GAAE,0BAA+B,GAAG,mBAAmB,CAqGjG"}
1
+ {"version":3,"file":"vue.d.ts","sourceRoot":"","sources":["../../src/interaction/vue.ts"],"names":[],"mappings":"AA0BA,OAAO,KAAK,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAEtF,wFAAwF;AACxF,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,mBAAmB,GAAG,IAAI,GAAG,SAAS,CAAC;CAClD;AAED,MAAM,WAAW,0BAA0B;IACzC,gFAAgF;IAChF,QAAQ,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC;IACnD,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IACvC,8EAA8E;IAC9E,SAAS,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,IAAI,KAAK,IAAI,CAAC;IACrC;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,YAAY,CAAC;CACrB;AAED,MAAM,WAAW,sBAAsB;IACrC;;;;;;;OAOG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,mBAAmB;IAClC;;;;;OAKG;IACH,KAAK,CAAC,QAAQ,EAAE,mBAAmB,GAAG,IAAI,GAAG,SAAS,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,GAAG,gBAAgB,CAAC;IACzG,oFAAoF;IACpF,IAAI,CACF,QAAQ,EAAE,mBAAmB,GAAG,IAAI,GAAG,SAAS,EAChD,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,sBAAsB,GAC/B,MAAM,CAAC,MAAM,EAAE,MAAM,IAAI,CAAC,CAAC;IAC9B;;;;;;OAMG;IACH,IAAI,CAAC,OAAO,EAAE,gBAAgB,EAAE,GAAG,IAAI,CAAC;IACxC,oEAAoE;IACpE,IAAI,IAAI,IAAI,CAAC;IACb,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;IAC/B,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,OAAO,GAAG,IAAI,CAAC;CAC3C;AAUD,wBAAgB,mBAAmB,CAAC,OAAO,GAAE,0BAA+B,GAAG,mBAAmB,CAyGjG"}
@@ -115,7 +115,11 @@ export function useInteractionState(options = {}) {
115
115
  active[key] = false;
116
116
  timers.set(key, setTimer(() => { active[key] = !active[key]; }, interactionPeriodOf(property)));
117
117
  }
118
- else {
118
+ else if (trigger !== 'click') {
119
+ // Click is a state the player put the element into on purpose (which
120
+ // answer they picked). Navigating to another page and back re-syncs
121
+ // the timed triggers, and clearing their choice on the way would be
122
+ // a bug. Hover has no such meaning: the pointer really has left.
119
123
  active[key] = false;
120
124
  }
121
125
  }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * How an image sits inside its element box, and the content hash that makes a
3
+ * recompose idempotent.
4
+ *
5
+ * Canonical home for both. The runner's output media engine carries the same
6
+ * three modes (`qik-sense-runner/src/views/components/imageFit.ts`); it should
7
+ * import from here once it takes the souvenir path, so `fill`/`crop`/`contain`
8
+ * can never drift between the page the operator sees and the image we produce.
9
+ */
10
+ export declare const ImageFitMode: {
11
+ readonly FILL: "fill";
12
+ readonly CROP: "crop";
13
+ readonly CONTAIN: "contain";
14
+ };
15
+ export type ImageFitModeValue = (typeof ImageFitMode)[keyof typeof ImageFitMode];
16
+ export interface ImageFitDrawRect {
17
+ sx: number;
18
+ sy: number;
19
+ sw: number;
20
+ sh: number;
21
+ dx: number;
22
+ dy: number;
23
+ dw: number;
24
+ dh: number;
25
+ }
26
+ /** Unknown values fall back to `fill`, matching the Builder's default. */
27
+ export declare function normalizeImageFit(value: unknown): ImageFitModeValue;
28
+ /** The same mode expressed as CSS, for anyone rendering the element live. */
29
+ export declare function imageFitObjectFit(value: unknown): 'fill' | 'cover' | 'contain';
30
+ /**
31
+ * Source and destination rectangles for one `drawImage` call.
32
+ *
33
+ * `fill` stretches, `crop` centre-crops the source, `contain` letterboxes
34
+ * inside the box. Returns null when either side has no area to draw into.
35
+ */
36
+ export declare function computeImageFitDrawRect(sourceWidth: number, sourceHeight: number, targetX: number, targetY: number, targetWidth: number, targetHeight: number, value: unknown): ImageFitDrawRect | null;
37
+ /**
38
+ * 128-bit-ish digest of an arbitrary string. Same construction the runner uses
39
+ * for its composition cache tag, so the two agree on what "the same image"
40
+ * means.
41
+ */
42
+ export declare function hashSignature(input: string): string;
43
+ //# sourceMappingURL=imageFit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"imageFit.d.ts","sourceRoot":"","sources":["../../src/souvenir/imageFit.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,eAAO,MAAM,YAAY;;;;CAIf,CAAC;AAEX,MAAM,MAAM,iBAAiB,GAAG,CAAC,OAAO,YAAY,CAAC,CAAC,MAAM,OAAO,YAAY,CAAC,CAAC;AAEjF,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,0EAA0E;AAC1E,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,iBAAiB,CAGnE;AAED,6EAA6E;AAC7E,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,OAAO,GAAG,SAAS,CAI9E;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CACrC,WAAW,EAAE,MAAM,EACnB,YAAY,EAAE,MAAM,EACpB,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,MAAM,EACnB,YAAY,EAAE,MAAM,EACpB,KAAK,EAAE,OAAO,GACb,gBAAgB,GAAG,IAAI,CAkCzB;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAanD"}
@@ -0,0 +1,85 @@
1
+ /**
2
+ * How an image sits inside its element box, and the content hash that makes a
3
+ * recompose idempotent.
4
+ *
5
+ * Canonical home for both. The runner's output media engine carries the same
6
+ * three modes (`qik-sense-runner/src/views/components/imageFit.ts`); it should
7
+ * import from here once it takes the souvenir path, so `fill`/`crop`/`contain`
8
+ * can never drift between the page the operator sees and the image we produce.
9
+ */
10
+ export const ImageFitMode = {
11
+ FILL: 'fill',
12
+ CROP: 'crop',
13
+ CONTAIN: 'contain',
14
+ };
15
+ /** Unknown values fall back to `fill`, matching the Builder's default. */
16
+ export function normalizeImageFit(value) {
17
+ if (value === ImageFitMode.CROP || value === ImageFitMode.CONTAIN)
18
+ return value;
19
+ return ImageFitMode.FILL;
20
+ }
21
+ /** The same mode expressed as CSS, for anyone rendering the element live. */
22
+ export function imageFitObjectFit(value) {
23
+ const mode = normalizeImageFit(value);
24
+ if (mode === ImageFitMode.CROP)
25
+ return 'cover';
26
+ return mode;
27
+ }
28
+ /**
29
+ * Source and destination rectangles for one `drawImage` call.
30
+ *
31
+ * `fill` stretches, `crop` centre-crops the source, `contain` letterboxes
32
+ * inside the box. Returns null when either side has no area to draw into.
33
+ */
34
+ export function computeImageFitDrawRect(sourceWidth, sourceHeight, targetX, targetY, targetWidth, targetHeight, value) {
35
+ if (sourceWidth <= 0 || sourceHeight <= 0 || targetWidth <= 0 || targetHeight <= 0)
36
+ return null;
37
+ const rect = {
38
+ sx: 0,
39
+ sy: 0,
40
+ sw: sourceWidth,
41
+ sh: sourceHeight,
42
+ dx: targetX,
43
+ dy: targetY,
44
+ dw: targetWidth,
45
+ dh: targetHeight,
46
+ };
47
+ const mode = normalizeImageFit(value);
48
+ if (mode === ImageFitMode.CROP) {
49
+ const targetAspect = targetWidth / targetHeight;
50
+ const sourceAspect = sourceWidth / sourceHeight;
51
+ if (targetAspect > sourceAspect) {
52
+ rect.sh = sourceWidth / targetAspect;
53
+ rect.sy = (sourceHeight - rect.sh) / 2;
54
+ }
55
+ else {
56
+ rect.sw = sourceHeight * targetAspect;
57
+ rect.sx = (sourceWidth - rect.sw) / 2;
58
+ }
59
+ }
60
+ else if (mode === ImageFitMode.CONTAIN) {
61
+ const scale = Math.min(targetWidth / sourceWidth, targetHeight / sourceHeight);
62
+ rect.dw = sourceWidth * scale;
63
+ rect.dh = sourceHeight * scale;
64
+ rect.dx = targetX + (targetWidth - rect.dw) / 2;
65
+ rect.dy = targetY + (targetHeight - rect.dh) / 2;
66
+ }
67
+ return rect;
68
+ }
69
+ /**
70
+ * 128-bit-ish digest of an arbitrary string. Same construction the runner uses
71
+ * for its composition cache tag, so the two agree on what "the same image"
72
+ * means.
73
+ */
74
+ export function hashSignature(input) {
75
+ let first = 0xdeadbeef ^ input.length;
76
+ let second = 0x41c6ce57 ^ input.length;
77
+ for (let index = 0; index < input.length; index++) {
78
+ const code = input.charCodeAt(index);
79
+ first = Math.imul(first ^ code, 2654435761);
80
+ second = Math.imul(second ^ code, 1597334677);
81
+ }
82
+ first = Math.imul(first ^ (first >>> 16), 2246822507) ^ Math.imul(second ^ (second >>> 13), 3266489909);
83
+ second = Math.imul(second ^ (second >>> 16), 2246822507) ^ Math.imul(first ^ (first >>> 13), 3266489909);
84
+ return `${(first >>> 0).toString(16).padStart(8, '0')}${(second >>> 0).toString(16).padStart(8, '0')}`;
85
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Souvenir composer — one image out of a Builder page.
3
+ *
4
+ * `composeSouvenir` is the whole public surface most callers need: hand it the
5
+ * souvenir page's saved editor config plus the values for its bound elements,
6
+ * get back a File ready to upload and a tag that makes re-composing the same
7
+ * content a no-op on the server.
8
+ *
9
+ * See `README.md` in this folder for the contract and the platform wiring.
10
+ */
11
+ export * from './types.js';
12
+ export * from './imageFit.js';
13
+ export { wrapText, tokenize, layoutLines, type MeasureText, type TextAlign, type TextVAlign, type PlacedLine, } from './text.js';
14
+ export { renderSouvenir, drawOrder, pageHasOpaqueBackground, souvenirSignature, loadSouvenirImage, } from './render.js';
15
+ import { type SouvenirOptions, type SouvenirPage, type SouvenirResult, type SouvenirValues } from './types.js';
16
+ /**
17
+ * Element ids for output-bound elements are `outputs@@nodeId@@key[@@tab@@rand]`.
18
+ * Modules know field keys; the page knows element ids. This bridges the two.
19
+ */
20
+ export declare function parseSouvenirOutputId(eid: string): {
21
+ nodeId: string;
22
+ key: string;
23
+ } | null;
24
+ /**
25
+ * Spreads `{ fieldKey: value }` over every element on the page bound to that
26
+ * field, so a module never has to know how element ids are built.
27
+ *
28
+ * Pass `nodeId` to ignore elements bound to some other node's output.
29
+ */
30
+ export declare function resolveSouvenirValues(page: SouvenirPage, fieldValues: Record<string, string | null | undefined>, nodeId?: string): SouvenirValues;
31
+ /**
32
+ * Renders the page and packages it as a File.
33
+ *
34
+ * `auto` keeps JPEG for the usual case (a designed card with a full background,
35
+ * where JPEG is three to five times smaller on a phone's connection) and only
36
+ * falls back to PNG when the page has no opaque background to flatten onto.
37
+ */
38
+ export declare function composeSouvenir(page: SouvenirPage, values?: SouvenirValues, options?: SouvenirOptions): Promise<SouvenirResult>;
39
+ /**
40
+ * The upload tag for this exact souvenir.
41
+ *
42
+ * The media endpoint is idempotent per (project, session, tag): re-composing
43
+ * unchanged content returns the URL already stored instead of a second file.
44
+ * Anything else uploaded in the same submission must use a different tag, or
45
+ * it will be handed back the first file.
46
+ */
47
+ export declare function souvenirTag(page: SouvenirPage, values?: SouvenirValues): string;
48
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/souvenir/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,cAAc,YAAY,CAAC;AAC3B,cAAc,eAAe,CAAC;AAC9B,OAAO,EACL,QAAQ,EACR,QAAQ,EACR,WAAW,EACX,KAAK,WAAW,EAChB,KAAK,SAAS,EACd,KAAK,UAAU,EACf,KAAK,UAAU,GAChB,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,cAAc,EACd,SAAS,EACT,uBAAuB,EACvB,iBAAiB,EACjB,iBAAiB,GAClB,MAAM,aAAa,CAAC;AAGrB,OAAO,EAEL,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,KAAK,cAAc,EACnB,KAAK,cAAc,EACpB,MAAM,YAAY,CAAC;AAKpB;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAKzF;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,YAAY,EAClB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,EACtD,MAAM,CAAC,EAAE,MAAM,GACd,cAAc,CAUhB;AAYD;;;;;;GAMG;AACH,wBAAsB,eAAe,CACnC,IAAI,EAAE,YAAY,EAClB,MAAM,GAAE,cAAmB,EAC3B,OAAO,GAAE,eAAoB,GAC5B,OAAO,CAAC,cAAc,CAAC,CAqBzB;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,YAAY,EAAE,MAAM,GAAE,cAAmB,GAAG,MAAM,CAEnF"}
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Souvenir composer — one image out of a Builder page.
3
+ *
4
+ * `composeSouvenir` is the whole public surface most callers need: hand it the
5
+ * souvenir page's saved editor config plus the values for its bound elements,
6
+ * get back a File ready to upload and a tag that makes re-composing the same
7
+ * content a no-op on the server.
8
+ *
9
+ * See `README.md` in this folder for the contract and the platform wiring.
10
+ */
11
+ export * from './types.js';
12
+ export * from './imageFit.js';
13
+ export { wrapText, tokenize, layoutLines, } from './text.js';
14
+ export { renderSouvenir, drawOrder, pageHasOpaqueBackground, souvenirSignature, loadSouvenirImage, } from './render.js';
15
+ import { pageHasOpaqueBackground, renderSouvenir, souvenirSignature } from './render.js';
16
+ import { SouvenirError, } from './types.js';
17
+ const OUTPUT_SEPARATOR = '@@';
18
+ const OUTPUT_PREFIX = 'outputs';
19
+ /**
20
+ * Element ids for output-bound elements are `outputs@@nodeId@@key[@@tab@@rand]`.
21
+ * Modules know field keys; the page knows element ids. This bridges the two.
22
+ */
23
+ export function parseSouvenirOutputId(eid) {
24
+ if (!eid.startsWith(OUTPUT_PREFIX + OUTPUT_SEPARATOR))
25
+ return null;
26
+ const parts = eid.split(OUTPUT_SEPARATOR);
27
+ if (parts.length < 3)
28
+ return null;
29
+ return { nodeId: parts[1], key: parts[2] };
30
+ }
31
+ /**
32
+ * Spreads `{ fieldKey: value }` over every element on the page bound to that
33
+ * field, so a module never has to know how element ids are built.
34
+ *
35
+ * Pass `nodeId` to ignore elements bound to some other node's output.
36
+ */
37
+ export function resolveSouvenirValues(page, fieldValues, nodeId) {
38
+ const values = {};
39
+ for (const eid of Object.keys(page.elements ?? {})) {
40
+ const parsed = parseSouvenirOutputId(eid);
41
+ if (!parsed)
42
+ continue;
43
+ if (nodeId && parsed.nodeId !== nodeId)
44
+ continue;
45
+ if (!(parsed.key in fieldValues))
46
+ continue;
47
+ values[eid] = fieldValues[parsed.key];
48
+ }
49
+ return values;
50
+ }
51
+ function encode(canvas, type, quality) {
52
+ return new Promise((resolve, reject) => {
53
+ canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new SouvenirError('encode_failed', 'Could not encode the souvenir.'))), type, quality);
54
+ });
55
+ }
56
+ /**
57
+ * Renders the page and packages it as a File.
58
+ *
59
+ * `auto` keeps JPEG for the usual case (a designed card with a full background,
60
+ * where JPEG is three to five times smaller on a phone's connection) and only
61
+ * falls back to PNG when the page has no opaque background to flatten onto.
62
+ */
63
+ export async function composeSouvenir(page, values = {}, options = {}) {
64
+ const canvas = await renderSouvenir(page, values, options);
65
+ const format = options.format && options.format !== 'auto'
66
+ ? options.format
67
+ : pageHasOpaqueBackground(page)
68
+ ? 'jpeg'
69
+ : 'png';
70
+ const quality = typeof options.quality === 'number' ? options.quality : 0.9;
71
+ const mime = format === 'png' ? 'image/png' : 'image/jpeg';
72
+ const blob = await encode(canvas, mime, quality);
73
+ const name = (options.fileName || 'souvenir').replace(/\.(jpe?g|png)$/i, '');
74
+ const file = new File([blob], `${name}.${format === 'png' ? 'png' : 'jpg'}`, { type: mime });
75
+ return {
76
+ file,
77
+ canvas,
78
+ tag: souvenirTag(page, values),
79
+ width: canvas.width,
80
+ height: canvas.height,
81
+ format,
82
+ };
83
+ }
84
+ /**
85
+ * The upload tag for this exact souvenir.
86
+ *
87
+ * The media endpoint is idempotent per (project, session, tag): re-composing
88
+ * unchanged content returns the URL already stored instead of a second file.
89
+ * Anything else uploaded in the same submission must use a different tag, or
90
+ * it will be handed back the first file.
91
+ */
92
+ export function souvenirTag(page, values = {}) {
93
+ return `souvenir-${souvenirSignature(page, values)}`;
94
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The composer itself: a souvenir page's saved properties, redrawn onto a
3
+ * canvas at the page's own pixel size.
4
+ *
5
+ * It redraws from the property bag rather than screenshotting the DOM. The
6
+ * souvenir page is never mounted in the runner, so there is no DOM to shoot;
7
+ * and reading properties keeps the result independent of screen size, scroll
8
+ * position, font fallback and half-finished animations. Every mapping below
9
+ * mirrors the runner's `elementStyle`, which is what the operator sees in the
10
+ * editor and the preview.
11
+ */
12
+ import { type SouvenirOptions, type SouvenirPage, type SouvenirValues } from './types.js';
13
+ /** Loads one image with EXIF orientation applied, or throws a SouvenirError. */
14
+ export declare function loadSouvenirImage(url: string, crossOrigin: string | null | undefined): Promise<CanvasImageSource & {
15
+ width: number;
16
+ height: number;
17
+ }>;
18
+ /** Element ids in draw order: z-index ascending, insertion order for ties. */
19
+ export declare function drawOrder(page: SouvenirPage): string[];
20
+ /**
21
+ * True when every pixel is guaranteed opaque, i.e. a `bg` element covers the
22
+ * page with a colour or an image. Decides JPEG versus PNG in `auto`.
23
+ */
24
+ export declare function pageHasOpaqueBackground(page: SouvenirPage): boolean;
25
+ /** A stable signature for (page, values): same inputs, same tag, same upload. */
26
+ export declare function souvenirSignature(page: SouvenirPage, values: SouvenirValues): string;
27
+ /**
28
+ * Draws the page and returns the canvas.
29
+ *
30
+ * Every image is loaded before anything is drawn, so a slow CDN cannot leave a
31
+ * half-composed image behind; a failed load throws rather than quietly
32
+ * producing a souvenir with a hole in it.
33
+ */
34
+ export declare function renderSouvenir(page: SouvenirPage, values?: SouvenirValues, options?: SouvenirOptions): Promise<HTMLCanvasElement>;
35
+ //# sourceMappingURL=render.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../../src/souvenir/render.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAIH,OAAO,EAGL,KAAK,eAAe,EACpB,KAAK,YAAY,EAEjB,KAAK,cAAc,EAEpB,MAAM,YAAY,CAAC;AAkRpB,gFAAgF;AAChF,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,MAAM,EACX,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GACrC,OAAO,CAAC,iBAAiB,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAoBhE;AAED,8EAA8E;AAC9E,wBAAgB,SAAS,CAAC,IAAI,EAAE,YAAY,GAAG,MAAM,EAAE,CAOtD;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAYnE;AAED,iFAAiF;AACjF,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,YAAY,EAAE,MAAM,EAAE,cAAc,GAAG,MAAM,CAMpF;AAED;;;;;;GAMG;AACH,wBAAsB,cAAc,CAClC,IAAI,EAAE,YAAY,EAClB,MAAM,GAAE,cAAmB,EAC3B,OAAO,GAAE,eAAoB,GAC5B,OAAO,CAAC,iBAAiB,CAAC,CAoE5B"}
@@ -0,0 +1,357 @@
1
+ /**
2
+ * The composer itself: a souvenir page's saved properties, redrawn onto a
3
+ * canvas at the page's own pixel size.
4
+ *
5
+ * It redraws from the property bag rather than screenshotting the DOM. The
6
+ * souvenir page is never mounted in the runner, so there is no DOM to shoot;
7
+ * and reading properties keeps the result independent of screen size, scroll
8
+ * position, font fallback and half-finished animations. Every mapping below
9
+ * mirrors the runner's `elementStyle`, which is what the operator sees in the
10
+ * editor and the preview.
11
+ */
12
+ import { computeImageFitDrawRect, hashSignature, normalizeImageFit } from './imageFit.js';
13
+ import { layoutLines, wrapText } from './text.js';
14
+ import { isSouvenirSizeAllowed, SouvenirError, SOUVENIR_ELEMENT_TYPES, } from './types.js';
15
+ const DEFAULT_LINE_HEIGHT = 1.2;
16
+ const TRANSPARENT = 'TRANSPARENT';
17
+ const num = (value, fallback = 0) => {
18
+ const parsed = typeof value === 'number' ? value : parseFloat(String(value ?? ''));
19
+ return Number.isFinite(parsed) ? parsed : fallback;
20
+ };
21
+ const colorOf = (value) => {
22
+ const text = typeof value === 'string' ? value.trim() : '';
23
+ if (!text || text === TRANSPARENT)
24
+ return '';
25
+ return text;
26
+ };
27
+ function geometryOf(property) {
28
+ return {
29
+ x: num(property['x-position']),
30
+ y: num(property['y-position']),
31
+ width: num(property['width']),
32
+ height: num(property['height']),
33
+ zIndex: num(property['zIndex']),
34
+ rotation: num(property['rotation']),
35
+ opacity: 'opacity' in property ? num(property['opacity'], 100) : 100,
36
+ radius: num(property['radius']),
37
+ };
38
+ }
39
+ /** `display: 'off'` is the layer eye in the Builder. A hidden layer is not in the image. */
40
+ function isHidden(property) {
41
+ return property['display'] === 'off';
42
+ }
43
+ function roundedRectPath(ctx, x, y, width, height, radius) {
44
+ const r = Math.max(0, Math.min(radius, Math.min(width, height) / 2));
45
+ ctx.beginPath();
46
+ if (r <= 0) {
47
+ ctx.rect(x, y, width, height);
48
+ return;
49
+ }
50
+ ctx.moveTo(x + r, y);
51
+ ctx.lineTo(x + width - r, y);
52
+ ctx.quadraticCurveTo(x + width, y, x + width, y + r);
53
+ ctx.lineTo(x + width, y + height - r);
54
+ ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height);
55
+ ctx.lineTo(x + r, y + height);
56
+ ctx.quadraticCurveTo(x, y + height, x, y + height - r);
57
+ ctx.lineTo(x, y + r);
58
+ ctx.quadraticCurveTo(x, y, x + r, y);
59
+ ctx.closePath();
60
+ }
61
+ function ellipsePath(ctx, x, y, width, height) {
62
+ ctx.beginPath();
63
+ ctx.ellipse(x + width / 2, y + height / 2, width / 2, height / 2, 0, 0, Math.PI * 2);
64
+ ctx.closePath();
65
+ }
66
+ /**
67
+ * Runs `draw` in the element's own space: origin at the box's top left, the
68
+ * box rotated about its centre and faded like CSS `opacity`.
69
+ */
70
+ function inElementSpace(ctx, geo, draw) {
71
+ ctx.save();
72
+ ctx.globalAlpha = Math.max(0, Math.min(1, geo.opacity / 100));
73
+ ctx.translate(geo.x + geo.width / 2, geo.y + geo.height / 2);
74
+ if (geo.rotation)
75
+ ctx.rotate((geo.rotation * Math.PI) / 180);
76
+ ctx.translate(-geo.width / 2, -geo.height / 2);
77
+ draw(ctx, geo.width, geo.height);
78
+ ctx.restore();
79
+ }
80
+ function fillBackground(ctx, property, width, height, radius, ellipse) {
81
+ const background = colorOf(property['bg-color']) || colorOf(property['fill-color']);
82
+ if (!background)
83
+ return;
84
+ if (ellipse)
85
+ ellipsePath(ctx, 0, 0, width, height);
86
+ else
87
+ roundedRectPath(ctx, 0, 0, width, height, radius);
88
+ ctx.fillStyle = background;
89
+ ctx.fill();
90
+ }
91
+ /**
92
+ * Strokes the element's border inside its box.
93
+ *
94
+ * CSS would grow the box outward; drawing inside keeps the element exactly the
95
+ * size the operator gave it, at the cost of half a border width along the edge.
96
+ */
97
+ function strokeBorder(ctx, property, width, height, radius, ellipse) {
98
+ if (property['border-style'] === 'none')
99
+ return;
100
+ const borderWidth = num(property['border-width']);
101
+ if (borderWidth <= 0)
102
+ return;
103
+ const color = colorOf(property['border-color']) || '#94a3b8';
104
+ if (!color)
105
+ return;
106
+ const inset = borderWidth / 2;
107
+ if (ellipse)
108
+ ellipsePath(ctx, inset, inset, width - borderWidth, height - borderWidth);
109
+ else
110
+ roundedRectPath(ctx, inset, inset, width - borderWidth, height - borderWidth, Math.max(0, radius - inset));
111
+ ctx.lineWidth = borderWidth;
112
+ ctx.strokeStyle = color;
113
+ ctx.stroke();
114
+ }
115
+ function fontOf(property, fontSize) {
116
+ const formatting = Array.isArray(property['formatting']) ? property['formatting'] : [];
117
+ const style = formatting.includes('Italic') ? 'italic ' : '';
118
+ const weight = formatting.includes('Bold') ? 'bold ' : '';
119
+ const rawFamily = typeof property['family'] === 'string' ? property['family'].trim() : '';
120
+ const family = !rawFamily || rawFamily === 'inherit' ? 'sans-serif' : rawFamily;
121
+ const quoted = /[",]/.test(family) || !/\s/.test(family) ? family : `"${family}"`;
122
+ return `${style}${weight}${fontSize}px ${quoted}`;
123
+ }
124
+ function drawText(ctx, property, geo, content) {
125
+ const fontSize = num(property['font-size'], 14);
126
+ if (fontSize <= 0 || !content)
127
+ return;
128
+ const lineHeight = num(property['line-height'], DEFAULT_LINE_HEIGHT) || DEFAULT_LINE_HEIGHT;
129
+ const align = (property['alignment'] || 'center');
130
+ const vAlign = (property['v-alignment'] || 'middle');
131
+ inElementSpace(ctx, geo, (c, width, height) => {
132
+ fillBackground(c, property, width, height, geo.radius, false);
133
+ roundedRectPath(c, 0, 0, width, height, geo.radius);
134
+ c.clip();
135
+ c.font = fontOf(property, fontSize);
136
+ c.textBaseline = 'alphabetic';
137
+ c.textAlign = 'left';
138
+ c.fillStyle = colorOf(property['color']) || '#000000';
139
+ const measure = (text) => c.measureText(text).width;
140
+ const lines = wrapText(content, width, measure);
141
+ const placed = layoutLines(lines, { x: 0, y: 0, width, height }, fontSize, lineHeight, align, vAlign, measure);
142
+ const formatting = Array.isArray(property['formatting']) ? property['formatting'] : [];
143
+ const underline = formatting.includes('Underline');
144
+ const strike = formatting.includes('Strikethrough');
145
+ for (const line of placed) {
146
+ if (!line.text)
147
+ continue;
148
+ c.fillText(line.text, line.x, line.y);
149
+ if (underline)
150
+ c.fillRect(line.x, line.y + fontSize * 0.12, line.width, Math.max(1, fontSize / 16));
151
+ if (strike)
152
+ c.fillRect(line.x, line.y - fontSize * 0.28, line.width, Math.max(1, fontSize / 16));
153
+ }
154
+ });
155
+ inElementSpace(ctx, geo, (c, width, height) => strokeBorder(c, property, width, height, geo.radius, false));
156
+ }
157
+ function drawImage(ctx, property, geo, image) {
158
+ inElementSpace(ctx, geo, (c, width, height) => {
159
+ fillBackground(c, property, width, height, geo.radius, false);
160
+ roundedRectPath(c, 0, 0, width, height, geo.radius);
161
+ c.clip();
162
+ const rect = computeImageFitDrawRect(image.width, image.height, 0, 0, width, height, normalizeImageFit(property['image-fit']));
163
+ if (rect)
164
+ c.drawImage(image, rect.sx, rect.sy, rect.sw, rect.sh, rect.dx, rect.dy, rect.dw, rect.dh);
165
+ });
166
+ inElementSpace(ctx, geo, (c, width, height) => strokeBorder(c, property, width, height, geo.radius, false));
167
+ }
168
+ function drawShape(ctx, type, property, geo) {
169
+ inElementSpace(ctx, geo, (c, width, height) => {
170
+ if (type === 'shape-line') {
171
+ const strokeWidth = num(property['stroke-width'], 1);
172
+ const color = colorOf(property['stroke-color']) || '#000000';
173
+ if (strokeWidth <= 0 || !color)
174
+ return;
175
+ if (property['line-style'] === 'dashed')
176
+ c.setLineDash([strokeWidth * 3, strokeWidth * 2]);
177
+ else if (property['line-style'] === 'dotted')
178
+ c.setLineDash([strokeWidth, strokeWidth * 2]);
179
+ c.lineCap = 'round';
180
+ c.strokeStyle = color;
181
+ c.lineWidth = strokeWidth;
182
+ const y = height / 2;
183
+ c.beginPath();
184
+ c.moveTo(0, y);
185
+ c.lineTo(width, y);
186
+ c.stroke();
187
+ c.setLineDash([]);
188
+ if (property['arrow'] && property['arrow'] !== 'none') {
189
+ const head = Math.max(strokeWidth * 3, 6);
190
+ c.beginPath();
191
+ c.moveTo(width, y);
192
+ c.lineTo(width - head, y - head / 2);
193
+ c.lineTo(width - head, y + head / 2);
194
+ c.closePath();
195
+ c.fillStyle = color;
196
+ c.fill();
197
+ }
198
+ return;
199
+ }
200
+ const ellipse = type === 'shape-ellipse';
201
+ const fill = colorOf(property['fill-color']);
202
+ if (fill) {
203
+ if (ellipse)
204
+ ellipsePath(c, 0, 0, width, height);
205
+ else
206
+ roundedRectPath(c, 0, 0, width, height, geo.radius);
207
+ c.fillStyle = fill;
208
+ c.fill();
209
+ }
210
+ const strokeWidth = num(property['stroke-width'], 0) || num(property['border-width'], 0);
211
+ const strokeColor = colorOf(property['stroke-color']) || colorOf(property['border-color']);
212
+ if (strokeWidth > 0 && strokeColor) {
213
+ const inset = strokeWidth / 2;
214
+ if (ellipse)
215
+ ellipsePath(c, inset, inset, width - strokeWidth, height - strokeWidth);
216
+ else
217
+ roundedRectPath(c, inset, inset, width - strokeWidth, height - strokeWidth, Math.max(0, geo.radius - inset));
218
+ c.lineWidth = strokeWidth;
219
+ c.strokeStyle = strokeColor;
220
+ c.stroke();
221
+ }
222
+ });
223
+ }
224
+ /** Loads one image with EXIF orientation applied, or throws a SouvenirError. */
225
+ export async function loadSouvenirImage(url, crossOrigin) {
226
+ if (typeof createImageBitmap === 'function' && typeof fetch === 'function') {
227
+ try {
228
+ const response = await fetch(url, { mode: crossOrigin === null ? 'same-origin' : 'cors' });
229
+ if (!response.ok)
230
+ throw new Error(`HTTP ${response.status}`);
231
+ const blob = await response.blob();
232
+ // A phone's portrait photo carries its rotation in EXIF. Without this the
233
+ // person in the souvenir is lying on their side.
234
+ return await createImageBitmap(blob, { imageOrientation: 'from-image' });
235
+ }
236
+ catch {
237
+ // Fall through: some CDNs refuse the fetch but serve <img> fine.
238
+ }
239
+ }
240
+ return await new Promise((resolve, reject) => {
241
+ const image = new Image();
242
+ if (crossOrigin !== null)
243
+ image.crossOrigin = crossOrigin ?? 'anonymous';
244
+ image.onload = () => resolve(image);
245
+ image.onerror = () => reject(new SouvenirError('image_failed', `Could not load image: ${url}`));
246
+ image.src = url;
247
+ });
248
+ }
249
+ /** Element ids in draw order: z-index ascending, insertion order for ties. */
250
+ export function drawOrder(page) {
251
+ return Object.keys(page.elements ?? {})
252
+ .filter((eid) => SOUVENIR_ELEMENT_TYPES.includes(page.elements[eid]?.type))
253
+ .filter((eid) => !isHidden(page.properties?.[eid] ?? {}))
254
+ .map((eid, index) => ({ eid, index, z: num((page.properties?.[eid] ?? {})['zIndex']) }))
255
+ .sort((a, b) => (a.z === b.z ? a.index - b.index : a.z - b.z))
256
+ .map((entry) => entry.eid);
257
+ }
258
+ /**
259
+ * True when every pixel is guaranteed opaque, i.e. a `bg` element covers the
260
+ * page with a colour or an image. Decides JPEG versus PNG in `auto`.
261
+ */
262
+ export function pageHasOpaqueBackground(page) {
263
+ for (const eid of Object.keys(page.elements ?? {})) {
264
+ if (page.elements[eid]?.type !== 'bg')
265
+ continue;
266
+ const property = page.properties?.[eid] ?? {};
267
+ if (isHidden(property))
268
+ continue;
269
+ const geo = geometryOf(property);
270
+ const covers = geo.x <= 0 && geo.y <= 0 && geo.width >= page.width && geo.height >= page.height;
271
+ if (!covers)
272
+ continue;
273
+ if (geo.opacity < 100 || geo.radius > 0 || geo.rotation)
274
+ continue;
275
+ if (colorOf(property['bg-color']) || typeof property['source'] === 'string' && property['source'])
276
+ return true;
277
+ }
278
+ return false;
279
+ }
280
+ /** A stable signature for (page, values): same inputs, same tag, same upload. */
281
+ export function souvenirSignature(page, values) {
282
+ const parts = [`${page.width}x${page.height}`];
283
+ for (const eid of drawOrder(page)) {
284
+ parts.push(eid, page.elements[eid]?.type ?? '', JSON.stringify(page.properties?.[eid] ?? {}), String(values[eid] ?? ''));
285
+ }
286
+ return hashSignature(parts.join('|'));
287
+ }
288
+ /**
289
+ * Draws the page and returns the canvas.
290
+ *
291
+ * Every image is loaded before anything is drawn, so a slow CDN cannot leave a
292
+ * half-composed image behind; a failed load throws rather than quietly
293
+ * producing a souvenir with a hole in it.
294
+ */
295
+ export async function renderSouvenir(page, values = {}, options = {}) {
296
+ if (typeof document === 'undefined') {
297
+ throw new SouvenirError('no_dom', 'The souvenir composer needs a browser document.');
298
+ }
299
+ const width = Math.round(num(page.width));
300
+ const height = Math.round(num(page.height));
301
+ if (!isSouvenirSizeAllowed(width, height)) {
302
+ throw new SouvenirError(width < 1 || height < 1 ? 'bad_size' : 'too_large', `Souvenir page size ${width}x${height} is out of range.`);
303
+ }
304
+ const order = drawOrder(page);
305
+ // Webfonts first: measuring before they land silently lays the text out in
306
+ // the fallback font and the first souvenir of an event comes out wrong.
307
+ if (!options.skipFontWait && typeof document !== 'undefined' && document.fonts?.ready) {
308
+ try {
309
+ await document.fonts.ready;
310
+ }
311
+ catch {
312
+ /* a font that never resolves must not block the souvenir */
313
+ }
314
+ }
315
+ const sources = new Map();
316
+ await Promise.all(order.map(async (eid) => {
317
+ const type = page.elements[eid]?.type;
318
+ if (type !== 'image' && type !== 'image-frame' && type !== 'bg')
319
+ return;
320
+ const property = page.properties?.[eid] ?? {};
321
+ const url = (values[eid] ?? property['source'] ?? '').toString().trim();
322
+ if (!url)
323
+ return;
324
+ sources.set(eid, await loadSouvenirImage(url, options.crossOrigin));
325
+ }));
326
+ const canvas = document.createElement('canvas');
327
+ canvas.width = width;
328
+ canvas.height = height;
329
+ const ctx = canvas.getContext('2d');
330
+ if (!ctx)
331
+ throw new SouvenirError('canvas_unavailable', 'Could not get a 2d context.');
332
+ for (const eid of order) {
333
+ const type = page.elements[eid]?.type ?? '';
334
+ const property = page.properties?.[eid] ?? {};
335
+ const geo = geometryOf(property);
336
+ if (geo.width <= 0 || geo.height <= 0)
337
+ continue;
338
+ if (type === 'text') {
339
+ const content = (values[eid] ?? property['content'] ?? '').toString();
340
+ drawText(ctx, property, geo, content);
341
+ continue;
342
+ }
343
+ if (type === 'image' || type === 'image-frame' || type === 'bg') {
344
+ const image = sources.get(eid);
345
+ if (image)
346
+ drawImage(ctx, property, geo, image);
347
+ else
348
+ inElementSpace(ctx, geo, (c, w, h) => {
349
+ fillBackground(c, property, w, h, geo.radius, false);
350
+ strokeBorder(c, property, w, h, geo.radius, false);
351
+ });
352
+ continue;
353
+ }
354
+ drawShape(ctx, type, property, geo);
355
+ }
356
+ return canvas;
357
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Text layout for the souvenir composer.
3
+ *
4
+ * Canvas has no line box: every wrap decision has to be made here. The rule
5
+ * that matters is PF's 2026-09-12 ruling for the live wall, carried over to
6
+ * every composed image: **a word is never broken across lines**. A word only
7
+ * splits when it alone is wider than the box, which is the one case where
8
+ * keeping it whole would push it out of frame entirely.
9
+ *
10
+ * CJK has no spaces, so a run of CJK characters breaks per character. Mixed
11
+ * text therefore behaves the way a reader expects on both sides.
12
+ */
13
+ /** Measures one string in the current canvas font. Injected so layout is testable. */
14
+ export type MeasureText = (text: string) => number;
15
+ export type TextAlign = 'left' | 'center' | 'right';
16
+ export type TextVAlign = 'top' | 'middle' | 'bottom';
17
+ /**
18
+ * Splits a line into the smallest pieces a break is allowed to fall between:
19
+ * a whole latin word, a single CJK character, or a run of spaces.
20
+ */
21
+ export declare function tokenize(line: string): string[];
22
+ /**
23
+ * Wraps text to `maxWidth`, honouring existing newlines.
24
+ *
25
+ * Trailing spaces are dropped at a wrap point, the way a browser does, so a
26
+ * line never ends with the space that pushed it over.
27
+ */
28
+ export declare function wrapText(text: string, maxWidth: number, measure: MeasureText): string[];
29
+ export interface PlacedLine {
30
+ text: string;
31
+ /** Left edge to draw from; the caller keeps `textAlign` at `left`. */
32
+ x: number;
33
+ /** Baseline y. */
34
+ y: number;
35
+ width: number;
36
+ }
37
+ /**
38
+ * Places wrapped lines inside the element box.
39
+ *
40
+ * Vertical centring is the default because the runner sets `align-items: safe
41
+ * center` on any element that has an alignment, and the composed image has to
42
+ * match the page the operator laid out.
43
+ */
44
+ export declare function layoutLines(lines: string[], box: {
45
+ x: number;
46
+ y: number;
47
+ width: number;
48
+ height: number;
49
+ }, fontSize: number, lineHeight: number, align: TextAlign, vAlign: TextVAlign, measure: MeasureText): PlacedLine[];
50
+ //# sourceMappingURL=text.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"text.d.ts","sourceRoot":"","sources":["../../src/souvenir/text.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,sFAAsF;AACtF,MAAM,MAAM,WAAW,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;AAEnD,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,QAAQ,GAAG,OAAO,CAAC;AACpD,MAAM,MAAM,UAAU,GAAG,KAAK,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAKrD;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAwB/C;AAmBD;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,GAAG,MAAM,EAAE,CAmCvF;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,sEAAsE;IACtE,CAAC,EAAE,MAAM,CAAC;IACV,kBAAkB;IAClB,CAAC,EAAE,MAAM,CAAC;IACV,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CACzB,KAAK,EAAE,MAAM,EAAE,EACf,GAAG,EAAE;IAAE,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,EAC5D,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE,SAAS,EAChB,MAAM,EAAE,UAAU,EAClB,OAAO,EAAE,WAAW,GACnB,UAAU,EAAE,CAkBd"}
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Text layout for the souvenir composer.
3
+ *
4
+ * Canvas has no line box: every wrap decision has to be made here. The rule
5
+ * that matters is PF's 2026-09-12 ruling for the live wall, carried over to
6
+ * every composed image: **a word is never broken across lines**. A word only
7
+ * splits when it alone is wider than the box, which is the one case where
8
+ * keeping it whole would push it out of frame entirely.
9
+ *
10
+ * CJK has no spaces, so a run of CJK characters breaks per character. Mixed
11
+ * text therefore behaves the way a reader expects on both sides.
12
+ */
13
+ const CJK = /[ᄀ-ᇿ⺀-〿぀-ヿ㄰-㆏㐀-䶿一-鿿ꀀ-꓏가-힯豈-﫿︰-﹏＀-⦆¢-₩]/;
14
+ /**
15
+ * Splits a line into the smallest pieces a break is allowed to fall between:
16
+ * a whole latin word, a single CJK character, or a run of spaces.
17
+ */
18
+ export function tokenize(line) {
19
+ const tokens = [];
20
+ let word = '';
21
+ const flush = () => {
22
+ if (word)
23
+ tokens.push(word);
24
+ word = '';
25
+ };
26
+ for (const char of Array.from(line)) {
27
+ if (char === ' ' || char === '\t') {
28
+ flush();
29
+ const last = tokens[tokens.length - 1];
30
+ if (last && (last === ' ' || last.endsWith(' ')))
31
+ tokens[tokens.length - 1] = last + char;
32
+ else
33
+ tokens.push(char);
34
+ continue;
35
+ }
36
+ if (CJK.test(char)) {
37
+ flush();
38
+ tokens.push(char);
39
+ continue;
40
+ }
41
+ word += char;
42
+ }
43
+ flush();
44
+ return tokens;
45
+ }
46
+ /** Breaks one over-wide token by characters. Only reached when a single token cannot fit. */
47
+ function splitOversized(token, maxWidth, measure) {
48
+ const out = [];
49
+ let current = '';
50
+ for (const char of Array.from(token)) {
51
+ const next = current + char;
52
+ if (current && measure(next) > maxWidth) {
53
+ out.push(current);
54
+ current = char;
55
+ }
56
+ else {
57
+ current = next;
58
+ }
59
+ }
60
+ if (current)
61
+ out.push(current);
62
+ return out;
63
+ }
64
+ /**
65
+ * Wraps text to `maxWidth`, honouring existing newlines.
66
+ *
67
+ * Trailing spaces are dropped at a wrap point, the way a browser does, so a
68
+ * line never ends with the space that pushed it over.
69
+ */
70
+ export function wrapText(text, maxWidth, measure) {
71
+ if (!text)
72
+ return [''];
73
+ if (!(maxWidth > 0))
74
+ return text.split('\n');
75
+ const lines = [];
76
+ for (const rawLine of text.split('\n')) {
77
+ if (rawLine === '') {
78
+ lines.push('');
79
+ continue;
80
+ }
81
+ let current = '';
82
+ for (const token of tokenize(rawLine)) {
83
+ if (measure(current + token) <= maxWidth) {
84
+ current += token;
85
+ continue;
86
+ }
87
+ // Does not fit after what we already have: close the line first.
88
+ if (current) {
89
+ lines.push(current.replace(/[ \t]+$/, ''));
90
+ current = '';
91
+ }
92
+ // A space that caused the wrap is swallowed, as a browser does.
93
+ if (token.trim() === '')
94
+ continue;
95
+ if (measure(token) > maxWidth) {
96
+ // The one case where a word has to break: it does not fit on a line of its own.
97
+ const pieces = splitOversized(token, maxWidth, measure);
98
+ for (let i = 0; i < pieces.length - 1; i++)
99
+ lines.push(pieces[i]);
100
+ current = pieces[pieces.length - 1] ?? '';
101
+ }
102
+ else {
103
+ current = token;
104
+ }
105
+ }
106
+ lines.push(current.replace(/[ \t]+$/, ''));
107
+ }
108
+ return lines;
109
+ }
110
+ /**
111
+ * Places wrapped lines inside the element box.
112
+ *
113
+ * Vertical centring is the default because the runner sets `align-items: safe
114
+ * center` on any element that has an alignment, and the composed image has to
115
+ * match the page the operator laid out.
116
+ */
117
+ export function layoutLines(lines, box, fontSize, lineHeight, align, vAlign, measure) {
118
+ const step = fontSize * lineHeight;
119
+ const blockHeight = step * lines.length;
120
+ let top = box.y;
121
+ if (vAlign === 'middle')
122
+ top = box.y + (box.height - blockHeight) / 2;
123
+ else if (vAlign === 'bottom')
124
+ top = box.y + box.height - blockHeight;
125
+ return lines.map((text, index) => {
126
+ const width = measure(text);
127
+ let x = box.x;
128
+ if (align === 'center')
129
+ x = box.x + (box.width - width) / 2;
130
+ else if (align === 'right')
131
+ x = box.x + box.width - width;
132
+ // Baseline sits on the alphabetic line of a box `step` tall; 0.8 of the
133
+ // font size is the ratio browsers land on for the fonts we ship.
134
+ const y = top + index * step + fontSize * 0.8;
135
+ return { text, x, y, width };
136
+ });
137
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Souvenir — the shared definition of "one page of Builder elements, rendered
3
+ * into a single image".
4
+ *
5
+ * A node with `souvenir_setting.on` gets an extra editor page that the runner
6
+ * never renders: it exists only to be composed. The composer reads the very
7
+ * same property bag the Builder saves for any other page, so an operator lays
8
+ * the souvenir out with the tools they already know and what they see is what
9
+ * gets composed.
10
+ *
11
+ * Framework-free by construction: the renderer takes plain data and returns a
12
+ * canvas. Nothing here knows about Vue, the runner, or any single module.
13
+ */
14
+ /** Element types the composer draws. Anything else on the page is skipped. */
15
+ export declare const SOUVENIR_ELEMENT_TYPES: readonly ["bg", "image", "image-frame", "text", "shape-rect", "shape-ellipse", "shape-line"];
16
+ export type SouvenirElementType = (typeof SOUVENIR_ELEMENT_TYPES)[number];
17
+ /** Anything the Builder stores for one element. Values arrive untyped from the API. */
18
+ export type SouvenirProperty = Record<string, unknown>;
19
+ /** One element as the Builder's `detail.elements` records it. */
20
+ export interface SouvenirElement {
21
+ type: string;
22
+ name?: string;
23
+ }
24
+ /**
25
+ * One souvenir page, shaped exactly like the editor config the API returns:
26
+ * `detail.elements` keyed by element id, `detail.properties[tab]` for the bag.
27
+ * The caller picks the tab; the composer only ever sees one page.
28
+ */
29
+ export interface SouvenirPage {
30
+ /** Canvas pixels. This is the output size too: souvenir pages are never folded. */
31
+ width: number;
32
+ height: number;
33
+ elements: Record<string, SouvenirElement>;
34
+ properties: Record<string, SouvenirProperty>;
35
+ }
36
+ /**
37
+ * Runtime values for the elements bound to node outputs, keyed by element id.
38
+ * Text elements take the string; image elements take a URL.
39
+ *
40
+ * Use `resolveSouvenirValues` to go from field keys (what a module knows) to
41
+ * element ids (what the page knows).
42
+ */
43
+ export type SouvenirValues = Record<string, string | null | undefined>;
44
+ export type SouvenirFormat = 'auto' | 'jpeg' | 'png';
45
+ export interface SouvenirOptions {
46
+ /**
47
+ * `auto` (default) picks JPEG unless the page has no opaque background, in
48
+ * which case the transparency has to survive and PNG wins.
49
+ */
50
+ format?: SouvenirFormat;
51
+ /** JPEG quality, 0 to 1. Default 0.9. */
52
+ quality?: number;
53
+ /** File name handed to the upload endpoint. Extension is set from the format. */
54
+ fileName?: string;
55
+ /**
56
+ * `crossOrigin` for every image the page loads. Defaults to `anonymous`,
57
+ * which is what keeps the canvas untainted; pass `null` for same-origin-only
58
+ * pages that must not send the header.
59
+ */
60
+ crossOrigin?: string | null;
61
+ /** Skip `document.fonts.ready`. Only for tests: a missing wait costs the first compose its webfonts. */
62
+ skipFontWait?: boolean;
63
+ }
64
+ export interface SouvenirResult {
65
+ file: File;
66
+ canvas: HTMLCanvasElement;
67
+ /** Stable per (page, values) — the upload tag that makes recomposing idempotent. */
68
+ tag: string;
69
+ width: number;
70
+ height: number;
71
+ format: Exclude<SouvenirFormat, 'auto'>;
72
+ }
73
+ /** Thrown for every refusal the caller is expected to handle and show. */
74
+ export declare class SouvenirError extends Error {
75
+ readonly code: 'no_dom' | 'bad_size' | 'too_large' | 'canvas_unavailable' | 'image_failed' | 'encode_failed';
76
+ constructor(code: 'no_dom' | 'bad_size' | 'too_large' | 'canvas_unavailable' | 'image_failed' | 'encode_failed', message: string);
77
+ }
78
+ /**
79
+ * Canvas ceilings. iOS Safari refuses to rasterise past these and hands back a
80
+ * blank bitmap instead of an error, so the page is rejected at save time in the
81
+ * Builder rather than silently producing white images at an event.
82
+ */
83
+ export declare const SOUVENIR_MAX_EDGE = 4096;
84
+ export declare const SOUVENIR_MAX_AREA = 16777216;
85
+ /** `true` when the page is within what a mobile browser will actually rasterise. */
86
+ export declare function isSouvenirSizeAllowed(width: number, height: number): boolean;
87
+ /** The message the Builder shows when a size is refused. */
88
+ export declare function souvenirSizeError(width: number, height: number): string | '';
89
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/souvenir/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,8EAA8E;AAC9E,eAAO,MAAM,sBAAsB,8FAQzB,CAAC;AAEX,MAAM,MAAM,mBAAmB,GAAG,CAAC,OAAO,sBAAsB,CAAC,CAAC,MAAM,CAAC,CAAC;AAE1E,uFAAuF;AACvF,MAAM,MAAM,gBAAgB,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEvD,iEAAiE;AACjE,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC3B,mFAAmF;IACnF,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;IAC1C,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;CAC9C;AAED;;;;;;GAMG;AACH,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;AAEvE,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,MAAM,GAAG,KAAK,CAAC;AAErD,MAAM,WAAW,eAAe;IAC9B;;;OAGG;IACH,MAAM,CAAC,EAAE,cAAc,CAAC;IACxB,yCAAyC;IACzC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,iFAAiF;IACjF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,wGAAwG;IACxG,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,IAAI,CAAC;IACX,MAAM,EAAE,iBAAiB,CAAC;IAC1B,oFAAoF;IACpF,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,OAAO,CAAC,cAAc,EAAE,MAAM,CAAC,CAAC;CACzC;AAED,0EAA0E;AAC1E,qBAAa,aAAc,SAAQ,KAAK;aAEpB,IAAI,EAChB,QAAQ,GACR,UAAU,GACV,WAAW,GACX,oBAAoB,GACpB,cAAc,GACd,eAAe;gBANH,IAAI,EAChB,QAAQ,GACR,UAAU,GACV,WAAW,GACX,oBAAoB,GACpB,cAAc,GACd,eAAe,EACnB,OAAO,EAAE,MAAM;CAKlB;AAED;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,OAAO,CAAC;AACtC,eAAO,MAAM,iBAAiB,WAAa,CAAC;AAE5C,oFAAoF;AACpF,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAK5E;AAED,4DAA4D;AAC5D,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,EAAE,CAW5E"}
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Souvenir — the shared definition of "one page of Builder elements, rendered
3
+ * into a single image".
4
+ *
5
+ * A node with `souvenir_setting.on` gets an extra editor page that the runner
6
+ * never renders: it exists only to be composed. The composer reads the very
7
+ * same property bag the Builder saves for any other page, so an operator lays
8
+ * the souvenir out with the tools they already know and what they see is what
9
+ * gets composed.
10
+ *
11
+ * Framework-free by construction: the renderer takes plain data and returns a
12
+ * canvas. Nothing here knows about Vue, the runner, or any single module.
13
+ */
14
+ /** Element types the composer draws. Anything else on the page is skipped. */
15
+ export const SOUVENIR_ELEMENT_TYPES = [
16
+ 'bg',
17
+ 'image',
18
+ 'image-frame',
19
+ 'text',
20
+ 'shape-rect',
21
+ 'shape-ellipse',
22
+ 'shape-line',
23
+ ];
24
+ /** Thrown for every refusal the caller is expected to handle and show. */
25
+ export class SouvenirError extends Error {
26
+ code;
27
+ constructor(code, message) {
28
+ super(message);
29
+ this.code = code;
30
+ this.name = 'SouvenirError';
31
+ }
32
+ }
33
+ /**
34
+ * Canvas ceilings. iOS Safari refuses to rasterise past these and hands back a
35
+ * blank bitmap instead of an error, so the page is rejected at save time in the
36
+ * Builder rather than silently producing white images at an event.
37
+ */
38
+ export const SOUVENIR_MAX_EDGE = 4096;
39
+ export const SOUVENIR_MAX_AREA = 16_777_216;
40
+ /** `true` when the page is within what a mobile browser will actually rasterise. */
41
+ export function isSouvenirSizeAllowed(width, height) {
42
+ if (!Number.isFinite(width) || !Number.isFinite(height))
43
+ return false;
44
+ if (width < 1 || height < 1)
45
+ return false;
46
+ if (Math.max(width, height) > SOUVENIR_MAX_EDGE)
47
+ return false;
48
+ return width * height <= SOUVENIR_MAX_AREA;
49
+ }
50
+ /** The message the Builder shows when a size is refused. */
51
+ export function souvenirSizeError(width, height) {
52
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width < 1 || height < 1) {
53
+ return 'Enter a width and a height in pixels.';
54
+ }
55
+ if (Math.max(width, height) > SOUVENIR_MAX_EDGE) {
56
+ return `The longest side must be ${SOUVENIR_MAX_EDGE}px or less.`;
57
+ }
58
+ if (width * height > SOUVENIR_MAX_AREA) {
59
+ return `Width times height must be ${SOUVENIR_MAX_AREA.toLocaleString('en-US')}px or less.`;
60
+ }
61
+ return '';
62
+ }
package/package.json CHANGED
@@ -1,60 +1,64 @@
1
- {
2
- "name": "@realnation/builder-shared-sdk",
3
- "version": "2.2.0",
4
- "type": "module",
5
- "private": false,
6
- "exports": {
7
- "./package.json": "./package.json",
8
- ".": {
9
- "types": "./dist/index.d.ts",
10
- "import": "./dist/index.js"
11
- },
12
- "./runtime": {
13
- "types": "./dist/runtime/index.d.ts",
14
- "import": "./dist/runtime/index.js"
15
- },
16
- "./runtime/vue": {
17
- "types": "./dist/runtime/vue/index.d.ts",
18
- "import": "./dist/runtime/vue/index.js"
19
- },
20
- "./interaction": {
21
- "types": "./dist/interaction/index.d.ts",
22
- "import": "./dist/interaction/index.js"
23
- },
24
- "./interaction/vue": {
25
- "types": "./dist/interaction/vue.d.ts",
26
- "import": "./dist/interaction/vue.js"
27
- }
28
- },
29
- "files": [
30
- "dist"
31
- ],
32
- "scripts": {
33
- "build": "tsc -p tsconfig.json",
34
- "clean": "rimraf dist",
35
- "test": "vitest run",
36
- "test:watch": "vitest",
37
- "prepublishOnly": "npm run clean && npm run test && npm run build",
38
- "prepare": "npm run clean && npm run build"
39
- },
40
- "dependencies": {
41
- "ali-oss": "^6.23.0",
42
- "axios": "^1.7.7"
43
- },
44
- "peerDependencies": {
45
- "vue": "^3.4.0"
46
- },
47
- "peerDependenciesMeta": {
48
- "vue": {
49
- "optional": true
50
- }
51
- },
52
- "devDependencies": {
53
- "@types/ali-oss": "^6.16.11",
54
- "@types/node": "^26.1.2",
55
- "rimraf": "^5.0.5",
56
- "typescript": "^5.4.5",
57
- "vitest": "^2.1.0",
58
- "vue": "^3.4.0"
59
- }
60
- }
1
+ {
2
+ "name": "@realnation/builder-shared-sdk",
3
+ "version": "2.4.0",
4
+ "type": "module",
5
+ "private": false,
6
+ "exports": {
7
+ "./package.json": "./package.json",
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js"
11
+ },
12
+ "./runtime": {
13
+ "types": "./dist/runtime/index.d.ts",
14
+ "import": "./dist/runtime/index.js"
15
+ },
16
+ "./runtime/vue": {
17
+ "types": "./dist/runtime/vue/index.d.ts",
18
+ "import": "./dist/runtime/vue/index.js"
19
+ },
20
+ "./interaction": {
21
+ "types": "./dist/interaction/index.d.ts",
22
+ "import": "./dist/interaction/index.js"
23
+ },
24
+ "./interaction/vue": {
25
+ "types": "./dist/interaction/vue.d.ts",
26
+ "import": "./dist/interaction/vue.js"
27
+ },
28
+ "./souvenir": {
29
+ "types": "./dist/souvenir/index.d.ts",
30
+ "import": "./dist/souvenir/index.js"
31
+ }
32
+ },
33
+ "files": [
34
+ "dist"
35
+ ],
36
+ "scripts": {
37
+ "build": "tsc -p tsconfig.json",
38
+ "clean": "rimraf dist",
39
+ "test": "vitest run",
40
+ "test:watch": "vitest",
41
+ "prepublishOnly": "npm run clean && npm run test && npm run build",
42
+ "prepare": "npm run clean && npm run build"
43
+ },
44
+ "dependencies": {
45
+ "ali-oss": "^6.23.0",
46
+ "axios": "^1.7.7"
47
+ },
48
+ "peerDependencies": {
49
+ "vue": "^3.4.0"
50
+ },
51
+ "peerDependenciesMeta": {
52
+ "vue": {
53
+ "optional": true
54
+ }
55
+ },
56
+ "devDependencies": {
57
+ "@types/ali-oss": "^6.16.11",
58
+ "@types/node": "^26.1.2",
59
+ "rimraf": "^5.0.5",
60
+ "typescript": "^5.4.5",
61
+ "vitest": "^2.1.0",
62
+ "vue": "^3.4.0"
63
+ }
64
+ }