@realnation/builder-shared-sdk 2.4.1 → 2.5.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.
@@ -11,7 +11,7 @@
11
11
  export * from './types.js';
12
12
  export * from './imageFit.js';
13
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';
14
+ export { renderSouvenir, drawOrder, outputSize, regionOf, pageHasOpaqueBackground, souvenirSignature, loadSouvenirImage, } from './render.js';
15
15
  import { type SouvenirOptions, type SouvenirPage, type SouvenirResult, type SouvenirValues } from './types.js';
16
16
  export { parseSouvenirOutputId } from './outputId.js';
17
17
  /**
@@ -37,5 +37,5 @@ export declare function composeSouvenir(page: SouvenirPage, values?: SouvenirVal
37
37
  * Anything else uploaded in the same submission must use a different tag, or
38
38
  * it will be handed back the first file.
39
39
  */
40
- export declare function souvenirTag(page: SouvenirPage, values?: SouvenirValues): string;
40
+ export declare function souvenirTag(page: SouvenirPage, values?: SouvenirValues, options?: SouvenirOptions): string;
41
41
  //# sourceMappingURL=index.d.ts.map
@@ -1 +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;AAGpB,OAAO,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAC;AAEtD;;;;;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"}
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,UAAU,EACV,QAAQ,EACR,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;AAGpB,OAAO,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAC;AAEtD;;;;;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,EAAE,OAAO,GAAE,eAAoB,GAAG,MAAM,CAElH"}
@@ -11,7 +11,7 @@
11
11
  export * from './types.js';
12
12
  export * from './imageFit.js';
13
13
  export { wrapText, tokenize, layoutLines, } from './text.js';
14
- export { renderSouvenir, drawOrder, pageHasOpaqueBackground, souvenirSignature, loadSouvenirImage, } from './render.js';
14
+ export { renderSouvenir, drawOrder, outputSize, regionOf, pageHasOpaqueBackground, souvenirSignature, loadSouvenirImage, } from './render.js';
15
15
  import { pageHasOpaqueBackground, renderSouvenir, souvenirSignature } from './render.js';
16
16
  import { SouvenirError, } from './types.js';
17
17
  import { parseSouvenirOutputId } from './outputId.js';
