email-builder-utils 1.1.40 → 1.1.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"convertJsonToHtml.d.ts","sourceRoot":"","sources":["../../src/utils/convertJsonToHtml.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,iBAAiB,GAAU,UAAU,GAAG,oBA8GpD,CAAC"}
1
+ {"version":3,"file":"convertJsonToHtml.d.ts","sourceRoot":"","sources":["../../src/utils/convertJsonToHtml.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,iBAAiB,GAAU,UAAU,GAAG,oBAiHpD,CAAC"}
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.convertJsonToHtml = void 0;
4
+ const fontFallback_1 = require("./fontFallback");
4
5
  const jsonToHTML_1 = require("./jsonToHTML");
5
6
  const convertJsonToHtml = async (jsonData) => {
6
7
  const rootData = jsonData?.root?.data;
@@ -36,54 +37,58 @@ const convertJsonToHtml = async (jsonData) => {
36
37
  }
37
38
  .hide-mobile {
38
39
  display: block !important;
39
- mso-hide: all !important; /* Hide in Outlook */
40
40
  }
41
41
 
42
42
  .hide-desktop {
43
43
  display: block !important;
44
- mso-hide: all !important; /* Hide in Outlook */
44
+ mso-hide: all !important; /* Hide in Outlook (treated as desktop) */
45
45
  }
46
46
 
47
47
  @media only screen and (max-width: 600px) {
48
- .hide-mobile {
49
- display: none !important;
50
- max-height: 0 !important;
48
+ .hide-mobile {
49
+ display: none !important;
50
+ max-height: 0 !important;
51
51
  overflow: hidden !important;
52
- mso-hide: all !important;
53
- }
54
- }
52
+ mso-hide: all !important;
53
+ }
54
+ }
55
55
 
56
- @media only screen and (min-width: 601px) {
57
- .hide-desktop {
58
- display: none !important;
59
- max-height: 0 !important;
60
- overflow: hidden !important;
61
- mso-hide: all !important;
56
+ @media only screen and (min-width: 601px) {
57
+ .hide-desktop {
58
+ display: none !important;
59
+ max-height: 0 !important;
60
+ overflow: hidden !important;
61
+ mso-hide: all !important;
62
+ }
62
63
  }
63
- }
64
64
 
65
65
  </style>
66
66
  </head>
67
- <body>
68
- <center>
67
+ <body style="margin:0; padding:0; background-color:${canvasColor};">
68
+ <center style="width:100%; background-color:${canvasColor};">
69
69
  <table
70
70
  class="responsive-table"
71
+ role="presentation"
71
72
  bgcolor="${canvasColor}"
73
+ cellpadding="0"
74
+ cellspacing="0"
75
+ border="0"
76
+ width="600"
77
+ align="center"
72
78
  style="
73
- font-family: ${fontFamily};
79
+ font-family: ${(0, fontFallback_1.withFontFallback)(fontFamily)};
74
80
  margin: 0 auto;
75
81
  table-layout:fixed;
76
82
  width:600px;
77
83
  max-width:600px;
78
84
  background-color: ${canvasColor};
79
85
  color: ${textColor};
80
- padding: ${top}px ${right}px ${bottom}px ${left}px;
81
86
  border: ${borderWidth}px ${borderStyle} ${borderColor};
82
87
  border-radius: ${borderRadius}px; "
83
88
  >
84
89
  <tbody>
85
90
  <tr>
86
- <td style="padding: 0;">
91
+ <td style="padding: ${top}px ${right}px ${bottom}px ${left}px;">
87
92
  ${blocksHtml.join("")}
88
93
  </td>
89
94
  </tr>
@@ -0,0 +1,7 @@
1
+ /**
2
+ * If the font-family value doesn't already end with a generic/web-safe family,
3
+ * append an appropriate fallback so Outlook desktop (which silently falls back
4
+ * to Times New Roman for any non-web-safe font) picks a reasonable match.
5
+ */
6
+ export declare function withFontFallback(fontFamily: string | undefined | null): string;
7
+ //# sourceMappingURL=fontFallback.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fontFallback.d.ts","sourceRoot":"","sources":["../../src/utils/fontFallback.ts"],"names":[],"mappings":"AA6CA;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,GAAG,MAAM,CAY9E"}
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.withFontFallback = withFontFallback;
4
+ // Web-safe fonts that render natively in Outlook desktop (Word engine).
5
+ // Anything not in this set falls back silently to Times New Roman unless we
6
+ // append a generic stack. Keep lowercase for comparison.
7
+ const WEB_SAFE_FONTS = new Set([
8
+ "arial",
9
+ "arial black",
10
+ "helvetica",
11
+ "helvetica neue",
12
+ "verdana",
13
+ "tahoma",
14
+ "trebuchet ms",
15
+ "times",
16
+ "times new roman",
17
+ "georgia",
18
+ "garamond",
19
+ "courier",
20
+ "courier new",
21
+ "lucida console",
22
+ "lucida sans unicode",
23
+ "palatino",
24
+ "palatino linotype",
25
+ "book antiqua",
26
+ "impact",
27
+ "serif",
28
+ "sans-serif",
29
+ "monospace",
30
+ "cursive",
31
+ "fantasy",
32
+ "system-ui",
33
+ ]);
34
+ const DEFAULT_FALLBACK = "Arial, sans-serif";
35
+ const SERIF_FALLBACK = "Georgia, 'Times New Roman', serif";
36
+ const MONO_FALLBACK = "'Courier New', Courier, monospace";
37
+ const SERIF_HINTS = ["serif", "playfair", "merriweather", "lora", "roboto slab", "source serif", "pt serif", "noto serif"];
38
+ const MONO_HINTS = ["mono", "code", "courier", "consolas", "menlo"];
39
+ function pickGenericFallback(name) {
40
+ const lower = name.toLowerCase();
41
+ if (MONO_HINTS.some((h) => lower.includes(h)))
42
+ return MONO_FALLBACK;
43
+ if (SERIF_HINTS.some((h) => lower.includes(h)))
44
+ return SERIF_FALLBACK;
45
+ return DEFAULT_FALLBACK;
46
+ }
47
+ /**
48
+ * If the font-family value doesn't already end with a generic/web-safe family,
49
+ * append an appropriate fallback so Outlook desktop (which silently falls back
50
+ * to Times New Roman for any non-web-safe font) picks a reasonable match.
51
+ */
52
+ function withFontFallback(fontFamily) {
53
+ if (!fontFamily)
54
+ return DEFAULT_FALLBACK;
55
+ const trimmed = String(fontFamily).trim();
56
+ if (!trimmed)
57
+ return DEFAULT_FALLBACK;
58
+ const parts = trimmed.split(",").map((p) => p.trim().replace(/^["']|["']$/g, ""));
59
+ const alreadySafe = parts.some((p) => WEB_SAFE_FONTS.has(p.toLowerCase()));
60
+ if (alreadySafe)
61
+ return trimmed;
62
+ const primary = parts[0] || "";
63
+ const fallback = pickGenericFallback(primary);
64
+ return `${trimmed}, ${fallback}`;
65
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"jsonToHTML.d.ts","sourceRoot":"","sources":["../../src/utils/jsonToHTML.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAGrC,UAAU,cAAc;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IAC1B,aAAa,EAAE,MAAM,CAAC;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAED,UAAU,UAAU;IAClB,IAAI,EAAE,SAAS,CAAC;IAChB,IAAI,EAAE;QACJ,KAAK,EAAE,cAAc,CAAC;QACtB,KAAK,EAAE,GAAG,CAAC;QACX,WAAW,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;KAC7B,CAAC;CACH;AAYD,eAAO,MAAM,gBAAgB,kDAAkD,CAAC;AAwFhF,wBAAsB,aAAa,CACjC,SAAS,EAAE,UAAU,EACrB,QAAQ,EAAE,GAAG,EACb,aAAa,EAAE,MAAM,mBAwBtB;AA4qBD,wBAAsB,iBAAiB,CAAC,SAAS,EAAE,GAAG,EAAE,aAAa,EAAE,MAAM,mBAyL5E"}
1
+ {"version":3,"file":"jsonToHTML.d.ts","sourceRoot":"","sources":["../../src/utils/jsonToHTML.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAIrC,UAAU,cAAc;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IAC1B,aAAa,EAAE,MAAM,CAAC;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAED,UAAU,UAAU;IAClB,IAAI,EAAE,SAAS,CAAC;IAChB,IAAI,EAAE;QACJ,KAAK,EAAE,cAAc,CAAC;QACtB,KAAK,EAAE,GAAG,CAAC;QACX,WAAW,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;KAC7B,CAAC;CACH;AAYD,eAAO,MAAM,gBAAgB,kDAAkD,CAAC;AAqGhF,wBAAsB,aAAa,CACjC,SAAS,EAAE,UAAU,EACrB,QAAQ,EAAE,GAAG,EACb,aAAa,EAAE,MAAM,mBAwBtB;AA68BD,wBAAsB,iBAAiB,CAAC,SAAS,EAAE,GAAG,EAAE,aAAa,EAAE,MAAM,mBA0L5E"}
@@ -3,9 +3,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.tableCommonStyle = void 0;
4
4
  exports.convertToHtml = convertToHtml;
5
5
  exports.convertVideoBlock = convertVideoBlock;
6
- const jimp_1 = require("jimp");
7
6
  const types_1 = require("../types");
8
7
  const common_1 = require("./common");
8
+ const fontFallback_1 = require("./fontFallback");
9
9
  const addPxToAttributes = [
10
10
  "fontSize",
11
11
  "lineHeight",
@@ -15,23 +15,13 @@ const addPxToAttributes = [
15
15
  const addPxOrPerToAttributes = ["width", "height"];
16
16
  const allPxAttributes = [...addPxToAttributes, ...addPxOrPerToAttributes];
17
17
  exports.tableCommonStyle = "border-collapse:collapse; table-layout:fixed;";
18
- function cleanJson(obj) {
19
- if (typeof obj !== "object" || obj === null)
20
- return obj;
21
- if (Array.isArray(obj))
22
- return obj.map(cleanJson);
23
- return Object.fromEntries(Object.entries(obj)
24
- .filter(([_, value]) => value !== undefined && value !== null && value !== "")
25
- .map(([key, value]) => [key, cleanJson(value)]));
26
- }
27
- function jsonToPlainString(obj) {
28
- if (typeof obj !== "object" || obj === null)
29
- return String(obj);
30
- if (Array.isArray(obj))
31
- return obj.map(jsonToPlainString).join(", ");
32
- return Object.entries(obj)
33
- .map(([key, value]) => `${key}:${jsonToPlainString(value)}; `)
34
- .join("");
18
+ async function loadImageNaturalDimensions(imageUrl) {
19
+ return new Promise((resolve, reject) => {
20
+ const img = new Image();
21
+ img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
22
+ img.onerror = () => reject(new Error(`Failed to load image: ${imageUrl}`));
23
+ img.src = imageUrl;
24
+ });
35
25
  }
36
26
  function buildStyles(style, { pxChanges, perChanges }) {
37
27
  if (!style)
@@ -47,7 +37,9 @@ function buildStyles(style, { pxChanges, perChanges }) {
47
37
  "childWidth",
48
38
  "visibility",
49
39
  "hideOnMobile",
50
- "hideOnDesktop"
40
+ "hideOnDesktop",
41
+ "label",
42
+ "alignment",
51
43
  ];
52
44
  if (INVALID_KEYS.includes(key))
53
45
  return;
@@ -66,11 +58,26 @@ function buildStyles(style, { pxChanges, perChanges }) {
66
58
  };
67
59
  value = `${safePad.top}px ${safePad.right}px ${safePad.bottom}px ${safePad.left}px`;
68
60
  }
61
+ // Sanitize fontFamily: replace double quotes with single quotes to avoid
62
+ // breaking the surrounding style="..." HTML attribute
63
+ if (key === "fontFamily" && typeof value === "string") {
64
+ value = (0, fontFallback_1.withFontFallback)(value).replace(/"/g, "'");
65
+ }
66
+ // Wrap backgroundImage values in url() if not already wrapped
67
+ if (key === "backgroundImage" && typeof value === "string" && !String(value).startsWith("url(")) {
68
+ value = `url('${value}')`;
69
+ }
70
+ // lineHeight: values >= 4 are pixel values; smaller values are unitless multipliers (e.g. 1.5)
71
+ if (key === "lineHeight" && typeof value === "number") {
72
+ stylesObj["line-height"] = value >= 4 ? `${value}px` : String(value);
73
+ return;
74
+ }
69
75
  const cssKey = key.replace(/([A-Z])/g, "-$1").toLowerCase();
70
76
  // FIX 2 — Sanitize invalid px/per values
71
77
  if (pxChanges.includes(key)) {
72
78
  if (typeof value === "number") {
73
- stylesObj[cssKey] = `${value}px`;
79
+ const rounded = Math.round(value * 100) / 100;
80
+ stylesObj[cssKey] = `${rounded}px`;
74
81
  }
75
82
  else if (typeof value === "string" && value.includes("null")) {
76
83
  // Skip invalid styles
@@ -92,12 +99,17 @@ function buildStyles(style, { pxChanges, perChanges }) {
92
99
  stylesObj[cssKey] = value;
93
100
  }
94
101
  });
95
- return `${jsonToPlainString(cleanJson(stylesObj))}${style.customCss || ""}`.trim();
102
+ const parts = Object.entries(stylesObj)
103
+ .filter(([, v]) => v !== undefined && v !== null && v !== '')
104
+ .map(([k, v]) => `${k}:${v}`);
105
+ if (style.customCss)
106
+ parts.push(style.customCss);
107
+ return parts.join('; ').trim();
96
108
  }
97
109
  async function convertToHtml(blockData, rootData, cellWidthInPx) {
98
110
  switch (blockData.type) {
99
111
  case types_1.BlockType.TEXT:
100
- return convertTextBlock(blockData);
112
+ return convertTextBlock(blockData, cellWidthInPx);
101
113
  case types_1.BlockType.IMAGE:
102
114
  return await convertImageBlock(blockData, cellWidthInPx);
103
115
  case types_1.BlockType.BUTTON:
@@ -118,12 +130,7 @@ async function convertToHtml(blockData, rootData, cellWidthInPx) {
118
130
  return "";
119
131
  }
120
132
  }
121
- // function appendOutlookSupport(content: string, contentStyle: string) {
122
- // return `
123
- // <table width="100%" style="${tableCommonStyle}"><tr><td style="${contentStyle}">${content}</td></tr></table>
124
- // `;
125
- // }
126
- function appendOutlookSupport(content, contentStyle, className) {
133
+ function appendOutlookSupport(content, contentStyle, className, msoWidth) {
127
134
  const visibilityClass = className || "";
128
135
  const shouldHideInOutlook = visibilityClass.includes("hide-desktop");
129
136
  if (shouldHideInOutlook) {
@@ -133,9 +140,24 @@ function appendOutlookSupport(content, contentStyle, className) {
133
140
  <!--<![endif]-->
134
141
  `;
135
142
  }
136
- // Use a single table that works for both MSO and non-MSO
137
- // The content inside already has its own MSO conditional comments
138
- // width="100%" works for modern clients, Outlook will use the td width from nested tables
143
+ // When an explicit pixel width is provided (e.g. inside a column cell), use dual MSO/non-MSO
144
+ // tables. Old Outlook (Word engine) ignores max-width and can resolve width="100%" to the
145
+ // full email width (600px) rather than the column width, causing images/buttons to expand.
146
+ if (msoWidth) {
147
+ return `
148
+ <!--[if mso]>
149
+ <table border="0" cellpadding="0" cellspacing="0" width="${msoWidth}" style="border-collapse:collapse;width:${msoWidth}px;"><tr><td width="${msoWidth}" style="${contentStyle}">
150
+ <![endif]-->
151
+ <!--[if !mso]><!-->
152
+ <table width="100%" border="0" cellpadding="0" cellspacing="0" style="${exports.tableCommonStyle}; max-width:600px;" class="${visibilityClass}"><tr><td width="100%" style="${contentStyle}">
153
+ <!--<![endif]-->
154
+ ${content}
155
+ <!--[if mso]></td></tr></table><![endif]-->
156
+ <!--[if !mso]><!-->
157
+ </td></tr></table>
158
+ <!--<![endif]-->
159
+ `;
160
+ }
139
161
  return `
140
162
  <table width="100%" border="0" cellpadding="0" cellspacing="0" style="${exports.tableCommonStyle}; max-width:600px;" class="${visibilityClass}"><tr><td width="100%" style="${contentStyle}">${content}</td></tr></table>
141
163
  `;
@@ -183,28 +205,13 @@ function convertSpacerBlockToHtml(blockData) {
183
205
  });
184
206
  return appendOutlookSupport(``, styles, visibilityClass);
185
207
  }
186
- // function convertTextBlock(blockData: IBlockData) {
187
- // const { style, props } = blockData.data;
188
- // const styles = buildStyles(style, {
189
- // perChanges: [],
190
- // pxChanges: allPxAttributes,
191
- // });
192
- // const text = props.text || "";
193
- // const navigateToUrl = props.navigateToUrl || "";
194
- // const textContent = appendOutlookSupport(
195
- // text.replaceAll(/\n/g, "<br>"),
196
- // styles
197
- // );
198
- // return navigateToUrl
199
- // ? `<a href="${navigateToUrl}" rel="noreferrer noopener" style="color:inherit; text-decoration:none; cursor:pointer;">${textContent}</a>`
200
- // : textContent;
201
- // }
202
- function convertTextBlock(blockData) {
208
+ function convertTextBlock(blockData, cellWidthInPx) {
203
209
  const { style, props } = blockData.data;
204
210
  const visibilityClass = (0, common_1.getVisibilityClass)(props);
205
- const { width, backgroundColor, padding, borderRadius, borderStyle, borderColor, borderWidth, textContainerBackgroundColor, textContainerPadding, ...rest } = style;
211
+ const { width, backgroundColor, padding, borderRadius, borderStyle, borderColor, borderWidth, textContainerBackgroundColor, textContainerPadding, fontSize, whiteSpace: _whiteSpace, // strip from outer td — pre-wrap on a td preserves editor whitespace
212
+ ...rest } = style;
213
+ // Text box decoration styles (border, background, padding) — no width
206
214
  const textBoxStyle = {
207
- width,
208
215
  backgroundColor,
209
216
  padding,
210
217
  borderRadius,
@@ -216,6 +223,7 @@ function convertTextBlock(blockData) {
216
223
  perChanges: [],
217
224
  pxChanges: allPxAttributes,
218
225
  });
226
+ // Outer td styles: layout only, no typography, no white-space
219
227
  const styles = buildStyles({
220
228
  padding: textContainerPadding,
221
229
  backgroundColor: textContainerBackgroundColor,
@@ -225,11 +233,15 @@ function convertTextBlock(blockData) {
225
233
  pxChanges: allPxAttributes,
226
234
  });
227
235
  const sanitizedText = (props.text ?? "")
228
- .replaceAll(/<p>/g, "<div>")
229
- .replaceAll(/<\/p>/g, "</div>");
236
+ .replace(/<p(\s[^>]*)?>/gi, (_, attrs) => `<div${attrs || ""}>`)
237
+ .replace(/<\/p>/gi, "</div>");
230
238
  const navigateToUrl = props.navigateToUrl || "";
231
- const convertedTextBox = `<div style="display: inline-block; max-width: 100%; box-sizing: border-box; ${convertedTextStyle}">${sanitizedText.replaceAll(/\n/g, "<br>")}</div>`;
232
- const textContent = appendOutlookSupport(convertedTextBox, styles, visibilityClass);
239
+ const fontSizeStyle = fontSize != null ? `font-size:${fontSize}px;` : "";
240
+ // Use display:block + width:100% so text fills the column naturally.
241
+ // display:inline-block with a pixel width (e.g. 400px) breaks narrow grid cells.
242
+ const convertedTextBox = `<div style="display:block; width:100%; box-sizing:border-box; ${fontSizeStyle}${convertedTextStyle}">${sanitizedText.replaceAll(/\n/g, "<br>")}</div>`;
243
+ const safeCellWidth = cellWidthInPx ? Math.min(cellWidthInPx, 600) : undefined;
244
+ const textContent = appendOutlookSupport(convertedTextBox, styles, visibilityClass, safeCellWidth);
233
245
  return navigateToUrl
234
246
  ? `<a href="${navigateToUrl}" rel="noreferrer noopener" style="color:inherit;text-decoration:none;cursor:pointer;">${textContent}</a>`
235
247
  : textContent;
@@ -243,15 +255,21 @@ async function appendOutlookForImage(content, outerContainerWidth, innerContaine
243
255
  vmlWidth = finalWidth;
244
256
  vmlHeight = finalHeight;
245
257
  }
258
+ else if (imageUrl) {
259
+ try {
260
+ const { width: originalWidth, height: originalHeight } = await loadImageNaturalDimensions(imageUrl);
261
+ const widthScalingFactor = Math.min(outerContainerWidth / originalWidth, innerContainerWidth / originalWidth, 1);
262
+ vmlWidth = Math.round(originalWidth * widthScalingFactor);
263
+ vmlHeight = Math.round(originalHeight * widthScalingFactor);
264
+ }
265
+ catch {
266
+ vmlWidth = innerContainerWidth;
267
+ vmlHeight = innerContainerWidth;
268
+ }
269
+ }
246
270
  else {
247
- // Fallback: calculate from image
248
- const image = await jimp_1.Jimp.read(imageUrl);
249
- const originalWidth = image.bitmap.width;
250
- const originalHeight = image.bitmap.height;
251
- const widthScalingFactor = Math.min(outerContainerWidth / originalWidth, innerContainerWidth / originalWidth, 1 // Never scale up
252
- );
253
- vmlWidth = Math.round(originalWidth * widthScalingFactor);
254
- vmlHeight = Math.round(originalHeight * widthScalingFactor);
271
+ vmlWidth = innerContainerWidth;
272
+ vmlHeight = innerContainerWidth;
255
273
  }
256
274
  const borderWidth = parseInt(style?.borderWidth) || 0;
257
275
  const borderColor = style?.borderColor || "transparent";
@@ -310,13 +328,23 @@ async function appendOutlookForImage(content, outerContainerWidth, innerContaine
310
328
  `;
311
329
  }
312
330
  async function computeScaledDimensions(imageUrl, maxContainerWidthPx) {
313
- const image = await jimp_1.Jimp.read(imageUrl);
314
- const originalWidth = image.bitmap.width;
315
- const originalHeight = image.bitmap.height;
316
- const widthScalingFactor = Math.min(maxContainerWidthPx / originalWidth, 1);
317
- const scaledWidth = Math.round(originalWidth * widthScalingFactor);
318
- const scaledHeight = Math.round(originalHeight * widthScalingFactor);
319
- return { originalWidth, originalHeight, scaledWidth, scaledHeight };
331
+ if (!imageUrl) {
332
+ const w = Math.max(maxContainerWidthPx, 1);
333
+ const h = Math.round(w * (2 / 3));
334
+ return { originalWidth: w, originalHeight: h, scaledWidth: w, scaledHeight: h };
335
+ }
336
+ try {
337
+ const { width: originalWidth, height: originalHeight } = await loadImageNaturalDimensions(imageUrl);
338
+ const widthScalingFactor = Math.min(maxContainerWidthPx / originalWidth, 1);
339
+ const scaledWidth = Math.round(originalWidth * widthScalingFactor);
340
+ const scaledHeight = Math.round(originalHeight * widthScalingFactor);
341
+ return { originalWidth, originalHeight, scaledWidth, scaledHeight };
342
+ }
343
+ catch {
344
+ const w = Math.max(maxContainerWidthPx, 1);
345
+ const h = Math.round(w * (2 / 3));
346
+ return { originalWidth: w, originalHeight: h, scaledWidth: w, scaledHeight: h };
347
+ }
320
348
  }
321
349
  async function convertImageBlock(blockData, cellWidthInPx) {
322
350
  const { style, props } = blockData.data;
@@ -359,83 +387,233 @@ async function convertImageBlock(blockData, cellWidthInPx) {
359
387
  // OUTLOOK FIX: Image element with explicit dimensions
360
388
  // Outlook will use width/height attributes, modern clients use CSS
361
389
  // Use max-width instead of width:100% to prevent stretching
362
- const imageElement = `<img src="${imageUrl}" alt="${altText || "Image"}" border="0" width="${finalWidth}" height="${finalHeight}" style="${imageTagStyles}; display:block; max-width:100%; height:auto;" />`;
390
+ const imageElement = `<img src="${imageUrl}" alt="${altText || "Image"}" border="0" width="${finalWidth}" height="${finalHeight}" style="${imageTagStyles}; display:block; max-width:100%; height:auto; line-height: 0;" />`;
363
391
  const percentWidth = typeof width === "string" && width.endsWith("%")
364
392
  ? width
365
393
  : typeof width === "number"
366
394
  ? `${width}%`
367
395
  : "100%";
368
- // Non-MSO wrapper for responsive behavior
369
- const nonMsoWrapper = `<div style="display:inline-block; width:${percentWidth}; max-width:${originalWidth}px;">${imageElement}</div>`;
396
+ // Non-MSO wrapper: display:block removes the phantom inline-baseline gap that
397
+ // display:inline-block creates in Gmail / Apple Mail / Yahoo between images.
398
+ // margin handles alignment since text-align won't move block elements.
399
+ const imgTextAlign = containerStyle.textAlign || "left";
400
+ const imgMargin = imgTextAlign === "center" ? "margin:0 auto;" :
401
+ imgTextAlign === "right" ? "margin-left:auto; margin-right:0;" : "";
402
+ // OUTLOOK FIX: Use finalWidth (the actual displayed size) as max-width so the div
403
+ // doesn't claim more space than the image occupies. originalWidth is the natural
404
+ // image size (e.g. 636px for the Beefree logo rendered at 35px) which was
405
+ // misleadingly large and could confuse some rendering engines.
406
+ const nonMsoWrapper = `<div style="display:block; width:${percentWidth}; max-width:${finalWidth}px; line-height:0; font-size:0; ${imgMargin}">${imageElement}</div>`;
370
407
  // OUTLOOK FIX: Generate VML with corrected dimensions
371
408
  const outlookImage = await appendOutlookForImage(nonMsoWrapper, safeCellWidth, innerContainerWidth, imageUrl, style, finalWidth, finalHeight);
372
- const imageContent = appendOutlookSupport(outlookImage, containerStyles, visibilityClass);
409
+ const imageContent = appendOutlookSupport(outlookImage, containerStyles, visibilityClass, safeCellWidth);
373
410
  return navigateToUrl
374
411
  ? `<a href="${navigateToUrl}" target="_blank" rel="noreferrer noopener" style="display:block;">${imageContent}</a>`
375
412
  : imageContent;
376
413
  }
377
414
  function appendOutlookForButton(content, buttonStyle, navigateToUrl, text) {
378
- const { width = 200, height = 44, borderRadius = 0, borderColor = "transparent", borderWidth = 0, buttonColor = "none", buttonPadding = { top: 0, bottom: 0, left: 0, right: 0 }, color = "#000000", fontFamily = "Arial, sans-serif", fontSize = 16, fontWeight = 400, } = buttonStyle;
379
- const borderAttributes = borderWidth > 0
380
- ? `strokeweight="${borderWidth}px" strokecolor="${borderColor}"`
381
- : `stroked="false"`;
382
- return `
383
- <!--[if mso]>
384
- <v:${borderRadius ? "roundrect" : "rect"} xmlns:v="urn:schemas-microsoft-com:vml" href="${navigateToUrl}"
385
- style="height:${height}px;v-text-anchor:middle;width:${width}px;"
386
- arcsize="${borderRadius / height}" ${borderAttributes}
387
- fillcolor="${buttonColor}">
388
- <w:anchorlock/>
389
- <v:textbox inset="${buttonPadding.top}px,${buttonPadding.left}px,${buttonPadding.bottom}px,${buttonPadding.right}px">
390
- <center style="font-family:${fontFamily};font-size:${fontSize}px;font-weight:${fontWeight};color:${color};">
391
- ${text}
392
- </center>
393
- </v:textbox>
394
- </v:${borderRadius ? "roundrect" : "rect"}>
415
+ const pad = buttonStyle.buttonPadding || {};
416
+ const padTop = Number.isFinite(pad.top) ? pad.top : 10;
417
+ const padBottom = Number.isFinite(pad.bottom) ? pad.bottom : 10;
418
+ const padLeft = Number.isFinite(pad.left) ? pad.left : 20;
419
+ const padRight = Number.isFinite(pad.right) ? pad.right : 20;
420
+ const fontSize = buttonStyle.fontSize || 16;
421
+ const height = typeof buttonStyle.height === "number" && buttonStyle.height > 0
422
+ ? buttonStyle.height
423
+ : null;
424
+ // prevent layout break
425
+ const minHeight = padTop + padBottom + fontSize;
426
+ const finalHeight = height ? Math.max(height, minHeight) : null;
427
+ const borderRadius = buttonStyle.borderRadius || 0;
428
+ const borderColor = buttonStyle.borderColor || "transparent";
429
+ const borderWidth = buttonStyle.borderWidth || 0;
430
+ const borderStyle = buttonStyle.borderStyle || "solid";
431
+ const bgColor = buttonStyle.buttonColor || "transparent";
432
+ const color = buttonStyle.color || "#ffffff";
433
+ const fontFamily = (0, fontFallback_1.withFontFallback)(buttonStyle.fontFamily).replace(/"/g, "'");
434
+ const fontWeight = buttonStyle.fontWeight || 400;
435
+ const width = typeof buttonStyle.width === "number"
436
+ ? `width="${buttonStyle.width}"`
437
+ : "";
438
+ return `<!--[if mso]>
439
+ <table role="presentation" cellspacing="0" cellpadding="0" border="0" style="display:inline-table;">
440
+ <tr>
441
+ <td align="center"
442
+ valign="middle"
443
+ ${width}
444
+ ${finalHeight ? `height="${finalHeight}"` : ""}
445
+ bgcolor="${bgColor}"
446
+ style="
447
+ ${finalHeight ? `height:${finalHeight}px;` : ""}
448
+ background-color:${bgColor};
449
+ border-radius:${borderRadius}px;
450
+ border:${borderWidth}px ${borderStyle} ${borderColor};
451
+ overflow:hidden;
452
+ mso-line-height-rule:exactly;
453
+ ">
454
+
455
+ <table role="presentation" cellspacing="0" cellpadding="0" border="0">
456
+ <tr>
457
+ <td align="center" valign="middle"
458
+ style="padding:${padTop}px ${padRight}px ${padBottom}px ${padLeft}px;">
459
+
460
+ <a href="${navigateToUrl}"
461
+ style="
462
+ display:inline-block;
463
+ color:${color};
464
+ text-decoration:none;
465
+ font-family:${fontFamily};
466
+ font-size:${fontSize}px;
467
+ font-weight:${fontWeight};
468
+ line-height:normal;
469
+ ">
470
+ ${text}
471
+ </a>
472
+
473
+ </td>
474
+ </tr>
475
+ </table>
476
+
477
+ </td>
478
+ </tr>
479
+ </table>
395
480
  <![endif]-->
396
481
  <!--[if !mso]><!-->
397
- ${content}
398
- <!--<![endif]-->
399
- `;
482
+ ${content}
483
+ <!--<![endif]-->`;
400
484
  }
401
485
  function convertButtonBlock(blockData) {
402
486
  const { style, props } = blockData.data;
403
487
  const { text, navigateToUrl } = props;
404
- const visibilityClass = (0, common_1.getVisibilityClass)(props);
405
- const { fontFamily, fontSize, fontWeight, borderColor, borderRadius, borderWidth, borderStyle, buttonPadding, color, buttonColor, width, height, ...rest } = style;
406
- const buttonStyle = {
407
- width,
408
- height,
409
- fontFamily,
410
- fontSize,
411
- fontWeight,
412
- borderColor,
413
- borderRadius,
414
- borderWidth,
415
- borderStyle,
416
- padding: buttonPadding,
417
- color,
418
- backgroundColor: buttonColor,
488
+ const { fontFamily, fontSize, fontWeight, textAlign, borderColor, borderRadius, borderWidth, borderStyle, buttonPadding, color, buttonColor, width, height, alignment, padding, backgroundColor: containerBg, margin, } = style;
489
+ const pad = buttonPadding || {};
490
+ const padTop = Number.isFinite(pad.top) ? pad.top : 10;
491
+ const padBottom = Number.isFinite(pad.bottom) ? pad.bottom : 10;
492
+ const padLeft = Number.isFinite(pad.left) ? pad.left : 20;
493
+ const padRight = Number.isFinite(pad.right) ? pad.right : 20;
494
+ const fs = fontSize || 16;
495
+ // prevent layout break
496
+ const minHeight = padTop + padBottom + fs;
497
+ const finalHeight = typeof height === "number" && height > 0
498
+ ? Math.max(height, minHeight)
499
+ : null;
500
+ const safeFF = (0, fontFallback_1.withFontFallback)(fontFamily).replace(/"/g, "'");
501
+ const safeColor = color || "#ffffff";
502
+ const bgColor = buttonColor || "transparent";
503
+ const bdColor = borderColor || "transparent";
504
+ const bdStyle = borderStyle || "solid";
505
+ const bw = borderWidth || 0;
506
+ const br = borderRadius || 0;
507
+ const containerAlign = alignment || textAlign || "left";
508
+ const widthAttr = typeof width === "number"
509
+ ? `width="${width}"`
510
+ : "";
511
+ // ✅ FIX: no width=100% anywhere
512
+ const buttonTable = `
513
+ <table role="presentation" cellspacing="0" cellpadding="0" border="0"
514
+ style="display:inline-table; border-collapse:separate;"
515
+ ${widthAttr}>
516
+ <tr>
517
+ <td
518
+ align="center"
519
+ valign="middle"
520
+ ${finalHeight ? `height="${finalHeight}"` : ""}
521
+ style="
522
+ ${finalHeight ? `height:${finalHeight}px;` : ""}
523
+ background-color:${bgColor};
524
+ border-radius:${br}px;
525
+ border:${bw}px ${bdStyle} ${bdColor};
526
+ overflow:hidden;
527
+ mso-line-height-rule:exactly;
528
+ "
529
+ >
530
+
531
+ <table role="presentation" cellspacing="0" cellpadding="0" border="0">
532
+ <tr>
533
+ <td align="center" valign="middle"
534
+ style="padding:${padTop}px ${padRight}px ${padBottom}px ${padLeft}px;">
535
+
536
+ <a href="${navigateToUrl}"
537
+ style="
538
+ display:inline-block;
539
+ color:${safeColor};
540
+ text-decoration:none;
541
+ font-family:${safeFF};
542
+ font-size:${fs}px;
543
+ font-weight:${fontWeight || 400};
544
+ line-height:normal;
545
+ white-space:nowrap;
546
+ ">
547
+ ${text}
548
+ </a>
549
+
550
+ </td>
551
+ </tr>
552
+ </table>
553
+
554
+ </td>
555
+ </tr>
556
+ </table>
557
+ `;
558
+ const aligned = containerAlign === "center"
559
+ ? `<center>${buttonTable}</center>`
560
+ : `<div style="text-align:${containerAlign};">${buttonTable}</div>`;
561
+ const buttonWithOutlook = appendOutlookForButton(aligned, style, navigateToUrl, text);
562
+ return `
563
+ <table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
564
+ <tr>
565
+ <td align="${containerAlign}"
566
+ style="padding:${padding?.top || 0}px ${padding?.right || 0}px ${padding?.bottom || 0}px ${padding?.left || 0}px;
567
+ background-color:${containerBg || "transparent"};">
568
+ ${buttonWithOutlook}
569
+ </td>
570
+ </tr>
571
+ </table>
572
+ `;
573
+ }
574
+ /**
575
+ * Extract the first solid-color stop from a CSS gradient in customCss.
576
+ * Used as a MSO/Outlook bgcolor fallback when backgroundColor is not set
577
+ * but the block has a gradient in customCss (e.g. linear-gradient imports).
578
+ */
579
+ function extractCssFallbackColor(customCss) {
580
+ if (!customCss)
581
+ return '';
582
+ const m = customCss.match(/(?:linear|radial|conic)-gradient\([^)]*?(#[0-9a-fA-F]{3,8}|rgb\([^)]+\)|rgba\([^)]+\))/);
583
+ return m?.[1] ?? '';
584
+ }
585
+ function parseGradient(gradient) {
586
+ if (!gradient)
587
+ return null;
588
+ const angleMatch = gradient.match(/(\d+)deg/);
589
+ const angle = angleMatch ? parseInt(angleMatch[1]) : 180;
590
+ const colors = gradient.match(/#([0-9a-fA-F]{3,8})|rgb[a]?\([^)]+\)/g) || [];
591
+ return {
592
+ angle,
593
+ colors,
594
+ fallback: colors[0] || "#ffffff",
419
595
  };
420
- const convertedButtonStyle = buildStyles(buttonStyle, {
421
- perChanges: [],
422
- pxChanges: allPxAttributes,
423
- });
424
- const convertedStyles = buildStyles({ maxWidth: "100%", boxSizing: "border-box", ...rest }, {
425
- perChanges: [],
426
- pxChanges: allPxAttributes,
427
- });
428
- const buttonElement = `<a href="${navigateToUrl}" rel="noreferrer noopener" style="display:inline-block; text-decoration:none; cursor:pointer;"><button style="${convertedButtonStyle}">${text}</button></a>`;
429
- const buttonContent = appendOutlookSupport(appendOutlookForButton(buttonElement, style, navigateToUrl, text), convertedStyles, visibilityClass);
430
- return buttonContent;
596
+ }
597
+ function cssAngleToVml(angle) {
598
+ return (angle + 90) % 360;
431
599
  }
432
600
  async function convertGridBlock(blockData, rootData, cellWidthInPx) {
433
601
  const { style = {}, childrenIds = [], props } = blockData.data;
434
602
  const { columns = 1, cellWidths = [], responsive = true } = props;
435
- const { columnGap = 0, ...restStyle } = style;
603
+ const { columnGap = 0, backgroundImage, backgroundColor, ...restStyle } = style;
436
604
  const gridVisibilityClass = (0, common_1.getVisibilityClass)(props);
605
+ // Strip url() wrapper if already present, so we have a raw URL for VML
606
+ const isGradient = typeof backgroundImage === "string" && backgroundImage.includes("gradient");
607
+ const parsedGradient = isGradient ? parseGradient(backgroundImage) : null;
608
+ const fallbackBgColor = backgroundColor ||
609
+ parsedGradient?.fallback ||
610
+ extractCssFallbackColor(restStyle.customCss) ||
611
+ "#ffffff";
612
+ const rawBgImageUrl = !isGradient && backgroundImage
613
+ ? String(backgroundImage).replace(/^url\(['"]?/, "").replace(/['"]?\)$/, "")
614
+ : null;
437
615
  // FIX: avoid table-layout:fixed – causes shrink in many clients
438
- const tableStyles = buildStyles(restStyle, {
616
+ const tableStyles = buildStyles({ backgroundColor, ...restStyle }, {
439
617
  perChanges: [],
440
618
  pxChanges: allPxAttributes,
441
619
  });
@@ -443,45 +621,75 @@ async function convertGridBlock(blockData, rootData, cellWidthInPx) {
443
621
  const visualRows = Math.ceil(total / columns);
444
622
  // OUTLOOK FIX: Use explicit pixel width for Old Outlook (Word engine)
445
623
  const msoTableWidth = Math.min(cellWidthInPx, 600);
624
+ // When a background image is present, the background is applied on an outer
625
+ // wrapper <td> (see bottom of function). The inner grid tables must be clean
626
+ // (no background) so that outer td background shows through.
627
+ // When no background image, the MSO table gets bgcolor for solid-color sections.
628
+ const msoBgColor = !rawBgImageUrl
629
+ ? (backgroundColor || extractCssFallbackColor(restStyle.customCss))
630
+ : '';
631
+ const msoBgAttr = msoBgColor ? ` bgcolor="${msoBgColor}"` : '';
632
+ const msoBgStyle = msoBgColor ? `background-color:${msoBgColor};` : '';
633
+ // Inner non-MSO table: strip ALL background-related props when an outer bg-td
634
+ // wrapper is used. background-position/repeat/size without a background-image
635
+ // are harmless, but keeping them in the inner table style is confusing and
636
+ // could conflict with email client default styles.
637
+ const innerNonMsoStyle = rawBgImageUrl
638
+ ? buildStyles({
639
+ ...restStyle,
640
+ customCss: '',
641
+ backgroundSize: undefined,
642
+ backgroundPosition: undefined,
643
+ backgroundRepeat: undefined,
644
+ }, { perChanges: [], pxChanges: allPxAttributes })
645
+ : tableStyles;
646
+ // When bg image is present, inner tables must be explicitly transparent so the
647
+ // outer <td> background shows through (email clients may default table bg to white).
648
+ const innerBgTransparent = (rawBgImageUrl || isGradient)
649
+ ? 'background-color:transparent;'
650
+ : '';
651
+ // bgcolor attribute on both tables: survives Outlook compose paste (Word + Web
652
+ // both strip background-image CSS but keep the bgcolor HTML attribute).
653
+ const nonMsoBgAttr = !rawBgImageUrl && backgroundColor ? ` bgcolor="${backgroundColor}"` : '';
446
654
  let html = `
447
655
  <!--[if mso]>
448
- <table border="0" cellpadding="0" cellspacing="${columnGap}" width="${msoTableWidth}"
449
- style="border-collapse:separate;border-spacing:${columnGap}px;width:${msoTableWidth}px;"
656
+ <table border="0" cellpadding="0" cellspacing="0" width="${msoTableWidth}"${msoBgAttr}
657
+ style="border-collapse:collapse;width:${msoTableWidth}px;${msoBgStyle}${innerBgTransparent}"
450
658
  class="${gridVisibilityClass}">
451
659
  <![endif]-->
452
660
  <!--[if !mso]><!-->
453
- <table border="0" cellpadding="0" cellspacing="${columnGap}" width="100%"
454
- role="presentation"
455
- style="border-collapse:separate;border-spacing:${columnGap}px; ${tableStyles}; max-width:600px;"
661
+ <table border="0" cellpadding="0" cellspacing="0" width="100%"
662
+ role="presentation"${nonMsoBgAttr}
663
+ style="border-collapse:collapse; ${innerBgTransparent}${innerNonMsoStyle}; max-width:600px;"
456
664
  class="${gridVisibilityClass}">
457
665
  <!--<![endif]-->
458
666
  `;
459
667
  for (let r = 0; r < visualRows; r++) {
460
668
  html += "<tr>";
461
- // COUNT visible cells only
669
+ // COUNT visible cells and find last visible column index
462
670
  let visibleCells = 0;
671
+ let lastVisibleCol = 0;
463
672
  const rowIds = [];
464
673
  for (let c = 0; c < columns; c++) {
465
674
  const idx = r * columns + c;
466
675
  const id = childrenIds[idx] ?? null;
467
676
  rowIds.push(id);
468
- if (id) {
469
- const child = rootData[id];
470
- const isHidden = child?.data?.props?.hideOnDesktop;
471
- if (!isHidden)
472
- visibleCells++;
677
+ const child = id ? rootData[id] : null;
678
+ const isHidden = child?.data?.props?.hideOnDesktop;
679
+ if (!isHidden) {
680
+ visibleCells++;
681
+ lastVisibleCol = c;
473
682
  }
474
683
  }
475
- // OUTLOOK FIX: Calculate safe width based on visible cells
476
- // If we have visible cells, distribute 100% width evenly
477
684
  const safeWidth = visibleCells > 0 ? 100 / visibleCells : 100 / columns;
478
- // OUTLOOK FIX: Calculate total width used by all cells
685
+ // Reserve pixel space for spacer tds between visible cells (N-1 gaps for N visible cells)
686
+ const totalGapPx = columnGap * Math.max(visibleCells - 1, 0);
687
+ const adjustedTableWidth = Math.max(msoTableWidth - totalGapPx, 1);
479
688
  let totalWidth = 0;
480
689
  const cellWidthPercents = [];
481
690
  for (let c = 0; c < columns; c++) {
482
691
  const id = rowIds[c];
483
692
  let widthPercent = cellWidths[c] ?? safeWidth;
484
- // Validate width
485
693
  if (widthPercent <= 0 || widthPercent > 100) {
486
694
  widthPercent = safeWidth;
487
695
  }
@@ -494,49 +702,48 @@ async function convertGridBlock(blockData, rootData, cellWidthInPx) {
494
702
  }
495
703
  }
496
704
  }
497
- // OUTLOOK FIX: If total width < 100%, scale up to fill container
498
- // This prevents orphaned space that Outlook handles poorly
499
705
  const scaleFactor = totalWidth > 0 && totalWidth < 100 ? 100 / totalWidth : 1;
500
706
  for (let c = 0; c < columns; c++) {
501
- const idx = r * columns + c;
502
707
  const id = rowIds[c];
503
- // OUTLOOK FIX: Scale width to ensure cells fill 100% of container
504
708
  let widthPercent = cellWidthPercents[c] * scaleFactor;
505
- // OUTLOOK FIX: Ensure width doesn't exceed 100% after scaling
506
709
  widthPercent = Math.min(widthPercent, 100);
710
+ // Cell pixel width is a share of the gap-adjusted table width
711
+ const cellWidthPx = Math.round((widthPercent / 100) * adjustedTableWidth);
507
712
  if (id) {
508
713
  const child = rootData[id];
509
714
  const { style: cellStyle = {}, props: childProps = {} } = child.data;
510
715
  const verticalAlign = cellStyle.verticalAlign || "top";
511
716
  const childVisible = !childProps.hideOnDesktop;
512
717
  const visibilityClass = (0, common_1.getVisibilityClass)(childProps);
513
- // Only render if visible
514
718
  if (childVisible) {
515
- const { html: childHtml, styles } = await convertGridCellBlock(child, rootData, widthPercent, cellWidthInPx);
516
- // OUTLOOK FIX: Calculate pixel width for Outlook
517
- const cellWidthPx = Math.round((widthPercent / 100) * cellWidthInPx);
719
+ const { html: childHtml, styles } = await convertGridCellBlock(child, rootData, widthPercent, adjustedTableWidth);
720
+ // bgcolor on the cell <td> ensures background-color survives Outlook
721
+ // compose paste (Word/Web editors strip CSS but keep bgcolor attribute).
722
+ const cellBgColor = cellStyle.backgroundColor || '';
723
+ const cellBgAttr = cellBgColor ? ` bgcolor="${cellBgColor}"` : '';
518
724
  html += `
519
725
  <td
520
- width="${Math.round(widthPercent)}%"
521
- class="${[
522
- responsive ? "stack-column" : "",
523
- visibilityClass,
524
- ].filter(Boolean).join(" ")}"
726
+ width="${cellWidthPx}"${cellBgAttr}
727
+ class="${[responsive ? "stack-column" : "", visibilityClass].filter(Boolean).join(" ")}"
525
728
  style="width:${cellWidthPx}px;vertical-align:${verticalAlign};word-break:break-word;${styles}"
526
729
  >
527
730
  ${childHtml}
528
731
  </td>`;
732
+ // Spacer td between columns — fixed pixel width, invisible to screen readers
733
+ if (columnGap > 0 && c !== lastVisibleCol) {
734
+ html += `<td width="${columnGap}" style="width:${columnGap}px;font-size:0;line-height:0;padding:0;" aria-hidden="true"></td>`;
735
+ }
529
736
  }
530
737
  }
531
738
  else {
532
- // SAFE empty cell (keeps layout stable)
533
- // OUTLOOK FIX: Calculate pixel width for Outlook
534
- const cellWidthPx = Math.round((widthPercent / 100) * cellWidthInPx);
535
739
  html += `
536
- <td width="${Math.round(widthPercent)}%"
740
+ <td width="${cellWidthPx}"
537
741
  ${responsive ? 'class="stack-column"' : ""}
538
742
  style="width:${cellWidthPx}px;vertical-align:top;">
539
743
  </td>`;
744
+ if (columnGap > 0 && c !== lastVisibleCol) {
745
+ html += `<td width="${columnGap}" style="width:${columnGap}px;font-size:0;line-height:0;padding:0;" aria-hidden="true"></td>`;
746
+ }
540
747
  }
541
748
  }
542
749
  html += "</tr>";
@@ -550,6 +757,60 @@ async function convertGridBlock(blockData, rootData, cellWidthInPx) {
550
757
  </table>
551
758
  <!--<![endif]-->
552
759
  `;
760
+ // ── Background image: canonical multi-client approach ────────────────────
761
+ //
762
+ // Problem: `background-image` on a <table> element is stripped by:
763
+ // • New Outlook Mac / Windows (Chromium-based app)
764
+ // • Outlook.com
765
+ // • Old Outlook (Word engine) — ignores CSS entirely
766
+ //
767
+ // Solution: wrap the grid in an outer <table><tr><td> where the <td> carries
768
+ // the background. Different clients pick it up via different mechanisms:
769
+ //
770
+ // background="" attribute on <td> → Yahoo Mail, older webmail
771
+ // CSS background-image on <td> → Gmail, Apple Mail, new Outlook Mac ✓
772
+ // VML v:rect inside the <td> → Old Outlook (Word engine) ✓
773
+ //
774
+ // The inner grid tables have NO background so the outer <td> bg shows through.
775
+ if (rawBgImageUrl || isGradient) {
776
+ const vmlFill = isGradient
777
+ ? (() => {
778
+ const vmlAngle = cssAngleToVml(parsedGradient?.angle || 180);
779
+ const c1 = parsedGradient?.fallback || '#ffffff';
780
+ const c2 = parsedGradient?.colors[parsedGradient.colors.length - 1] || c1;
781
+ return `<v:fill type="gradient" color="${c1}" color2="${c2}" angle="${vmlAngle}" />`;
782
+ })()
783
+ : `<v:fill type="frame" src="${rawBgImageUrl}" color="${fallbackBgColor}" />`;
784
+ html = `
785
+ <table border="0" cellpadding="0" cellspacing="0" width="${msoTableWidth}" role="presentation"
786
+ style="border-collapse:collapse;width:${msoTableWidth}px;">
787
+ <tr>
788
+ <td width="${msoTableWidth}" bgcolor="${fallbackBgColor}" valign="top"
789
+ style="
790
+ width:${msoTableWidth}px;
791
+ background-color:${fallbackBgColor};
792
+ ${isGradient ? `background:${backgroundImage};` : `background:url('${rawBgImageUrl}') center/cover no-repeat;`}
793
+ ">
794
+
795
+ <!--[if gte mso 9]>
796
+ <v:rect xmlns:v="urn:schemas-microsoft-com:vml"
797
+ fill="true" stroke="false"
798
+ style="width:${msoTableWidth}px;">
799
+ ${vmlFill}
800
+ <v:textbox inset="0,0,0,0">
801
+ <![endif]-->
802
+
803
+ ${html}
804
+
805
+ <!--[if gte mso 9]>
806
+ </v:textbox>
807
+ </v:rect>
808
+ <![endif]-->
809
+
810
+ </td>
811
+ </tr>
812
+ </table>`;
813
+ }
553
814
  return html;
554
815
  }
555
816
  async function convertGridCellBlock(blockData, rootData, cellWidthPercent, parentCellWidthPx) {
@@ -563,16 +824,39 @@ async function convertGridCellBlock(blockData, rootData, cellWidthPercent, paren
563
824
  // OUTLOOK FIX: Calculate the actual cell width in pixels based on percentage
564
825
  // If parent is 600px and cell is 50%, cell width should be 300px, not 600px
565
826
  const cellWidthPx = Math.round((cellWidthPercent / 100) * parentCellWidthPx);
827
+ // Subtract the cell's own padding so children receive the actual content-area width.
828
+ // Old Outlook honours explicit img/table width attributes — if a child is sized to the
829
+ // full column width (ignoring padding) it overflows and expands the column.
830
+ const cellPad = style?.padding || {};
831
+ const cellPadLeft = Number.isFinite(cellPad.left) ? cellPad.left : 0;
832
+ const cellPadRight = Number.isFinite(cellPad.right) ? cellPad.right : 0;
833
+ const contentWidthPx = Math.max(cellWidthPx - cellPadLeft - cellPadRight, 20);
566
834
  // OUTLOOK FIX: Ensure cell width is reasonable and capped at 600px
567
- const safeCellWidthPx = Math.min(Math.max(cellWidthPx, 20), 600);
835
+ const safeCellWidthPx = Math.min(contentWidthPx, 600);
568
836
  for (const childId of childrenIds) {
569
837
  const child = rootData[childId];
570
838
  if (child) {
571
839
  parts.push(await convertToHtml(child, rootData, safeCellWidthPx));
572
840
  }
573
841
  }
842
+ const borderRadius = style?.borderRadius || 0;
843
+ const bgColor = style?.backgroundColor || "transparent";
844
+ // IMPORTANT: radius only for non-Outlook
845
+ const wrapped = `
846
+ <!--[if !mso]><!-->
847
+ <div style="
848
+ border-radius:${borderRadius}px;
849
+ overflow:hidden;
850
+ background-color:${bgColor};
851
+ ">
852
+ <!--<![endif]-->
853
+ ${parts.join("")}
854
+ <!--[if !mso]><!-->
855
+ </div>
856
+ <!--<![endif]-->
857
+ `;
574
858
  return {
575
- html: parts.join(""),
859
+ html: wrapped,
576
860
  styles,
577
861
  };
578
862
  }
@@ -666,7 +950,8 @@ async function convertVideoBlock(blockData, cellWidthInPx) {
666
950
  coordsize="${innerContainerWidth},${calculatedHeight}"
667
951
  href="${videoLink}"
668
952
  style="width:${innerContainerWidth}px;height:${calculatedHeight}px;">
669
- <v:rect fill="t" style="position:absolute;width:${innerContainerWidth}px;height:${calculatedHeight}px; stroked="t"
953
+ <v:rect fill="t" style="position:absolute;width:${innerContainerWidth}px;height:${calculatedHeight}px;"
954
+ stroked="t"
670
955
  strokeweight="${borderWidth}px"
671
956
  strokecolor="${borderColor}"
672
957
  ${borderRadius > 0 ? `arcsize="${Math.min(borderRadius / calculatedHeight, 1).toFixed(2)}"` : ""}
@@ -869,7 +1154,7 @@ async function convertShapeBlock(blockData) {
869
1154
  </div>`;
870
1155
  }
871
1156
  // Outlook (VML) fallback
872
- const outlookContent = await appendOutlookForShape(nonMsoContent, resolvedWidthPx, resolvedWidthPx, {
1157
+ const outlookContent = appendOutlookForShape(nonMsoContent, resolvedWidthPx, resolvedWidthPx, {
873
1158
  shape,
874
1159
  imageUrl,
875
1160
  backgroundColor,
@@ -1001,27 +1286,24 @@ function appendOutlookForShape(content, outerContainerWidth, innerContainerWidth
1001
1286
  }
1002
1287
  function convertVerticalDividerBlockToHtml(blockData) {
1003
1288
  const { style, props } = blockData.data;
1004
- const { width, height, dividerColor, ...rest } = style;
1289
+ const { width, height, dividerColor, padding, backgroundColor } = style;
1005
1290
  const visibilityClass = (0, common_1.getVisibilityClass)(props);
1006
- // Convert other styles to inline-safe HTML attributes
1007
- const convertedStyle = buildStyles(rest, {
1291
+ const outerStyles = buildStyles({ padding, backgroundColor }, {
1008
1292
  perChanges: [],
1009
1293
  pxChanges: allPxAttributes,
1010
1294
  });
1011
- // Outlook-safe vertical divider
1012
- const dividerContent = `
1013
- <table cellpadding="0" cellspacing="0" border="0" align="center" style="width:auto; ${convertedStyle}">
1014
- <tr>
1015
- <td style="vertical-align: middle; text-align: center;">
1016
- <!--[if mso | IE]>
1017
- <v:rect xmlns:v="urn:schemas-microsoft-com:vml" fillcolor="${dividerColor}" style="width:${width}px;height:${height}px;" stroke="f"></v:rect>
1018
- <![endif]-->
1019
- <!--[if !mso]><!-- -->
1020
- <div style="display:inline-block;width:${width}px;height:${height}px;background:${dividerColor};line-height:0;font-size:0;">&nbsp;</div>
1021
- <!--<![endif]-->
1022
- </td>
1023
- </tr>
1024
- </table>
1025
- `;
1026
- return appendOutlookSupport(dividerContent, convertedStyle, visibilityClass);
1295
+ return `
1296
+ <table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation"
1297
+ style="${exports.tableCommonStyle} max-width:600px;" class="${visibilityClass}">
1298
+ <tr>
1299
+ <td style="${outerStyles}; text-align:center; vertical-align:middle;">
1300
+ <!--[if mso | IE]>
1301
+ <v:rect xmlns:v="urn:schemas-microsoft-com:vml" fillcolor="${dividerColor}" style="width:${width}px;height:${height}px;" stroke="f"></v:rect>
1302
+ <![endif]-->
1303
+ <!--[if !mso]><!-->
1304
+ <div style="display:inline-block;width:${width}px;height:${height}px;background:${dividerColor};line-height:0;font-size:0;">&nbsp;</div>
1305
+ <!--<![endif]-->
1306
+ </td>
1307
+ </tr>
1308
+ </table>`;
1027
1309
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "email-builder-utils",
3
- "version": "1.1.40",
3
+ "version": "1.1.42",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "files": [