@shotstack/shotstack-canvas 1.4.7 → 1.5.1

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.
@@ -132,11 +132,26 @@ var animationSchema = import_joi.default.object({
132
132
  otherwise: import_joi.default.forbidden()
133
133
  })
134
134
  }).unknown(false);
135
+ var borderSchema = import_joi.default.object({
136
+ width: import_joi.default.number().min(0).default(0),
137
+ color: import_joi.default.string().pattern(HEX6).default("#000000"),
138
+ opacity: import_joi.default.number().min(0).max(1).default(1)
139
+ }).unknown(false);
135
140
  var backgroundSchema = import_joi.default.object({
136
141
  color: import_joi.default.string().pattern(HEX6).optional(),
137
142
  opacity: import_joi.default.number().min(0).max(1).default(1),
138
- borderRadius: import_joi.default.number().min(0).default(0)
143
+ borderRadius: import_joi.default.number().min(0).default(0),
144
+ border: borderSchema.optional()
139
145
  }).unknown(false);
146
+ var paddingSchema = import_joi.default.alternatives().try(
147
+ import_joi.default.number().min(0).default(0),
148
+ import_joi.default.object({
149
+ top: import_joi.default.number().min(0).default(0),
150
+ right: import_joi.default.number().min(0).default(0),
151
+ bottom: import_joi.default.number().min(0).default(0),
152
+ left: import_joi.default.number().min(0).default(0)
153
+ }).unknown(false)
154
+ );
140
155
  var customFontSchema = import_joi.default.object({
141
156
  src: import_joi.default.string().uri().required(),
142
157
  family: import_joi.default.string().required(),
@@ -154,11 +169,10 @@ var RichTextAssetSchema = import_joi.default.object({
154
169
  stroke: strokeSchema.optional(),
155
170
  shadow: shadowSchema.optional(),
156
171
  background: backgroundSchema.optional(),
172
+ padding: paddingSchema.optional(),
157
173
  align: alignmentSchema.optional(),
158
174
  animation: animationSchema.optional(),
159
- customFonts: import_joi.default.array().items(customFontSchema).optional(),
160
- cacheEnabled: import_joi.default.boolean().default(true),
161
- pixelRatio: import_joi.default.number().min(1).max(3).default(CANVAS_CONFIG.DEFAULTS.pixelRatio)
175
+ customFonts: import_joi.default.array().items(customFontSchema).optional()
162
176
  }).unknown(false);
163
177
 
164
178
  // src/wasm/hb-loader.ts
@@ -828,15 +842,27 @@ function decorationGeometry(kind, p) {
828
842
  }
829
843
 
830
844
  // src/core/drawops.ts
845
+ function normalizePadding(padding) {
846
+ if (padding === void 0 || padding === null) {
847
+ return { top: 0, right: 0, bottom: 0, left: 0 };
848
+ }
849
+ if (typeof padding === "number") {
850
+ return { top: padding, right: padding, bottom: padding, left: padding };
851
+ }
852
+ return padding;
853
+ }
831
854
  async function buildDrawOps(p) {
832
855
  const ops = [];
856
+ const padding = normalizePadding(p.padding);
857
+ const borderWidth = p.background?.border?.width ?? 0;
833
858
  ops.push({
834
859
  op: "BeginFrame",
835
860
  width: p.canvas.width,
836
861
  height: p.canvas.height,
837
862
  pixelRatio: p.canvas.pixelRatio,
838
863
  clear: true,
839
- bg: p.background && p.background.color ? { color: p.background.color, opacity: p.background.opacity, radius: p.background.borderRadius } : void 0
864
+ bg: void 0
865
+ // Background will be drawn as a separate layer with proper padding/border
840
866
  });
841
867
  if (p.lines.length === 0) return ops;
842
868
  const upem = Math.max(1, await p.getUnitsPerEm());
@@ -846,33 +872,34 @@ async function buildDrawOps(p) {
846
872
  let blockY;
847
873
  switch (p.align.vertical) {
848
874
  case "top":
849
- blockY = p.font.size;
875
+ blockY = p.font.size + padding.top;
850
876
  break;
851
877
  case "bottom":
852
- blockY = p.textRect.height - (numLines - 1) * lineHeightPx;
878
+ blockY = p.textRect.height - (numLines - 1) * lineHeightPx + padding.top;
853
879
  break;
854
880
  case "middle":
855
881
  default:
856
882
  const capHeightRatio = 0.35;
857
883
  const visualOffset = p.font.size * capHeightRatio;
858
- blockY = (p.textRect.height - (numLines - 1) * lineHeightPx) / 2 + visualOffset;
884
+ blockY = (p.textRect.height - (numLines - 1) * lineHeightPx) / 2 + visualOffset + padding.top;
859
885
  break;
860
886
  }
861
887
  const fill = p.style.gradient ? gradientSpecFrom(p.style.gradient, 1) : { kind: "solid", color: p.font.color, opacity: p.font.opacity };
862
888
  const decoColor = p.style.gradient ? p.style.gradient.stops[p.style.gradient.stops.length - 1]?.color ?? p.font.color : p.font.color;
889
+ const textOps = [];
863
890
  let gMinX = Infinity, gMinY = Infinity, gMaxX = -Infinity, gMaxY = -Infinity;
864
891
  for (const line of p.lines) {
865
892
  let lineX;
866
893
  switch (p.align.horizontal) {
867
894
  case "left":
868
- lineX = 0;
895
+ lineX = padding.left;
869
896
  break;
870
897
  case "right":
871
- lineX = p.textRect.width - line.width;
898
+ lineX = p.textRect.width - line.width + padding.left;
872
899
  break;
873
900
  case "center":
874
901
  default:
875
- lineX = (p.textRect.width - line.width) / 2;
902
+ lineX = (p.textRect.width - line.width) / 2 + padding.left;
876
903
  break;
877
904
  }
878
905
  let xCursor = lineX;
@@ -896,7 +923,7 @@ async function buildDrawOps(p) {
896
923
  if (x2 > gMaxX) gMaxX = x2;
897
924
  if (y2 > gMaxY) gMaxY = y2;
898
925
  if (p.shadow && p.shadow.blur > 0) {
899
- ops.push({
926
+ textOps.push({
900
927
  isShadow: true,
901
928
  op: "FillPath",
902
929
  path,
@@ -907,7 +934,7 @@ async function buildDrawOps(p) {
907
934
  });
908
935
  }
909
936
  if (p.stroke && p.stroke.width > 0) {
910
- ops.push({
937
+ textOps.push({
911
938
  op: "StrokePath",
912
939
  path,
913
940
  x: glyphX,
@@ -918,7 +945,7 @@ async function buildDrawOps(p) {
918
945
  opacity: p.stroke.opacity
919
946
  });
920
947
  }
921
- ops.push({
948
+ textOps.push({
922
949
  op: "FillPath",
923
950
  path,
924
951
  x: glyphX,
@@ -935,7 +962,7 @@ async function buildDrawOps(p) {
935
962
  lineWidth: line.width,
936
963
  xStart: lineX
937
964
  });
938
- ops.push({
965
+ textOps.push({
939
966
  op: "DecorationLine",
940
967
  from: { x: deco.x1, y: deco.y },
941
968
  to: { x: deco.x2, y: deco.y },
@@ -947,12 +974,46 @@ async function buildDrawOps(p) {
947
974
  }
948
975
  if (gMinX !== Infinity) {
949
976
  const gbox = { x: gMinX, y: gMinY, w: Math.max(1, gMaxX - gMinX), h: Math.max(1, gMaxY - gMinY) };
950
- for (const op of ops) {
977
+ for (const op of textOps) {
951
978
  if (op.op === "FillPath" && !op.isShadow) {
952
979
  op.gradientBBox = gbox;
953
980
  }
954
981
  }
955
982
  }
983
+ if (p.background && (p.background.color || p.background.border)) {
984
+ const bgX = 0;
985
+ const bgY = 0;
986
+ const bgWidth = p.canvas.width;
987
+ const bgHeight = p.canvas.height;
988
+ if (p.background.color) {
989
+ ops.push({
990
+ op: "Rectangle",
991
+ x: bgX,
992
+ y: bgY,
993
+ width: bgWidth,
994
+ height: bgHeight,
995
+ fill: { kind: "solid", color: p.background.color, opacity: p.background.opacity },
996
+ borderRadius: p.background.borderRadius
997
+ });
998
+ }
999
+ if (p.background.border && p.background.border.width > 0) {
1000
+ const halfBorder = p.background.border.width / 2;
1001
+ ops.push({
1002
+ op: "RectangleStroke",
1003
+ x: bgX + halfBorder,
1004
+ y: bgY + halfBorder,
1005
+ width: bgWidth - p.background.border.width,
1006
+ height: bgHeight - p.background.border.width,
1007
+ stroke: {
1008
+ width: p.background.border.width,
1009
+ color: p.background.border.color,
1010
+ opacity: p.background.border.opacity
1011
+ },
1012
+ borderRadius: Math.max(0, p.background.borderRadius - halfBorder)
1013
+ });
1014
+ }
1015
+ }
1016
+ ops.push(...textOps);
956
1017
  return ops;
957
1018
  }
958
1019
  function tokenizePath(d) {
@@ -1762,6 +1823,44 @@ async function createNodePainter(opts) {
1762
1823
  });
1763
1824
  continue;
1764
1825
  }
1826
+ if (op.op === "Rectangle") {
1827
+ renderToBoth((context) => {
1828
+ context.save();
1829
+ const fill = makeGradientFromBBox(context, op.fill, {
1830
+ x: op.x,
1831
+ y: op.y,
1832
+ w: op.width,
1833
+ h: op.height
1834
+ });
1835
+ context.fillStyle = fill;
1836
+ if (op.borderRadius && op.borderRadius > 0) {
1837
+ context.beginPath();
1838
+ roundRectPath(context, op.x, op.y, op.width, op.height, op.borderRadius);
1839
+ context.fill();
1840
+ } else {
1841
+ context.fillRect(op.x, op.y, op.width, op.height);
1842
+ }
1843
+ context.restore();
1844
+ });
1845
+ continue;
1846
+ }
1847
+ if (op.op === "RectangleStroke") {
1848
+ renderToBoth((context) => {
1849
+ context.save();
1850
+ const c = parseHex6(op.stroke.color, op.stroke.opacity);
1851
+ context.strokeStyle = `rgba(${c.r},${c.g},${c.b},${c.a})`;
1852
+ context.lineWidth = op.stroke.width;
1853
+ if (op.borderRadius && op.borderRadius > 0) {
1854
+ context.beginPath();
1855
+ roundRectPath(context, op.x, op.y, op.width, op.height, op.borderRadius);
1856
+ context.stroke();
1857
+ } else {
1858
+ context.strokeRect(op.x, op.y, op.width, op.height);
1859
+ }
1860
+ context.restore();
1861
+ });
1862
+ continue;
1863
+ }
1765
1864
  }
1766
1865
  if (needsAlphaExtraction) {
1767
1866
  const whiteData = ctx.getImageData(0, 0, canvas.width, canvas.height);
@@ -2325,15 +2424,16 @@ async function createTextEngine(opts = {}) {
2325
2424
  `Failed to layout text: ${err instanceof Error ? err.message : String(err)}`
2326
2425
  );
2327
2426
  }
2427
+ const padding = asset.padding ? typeof asset.padding === "number" ? { top: asset.padding, right: asset.padding, bottom: asset.padding, left: asset.padding } : asset.padding : { top: 0, right: 0, bottom: 0, left: 0 };
2328
2428
  const textRect = {
2329
2429
  x: 0,
2330
2430
  y: 0,
2331
2431
  width: asset.width ?? width,
2332
2432
  height: asset.height ?? height
2333
2433
  };
2334
- const canvasW = asset.width ?? width;
2335
- const canvasH = asset.height ?? height;
2336
- const canvasPR = asset.pixelRatio ?? pixelRatio;
2434
+ const canvasW = (asset.width ?? width) + padding.left + padding.right;
2435
+ const canvasH = (asset.height ?? height) + padding.top + padding.bottom;
2436
+ const canvasPR = pixelRatio;
2337
2437
  let ops0;
2338
2438
  try {
2339
2439
  ops0 = await buildDrawOps({
@@ -2359,6 +2459,7 @@ async function createTextEngine(opts = {}) {
2359
2459
  vertical: asset.align?.vertical ?? "middle"
2360
2460
  },
2361
2461
  background: asset.background,
2462
+ padding: asset.padding,
2362
2463
  glyphPathProvider: (gid, fontDesc) => fonts.glyphPath(fontDesc || desc, gid),
2363
2464
  getUnitsPerEm: (fontDesc) => fonts.getUnitsPerEm(fontDesc || desc)
2364
2465
  });
@@ -2421,7 +2522,7 @@ async function createTextEngine(opts = {}) {
2421
2522
  fps,
2422
2523
  duration: options.duration ?? 3,
2423
2524
  outputPath: options.outputPath ?? "output.mp4",
2424
- pixelRatio: asset.pixelRatio ?? pixelRatio,
2525
+ pixelRatio,
2425
2526
  hasAlpha: needsAlpha,
2426
2527
  ...options
2427
2528
  };
@@ -64,6 +64,17 @@ type RichTextValidated = Required<{
64
64
  color?: string;
65
65
  opacity: number;
66
66
  borderRadius: number;
67
+ border?: {
68
+ width: number;
69
+ color: string;
70
+ opacity: number;
71
+ };
72
+ };
73
+ padding?: number | {
74
+ top: number;
75
+ right: number;
76
+ bottom: number;
77
+ left: number;
67
78
  };
68
79
  align?: {
69
80
  horizontal: "left" | "center" | "right";
@@ -83,8 +94,6 @@ type RichTextValidated = Required<{
83
94
  style?: string;
84
95
  originalFamily?: string;
85
96
  }[];
86
- cacheEnabled: boolean;
87
- pixelRatio: number;
88
97
  }>;
89
98
 
90
99
  type RGBA = {
@@ -177,6 +186,26 @@ type DrawOp = {
177
186
  width: number;
178
187
  color: string;
179
188
  opacity: number;
189
+ } | {
190
+ op: "Rectangle";
191
+ x: number;
192
+ y: number;
193
+ width: number;
194
+ height: number;
195
+ fill: GradientSpec;
196
+ borderRadius?: number;
197
+ } | {
198
+ op: "RectangleStroke";
199
+ x: number;
200
+ y: number;
201
+ width: number;
202
+ height: number;
203
+ stroke: {
204
+ width: number;
205
+ color: string;
206
+ opacity: number;
207
+ };
208
+ borderRadius?: number;
180
209
  };
181
210
  type EngineInit = {
182
211
  width: number;
@@ -64,6 +64,17 @@ type RichTextValidated = Required<{
64
64
  color?: string;
65
65
  opacity: number;
66
66
  borderRadius: number;
67
+ border?: {
68
+ width: number;
69
+ color: string;
70
+ opacity: number;
71
+ };
72
+ };
73
+ padding?: number | {
74
+ top: number;
75
+ right: number;
76
+ bottom: number;
77
+ left: number;
67
78
  };
68
79
  align?: {
69
80
  horizontal: "left" | "center" | "right";
@@ -83,8 +94,6 @@ type RichTextValidated = Required<{
83
94
  style?: string;
84
95
  originalFamily?: string;
85
96
  }[];
86
- cacheEnabled: boolean;
87
- pixelRatio: number;
88
97
  }>;
89
98
 
90
99
  type RGBA = {
@@ -177,6 +186,26 @@ type DrawOp = {
177
186
  width: number;
178
187
  color: string;
179
188
  opacity: number;
189
+ } | {
190
+ op: "Rectangle";
191
+ x: number;
192
+ y: number;
193
+ width: number;
194
+ height: number;
195
+ fill: GradientSpec;
196
+ borderRadius?: number;
197
+ } | {
198
+ op: "RectangleStroke";
199
+ x: number;
200
+ y: number;
201
+ width: number;
202
+ height: number;
203
+ stroke: {
204
+ width: number;
205
+ color: string;
206
+ opacity: number;
207
+ };
208
+ borderRadius?: number;
180
209
  };
181
210
  type EngineInit = {
182
211
  width: number;
@@ -94,11 +94,26 @@ var animationSchema = Joi.object({
94
94
  otherwise: Joi.forbidden()
95
95
  })
96
96
  }).unknown(false);
97
+ var borderSchema = Joi.object({
98
+ width: Joi.number().min(0).default(0),
99
+ color: Joi.string().pattern(HEX6).default("#000000"),
100
+ opacity: Joi.number().min(0).max(1).default(1)
101
+ }).unknown(false);
97
102
  var backgroundSchema = Joi.object({
98
103
  color: Joi.string().pattern(HEX6).optional(),
99
104
  opacity: Joi.number().min(0).max(1).default(1),
100
- borderRadius: Joi.number().min(0).default(0)
105
+ borderRadius: Joi.number().min(0).default(0),
106
+ border: borderSchema.optional()
101
107
  }).unknown(false);
108
+ var paddingSchema = Joi.alternatives().try(
109
+ Joi.number().min(0).default(0),
110
+ Joi.object({
111
+ top: Joi.number().min(0).default(0),
112
+ right: Joi.number().min(0).default(0),
113
+ bottom: Joi.number().min(0).default(0),
114
+ left: Joi.number().min(0).default(0)
115
+ }).unknown(false)
116
+ );
102
117
  var customFontSchema = Joi.object({
103
118
  src: Joi.string().uri().required(),
104
119
  family: Joi.string().required(),
@@ -116,11 +131,10 @@ var RichTextAssetSchema = Joi.object({
116
131
  stroke: strokeSchema.optional(),
117
132
  shadow: shadowSchema.optional(),
118
133
  background: backgroundSchema.optional(),
134
+ padding: paddingSchema.optional(),
119
135
  align: alignmentSchema.optional(),
120
136
  animation: animationSchema.optional(),
121
- customFonts: Joi.array().items(customFontSchema).optional(),
122
- cacheEnabled: Joi.boolean().default(true),
123
- pixelRatio: Joi.number().min(1).max(3).default(CANVAS_CONFIG.DEFAULTS.pixelRatio)
137
+ customFonts: Joi.array().items(customFontSchema).optional()
124
138
  }).unknown(false);
125
139
 
126
140
  // src/wasm/hb-loader.ts
@@ -789,15 +803,27 @@ function decorationGeometry(kind, p) {
789
803
  }
790
804
 
791
805
  // src/core/drawops.ts
806
+ function normalizePadding(padding) {
807
+ if (padding === void 0 || padding === null) {
808
+ return { top: 0, right: 0, bottom: 0, left: 0 };
809
+ }
810
+ if (typeof padding === "number") {
811
+ return { top: padding, right: padding, bottom: padding, left: padding };
812
+ }
813
+ return padding;
814
+ }
792
815
  async function buildDrawOps(p) {
793
816
  const ops = [];
817
+ const padding = normalizePadding(p.padding);
818
+ const borderWidth = p.background?.border?.width ?? 0;
794
819
  ops.push({
795
820
  op: "BeginFrame",
796
821
  width: p.canvas.width,
797
822
  height: p.canvas.height,
798
823
  pixelRatio: p.canvas.pixelRatio,
799
824
  clear: true,
800
- bg: p.background && p.background.color ? { color: p.background.color, opacity: p.background.opacity, radius: p.background.borderRadius } : void 0
825
+ bg: void 0
826
+ // Background will be drawn as a separate layer with proper padding/border
801
827
  });
802
828
  if (p.lines.length === 0) return ops;
803
829
  const upem = Math.max(1, await p.getUnitsPerEm());
@@ -807,33 +833,34 @@ async function buildDrawOps(p) {
807
833
  let blockY;
808
834
  switch (p.align.vertical) {
809
835
  case "top":
810
- blockY = p.font.size;
836
+ blockY = p.font.size + padding.top;
811
837
  break;
812
838
  case "bottom":
813
- blockY = p.textRect.height - (numLines - 1) * lineHeightPx;
839
+ blockY = p.textRect.height - (numLines - 1) * lineHeightPx + padding.top;
814
840
  break;
815
841
  case "middle":
816
842
  default:
817
843
  const capHeightRatio = 0.35;
818
844
  const visualOffset = p.font.size * capHeightRatio;
819
- blockY = (p.textRect.height - (numLines - 1) * lineHeightPx) / 2 + visualOffset;
845
+ blockY = (p.textRect.height - (numLines - 1) * lineHeightPx) / 2 + visualOffset + padding.top;
820
846
  break;
821
847
  }
822
848
  const fill = p.style.gradient ? gradientSpecFrom(p.style.gradient, 1) : { kind: "solid", color: p.font.color, opacity: p.font.opacity };
823
849
  const decoColor = p.style.gradient ? p.style.gradient.stops[p.style.gradient.stops.length - 1]?.color ?? p.font.color : p.font.color;
850
+ const textOps = [];
824
851
  let gMinX = Infinity, gMinY = Infinity, gMaxX = -Infinity, gMaxY = -Infinity;
825
852
  for (const line of p.lines) {
826
853
  let lineX;
827
854
  switch (p.align.horizontal) {
828
855
  case "left":
829
- lineX = 0;
856
+ lineX = padding.left;
830
857
  break;
831
858
  case "right":
832
- lineX = p.textRect.width - line.width;
859
+ lineX = p.textRect.width - line.width + padding.left;
833
860
  break;
834
861
  case "center":
835
862
  default:
836
- lineX = (p.textRect.width - line.width) / 2;
863
+ lineX = (p.textRect.width - line.width) / 2 + padding.left;
837
864
  break;
838
865
  }
839
866
  let xCursor = lineX;
@@ -857,7 +884,7 @@ async function buildDrawOps(p) {
857
884
  if (x2 > gMaxX) gMaxX = x2;
858
885
  if (y2 > gMaxY) gMaxY = y2;
859
886
  if (p.shadow && p.shadow.blur > 0) {
860
- ops.push({
887
+ textOps.push({
861
888
  isShadow: true,
862
889
  op: "FillPath",
863
890
  path,
@@ -868,7 +895,7 @@ async function buildDrawOps(p) {
868
895
  });
869
896
  }
870
897
  if (p.stroke && p.stroke.width > 0) {
871
- ops.push({
898
+ textOps.push({
872
899
  op: "StrokePath",
873
900
  path,
874
901
  x: glyphX,
@@ -879,7 +906,7 @@ async function buildDrawOps(p) {
879
906
  opacity: p.stroke.opacity
880
907
  });
881
908
  }
882
- ops.push({
909
+ textOps.push({
883
910
  op: "FillPath",
884
911
  path,
885
912
  x: glyphX,
@@ -896,7 +923,7 @@ async function buildDrawOps(p) {
896
923
  lineWidth: line.width,
897
924
  xStart: lineX
898
925
  });
899
- ops.push({
926
+ textOps.push({
900
927
  op: "DecorationLine",
901
928
  from: { x: deco.x1, y: deco.y },
902
929
  to: { x: deco.x2, y: deco.y },
@@ -908,12 +935,46 @@ async function buildDrawOps(p) {
908
935
  }
909
936
  if (gMinX !== Infinity) {
910
937
  const gbox = { x: gMinX, y: gMinY, w: Math.max(1, gMaxX - gMinX), h: Math.max(1, gMaxY - gMinY) };
911
- for (const op of ops) {
938
+ for (const op of textOps) {
912
939
  if (op.op === "FillPath" && !op.isShadow) {
913
940
  op.gradientBBox = gbox;
914
941
  }
915
942
  }
916
943
  }
944
+ if (p.background && (p.background.color || p.background.border)) {
945
+ const bgX = 0;
946
+ const bgY = 0;
947
+ const bgWidth = p.canvas.width;
948
+ const bgHeight = p.canvas.height;
949
+ if (p.background.color) {
950
+ ops.push({
951
+ op: "Rectangle",
952
+ x: bgX,
953
+ y: bgY,
954
+ width: bgWidth,
955
+ height: bgHeight,
956
+ fill: { kind: "solid", color: p.background.color, opacity: p.background.opacity },
957
+ borderRadius: p.background.borderRadius
958
+ });
959
+ }
960
+ if (p.background.border && p.background.border.width > 0) {
961
+ const halfBorder = p.background.border.width / 2;
962
+ ops.push({
963
+ op: "RectangleStroke",
964
+ x: bgX + halfBorder,
965
+ y: bgY + halfBorder,
966
+ width: bgWidth - p.background.border.width,
967
+ height: bgHeight - p.background.border.width,
968
+ stroke: {
969
+ width: p.background.border.width,
970
+ color: p.background.border.color,
971
+ opacity: p.background.border.opacity
972
+ },
973
+ borderRadius: Math.max(0, p.background.borderRadius - halfBorder)
974
+ });
975
+ }
976
+ }
977
+ ops.push(...textOps);
917
978
  return ops;
918
979
  }
919
980
  function tokenizePath(d) {
@@ -1723,6 +1784,44 @@ async function createNodePainter(opts) {
1723
1784
  });
1724
1785
  continue;
1725
1786
  }
1787
+ if (op.op === "Rectangle") {
1788
+ renderToBoth((context) => {
1789
+ context.save();
1790
+ const fill = makeGradientFromBBox(context, op.fill, {
1791
+ x: op.x,
1792
+ y: op.y,
1793
+ w: op.width,
1794
+ h: op.height
1795
+ });
1796
+ context.fillStyle = fill;
1797
+ if (op.borderRadius && op.borderRadius > 0) {
1798
+ context.beginPath();
1799
+ roundRectPath(context, op.x, op.y, op.width, op.height, op.borderRadius);
1800
+ context.fill();
1801
+ } else {
1802
+ context.fillRect(op.x, op.y, op.width, op.height);
1803
+ }
1804
+ context.restore();
1805
+ });
1806
+ continue;
1807
+ }
1808
+ if (op.op === "RectangleStroke") {
1809
+ renderToBoth((context) => {
1810
+ context.save();
1811
+ const c = parseHex6(op.stroke.color, op.stroke.opacity);
1812
+ context.strokeStyle = `rgba(${c.r},${c.g},${c.b},${c.a})`;
1813
+ context.lineWidth = op.stroke.width;
1814
+ if (op.borderRadius && op.borderRadius > 0) {
1815
+ context.beginPath();
1816
+ roundRectPath(context, op.x, op.y, op.width, op.height, op.borderRadius);
1817
+ context.stroke();
1818
+ } else {
1819
+ context.strokeRect(op.x, op.y, op.width, op.height);
1820
+ }
1821
+ context.restore();
1822
+ });
1823
+ continue;
1824
+ }
1726
1825
  }
1727
1826
  if (needsAlphaExtraction) {
1728
1827
  const whiteData = ctx.getImageData(0, 0, canvas.width, canvas.height);
@@ -2286,15 +2385,16 @@ async function createTextEngine(opts = {}) {
2286
2385
  `Failed to layout text: ${err instanceof Error ? err.message : String(err)}`
2287
2386
  );
2288
2387
  }
2388
+ const padding = asset.padding ? typeof asset.padding === "number" ? { top: asset.padding, right: asset.padding, bottom: asset.padding, left: asset.padding } : asset.padding : { top: 0, right: 0, bottom: 0, left: 0 };
2289
2389
  const textRect = {
2290
2390
  x: 0,
2291
2391
  y: 0,
2292
2392
  width: asset.width ?? width,
2293
2393
  height: asset.height ?? height
2294
2394
  };
2295
- const canvasW = asset.width ?? width;
2296
- const canvasH = asset.height ?? height;
2297
- const canvasPR = asset.pixelRatio ?? pixelRatio;
2395
+ const canvasW = (asset.width ?? width) + padding.left + padding.right;
2396
+ const canvasH = (asset.height ?? height) + padding.top + padding.bottom;
2397
+ const canvasPR = pixelRatio;
2298
2398
  let ops0;
2299
2399
  try {
2300
2400
  ops0 = await buildDrawOps({
@@ -2320,6 +2420,7 @@ async function createTextEngine(opts = {}) {
2320
2420
  vertical: asset.align?.vertical ?? "middle"
2321
2421
  },
2322
2422
  background: asset.background,
2423
+ padding: asset.padding,
2323
2424
  glyphPathProvider: (gid, fontDesc) => fonts.glyphPath(fontDesc || desc, gid),
2324
2425
  getUnitsPerEm: (fontDesc) => fonts.getUnitsPerEm(fontDesc || desc)
2325
2426
  });
@@ -2382,7 +2483,7 @@ async function createTextEngine(opts = {}) {
2382
2483
  fps,
2383
2484
  duration: options.duration ?? 3,
2384
2485
  outputPath: options.outputPath ?? "output.mp4",
2385
- pixelRatio: asset.pixelRatio ?? pixelRatio,
2486
+ pixelRatio,
2386
2487
  hasAlpha: needsAlpha,
2387
2488
  ...options
2388
2489
  };
@@ -64,6 +64,17 @@ type RichTextValidated = Required<{
64
64
  color?: string;
65
65
  opacity: number;
66
66
  borderRadius: number;
67
+ border?: {
68
+ width: number;
69
+ color: string;
70
+ opacity: number;
71
+ };
72
+ };
73
+ padding?: number | {
74
+ top: number;
75
+ right: number;
76
+ bottom: number;
77
+ left: number;
67
78
  };
68
79
  align?: {
69
80
  horizontal: "left" | "center" | "right";
@@ -83,8 +94,6 @@ type RichTextValidated = Required<{
83
94
  style?: string;
84
95
  originalFamily?: string;
85
96
  }[];
86
- cacheEnabled: boolean;
87
- pixelRatio: number;
88
97
  }>;
89
98
 
90
99
  type RGBA = {
@@ -177,6 +186,26 @@ type DrawOp = {
177
186
  width: number;
178
187
  color: string;
179
188
  opacity: number;
189
+ } | {
190
+ op: "Rectangle";
191
+ x: number;
192
+ y: number;
193
+ width: number;
194
+ height: number;
195
+ fill: GradientSpec;
196
+ borderRadius?: number;
197
+ } | {
198
+ op: "RectangleStroke";
199
+ x: number;
200
+ y: number;
201
+ width: number;
202
+ height: number;
203
+ stroke: {
204
+ width: number;
205
+ color: string;
206
+ opacity: number;
207
+ };
208
+ borderRadius?: number;
180
209
  };
181
210
  type EngineInit = {
182
211
  width: number;
package/dist/entry.web.js CHANGED
@@ -98,11 +98,26 @@ var animationSchema = Joi.object({
98
98
  otherwise: Joi.forbidden()
99
99
  })
100
100
  }).unknown(false);
101
+ var borderSchema = Joi.object({
102
+ width: Joi.number().min(0).default(0),
103
+ color: Joi.string().pattern(HEX6).default("#000000"),
104
+ opacity: Joi.number().min(0).max(1).default(1)
105
+ }).unknown(false);
101
106
  var backgroundSchema = Joi.object({
102
107
  color: Joi.string().pattern(HEX6).optional(),
103
108
  opacity: Joi.number().min(0).max(1).default(1),
104
- borderRadius: Joi.number().min(0).default(0)
109
+ borderRadius: Joi.number().min(0).default(0),
110
+ border: borderSchema.optional()
105
111
  }).unknown(false);
112
+ var paddingSchema = Joi.alternatives().try(
113
+ Joi.number().min(0).default(0),
114
+ Joi.object({
115
+ top: Joi.number().min(0).default(0),
116
+ right: Joi.number().min(0).default(0),
117
+ bottom: Joi.number().min(0).default(0),
118
+ left: Joi.number().min(0).default(0)
119
+ }).unknown(false)
120
+ );
106
121
  var customFontSchema = Joi.object({
107
122
  src: Joi.string().uri().required(),
108
123
  family: Joi.string().required(),
@@ -120,11 +135,10 @@ var RichTextAssetSchema = Joi.object({
120
135
  stroke: strokeSchema.optional(),
121
136
  shadow: shadowSchema.optional(),
122
137
  background: backgroundSchema.optional(),
138
+ padding: paddingSchema.optional(),
123
139
  align: alignmentSchema.optional(),
124
140
  animation: animationSchema.optional(),
125
- customFonts: Joi.array().items(customFontSchema).optional(),
126
- cacheEnabled: Joi.boolean().default(true),
127
- pixelRatio: Joi.number().min(1).max(3).default(CANVAS_CONFIG.DEFAULTS.pixelRatio)
141
+ customFonts: Joi.array().items(customFontSchema).optional()
128
142
  }).unknown(false);
129
143
 
130
144
  // src/wasm/hb-loader.ts
@@ -794,15 +808,27 @@ function decorationGeometry(kind, p) {
794
808
  }
795
809
 
796
810
  // src/core/drawops.ts
811
+ function normalizePadding(padding) {
812
+ if (padding === void 0 || padding === null) {
813
+ return { top: 0, right: 0, bottom: 0, left: 0 };
814
+ }
815
+ if (typeof padding === "number") {
816
+ return { top: padding, right: padding, bottom: padding, left: padding };
817
+ }
818
+ return padding;
819
+ }
797
820
  async function buildDrawOps(p) {
798
821
  const ops = [];
822
+ const padding = normalizePadding(p.padding);
823
+ const borderWidth = p.background?.border?.width ?? 0;
799
824
  ops.push({
800
825
  op: "BeginFrame",
801
826
  width: p.canvas.width,
802
827
  height: p.canvas.height,
803
828
  pixelRatio: p.canvas.pixelRatio,
804
829
  clear: true,
805
- bg: p.background && p.background.color ? { color: p.background.color, opacity: p.background.opacity, radius: p.background.borderRadius } : void 0
830
+ bg: void 0
831
+ // Background will be drawn as a separate layer with proper padding/border
806
832
  });
807
833
  if (p.lines.length === 0) return ops;
808
834
  const upem = Math.max(1, await p.getUnitsPerEm());
@@ -812,33 +838,34 @@ async function buildDrawOps(p) {
812
838
  let blockY;
813
839
  switch (p.align.vertical) {
814
840
  case "top":
815
- blockY = p.font.size;
841
+ blockY = p.font.size + padding.top;
816
842
  break;
817
843
  case "bottom":
818
- blockY = p.textRect.height - (numLines - 1) * lineHeightPx;
844
+ blockY = p.textRect.height - (numLines - 1) * lineHeightPx + padding.top;
819
845
  break;
820
846
  case "middle":
821
847
  default:
822
848
  const capHeightRatio = 0.35;
823
849
  const visualOffset = p.font.size * capHeightRatio;
824
- blockY = (p.textRect.height - (numLines - 1) * lineHeightPx) / 2 + visualOffset;
850
+ blockY = (p.textRect.height - (numLines - 1) * lineHeightPx) / 2 + visualOffset + padding.top;
825
851
  break;
826
852
  }
827
853
  const fill = p.style.gradient ? gradientSpecFrom(p.style.gradient, 1) : { kind: "solid", color: p.font.color, opacity: p.font.opacity };
828
854
  const decoColor = p.style.gradient ? p.style.gradient.stops[p.style.gradient.stops.length - 1]?.color ?? p.font.color : p.font.color;
855
+ const textOps = [];
829
856
  let gMinX = Infinity, gMinY = Infinity, gMaxX = -Infinity, gMaxY = -Infinity;
830
857
  for (const line of p.lines) {
831
858
  let lineX;
832
859
  switch (p.align.horizontal) {
833
860
  case "left":
834
- lineX = 0;
861
+ lineX = padding.left;
835
862
  break;
836
863
  case "right":
837
- lineX = p.textRect.width - line.width;
864
+ lineX = p.textRect.width - line.width + padding.left;
838
865
  break;
839
866
  case "center":
840
867
  default:
841
- lineX = (p.textRect.width - line.width) / 2;
868
+ lineX = (p.textRect.width - line.width) / 2 + padding.left;
842
869
  break;
843
870
  }
844
871
  let xCursor = lineX;
@@ -862,7 +889,7 @@ async function buildDrawOps(p) {
862
889
  if (x2 > gMaxX) gMaxX = x2;
863
890
  if (y2 > gMaxY) gMaxY = y2;
864
891
  if (p.shadow && p.shadow.blur > 0) {
865
- ops.push({
892
+ textOps.push({
866
893
  isShadow: true,
867
894
  op: "FillPath",
868
895
  path,
@@ -873,7 +900,7 @@ async function buildDrawOps(p) {
873
900
  });
874
901
  }
875
902
  if (p.stroke && p.stroke.width > 0) {
876
- ops.push({
903
+ textOps.push({
877
904
  op: "StrokePath",
878
905
  path,
879
906
  x: glyphX,
@@ -884,7 +911,7 @@ async function buildDrawOps(p) {
884
911
  opacity: p.stroke.opacity
885
912
  });
886
913
  }
887
- ops.push({
914
+ textOps.push({
888
915
  op: "FillPath",
889
916
  path,
890
917
  x: glyphX,
@@ -901,7 +928,7 @@ async function buildDrawOps(p) {
901
928
  lineWidth: line.width,
902
929
  xStart: lineX
903
930
  });
904
- ops.push({
931
+ textOps.push({
905
932
  op: "DecorationLine",
906
933
  from: { x: deco.x1, y: deco.y },
907
934
  to: { x: deco.x2, y: deco.y },
@@ -913,12 +940,46 @@ async function buildDrawOps(p) {
913
940
  }
914
941
  if (gMinX !== Infinity) {
915
942
  const gbox = { x: gMinX, y: gMinY, w: Math.max(1, gMaxX - gMinX), h: Math.max(1, gMaxY - gMinY) };
916
- for (const op of ops) {
943
+ for (const op of textOps) {
917
944
  if (op.op === "FillPath" && !op.isShadow) {
918
945
  op.gradientBBox = gbox;
919
946
  }
920
947
  }
921
948
  }
949
+ if (p.background && (p.background.color || p.background.border)) {
950
+ const bgX = 0;
951
+ const bgY = 0;
952
+ const bgWidth = p.canvas.width;
953
+ const bgHeight = p.canvas.height;
954
+ if (p.background.color) {
955
+ ops.push({
956
+ op: "Rectangle",
957
+ x: bgX,
958
+ y: bgY,
959
+ width: bgWidth,
960
+ height: bgHeight,
961
+ fill: { kind: "solid", color: p.background.color, opacity: p.background.opacity },
962
+ borderRadius: p.background.borderRadius
963
+ });
964
+ }
965
+ if (p.background.border && p.background.border.width > 0) {
966
+ const halfBorder = p.background.border.width / 2;
967
+ ops.push({
968
+ op: "RectangleStroke",
969
+ x: bgX + halfBorder,
970
+ y: bgY + halfBorder,
971
+ width: bgWidth - p.background.border.width,
972
+ height: bgHeight - p.background.border.width,
973
+ stroke: {
974
+ width: p.background.border.width,
975
+ color: p.background.border.color,
976
+ opacity: p.background.border.opacity
977
+ },
978
+ borderRadius: Math.max(0, p.background.borderRadius - halfBorder)
979
+ });
980
+ }
981
+ }
982
+ ops.push(...textOps);
922
983
  return ops;
923
984
  }
924
985
  function tokenizePath(d) {
@@ -1676,6 +1737,48 @@ function createWebPainter(canvas) {
1676
1737
  ctx.restore();
1677
1738
  continue;
1678
1739
  }
1740
+ if (op.op === "Rectangle") {
1741
+ ctx.save();
1742
+ const fill = makeGradientFromBBox(ctx, op.fill, {
1743
+ x: op.x,
1744
+ y: op.y,
1745
+ w: op.width,
1746
+ h: op.height
1747
+ });
1748
+ ctx.fillStyle = fill;
1749
+ if (op.borderRadius && op.borderRadius > 0) {
1750
+ drawRoundedRect(ctx, op.x, op.y, op.width, op.height, op.borderRadius);
1751
+ } else {
1752
+ ctx.fillRect(op.x, op.y, op.width, op.height);
1753
+ }
1754
+ ctx.restore();
1755
+ continue;
1756
+ }
1757
+ if (op.op === "RectangleStroke") {
1758
+ ctx.save();
1759
+ const c = parseHex6(op.stroke.color, op.stroke.opacity);
1760
+ ctx.strokeStyle = `rgba(${c.r},${c.g},${c.b},${c.a})`;
1761
+ ctx.lineWidth = op.stroke.width;
1762
+ if (op.borderRadius && op.borderRadius > 0) {
1763
+ const p = new Path2D();
1764
+ const r = op.borderRadius;
1765
+ const x = op.x;
1766
+ const y = op.y;
1767
+ const w = op.width;
1768
+ const h = op.height;
1769
+ p.moveTo(x + r, y);
1770
+ p.arcTo(x + w, y, x + w, y + h, r);
1771
+ p.arcTo(x + w, y + h, x, y + h, r);
1772
+ p.arcTo(x, y + h, x, y, r);
1773
+ p.arcTo(x, y, x + w, y, r);
1774
+ p.closePath();
1775
+ ctx.stroke(p);
1776
+ } else {
1777
+ ctx.strokeRect(op.x, op.y, op.width, op.height);
1778
+ }
1779
+ ctx.restore();
1780
+ continue;
1781
+ }
1679
1782
  }
1680
1783
  }
1681
1784
  };
@@ -2005,15 +2108,16 @@ async function createTextEngine(opts = {}) {
2005
2108
  `Failed to layout text: ${err instanceof Error ? err.message : String(err)}`
2006
2109
  );
2007
2110
  }
2111
+ const padding = asset.padding ? typeof asset.padding === "number" ? { top: asset.padding, right: asset.padding, bottom: asset.padding, left: asset.padding } : asset.padding : { top: 0, right: 0, bottom: 0, left: 0 };
2008
2112
  const textRect = {
2009
2113
  x: 0,
2010
2114
  y: 0,
2011
2115
  width: asset.width ?? width,
2012
2116
  height: asset.height ?? height
2013
2117
  };
2014
- const canvasW = asset.width ?? width;
2015
- const canvasH = asset.height ?? height;
2016
- const canvasPR = asset.pixelRatio ?? pixelRatio;
2118
+ const canvasW = (asset.width ?? width) + padding.left + padding.right;
2119
+ const canvasH = (asset.height ?? height) + padding.top + padding.bottom;
2120
+ const canvasPR = pixelRatio;
2017
2121
  let ops0;
2018
2122
  try {
2019
2123
  ops0 = await buildDrawOps({
@@ -2039,6 +2143,7 @@ async function createTextEngine(opts = {}) {
2039
2143
  vertical: asset.align?.vertical ?? "middle"
2040
2144
  },
2041
2145
  background: asset.background,
2146
+ padding: asset.padding,
2042
2147
  glyphPathProvider: (gid, fontDesc) => fonts.glyphPath(fontDesc || desc, gid),
2043
2148
  getUnitsPerEm: (fontDesc) => fonts.getUnitsPerEm(fontDesc || desc)
2044
2149
  });
package/package.json CHANGED
@@ -1,62 +1,62 @@
1
- {
2
- "name": "@shotstack/shotstack-canvas",
3
- "version": "1.4.7",
4
- "description": "Text layout & animation engine (HarfBuzz) for Node & Web - fully self-contained.",
5
- "type": "module",
6
- "main": "./dist/entry.node.cjs",
7
- "module": "./dist/entry.node.js",
8
- "browser": "./dist/entry.web.js",
9
- "types": "./dist/entry.node.d.ts",
10
- "exports": {
11
- ".": {
12
- "node": {
13
- "import": "./dist/entry.node.js",
14
- "require": "./dist/entry.node.cjs"
15
- },
16
- "browser": "./dist/entry.web.js",
17
- "default": "./dist/entry.web.js"
18
- }
19
- },
20
- "files": [
21
- "dist/**",
22
- "scripts/postinstall.js",
23
- "README.md",
24
- "LICENSE"
25
- ],
26
- "scripts": {
27
- "dev": "tsup --watch",
28
- "build": "tsup",
29
- "postinstall": "node scripts/postinstall.js",
30
- "vendor:harfbuzz": "node scripts/vendor-harfbuzz.js",
31
- "example:node": "node examples/node-example.mjs",
32
- "example:video": "node examples/node-video.mjs",
33
- "example:web": "vite dev examples/web-example",
34
- "prepublishOnly": "npm run build"
35
- },
36
- "publishConfig": {
37
- "access": "public",
38
- "registry": "https://registry.npmjs.org/"
39
- },
40
- "engines": {
41
- "node": ">=18"
42
- },
43
- "sideEffects": false,
44
- "dependencies": {
45
- "canvas": "npm:@napi-rs/canvas@^0.1.54",
46
- "ffmpeg-static": "^5.2.0",
47
- "fontkit": "^2.0.4",
48
- "harfbuzzjs": "0.4.12",
49
- "joi": "^17.13.3",
50
- "opentype.js": "^1.3.4"
51
- },
52
- "devDependencies": {
53
- "@types/fluent-ffmpeg": "2.1.27",
54
- "@types/node": "^20.14.10",
55
- "fluent-ffmpeg": "^2.1.3",
56
- "tsup": "^8.2.3",
57
- "typescript": "^5.5.3",
58
- "vite": "^5.3.3",
59
- "vite-plugin-top-level-await": "1.6.0",
60
- "vite-plugin-wasm": "3.5.0"
61
- }
62
- }
1
+ {
2
+ "name": "@shotstack/shotstack-canvas",
3
+ "version": "1.5.1",
4
+ "description": "Text layout & animation engine (HarfBuzz) for Node & Web - fully self-contained.",
5
+ "type": "module",
6
+ "main": "./dist/entry.node.cjs",
7
+ "module": "./dist/entry.node.js",
8
+ "browser": "./dist/entry.web.js",
9
+ "types": "./dist/entry.node.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "node": {
13
+ "import": "./dist/entry.node.js",
14
+ "require": "./dist/entry.node.cjs"
15
+ },
16
+ "browser": "./dist/entry.web.js",
17
+ "default": "./dist/entry.web.js"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist/**",
22
+ "scripts/postinstall.js",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "scripts": {
27
+ "dev": "tsup --watch",
28
+ "build": "tsup",
29
+ "postinstall": "node scripts/postinstall.js",
30
+ "vendor:harfbuzz": "node scripts/vendor-harfbuzz.js",
31
+ "example:node": "node examples/node-example.mjs",
32
+ "example:video": "node examples/node-video.mjs",
33
+ "example:web": "vite dev examples/web-example",
34
+ "prepublishOnly": "npm run build"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public",
38
+ "registry": "https://registry.npmjs.org/"
39
+ },
40
+ "engines": {
41
+ "node": ">=18"
42
+ },
43
+ "sideEffects": false,
44
+ "dependencies": {
45
+ "canvas": "npm:@napi-rs/canvas@^0.1.54",
46
+ "ffmpeg-static": "^5.2.0",
47
+ "fontkit": "^2.0.4",
48
+ "harfbuzzjs": "0.4.12",
49
+ "joi": "^17.13.3",
50
+ "opentype.js": "^1.3.4"
51
+ },
52
+ "devDependencies": {
53
+ "@types/fluent-ffmpeg": "2.1.27",
54
+ "@types/node": "^20.14.10",
55
+ "fluent-ffmpeg": "^2.1.3",
56
+ "tsup": "^8.2.3",
57
+ "typescript": "^5.5.3",
58
+ "vite": "^5.3.3",
59
+ "vite-plugin-top-level-await": "1.6.0",
60
+ "vite-plugin-wasm": "3.5.0"
61
+ }
62
+ }