@shotstack/shotstack-canvas 1.4.7 → 1.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.
@@ -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,6 +169,7 @@ 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
175
  customFonts: import_joi.default.array().items(customFontSchema).optional(),
@@ -828,15 +844,27 @@ function decorationGeometry(kind, p) {
828
844
  }
829
845
 
830
846
  // src/core/drawops.ts
847
+ function normalizePadding(padding) {
848
+ if (padding === void 0 || padding === null) {
849
+ return { top: 0, right: 0, bottom: 0, left: 0 };
850
+ }
851
+ if (typeof padding === "number") {
852
+ return { top: padding, right: padding, bottom: padding, left: padding };
853
+ }
854
+ return padding;
855
+ }
831
856
  async function buildDrawOps(p) {
832
857
  const ops = [];
858
+ const padding = normalizePadding(p.padding);
859
+ const borderWidth = p.background?.border?.width ?? 0;
833
860
  ops.push({
834
861
  op: "BeginFrame",
835
862
  width: p.canvas.width,
836
863
  height: p.canvas.height,
837
864
  pixelRatio: p.canvas.pixelRatio,
838
865
  clear: true,
839
- bg: p.background && p.background.color ? { color: p.background.color, opacity: p.background.opacity, radius: p.background.borderRadius } : void 0
866
+ bg: void 0
867
+ // Background will be drawn as a separate layer with proper padding/border
840
868
  });
841
869
  if (p.lines.length === 0) return ops;
842
870
  const upem = Math.max(1, await p.getUnitsPerEm());
@@ -846,33 +874,34 @@ async function buildDrawOps(p) {
846
874
  let blockY;
847
875
  switch (p.align.vertical) {
848
876
  case "top":
849
- blockY = p.font.size;
877
+ blockY = p.font.size + padding.top;
850
878
  break;
851
879
  case "bottom":
852
- blockY = p.textRect.height - (numLines - 1) * lineHeightPx;
880
+ blockY = p.textRect.height - (numLines - 1) * lineHeightPx + padding.top;
853
881
  break;
854
882
  case "middle":
855
883
  default:
856
884
  const capHeightRatio = 0.35;
857
885
  const visualOffset = p.font.size * capHeightRatio;
858
- blockY = (p.textRect.height - (numLines - 1) * lineHeightPx) / 2 + visualOffset;
886
+ blockY = (p.textRect.height - (numLines - 1) * lineHeightPx) / 2 + visualOffset + padding.top;
859
887
  break;
860
888
  }
861
889
  const fill = p.style.gradient ? gradientSpecFrom(p.style.gradient, 1) : { kind: "solid", color: p.font.color, opacity: p.font.opacity };
862
890
  const decoColor = p.style.gradient ? p.style.gradient.stops[p.style.gradient.stops.length - 1]?.color ?? p.font.color : p.font.color;
891
+ const textOps = [];
863
892
  let gMinX = Infinity, gMinY = Infinity, gMaxX = -Infinity, gMaxY = -Infinity;
864
893
  for (const line of p.lines) {
865
894
  let lineX;
866
895
  switch (p.align.horizontal) {
867
896
  case "left":
868
- lineX = 0;
897
+ lineX = padding.left;
869
898
  break;
870
899
  case "right":
871
- lineX = p.textRect.width - line.width;
900
+ lineX = p.textRect.width - line.width + padding.left;
872
901
  break;
873
902
  case "center":
874
903
  default:
875
- lineX = (p.textRect.width - line.width) / 2;
904
+ lineX = (p.textRect.width - line.width) / 2 + padding.left;
876
905
  break;
877
906
  }
878
907
  let xCursor = lineX;
@@ -896,7 +925,7 @@ async function buildDrawOps(p) {
896
925
  if (x2 > gMaxX) gMaxX = x2;
897
926
  if (y2 > gMaxY) gMaxY = y2;
898
927
  if (p.shadow && p.shadow.blur > 0) {
899
- ops.push({
928
+ textOps.push({
900
929
  isShadow: true,
901
930
  op: "FillPath",
902
931
  path,
@@ -907,7 +936,7 @@ async function buildDrawOps(p) {
907
936
  });
908
937
  }
909
938
  if (p.stroke && p.stroke.width > 0) {
910
- ops.push({
939
+ textOps.push({
911
940
  op: "StrokePath",
912
941
  path,
913
942
  x: glyphX,
@@ -918,7 +947,7 @@ async function buildDrawOps(p) {
918
947
  opacity: p.stroke.opacity
919
948
  });
920
949
  }
921
- ops.push({
950
+ textOps.push({
922
951
  op: "FillPath",
923
952
  path,
924
953
  x: glyphX,
@@ -935,7 +964,7 @@ async function buildDrawOps(p) {
935
964
  lineWidth: line.width,
936
965
  xStart: lineX
937
966
  });
938
- ops.push({
967
+ textOps.push({
939
968
  op: "DecorationLine",
940
969
  from: { x: deco.x1, y: deco.y },
941
970
  to: { x: deco.x2, y: deco.y },
@@ -947,12 +976,46 @@ async function buildDrawOps(p) {
947
976
  }
948
977
  if (gMinX !== Infinity) {
949
978
  const gbox = { x: gMinX, y: gMinY, w: Math.max(1, gMaxX - gMinX), h: Math.max(1, gMaxY - gMinY) };
950
- for (const op of ops) {
979
+ for (const op of textOps) {
951
980
  if (op.op === "FillPath" && !op.isShadow) {
952
981
  op.gradientBBox = gbox;
953
982
  }
954
983
  }
955
984
  }
985
+ if (p.background && (p.background.color || p.background.border)) {
986
+ const bgX = 0;
987
+ const bgY = 0;
988
+ const bgWidth = p.canvas.width;
989
+ const bgHeight = p.canvas.height;
990
+ if (p.background.color) {
991
+ ops.push({
992
+ op: "Rectangle",
993
+ x: bgX,
994
+ y: bgY,
995
+ width: bgWidth,
996
+ height: bgHeight,
997
+ fill: { kind: "solid", color: p.background.color, opacity: p.background.opacity },
998
+ borderRadius: p.background.borderRadius
999
+ });
1000
+ }
1001
+ if (p.background.border && p.background.border.width > 0) {
1002
+ const halfBorder = p.background.border.width / 2;
1003
+ ops.push({
1004
+ op: "RectangleStroke",
1005
+ x: bgX + halfBorder,
1006
+ y: bgY + halfBorder,
1007
+ width: bgWidth - p.background.border.width,
1008
+ height: bgHeight - p.background.border.width,
1009
+ stroke: {
1010
+ width: p.background.border.width,
1011
+ color: p.background.border.color,
1012
+ opacity: p.background.border.opacity
1013
+ },
1014
+ borderRadius: Math.max(0, p.background.borderRadius - halfBorder)
1015
+ });
1016
+ }
1017
+ }
1018
+ ops.push(...textOps);
956
1019
  return ops;
957
1020
  }
958
1021
  function tokenizePath(d) {
@@ -1762,6 +1825,44 @@ async function createNodePainter(opts) {
1762
1825
  });
1763
1826
  continue;
1764
1827
  }
1828
+ if (op.op === "Rectangle") {
1829
+ renderToBoth((context) => {
1830
+ context.save();
1831
+ const fill = makeGradientFromBBox(context, op.fill, {
1832
+ x: op.x,
1833
+ y: op.y,
1834
+ w: op.width,
1835
+ h: op.height
1836
+ });
1837
+ context.fillStyle = fill;
1838
+ if (op.borderRadius && op.borderRadius > 0) {
1839
+ context.beginPath();
1840
+ roundRectPath(context, op.x, op.y, op.width, op.height, op.borderRadius);
1841
+ context.fill();
1842
+ } else {
1843
+ context.fillRect(op.x, op.y, op.width, op.height);
1844
+ }
1845
+ context.restore();
1846
+ });
1847
+ continue;
1848
+ }
1849
+ if (op.op === "RectangleStroke") {
1850
+ renderToBoth((context) => {
1851
+ context.save();
1852
+ const c = parseHex6(op.stroke.color, op.stroke.opacity);
1853
+ context.strokeStyle = `rgba(${c.r},${c.g},${c.b},${c.a})`;
1854
+ context.lineWidth = op.stroke.width;
1855
+ if (op.borderRadius && op.borderRadius > 0) {
1856
+ context.beginPath();
1857
+ roundRectPath(context, op.x, op.y, op.width, op.height, op.borderRadius);
1858
+ context.stroke();
1859
+ } else {
1860
+ context.strokeRect(op.x, op.y, op.width, op.height);
1861
+ }
1862
+ context.restore();
1863
+ });
1864
+ continue;
1865
+ }
1765
1866
  }
1766
1867
  if (needsAlphaExtraction) {
1767
1868
  const whiteData = ctx.getImageData(0, 0, canvas.width, canvas.height);
@@ -2325,14 +2426,15 @@ async function createTextEngine(opts = {}) {
2325
2426
  `Failed to layout text: ${err instanceof Error ? err.message : String(err)}`
2326
2427
  );
2327
2428
  }
2429
+ 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
2430
  const textRect = {
2329
2431
  x: 0,
2330
2432
  y: 0,
2331
2433
  width: asset.width ?? width,
2332
2434
  height: asset.height ?? height
2333
2435
  };
2334
- const canvasW = asset.width ?? width;
2335
- const canvasH = asset.height ?? height;
2436
+ const canvasW = (asset.width ?? width) + padding.left + padding.right;
2437
+ const canvasH = (asset.height ?? height) + padding.top + padding.bottom;
2336
2438
  const canvasPR = asset.pixelRatio ?? pixelRatio;
2337
2439
  let ops0;
2338
2440
  try {
@@ -2359,6 +2461,7 @@ async function createTextEngine(opts = {}) {
2359
2461
  vertical: asset.align?.vertical ?? "middle"
2360
2462
  },
2361
2463
  background: asset.background,
2464
+ padding: asset.padding,
2362
2465
  glyphPathProvider: (gid, fontDesc) => fonts.glyphPath(fontDesc || desc, gid),
2363
2466
  getUnitsPerEm: (fontDesc) => fonts.getUnitsPerEm(fontDesc || desc)
2364
2467
  });
@@ -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";
@@ -177,6 +188,26 @@ type DrawOp = {
177
188
  width: number;
178
189
  color: string;
179
190
  opacity: number;
191
+ } | {
192
+ op: "Rectangle";
193
+ x: number;
194
+ y: number;
195
+ width: number;
196
+ height: number;
197
+ fill: GradientSpec;
198
+ borderRadius?: number;
199
+ } | {
200
+ op: "RectangleStroke";
201
+ x: number;
202
+ y: number;
203
+ width: number;
204
+ height: number;
205
+ stroke: {
206
+ width: number;
207
+ color: string;
208
+ opacity: number;
209
+ };
210
+ borderRadius?: number;
180
211
  };
181
212
  type EngineInit = {
182
213
  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";
@@ -177,6 +188,26 @@ type DrawOp = {
177
188
  width: number;
178
189
  color: string;
179
190
  opacity: number;
191
+ } | {
192
+ op: "Rectangle";
193
+ x: number;
194
+ y: number;
195
+ width: number;
196
+ height: number;
197
+ fill: GradientSpec;
198
+ borderRadius?: number;
199
+ } | {
200
+ op: "RectangleStroke";
201
+ x: number;
202
+ y: number;
203
+ width: number;
204
+ height: number;
205
+ stroke: {
206
+ width: number;
207
+ color: string;
208
+ opacity: number;
209
+ };
210
+ borderRadius?: number;
180
211
  };
181
212
  type EngineInit = {
182
213
  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,6 +131,7 @@ 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
137
  customFonts: Joi.array().items(customFontSchema).optional(),
@@ -789,15 +805,27 @@ function decorationGeometry(kind, p) {
789
805
  }
790
806
 
791
807
  // src/core/drawops.ts
808
+ function normalizePadding(padding) {
809
+ if (padding === void 0 || padding === null) {
810
+ return { top: 0, right: 0, bottom: 0, left: 0 };
811
+ }
812
+ if (typeof padding === "number") {
813
+ return { top: padding, right: padding, bottom: padding, left: padding };
814
+ }
815
+ return padding;
816
+ }
792
817
  async function buildDrawOps(p) {
793
818
  const ops = [];
819
+ const padding = normalizePadding(p.padding);
820
+ const borderWidth = p.background?.border?.width ?? 0;
794
821
  ops.push({
795
822
  op: "BeginFrame",
796
823
  width: p.canvas.width,
797
824
  height: p.canvas.height,
798
825
  pixelRatio: p.canvas.pixelRatio,
799
826
  clear: true,
800
- bg: p.background && p.background.color ? { color: p.background.color, opacity: p.background.opacity, radius: p.background.borderRadius } : void 0
827
+ bg: void 0
828
+ // Background will be drawn as a separate layer with proper padding/border
801
829
  });
802
830
  if (p.lines.length === 0) return ops;
803
831
  const upem = Math.max(1, await p.getUnitsPerEm());
@@ -807,33 +835,34 @@ async function buildDrawOps(p) {
807
835
  let blockY;
808
836
  switch (p.align.vertical) {
809
837
  case "top":
810
- blockY = p.font.size;
838
+ blockY = p.font.size + padding.top;
811
839
  break;
812
840
  case "bottom":
813
- blockY = p.textRect.height - (numLines - 1) * lineHeightPx;
841
+ blockY = p.textRect.height - (numLines - 1) * lineHeightPx + padding.top;
814
842
  break;
815
843
  case "middle":
816
844
  default:
817
845
  const capHeightRatio = 0.35;
818
846
  const visualOffset = p.font.size * capHeightRatio;
819
- blockY = (p.textRect.height - (numLines - 1) * lineHeightPx) / 2 + visualOffset;
847
+ blockY = (p.textRect.height - (numLines - 1) * lineHeightPx) / 2 + visualOffset + padding.top;
820
848
  break;
821
849
  }
822
850
  const fill = p.style.gradient ? gradientSpecFrom(p.style.gradient, 1) : { kind: "solid", color: p.font.color, opacity: p.font.opacity };
823
851
  const decoColor = p.style.gradient ? p.style.gradient.stops[p.style.gradient.stops.length - 1]?.color ?? p.font.color : p.font.color;
852
+ const textOps = [];
824
853
  let gMinX = Infinity, gMinY = Infinity, gMaxX = -Infinity, gMaxY = -Infinity;
825
854
  for (const line of p.lines) {
826
855
  let lineX;
827
856
  switch (p.align.horizontal) {
828
857
  case "left":
829
- lineX = 0;
858
+ lineX = padding.left;
830
859
  break;
831
860
  case "right":
832
- lineX = p.textRect.width - line.width;
861
+ lineX = p.textRect.width - line.width + padding.left;
833
862
  break;
834
863
  case "center":
835
864
  default:
836
- lineX = (p.textRect.width - line.width) / 2;
865
+ lineX = (p.textRect.width - line.width) / 2 + padding.left;
837
866
  break;
838
867
  }
839
868
  let xCursor = lineX;
@@ -857,7 +886,7 @@ async function buildDrawOps(p) {
857
886
  if (x2 > gMaxX) gMaxX = x2;
858
887
  if (y2 > gMaxY) gMaxY = y2;
859
888
  if (p.shadow && p.shadow.blur > 0) {
860
- ops.push({
889
+ textOps.push({
861
890
  isShadow: true,
862
891
  op: "FillPath",
863
892
  path,
@@ -868,7 +897,7 @@ async function buildDrawOps(p) {
868
897
  });
869
898
  }
870
899
  if (p.stroke && p.stroke.width > 0) {
871
- ops.push({
900
+ textOps.push({
872
901
  op: "StrokePath",
873
902
  path,
874
903
  x: glyphX,
@@ -879,7 +908,7 @@ async function buildDrawOps(p) {
879
908
  opacity: p.stroke.opacity
880
909
  });
881
910
  }
882
- ops.push({
911
+ textOps.push({
883
912
  op: "FillPath",
884
913
  path,
885
914
  x: glyphX,
@@ -896,7 +925,7 @@ async function buildDrawOps(p) {
896
925
  lineWidth: line.width,
897
926
  xStart: lineX
898
927
  });
899
- ops.push({
928
+ textOps.push({
900
929
  op: "DecorationLine",
901
930
  from: { x: deco.x1, y: deco.y },
902
931
  to: { x: deco.x2, y: deco.y },
@@ -908,12 +937,46 @@ async function buildDrawOps(p) {
908
937
  }
909
938
  if (gMinX !== Infinity) {
910
939
  const gbox = { x: gMinX, y: gMinY, w: Math.max(1, gMaxX - gMinX), h: Math.max(1, gMaxY - gMinY) };
911
- for (const op of ops) {
940
+ for (const op of textOps) {
912
941
  if (op.op === "FillPath" && !op.isShadow) {
913
942
  op.gradientBBox = gbox;
914
943
  }
915
944
  }
916
945
  }
946
+ if (p.background && (p.background.color || p.background.border)) {
947
+ const bgX = 0;
948
+ const bgY = 0;
949
+ const bgWidth = p.canvas.width;
950
+ const bgHeight = p.canvas.height;
951
+ if (p.background.color) {
952
+ ops.push({
953
+ op: "Rectangle",
954
+ x: bgX,
955
+ y: bgY,
956
+ width: bgWidth,
957
+ height: bgHeight,
958
+ fill: { kind: "solid", color: p.background.color, opacity: p.background.opacity },
959
+ borderRadius: p.background.borderRadius
960
+ });
961
+ }
962
+ if (p.background.border && p.background.border.width > 0) {
963
+ const halfBorder = p.background.border.width / 2;
964
+ ops.push({
965
+ op: "RectangleStroke",
966
+ x: bgX + halfBorder,
967
+ y: bgY + halfBorder,
968
+ width: bgWidth - p.background.border.width,
969
+ height: bgHeight - p.background.border.width,
970
+ stroke: {
971
+ width: p.background.border.width,
972
+ color: p.background.border.color,
973
+ opacity: p.background.border.opacity
974
+ },
975
+ borderRadius: Math.max(0, p.background.borderRadius - halfBorder)
976
+ });
977
+ }
978
+ }
979
+ ops.push(...textOps);
917
980
  return ops;
918
981
  }
919
982
  function tokenizePath(d) {
@@ -1723,6 +1786,44 @@ async function createNodePainter(opts) {
1723
1786
  });
1724
1787
  continue;
1725
1788
  }
1789
+ if (op.op === "Rectangle") {
1790
+ renderToBoth((context) => {
1791
+ context.save();
1792
+ const fill = makeGradientFromBBox(context, op.fill, {
1793
+ x: op.x,
1794
+ y: op.y,
1795
+ w: op.width,
1796
+ h: op.height
1797
+ });
1798
+ context.fillStyle = fill;
1799
+ if (op.borderRadius && op.borderRadius > 0) {
1800
+ context.beginPath();
1801
+ roundRectPath(context, op.x, op.y, op.width, op.height, op.borderRadius);
1802
+ context.fill();
1803
+ } else {
1804
+ context.fillRect(op.x, op.y, op.width, op.height);
1805
+ }
1806
+ context.restore();
1807
+ });
1808
+ continue;
1809
+ }
1810
+ if (op.op === "RectangleStroke") {
1811
+ renderToBoth((context) => {
1812
+ context.save();
1813
+ const c = parseHex6(op.stroke.color, op.stroke.opacity);
1814
+ context.strokeStyle = `rgba(${c.r},${c.g},${c.b},${c.a})`;
1815
+ context.lineWidth = op.stroke.width;
1816
+ if (op.borderRadius && op.borderRadius > 0) {
1817
+ context.beginPath();
1818
+ roundRectPath(context, op.x, op.y, op.width, op.height, op.borderRadius);
1819
+ context.stroke();
1820
+ } else {
1821
+ context.strokeRect(op.x, op.y, op.width, op.height);
1822
+ }
1823
+ context.restore();
1824
+ });
1825
+ continue;
1826
+ }
1726
1827
  }
1727
1828
  if (needsAlphaExtraction) {
1728
1829
  const whiteData = ctx.getImageData(0, 0, canvas.width, canvas.height);
@@ -2286,14 +2387,15 @@ async function createTextEngine(opts = {}) {
2286
2387
  `Failed to layout text: ${err instanceof Error ? err.message : String(err)}`
2287
2388
  );
2288
2389
  }
2390
+ 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
2391
  const textRect = {
2290
2392
  x: 0,
2291
2393
  y: 0,
2292
2394
  width: asset.width ?? width,
2293
2395
  height: asset.height ?? height
2294
2396
  };
2295
- const canvasW = asset.width ?? width;
2296
- const canvasH = asset.height ?? height;
2397
+ const canvasW = (asset.width ?? width) + padding.left + padding.right;
2398
+ const canvasH = (asset.height ?? height) + padding.top + padding.bottom;
2297
2399
  const canvasPR = asset.pixelRatio ?? pixelRatio;
2298
2400
  let ops0;
2299
2401
  try {
@@ -2320,6 +2422,7 @@ async function createTextEngine(opts = {}) {
2320
2422
  vertical: asset.align?.vertical ?? "middle"
2321
2423
  },
2322
2424
  background: asset.background,
2425
+ padding: asset.padding,
2323
2426
  glyphPathProvider: (gid, fontDesc) => fonts.glyphPath(fontDesc || desc, gid),
2324
2427
  getUnitsPerEm: (fontDesc) => fonts.getUnitsPerEm(fontDesc || desc)
2325
2428
  });
@@ -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";
@@ -177,6 +188,26 @@ type DrawOp = {
177
188
  width: number;
178
189
  color: string;
179
190
  opacity: number;
191
+ } | {
192
+ op: "Rectangle";
193
+ x: number;
194
+ y: number;
195
+ width: number;
196
+ height: number;
197
+ fill: GradientSpec;
198
+ borderRadius?: number;
199
+ } | {
200
+ op: "RectangleStroke";
201
+ x: number;
202
+ y: number;
203
+ width: number;
204
+ height: number;
205
+ stroke: {
206
+ width: number;
207
+ color: string;
208
+ opacity: number;
209
+ };
210
+ borderRadius?: number;
180
211
  };
181
212
  type EngineInit = {
182
213
  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,6 +135,7 @@ 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
141
  customFonts: Joi.array().items(customFontSchema).optional(),
@@ -794,15 +810,27 @@ function decorationGeometry(kind, p) {
794
810
  }
795
811
 
796
812
  // src/core/drawops.ts
813
+ function normalizePadding(padding) {
814
+ if (padding === void 0 || padding === null) {
815
+ return { top: 0, right: 0, bottom: 0, left: 0 };
816
+ }
817
+ if (typeof padding === "number") {
818
+ return { top: padding, right: padding, bottom: padding, left: padding };
819
+ }
820
+ return padding;
821
+ }
797
822
  async function buildDrawOps(p) {
798
823
  const ops = [];
824
+ const padding = normalizePadding(p.padding);
825
+ const borderWidth = p.background?.border?.width ?? 0;
799
826
  ops.push({
800
827
  op: "BeginFrame",
801
828
  width: p.canvas.width,
802
829
  height: p.canvas.height,
803
830
  pixelRatio: p.canvas.pixelRatio,
804
831
  clear: true,
805
- bg: p.background && p.background.color ? { color: p.background.color, opacity: p.background.opacity, radius: p.background.borderRadius } : void 0
832
+ bg: void 0
833
+ // Background will be drawn as a separate layer with proper padding/border
806
834
  });
807
835
  if (p.lines.length === 0) return ops;
808
836
  const upem = Math.max(1, await p.getUnitsPerEm());
@@ -812,33 +840,34 @@ async function buildDrawOps(p) {
812
840
  let blockY;
813
841
  switch (p.align.vertical) {
814
842
  case "top":
815
- blockY = p.font.size;
843
+ blockY = p.font.size + padding.top;
816
844
  break;
817
845
  case "bottom":
818
- blockY = p.textRect.height - (numLines - 1) * lineHeightPx;
846
+ blockY = p.textRect.height - (numLines - 1) * lineHeightPx + padding.top;
819
847
  break;
820
848
  case "middle":
821
849
  default:
822
850
  const capHeightRatio = 0.35;
823
851
  const visualOffset = p.font.size * capHeightRatio;
824
- blockY = (p.textRect.height - (numLines - 1) * lineHeightPx) / 2 + visualOffset;
852
+ blockY = (p.textRect.height - (numLines - 1) * lineHeightPx) / 2 + visualOffset + padding.top;
825
853
  break;
826
854
  }
827
855
  const fill = p.style.gradient ? gradientSpecFrom(p.style.gradient, 1) : { kind: "solid", color: p.font.color, opacity: p.font.opacity };
828
856
  const decoColor = p.style.gradient ? p.style.gradient.stops[p.style.gradient.stops.length - 1]?.color ?? p.font.color : p.font.color;
857
+ const textOps = [];
829
858
  let gMinX = Infinity, gMinY = Infinity, gMaxX = -Infinity, gMaxY = -Infinity;
830
859
  for (const line of p.lines) {
831
860
  let lineX;
832
861
  switch (p.align.horizontal) {
833
862
  case "left":
834
- lineX = 0;
863
+ lineX = padding.left;
835
864
  break;
836
865
  case "right":
837
- lineX = p.textRect.width - line.width;
866
+ lineX = p.textRect.width - line.width + padding.left;
838
867
  break;
839
868
  case "center":
840
869
  default:
841
- lineX = (p.textRect.width - line.width) / 2;
870
+ lineX = (p.textRect.width - line.width) / 2 + padding.left;
842
871
  break;
843
872
  }
844
873
  let xCursor = lineX;
@@ -862,7 +891,7 @@ async function buildDrawOps(p) {
862
891
  if (x2 > gMaxX) gMaxX = x2;
863
892
  if (y2 > gMaxY) gMaxY = y2;
864
893
  if (p.shadow && p.shadow.blur > 0) {
865
- ops.push({
894
+ textOps.push({
866
895
  isShadow: true,
867
896
  op: "FillPath",
868
897
  path,
@@ -873,7 +902,7 @@ async function buildDrawOps(p) {
873
902
  });
874
903
  }
875
904
  if (p.stroke && p.stroke.width > 0) {
876
- ops.push({
905
+ textOps.push({
877
906
  op: "StrokePath",
878
907
  path,
879
908
  x: glyphX,
@@ -884,7 +913,7 @@ async function buildDrawOps(p) {
884
913
  opacity: p.stroke.opacity
885
914
  });
886
915
  }
887
- ops.push({
916
+ textOps.push({
888
917
  op: "FillPath",
889
918
  path,
890
919
  x: glyphX,
@@ -901,7 +930,7 @@ async function buildDrawOps(p) {
901
930
  lineWidth: line.width,
902
931
  xStart: lineX
903
932
  });
904
- ops.push({
933
+ textOps.push({
905
934
  op: "DecorationLine",
906
935
  from: { x: deco.x1, y: deco.y },
907
936
  to: { x: deco.x2, y: deco.y },
@@ -913,12 +942,46 @@ async function buildDrawOps(p) {
913
942
  }
914
943
  if (gMinX !== Infinity) {
915
944
  const gbox = { x: gMinX, y: gMinY, w: Math.max(1, gMaxX - gMinX), h: Math.max(1, gMaxY - gMinY) };
916
- for (const op of ops) {
945
+ for (const op of textOps) {
917
946
  if (op.op === "FillPath" && !op.isShadow) {
918
947
  op.gradientBBox = gbox;
919
948
  }
920
949
  }
921
950
  }
951
+ if (p.background && (p.background.color || p.background.border)) {
952
+ const bgX = 0;
953
+ const bgY = 0;
954
+ const bgWidth = p.canvas.width;
955
+ const bgHeight = p.canvas.height;
956
+ if (p.background.color) {
957
+ ops.push({
958
+ op: "Rectangle",
959
+ x: bgX,
960
+ y: bgY,
961
+ width: bgWidth,
962
+ height: bgHeight,
963
+ fill: { kind: "solid", color: p.background.color, opacity: p.background.opacity },
964
+ borderRadius: p.background.borderRadius
965
+ });
966
+ }
967
+ if (p.background.border && p.background.border.width > 0) {
968
+ const halfBorder = p.background.border.width / 2;
969
+ ops.push({
970
+ op: "RectangleStroke",
971
+ x: bgX + halfBorder,
972
+ y: bgY + halfBorder,
973
+ width: bgWidth - p.background.border.width,
974
+ height: bgHeight - p.background.border.width,
975
+ stroke: {
976
+ width: p.background.border.width,
977
+ color: p.background.border.color,
978
+ opacity: p.background.border.opacity
979
+ },
980
+ borderRadius: Math.max(0, p.background.borderRadius - halfBorder)
981
+ });
982
+ }
983
+ }
984
+ ops.push(...textOps);
922
985
  return ops;
923
986
  }
924
987
  function tokenizePath(d) {
@@ -1676,6 +1739,48 @@ function createWebPainter(canvas) {
1676
1739
  ctx.restore();
1677
1740
  continue;
1678
1741
  }
1742
+ if (op.op === "Rectangle") {
1743
+ ctx.save();
1744
+ const fill = makeGradientFromBBox(ctx, op.fill, {
1745
+ x: op.x,
1746
+ y: op.y,
1747
+ w: op.width,
1748
+ h: op.height
1749
+ });
1750
+ ctx.fillStyle = fill;
1751
+ if (op.borderRadius && op.borderRadius > 0) {
1752
+ drawRoundedRect(ctx, op.x, op.y, op.width, op.height, op.borderRadius);
1753
+ } else {
1754
+ ctx.fillRect(op.x, op.y, op.width, op.height);
1755
+ }
1756
+ ctx.restore();
1757
+ continue;
1758
+ }
1759
+ if (op.op === "RectangleStroke") {
1760
+ ctx.save();
1761
+ const c = parseHex6(op.stroke.color, op.stroke.opacity);
1762
+ ctx.strokeStyle = `rgba(${c.r},${c.g},${c.b},${c.a})`;
1763
+ ctx.lineWidth = op.stroke.width;
1764
+ if (op.borderRadius && op.borderRadius > 0) {
1765
+ const p = new Path2D();
1766
+ const r = op.borderRadius;
1767
+ const x = op.x;
1768
+ const y = op.y;
1769
+ const w = op.width;
1770
+ const h = op.height;
1771
+ p.moveTo(x + r, y);
1772
+ p.arcTo(x + w, y, x + w, y + h, r);
1773
+ p.arcTo(x + w, y + h, x, y + h, r);
1774
+ p.arcTo(x, y + h, x, y, r);
1775
+ p.arcTo(x, y, x + w, y, r);
1776
+ p.closePath();
1777
+ ctx.stroke(p);
1778
+ } else {
1779
+ ctx.strokeRect(op.x, op.y, op.width, op.height);
1780
+ }
1781
+ ctx.restore();
1782
+ continue;
1783
+ }
1679
1784
  }
1680
1785
  }
1681
1786
  };
@@ -2005,14 +2110,15 @@ async function createTextEngine(opts = {}) {
2005
2110
  `Failed to layout text: ${err instanceof Error ? err.message : String(err)}`
2006
2111
  );
2007
2112
  }
2113
+ 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
2114
  const textRect = {
2009
2115
  x: 0,
2010
2116
  y: 0,
2011
2117
  width: asset.width ?? width,
2012
2118
  height: asset.height ?? height
2013
2119
  };
2014
- const canvasW = asset.width ?? width;
2015
- const canvasH = asset.height ?? height;
2120
+ const canvasW = (asset.width ?? width) + padding.left + padding.right;
2121
+ const canvasH = (asset.height ?? height) + padding.top + padding.bottom;
2016
2122
  const canvasPR = asset.pixelRatio ?? pixelRatio;
2017
2123
  let ops0;
2018
2124
  try {
@@ -2039,6 +2145,7 @@ async function createTextEngine(opts = {}) {
2039
2145
  vertical: asset.align?.vertical ?? "middle"
2040
2146
  },
2041
2147
  background: asset.background,
2148
+ padding: asset.padding,
2042
2149
  glyphPathProvider: (gid, fontDesc) => fonts.glyphPath(fontDesc || desc, gid),
2043
2150
  getUnitsPerEm: (fontDesc) => fonts.getUnitsPerEm(fontDesc || desc)
2044
2151
  });
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.0",
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
+ }