@@ -52,7 +52,7 @@ export async function composeSouvenir(page, values = {}, options = {}) {
52
52
  const canvas = await renderSouvenir(page, values, options);
53
53
  const format = options.format && options.format !== 'auto'
54
54
  ? options.format
55
- : pageHasOpaqueBackground(page)
55
+ : pageHasOpaqueBackground(page, options.region)
56
56
  ? 'jpeg'
57
57
  : 'png';
58
58
  const quality = typeof options.quality === 'number' ? options.quality : 0.9;
@@ -63,7 +63,7 @@ export async function composeSouvenir(page, values = {}, options = {}) {
63
63
  return {
64
64
  file,
65
65
  canvas,
66
- tag: souvenirTag(page, values),
66
+ tag: souvenirTag(page, values, options),
67
67
  width: canvas.width,
68
68
  height: canvas.height,
69
69
  format,
@@ -77,6 +77,6 @@ export async function composeSouvenir(page, values = {}, options = {}) {
77
77
  * Anything else uploaded in the same submission must use a different tag, or
78
78
  * it will be handed back the first file.
79
79
  */
80
- export function souvenirTag(page, values = {}) {
81
- return `souvenir-${souvenirSignature(page, values)}`;
80
+ export function souvenirTag(page, values = {}, options = {}) {
81
+ return `souvenir-${souvenirSignature(page, values, options)}`;
82
82
  }
@@ -9,7 +9,7 @@
9
9
  * mirrors the runner's `elementStyle`, which is what the operator sees in the
10
10
  * editor and the preview.
11
11
  */
12
- import { type SouvenirOptions, type SouvenirPage, type SouvenirProperty, type SouvenirValues } from './types.js';
12
+ import { type SouvenirOptions, type SouvenirPage, type SouvenirProperty, type SouvenirRegion, type SouvenirValues } from './types.js';
13
13
  /**
14
14
  * An element bound to a Data field whose field has no value is left out, the
15
15
  * way the runner hides it on a live page. Falling back to what the Builder
@@ -30,15 +30,25 @@ export declare function loadSouvenirImage(url: string, crossOrigin: string | nul
30
30
  width: number;
31
31
  height: number;
32
32
  }>;
33
+ /**
34
+ * 整页时用它,给了区域时按区域算。区域外的元件照常留在页面上,由 runner 画。
35
+ */
36
+ export declare function regionOf(page: SouvenirPage, region?: SouvenirRegion): SouvenirRegion;
37
+ /** 出图尺寸:形状跟着区域,长边取 longEdge;不给 longEdge 就按区域原尺寸。 */
38
+ export declare function outputSize(region: SouvenirRegion, longEdge?: number): {
39
+ width: number;
40
+ height: number;
41
+ scale: number;
42
+ };
33
43
  /** Element ids in draw order: z-index ascending, insertion order for ties. */
34
- export declare function drawOrder(page: SouvenirPage): string[];
44
+ export declare function drawOrder(page: SouvenirPage, region?: SouvenirRegion): string[];
35
45
  /**
36
46
  * True when every pixel is guaranteed opaque, i.e. a `bg` element covers the
37
47
  * page with a colour or an image. Decides JPEG versus PNG in `auto`.
38
48
  */
39
- export declare function pageHasOpaqueBackground(page: SouvenirPage): boolean;
40
- /** A stable signature for (page, values): same inputs, same tag, same upload. */
41
- export declare function souvenirSignature(page: SouvenirPage, values: SouvenirValues): string;
49
+ export declare function pageHasOpaqueBackground(page: SouvenirPage, region?: SouvenirRegion): boolean;
50
+ /** A stable signature for (page, values, region, size): same inputs, same tag, same upload. */
51
+ export declare function souvenirSignature(page: SouvenirPage, values: SouvenirValues, options?: SouvenirOptions): string;
42
52
  /**
43
53
  * Draws the page and returns the canvas.
44
54
  *
@@ -1 +1 @@
1
- {"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../../src/souvenir/render.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAKH,OAAO,EAGL,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,KAAK,gBAAgB,EACrB,KAAK,cAAc,EAEpB,MAAM,YAAY,CAAC;AA6CpB;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,cAAc,GAAG,OAAO,CAI7E;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,QAAQ,EAAE,gBAAgB,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAO5F;AA6ND,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,CAsE5B"}
1
+ {"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../../src/souvenir/render.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAKH,OAAO,EAKL,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,KAAK,gBAAgB,EACrB,KAAK,cAAc,EACnB,KAAK,cAAc,EAEpB,MAAM,YAAY,CAAC;AA6CpB;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,cAAc,GAAG,OAAO,CAI7E;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,QAAQ,EAAE,gBAAgB,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAO5F;AAyPD,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;;GAEG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,YAAY,EAAE,MAAM,CAAC,EAAE,cAAc,GAAG,cAAc,CAEpF;AAED,oDAAoD;AACpD,wBAAgB,UAAU,CAAC,MAAM,EAAE,cAAc,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAStH;AAED,8EAA8E;AAC9E,wBAAgB,SAAS,CAAC,IAAI,EAAE,YAAY,EAAE,MAAM,CAAC,EAAE,cAAc,GAAG,MAAM,EAAE,CAY/E;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,YAAY,EAAE,MAAM,CAAC,EAAE,cAAc,GAAG,OAAO,CAc5F;AAED,+FAA+F;AAC/F,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,YAAY,EAAE,MAAM,EAAE,cAAc,EAAE,OAAO,GAAE,eAAoB,GAAG,MAAM,CAQnH;AAED;;;;;;GAMG;AACH,wBAAsB,cAAc,CAClC,IAAI,EAAE,YAAY,EAClB,MAAM,GAAE,cAAmB,EAC3B,OAAO,GAAE,eAAoB,GAC5B,OAAO,CAAC,iBAAiB,CAAC,CA2E5B"}
@@ -12,7 +12,7 @@
12
12
  import { computeImageFitDrawRect, hashSignature, normalizeImageFit } from './imageFit.js';
13
13
  import { parseSouvenirOutputId } from './outputId.js';
14
14
  import { layoutLines, wrapText } from './text.js';
15
- import { isSouvenirSizeAllowed, SouvenirError, SOUVENIR_ELEMENT_TYPES, } from './types.js';
15
+ import { isInsideRegion, isSouvenirInteractive, isSouvenirSizeAllowed, SouvenirError, SOUVENIR_ELEMENT_TYPES, } from './types.js';
16
16
  const DEFAULT_LINE_HEIGHT = 1.2;
17
17
  const TRANSPARENT = 'TRANSPARENT';
18
18
  const num = (value, fallback = 0) => {
@@ -144,39 +144,55 @@ function fontOf(property, fontSize) {
144
144
  const quoted = /[",]/.test(family) || !/\s/.test(family) ? family : `"${family}"`;
145
145
  return `${style}${weight}${fontSize}px ${quoted}`;
146
146
  }
147
- function drawText(ctx, property, geo, content) {
147
+ /**
148
+ * Lays the text out inside the box already in element space. `padX` is the side
149
+ * padding the runner leaves on shape text.
150
+ */
151
+ function drawTextInBox(c, property, radius, width, height, content, padX = 0) {
148
152
  const fontSize = num(property['font-size'], 14);
149
153
  if (fontSize <= 0 || !content)
150
154
  return;
151
155
  const lineHeight = num(property['line-height'], DEFAULT_LINE_HEIGHT) || DEFAULT_LINE_HEIGHT;
152
156
  const align = (property['alignment'] || 'center');
153
157
  const vAlign = (property['v-alignment'] || 'middle');
158
+ const boxWidth = Math.max(0, width - padX * 2);
159
+ if (boxWidth <= 0)
160
+ return;
161
+ c.save();
162
+ roundedRectPath(c, 0, 0, width, height, radius);
163
+ c.clip();
164
+ c.font = fontOf(property, fontSize);
165
+ c.textBaseline = 'alphabetic';
166
+ c.textAlign = 'left';
167
+ c.fillStyle = colorOf(property['color']) || '#000000';
168
+ const measure = (text) => c.measureText(text).width;
169
+ const lines = wrapText(content, boxWidth, measure);
170
+ const placed = layoutLines(lines, { x: padX, y: 0, width: boxWidth, height }, fontSize, lineHeight, align, vAlign, measure);
171
+ const formatting = Array.isArray(property['formatting']) ? property['formatting'] : [];
172
+ const underline = formatting.includes('Underline');
173
+ const strike = formatting.includes('Strikethrough');
174
+ for (const line of placed) {
175
+ if (!line.text)
176
+ continue;
177
+ c.fillText(line.text, line.x, line.y);
178
+ if (underline)
179
+ c.fillRect(line.x, line.y + fontSize * 0.12, line.width, Math.max(1, fontSize / 16));
180
+ if (strike)
181
+ c.fillRect(line.x, line.y - fontSize * 0.28, line.width, Math.max(1, fontSize / 16));
182
+ }
183
+ c.restore();
184
+ }
185
+ function drawText(ctx, property, geo, content) {
186
+ if (!content)
187
+ return;
154
188
  inElementSpace(ctx, geo, (c, width, height) => {
155
189
  fillBackground(c, property, width, height, geo.radius, false);
156
- roundedRectPath(c, 0, 0, width, height, geo.radius);
157
- c.clip();
158
- c.font = fontOf(property, fontSize);
159
- c.textBaseline = 'alphabetic';
160
- c.textAlign = 'left';
161
- c.fillStyle = colorOf(property['color']) || '#000000';
162
- const measure = (text) => c.measureText(text).width;
163
- const lines = wrapText(content, width, measure);
164
- const placed = layoutLines(lines, { x: 0, y: 0, width, height }, fontSize, lineHeight, align, vAlign, measure);
165
- const formatting = Array.isArray(property['formatting']) ? property['formatting'] : [];
166
- const underline = formatting.includes('Underline');
167
- const strike = formatting.includes('Strikethrough');
168
- for (const line of placed) {
169
- if (!line.text)
170
- continue;
171
- c.fillText(line.text, line.x, line.y);
172
- if (underline)
173
- c.fillRect(line.x, line.y + fontSize * 0.12, line.width, Math.max(1, fontSize / 16));
174
- if (strike)
175
- c.fillRect(line.x, line.y - fontSize * 0.28, line.width, Math.max(1, fontSize / 16));
176
- }
190
+ drawTextInBox(c, property, geo.radius, width, height, content);
177
191
  });
178
192
  inElementSpace(ctx, geo, (c, width, height) => strokeBorder(c, property, width, height, geo.radius, false));
179
193
  }
194
+ /** The runner pads shape text by 4px on each side; match it so the wrap is the same. */
195
+ const SHAPE_TEXT_PADDING = 4;
180
196
  function drawImage(ctx, property, geo, image) {
181
197
  inElementSpace(ctx, geo, (c, width, height) => {
182
198
  fillBackground(c, property, width, height, geo.radius, false);
@@ -233,6 +249,11 @@ function drawShape(ctx, type, property, geo) {
233
249
  // Rectangles and ellipses take their outline from the border settings, like
234
250
  // on a live page; a new shape stores a 1px grey border with style None.
235
251
  strokeBorder(c, property, width, height, geo.radius, ellipse);
252
+ // Text typed into a shape (a Button preset is a shape with text) is part of
253
+ // the picture, the same as it is on a live page.
254
+ const content = typeof property['content'] === 'string' ? property['content'] : '';
255
+ if (content)
256
+ drawTextInBox(c, property, geo.radius, width, height, content, SHAPE_TEXT_PADDING);
236
257
  });
237
258
  }
238
259
  /** Loads one image with EXIF orientation applied, or throws a SouvenirError. */
@@ -260,11 +281,33 @@ export async function loadSouvenirImage(url, crossOrigin) {
260
281
  image.src = url;
261
282
  });
262
283
  }
284
+ /**
285
+ * 整页时用它,给了区域时按区域算。区域外的元件照常留在页面上,由 runner 画。
286
+ */
287
+ export function regionOf(page, region) {
288
+ return region ?? { x: 0, y: 0, width: num(page.width), height: num(page.height) };
289
+ }
290
+ /** 出图尺寸:形状跟着区域,长边取 longEdge;不给 longEdge 就按区域原尺寸。 */
291
+ export function outputSize(region, longEdge) {
292
+ const edge = Number.isFinite(longEdge) && longEdge > 0 ? longEdge : 0;
293
+ const longest = Math.max(region.width, region.height);
294
+ const scale = edge > 0 && longest > 0 ? edge / longest : 1;
295
+ return {
296
+ width: Math.max(1, Math.round(region.width * scale)),
297
+ height: Math.max(1, Math.round(region.height * scale)),
298
+ scale,
299
+ };
300
+ }
263
301
  /** Element ids in draw order: z-index ascending, insertion order for ties. */
264
- export function drawOrder(page) {
302
+ export function drawOrder(page, region) {
303
+ const box = region;
265
304
  return Object.keys(page.elements ?? {})
266
305
  .filter((eid) => SOUVENIR_ELEMENT_TYPES.includes(page.elements[eid]?.type))
267
306
  .filter((eid) => !isHidden(page.properties?.[eid] ?? {}))
307
+ // Controls stay on screen over the picture, so they are not in it
308
+ .filter((eid) => !isSouvenirInteractive(page.elements[eid]?.type, page.properties?.[eid] ?? {}))
309
+ // 框外的元件是页面的一部分,不进图
310
+ .filter((eid) => !box || isInsideRegion(page.properties?.[eid] ?? {}, box))
268
311
  .map((eid, index) => ({ eid, index, z: num((page.properties?.[eid] ?? {})['zIndex']) }))
269
312
  .sort((a, b) => (a.z === b.z ? a.index - b.index : a.z - b.z))
270
313
  .map((entry) => entry.eid);
@@ -273,7 +316,8 @@ export function drawOrder(page) {
273
316
  * True when every pixel is guaranteed opaque, i.e. a `bg` element covers the
274
317
  * page with a colour or an image. Decides JPEG versus PNG in `auto`.
275
318
  */
276
- export function pageHasOpaqueBackground(page) {
319
+ export function pageHasOpaqueBackground(page, region) {
320
+ const box = regionOf(page, region);
277
321
  for (const eid of Object.keys(page.elements ?? {})) {
278
322
  if (page.elements[eid]?.type !== 'bg')
279
323
  continue;
@@ -281,7 +325,7 @@ export function pageHasOpaqueBackground(page) {
281
325
  if (isHidden(property))
282
326
  continue;
283
327
  const geo = geometryOf(property);
284
- const covers = geo.x <= 0 && geo.y <= 0 && geo.width >= page.width && geo.height >= page.height;
328
+ const covers = geo.x <= box.x && geo.y <= box.y && geo.x + geo.width >= box.x + box.width && geo.y + geo.height >= box.y + box.height;
285
329
  if (!covers)
286
330
  continue;
287
331
  if (geo.opacity < 100 || geo.radius > 0 || geo.rotation)
@@ -291,10 +335,12 @@ export function pageHasOpaqueBackground(page) {
291
335
  }
292
336
  return false;
293
337
  }
294
- /** A stable signature for (page, values): same inputs, same tag, same upload. */
295
- export function souvenirSignature(page, values) {
296
- const parts = [`${page.width}x${page.height}`];
297
- for (const eid of drawOrder(page)) {
338
+ /** A stable signature for (page, values, region, size): same inputs, same tag, same upload. */
339
+ export function souvenirSignature(page, values, options = {}) {
340
+ const box = regionOf(page, options.region);
341
+ const size = outputSize(box, options.longEdge);
342
+ const parts = [`${box.x},${box.y},${box.width}x${box.height}`, `${size.width}x${size.height}`];
343
+ for (const eid of drawOrder(page, options.region)) {
298
344
  parts.push(eid, page.elements[eid]?.type ?? '', JSON.stringify(page.properties?.[eid] ?? {}), String(values[eid] ?? ''));
299
345
  }
300
346
  return hashSignature(parts.join('|'));
@@ -310,12 +356,14 @@ export async function renderSouvenir(page, values = {}, options = {}) {
310
356
  if (typeof document === 'undefined') {
311
357
  throw new SouvenirError('no_dom', 'The souvenir composer needs a browser document.');
312
358
  }
313
- const width = Math.round(num(page.width));
314
- const height = Math.round(num(page.height));
359
+ const box = regionOf(page, options.region);
360
+ const size = outputSize(box, options.longEdge);
361
+ const width = size.width;
362
+ const height = size.height;
315
363
  if (!isSouvenirSizeAllowed(width, height)) {
316
- throw new SouvenirError(width < 1 || height < 1 ? 'bad_size' : 'too_large', `Souvenir page size ${width}x${height} is out of range.`);
364
+ throw new SouvenirError(width < 1 || height < 1 ? 'bad_size' : 'too_large', `Souvenir image size ${width}x${height} is out of range.`);
317
365
  }
318
- const order = drawOrder(page);
366
+ const order = drawOrder(page, options.region);
319
367
  // Webfonts first: measuring before they land silently lays the text out in
320
368
  // the fallback font and the first souvenir of an event comes out wrong.
321
369
  if (!options.skipFontWait && typeof document !== 'undefined' && document.fonts?.ready) {
@@ -345,6 +393,9 @@ export async function renderSouvenir(page, values = {}, options = {}) {
345
393
  const ctx = canvas.getContext('2d');
346
394
  if (!ctx)
347
395
  throw new SouvenirError('canvas_unavailable', 'Could not get a 2d context.');
396
+ // 元件坐标是页面坐标:先按出图倍数放大,再把框的左上角移到画布原点。描边宽度与字号
397
+ // 跟着这一层变换等比放大,所以各元件照页面坐标画就行。
398
+ ctx.setTransform(size.scale, 0, 0, size.scale, -box.x * size.scale, -box.y * size.scale);
348
399
  for (const eid of order) {
349
400
  const type = page.elements[eid]?.type ?? '';
350
401
  const property = page.properties?.[eid] ?? {};
@@ -14,6 +14,19 @@
14
14
  /** Element types the composer draws. Anything else on the page is skipped. */
15
15
  export declare const SOUVENIR_ELEMENT_TYPES: readonly ["bg", "image", "image-frame", "text", "shape-rect", "shape-ellipse", "shape-line"];
16
16
  export type SouvenirElementType = (typeof SOUVENIR_ELEMENT_TYPES)[number];
17
+ /**
18
+ * Element types that do something when the participant taps them, or that count
19
+ * down. On a page the module composes and displays itself, the runner keeps
20
+ * these on screen over the picture, so the composer must leave them out or the
21
+ * same thing would appear twice (`qik-sense-runner/src/services/pageRender.ts`
22
+ * keeps the same list).
23
+ */
24
+ export declare const SOUVENIR_INTERACTIVE_TYPES: readonly ["timeout", "button", "qr", "link"];
25
+ /**
26
+ * True when this element is a control rather than part of the picture: an
27
+ * interactive type, or any element the operator gave a target to.
28
+ */
29
+ export declare function isSouvenirInteractive(type: string | undefined | null, property?: SouvenirProperty): boolean;
17
30
  /** Anything the Builder stores for one element. Values arrive untyped from the API. */
18
31
  export type SouvenirProperty = Record<string, unknown>;
19
32
  /** One element as the Builder's `detail.elements` records it. */
@@ -42,12 +55,34 @@ export interface SouvenirPage {
42
55
  */
43
56
  export type SouvenirValues = Record<string, string | null | undefined>;
44
57
  export type SouvenirFormat = 'auto' | 'jpeg' | 'png';
58
+ /**
59
+ * The part of the page that becomes the image, in the page's own coordinates.
60
+ *
61
+ * A module that lays the souvenir out on a page participants see marks a frame
62
+ * on it: what is inside becomes the picture, what is outside stays a normal
63
+ * page (PF 2026-09-18). Left out, the whole page is composed.
64
+ */
65
+ export interface SouvenirRegion {
66
+ x: number;
67
+ y: number;
68
+ width: number;
69
+ height: number;
70
+ }
71
+ /** 一个元件算不算在框里:按中心点判定(PF 2026-09-18),画的时候再按框裁切。 */
72
+ export declare function isInsideRegion(property: SouvenirProperty, region: SouvenirRegion): boolean;
45
73
  export interface SouvenirOptions {
46
74
  /**
47
75
  * `auto` (default) picks JPEG unless the page has no opaque background, in
48
76
  * which case the transparency has to survive and PNG wins.
49
77
  */
50
78
  format?: SouvenirFormat;
79
+ /** 只合成页面上的这一块。不给就是整页(Live Input 的离屏页走这一条)。 */
80
+ region?: SouvenirRegion;
81
+ /**
82
+ * 出图的长边像素。短边按区域比例算,所以运营改框的形状不必再改这个数。
83
+ * 不给就按区域的原始尺寸出图。
84
+ */
85
+ longEdge?: number;
51
86
  /** JPEG quality, 0 to 1. Default 0.9. */
52
87
  quality?: number;
53
88
  /** File name handed to the upload endpoint. Extension is set from the format. */
@@ -1 +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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/souvenir/types.ts"],"names":[],"mappings":"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"}
@@ -21,6 +21,38 @@ export const SOUVENIR_ELEMENT_TYPES = [
21
21
  'shape-ellipse',
22
22
  'shape-line',
23
23
  ];
24
+ /**
25
+ * Element types that do something when the participant taps them, or that count
26
+ * down. On a page the module composes and displays itself, the runner keeps
27
+ * these on screen over the picture, so the composer must leave them out or the
28
+ * same thing would appear twice (`qik-sense-runner/src/services/pageRender.ts`
29
+ * keeps the same list).
30
+ */
31
+ export const SOUVENIR_INTERACTIVE_TYPES = ['timeout', 'button', 'qr', 'link'];
32
+ /** Property keys that turn any element into something tappable. */
33
+ const INTERACTIVE_PROPERTIES = ['connection-key', 'url', 'key-code'];
34
+ /**
35
+ * True when this element is a control rather than part of the picture: an
36
+ * interactive type, or any element the operator gave a target to.
37
+ */
38
+ export function isSouvenirInteractive(type, property = {}) {
39
+ if (SOUVENIR_INTERACTIVE_TYPES.includes(String(type ?? '')))
40
+ return true;
41
+ return INTERACTIVE_PROPERTIES.some((key) => String(property?.[key] ?? '').trim() !== '');
42
+ }
43
+ /** 一个元件算不算在框里:按中心点判定(PF 2026-09-18),画的时候再按框裁切。 */
44
+ export function isInsideRegion(property, region) {
45
+ const num = (value) => {
46
+ const parsed = typeof value === 'number' ? value : parseFloat(String(value ?? ''));
47
+ return Number.isFinite(parsed) ? parsed : 0;
48
+ };
49
+ const centerX = num(property['x-position']) + num(property['width']) / 2;
50
+ const centerY = num(property['y-position']) + num(property['height']) / 2;
51
+ return (centerX >= region.x &&
52
+ centerX <= region.x + region.width &&
53
+ centerY >= region.y &&
54
+ centerY <= region.y + region.height);
55
+ }
24
56
  /** Thrown for every refusal the caller is expected to handle and show. */
25
57
  export class SouvenirError extends Error {
26
58
  code;
package/package.json CHANGED
@@ -1,64 +1,64 @@
1
- {
2
- "name": "@realnation/builder-shared-sdk",
3
- "version": "2.4.1",
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
- }
1
+ {
2
+ "name": "@realnation/builder-shared-sdk",
3
+ "version": "2.5.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
+ }