email-builder-utils 1.1.40 → 1.1.44

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.
@@ -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,18 @@ 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)]));
18
+ function encodeBlockProps(props) {
19
+ return JSON.stringify(props)
20
+ .replace(/&/g, '&')
21
+ .replace(/"/g, '"');
26
22
  }
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("");
23
+ async function loadImageNaturalDimensions(imageUrl) {
24
+ return new Promise((resolve, reject) => {
25
+ const img = new Image();
26
+ img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
27
+ img.onerror = () => reject(new Error(`Failed to load image: ${imageUrl}`));
28
+ img.src = imageUrl;
29
+ });
35
30
  }
36
31
  function buildStyles(style, { pxChanges, perChanges }) {
37
32
  if (!style)
@@ -47,7 +42,9 @@ function buildStyles(style, { pxChanges, perChanges }) {
47
42
  "childWidth",
48
43
  "visibility",
49
44
  "hideOnMobile",
50
- "hideOnDesktop"
45
+ "hideOnDesktop",
46
+ "label",
47
+ "alignment",
51
48
  ];
52
49
  if (INVALID_KEYS.includes(key))
53
50
  return;
@@ -66,11 +63,26 @@ function buildStyles(style, { pxChanges, perChanges }) {
66
63
  };
67
64
  value = `${safePad.top}px ${safePad.right}px ${safePad.bottom}px ${safePad.left}px`;
68
65
  }
66
+ if (key === "fontFamily" && typeof value === "string") {
67
+ value = (0, fontFallback_1.withFontFallback)(value).replace(/"/g, "'");
68
+ }
69
+ // Wrap backgroundImage values in url() if not already wrapped — skip gradients
70
+ if (key === "backgroundImage" && typeof value === "string"
71
+ && !String(value).startsWith("url(")
72
+ && !String(value).toLowerCase().includes("gradient(")) {
73
+ value = `url('${value}')`;
74
+ }
75
+ // lineHeight: values >= 4 are pixel values; smaller values are unitless multipliers (e.g. 1.5)
76
+ if (key === "lineHeight" && typeof value === "number") {
77
+ stylesObj["line-height"] = value >= 4 ? `${value}px` : String(value);
78
+ return;
79
+ }
69
80
  const cssKey = key.replace(/([A-Z])/g, "-$1").toLowerCase();
70
81
  // FIX 2 — Sanitize invalid px/per values
71
82
  if (pxChanges.includes(key)) {
72
83
  if (typeof value === "number") {
73
- stylesObj[cssKey] = `${value}px`;
84
+ const rounded = Math.round(value * 100) / 100;
85
+ stylesObj[cssKey] = `${rounded}px`;
74
86
  }
75
87
  else if (typeof value === "string" && value.includes("null")) {
76
88
  // Skip invalid styles
@@ -92,12 +104,17 @@ function buildStyles(style, { pxChanges, perChanges }) {
92
104
  stylesObj[cssKey] = value;
93
105
  }
94
106
  });
95
- return `${jsonToPlainString(cleanJson(stylesObj))}${style.customCss || ""}`.trim();
107
+ const parts = Object.entries(stylesObj)
108
+ .filter(([, v]) => v !== undefined && v !== null && v !== '')
109
+ .map(([k, v]) => `${k}:${v}`);
110
+ if (style.customCss)
111
+ parts.push(style.customCss);
112
+ return parts.join('; ').replace(/;\s*$/, '').trim();
96
113
  }
97
114
  async function convertToHtml(blockData, rootData, cellWidthInPx) {
98
115
  switch (blockData.type) {
99
116
  case types_1.BlockType.TEXT:
100
- return convertTextBlock(blockData);
117
+ return convertTextBlock(blockData, cellWidthInPx);
101
118
  case types_1.BlockType.IMAGE:
102
119
  return await convertImageBlock(blockData, cellWidthInPx);
103
120
  case types_1.BlockType.BUTTON:
@@ -118,26 +135,36 @@ async function convertToHtml(blockData, rootData, cellWidthInPx) {
118
135
  return "";
119
136
  }
120
137
  }
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) {
138
+ function appendOutlookSupport(content, contentStyle, className, msoWidth) {
127
139
  const visibilityClass = className || "";
128
140
  const shouldHideInOutlook = visibilityClass.includes("hide-desktop");
129
141
  if (shouldHideInOutlook) {
130
- return `
131
- <!--[if !mso]><!-->
132
- <table width="100%" style="${exports.tableCommonStyle}" class="${visibilityClass}"><tr><td style="${contentStyle}">${content}</td></tr></table>
133
- <!--<![endif]-->
142
+ return `
143
+ <!--[if !mso]><!-->
144
+ <table width="100%" style="${exports.tableCommonStyle}" class="${visibilityClass}"><tr><td style="${contentStyle}">${content}</td></tr></table>
145
+ <!--<![endif]-->
146
+ `;
147
+ }
148
+ // When an explicit pixel width is provided (e.g. inside a column cell), use dual MSO/non-MSO
149
+ // tables. Old Outlook (Word engine) ignores max-width and can resolve width="100%" to the
150
+ // full email width (600px) rather than the column width, causing images/buttons to expand.
151
+ if (msoWidth) {
152
+ return `
153
+ <!--[if mso]>
154
+ <table border="0" cellpadding="0" cellspacing="0" width="${msoWidth}" style="border-collapse:collapse;width:${msoWidth}px;"><tr><td width="${msoWidth}" style="${contentStyle}">
155
+ <![endif]-->
156
+ <!--[if !mso]><!-->
157
+ <table width="100%" border="0" cellpadding="0" cellspacing="0" style="${exports.tableCommonStyle}; max-width:600px;" class="${visibilityClass}"><tr><td width="100%" style="${contentStyle}">
158
+ <!--<![endif]-->
159
+ ${content}
160
+ <!--[if mso]></td></tr></table><![endif]-->
161
+ <!--[if !mso]><!-->
162
+ </td></tr></table>
163
+ <!--<![endif]-->
134
164
  `;
135
165
  }
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
139
- return `
140
- <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>
166
+ return `
167
+ <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
168
  `;
142
169
  }
143
170
  function convertDividerBlockToHtml(blockData) {
@@ -156,21 +183,21 @@ function convertDividerBlockToHtml(blockData) {
156
183
  ]
157
184
  .filter(Boolean)
158
185
  .join(" ");
159
- const dividerContent = `
160
- <table
161
- width="${dividerWidth}%"
162
- cellpadding="0"
163
- cellspacing="0"
164
- >
165
- <tr>
166
- <td
167
- height="${thickness}"
168
- style="font-size:1px; line-height:1px; background:${dividerColor}; width:${dividerWidth};"
169
- >
170
- &nbsp;
171
- </td>
172
- </tr>
173
- </table>
186
+ const dividerContent = `
187
+ <table
188
+ width="${dividerWidth}%"
189
+ cellpadding="0"
190
+ cellspacing="0"
191
+ >
192
+ <tr>
193
+ <td
194
+ height="${thickness}"
195
+ style="font-size:1px; line-height:1px; background:${dividerColor}; width:${dividerWidth};"
196
+ >
197
+ &nbsp;
198
+ </td>
199
+ </tr>
200
+ </table>
174
201
  `;
175
202
  return appendOutlookSupport(dividerContent, convertedStyle, visibilityClass);
176
203
  }
@@ -183,28 +210,13 @@ function convertSpacerBlockToHtml(blockData) {
183
210
  });
184
211
  return appendOutlookSupport(``, styles, visibilityClass);
185
212
  }
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) {
213
+ function convertTextBlock(blockData, cellWidthInPx) {
203
214
  const { style, props } = blockData.data;
204
215
  const visibilityClass = (0, common_1.getVisibilityClass)(props);
205
- const { width, backgroundColor, padding, borderRadius, borderStyle, borderColor, borderWidth, textContainerBackgroundColor, textContainerPadding, ...rest } = style;
216
+ 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
217
+ ...rest } = style;
218
+ // Text box decoration styles (border, background, padding) — no width
206
219
  const textBoxStyle = {
207
- width,
208
220
  backgroundColor,
209
221
  padding,
210
222
  borderRadius,
@@ -216,6 +228,7 @@ function convertTextBlock(blockData) {
216
228
  perChanges: [],
217
229
  pxChanges: allPxAttributes,
218
230
  });
231
+ // Outer td styles: layout only, no typography, no white-space
219
232
  const styles = buildStyles({
220
233
  padding: textContainerPadding,
221
234
  backgroundColor: textContainerBackgroundColor,
@@ -225,11 +238,15 @@ function convertTextBlock(blockData) {
225
238
  pxChanges: allPxAttributes,
226
239
  });
227
240
  const sanitizedText = (props.text ?? "")
228
- .replaceAll(/<p>/g, "<div>")
229
- .replaceAll(/<\/p>/g, "</div>");
241
+ .replace(/<p(\s[^>]*)?>/gi, (_, attrs) => `<div${attrs || ""}>`)
242
+ .replace(/<\/p>/gi, "</div>");
230
243
  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);
244
+ const fontSizeStyle = fontSize != null ? `font-size:${fontSize}px;` : "";
245
+ // Use display:block + width:100% so text fills the column naturally.
246
+ // display:inline-block with a pixel width (e.g. 400px) breaks narrow grid cells.
247
+ const convertedTextBox = `<div style="display:block; width:100%; box-sizing:border-box; ${fontSizeStyle}${convertedTextStyle}">${sanitizedText.replaceAll(/\n/g, "<br>")}</div>`;
248
+ const safeCellWidth = cellWidthInPx ? Math.min(cellWidthInPx, 600) : undefined;
249
+ const textContent = appendOutlookSupport(convertedTextBox, styles, visibilityClass, safeCellWidth);
233
250
  return navigateToUrl
234
251
  ? `<a href="${navigateToUrl}" rel="noreferrer noopener" style="color:inherit;text-decoration:none;cursor:pointer;">${textContent}</a>`
235
252
  : textContent;
@@ -243,15 +260,21 @@ async function appendOutlookForImage(content, outerContainerWidth, innerContaine
243
260
  vmlWidth = finalWidth;
244
261
  vmlHeight = finalHeight;
245
262
  }
263
+ else if (imageUrl) {
264
+ try {
265
+ const { width: originalWidth, height: originalHeight } = await loadImageNaturalDimensions(imageUrl);
266
+ const widthScalingFactor = Math.min(outerContainerWidth / originalWidth, innerContainerWidth / originalWidth, 1);
267
+ vmlWidth = Math.round(originalWidth * widthScalingFactor);
268
+ vmlHeight = Math.round(originalHeight * widthScalingFactor);
269
+ }
270
+ catch {
271
+ vmlWidth = innerContainerWidth;
272
+ vmlHeight = innerContainerWidth;
273
+ }
274
+ }
246
275
  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);
276
+ vmlWidth = innerContainerWidth;
277
+ vmlHeight = innerContainerWidth;
255
278
  }
256
279
  const borderWidth = parseInt(style?.borderWidth) || 0;
257
280
  const borderColor = style?.borderColor || "transparent";
@@ -269,21 +292,21 @@ async function appendOutlookForImage(content, outerContainerWidth, innerContaine
269
292
  if (useRoundRect && borderRadius > 0) {
270
293
  // Use VML for border radius - wrap in table to constrain width for Old Outlook (Word engine)
271
294
  // Use aspect="atmost" to prevent image from stretching beyond its bounds
272
- outlookImage = `<!--[if mso]>
273
- <table border="0" cellpadding="0" cellspacing="0" width="${vmlWidth}" style="width:${vmlWidth}px;">
274
- <tr>
275
- <td align="center" valign="top" width="${vmlWidth}" style="width:${vmlWidth}px;">
276
- <v:roundrect xmlns:v="urn:schemas-microsoft-com:vml"
277
- style="width:${vmlWidth}px;height:${vmlHeight}px;"
278
- ${borderAttributes}
279
- arcsize="${arcsize}"
280
- fill="true" fillcolor="none">
281
- <v:fill src="${imageUrl}" type="tile" aspect="atmost" />
282
- <v:textbox inset="0,0,0,0"><div style="display:none;">.</div></v:textbox>
283
- </v:roundrect>
284
- </td>
285
- </tr>
286
- </table>
295
+ outlookImage = `<!--[if mso]>
296
+ <table border="0" cellpadding="0" cellspacing="0" width="${vmlWidth}" style="width:${vmlWidth}px;">
297
+ <tr>
298
+ <td align="center" valign="top" width="${vmlWidth}" style="width:${vmlWidth}px;">
299
+ <v:roundrect xmlns:v="urn:schemas-microsoft-com:vml"
300
+ style="width:${vmlWidth}px;height:${vmlHeight}px;"
301
+ ${borderAttributes}
302
+ arcsize="${arcsize}"
303
+ fill="true" fillcolor="none">
304
+ <v:fill src="${imageUrl}" type="tile" aspect="atmost" />
305
+ <v:textbox inset="0,0,0,0"><div style="display:none;">.</div></v:textbox>
306
+ </v:roundrect>
307
+ </td>
308
+ </tr>
309
+ </table>
287
310
  <![endif]-->`;
288
311
  }
289
312
  else {
@@ -292,31 +315,41 @@ async function appendOutlookForImage(content, outerContainerWidth, innerContaine
292
315
  const borderStyleAttr = borderWidth > 0
293
316
  ? `border: ${borderWidth}px solid ${borderColor};`
294
317
  : '';
295
- outlookImage = `<!--[if mso]>
296
- <table border="0" cellpadding="0" cellspacing="0" width="${vmlWidth}" style="width:${vmlWidth}px;">
297
- <tr>
298
- <td align="center" valign="top" width="${vmlWidth}" style="width:${vmlWidth}px;">
299
- <img src="${imageUrl}" alt="Image" border="0" width="${vmlWidth}" height="${vmlHeight}" style="display:block; width:${vmlWidth}px; height:${vmlHeight}px; max-width:${vmlWidth}px; ${borderStyleAttr}" />
300
- </td>
301
- </tr>
302
- </table>
318
+ outlookImage = `<!--[if mso]>
319
+ <table border="0" cellpadding="0" cellspacing="0" width="${vmlWidth}" style="width:${vmlWidth}px;">
320
+ <tr>
321
+ <td align="center" valign="top" width="${vmlWidth}" style="width:${vmlWidth}px;">
322
+ <img src="${imageUrl}" alt="Image" border="0" width="${vmlWidth}" height="${vmlHeight}" style="display:block; width:${vmlWidth}px; height:${vmlHeight}px; max-width:${vmlWidth}px; ${borderStyleAttr}" />
323
+ </td>
324
+ </tr>
325
+ </table>
303
326
  <![endif]-->`;
304
327
  }
305
- return `
306
- ${outlookImage}
307
- <!--[if !mso]><!-->
308
- ${content}
309
- <!--<![endif]-->
328
+ return `
329
+ ${outlookImage}
330
+ <!--[if !mso]><!-->
331
+ ${content}
332
+ <!--<![endif]-->
310
333
  `;
311
334
  }
312
335
  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 };
336
+ if (!imageUrl) {
337
+ const w = Math.max(maxContainerWidthPx, 1);
338
+ const h = Math.round(w * (2 / 3));
339
+ return { originalWidth: w, originalHeight: h, scaledWidth: w, scaledHeight: h };
340
+ }
341
+ try {
342
+ const { width: originalWidth, height: originalHeight } = await loadImageNaturalDimensions(imageUrl);
343
+ const widthScalingFactor = Math.min(maxContainerWidthPx / originalWidth, 1);
344
+ const scaledWidth = Math.round(originalWidth * widthScalingFactor);
345
+ const scaledHeight = Math.round(originalHeight * widthScalingFactor);
346
+ return { originalWidth, originalHeight, scaledWidth, scaledHeight };
347
+ }
348
+ catch {
349
+ const w = Math.max(maxContainerWidthPx, 1);
350
+ const h = Math.round(w * (2 / 3));
351
+ return { originalWidth: w, originalHeight: h, scaledWidth: w, scaledHeight: h };
352
+ }
320
353
  }
321
354
  async function convertImageBlock(blockData, cellWidthInPx) {
322
355
  const { style, props } = blockData.data;
@@ -359,83 +392,302 @@ async function convertImageBlock(blockData, cellWidthInPx) {
359
392
  // OUTLOOK FIX: Image element with explicit dimensions
360
393
  // Outlook will use width/height attributes, modern clients use CSS
361
394
  // 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;" />`;
395
+ 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
396
  const percentWidth = typeof width === "string" && width.endsWith("%")
364
397
  ? width
365
398
  : typeof width === "number"
366
399
  ? `${width}%`
367
400
  : "100%";
368
- // Non-MSO wrapper for responsive behavior
369
- const nonMsoWrapper = `<div style="display:inline-block; width:${percentWidth}; max-width:${originalWidth}px;">${imageElement}</div>`;
401
+ // Non-MSO wrapper: display:block removes the phantom inline-baseline gap that
402
+ // display:inline-block creates in Gmail / Apple Mail / Yahoo between images.
403
+ // margin handles alignment since text-align won't move block elements.
404
+ const imgTextAlign = containerStyle.textAlign || "left";
405
+ const imgMargin = imgTextAlign === "center" ? "margin:0 auto;" :
406
+ imgTextAlign === "right" ? "margin-left:auto; margin-right:0;" : "";
407
+ // OUTLOOK FIX: Use finalWidth (the actual displayed size) as max-width so the div
408
+ // doesn't claim more space than the image occupies. originalWidth is the natural
409
+ // image size (e.g. 636px for the Beefree logo rendered at 35px) which was
410
+ // misleadingly large and could confuse some rendering engines.
411
+ const nonMsoWrapper = `<div style="display:block; width:${percentWidth}; max-width:${finalWidth}px; line-height:0; font-size:0; ${imgMargin}">${imageElement}</div>`;
370
412
  // OUTLOOK FIX: Generate VML with corrected dimensions
371
413
  const outlookImage = await appendOutlookForImage(nonMsoWrapper, safeCellWidth, innerContainerWidth, imageUrl, style, finalWidth, finalHeight);
372
- const imageContent = appendOutlookSupport(outlookImage, containerStyles, visibilityClass);
414
+ const imageContent = appendOutlookSupport(outlookImage, containerStyles, visibilityClass, safeCellWidth);
373
415
  return navigateToUrl
374
416
  ? `<a href="${navigateToUrl}" target="_blank" rel="noreferrer noopener" style="display:block;">${imageContent}</a>`
375
417
  : imageContent;
376
418
  }
377
419
  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"}>
395
- <![endif]-->
396
- <!--[if !mso]><!-->
397
- ${content}
398
- <!--<![endif]-->
399
- `;
420
+ const pad = buttonStyle.buttonPadding || {};
421
+ const padTop = Number.isFinite(pad.top) ? pad.top : 10;
422
+ const padBottom = Number.isFinite(pad.bottom) ? pad.bottom : 10;
423
+ const padLeft = Number.isFinite(pad.left) ? pad.left : 20;
424
+ const padRight = Number.isFinite(pad.right) ? pad.right : 20;
425
+ const fontSize = buttonStyle.fontSize || 16;
426
+ const height = typeof buttonStyle.height === "number" && buttonStyle.height > 0
427
+ ? buttonStyle.height
428
+ : null;
429
+ // prevent layout break
430
+ const minHeight = padTop + padBottom + fontSize;
431
+ const finalHeight = height ? Math.max(height, minHeight) : null;
432
+ const borderRadius = buttonStyle.borderRadius || 0;
433
+ const borderColor = buttonStyle.borderColor || "transparent";
434
+ const borderWidth = buttonStyle.borderWidth || 0;
435
+ const borderStyle = buttonStyle.borderStyle || "solid";
436
+ const bgColor = buttonStyle.buttonColor || "transparent";
437
+ const color = buttonStyle.color || "#ffffff";
438
+ const fontFamily = (0, fontFallback_1.withFontFallback)(buttonStyle.fontFamily).replace(/"/g, "'");
439
+ const fontWeight = buttonStyle.fontWeight || 400;
440
+ const width = typeof buttonStyle.width === "number"
441
+ ? `width="${buttonStyle.width}"`
442
+ : "";
443
+ return `<!--[if mso]>
444
+ <table role="presentation" cellspacing="0" cellpadding="0" border="0" style="display:inline-table;">
445
+ <tr>
446
+ <td align="center"
447
+ valign="middle"
448
+ ${width}
449
+ ${finalHeight ? `height="${finalHeight}"` : ""}
450
+ bgcolor="${bgColor}"
451
+ style="
452
+ ${finalHeight ? `height:${finalHeight}px;` : ""}
453
+ background-color:${bgColor};
454
+ border-radius:${borderRadius}px;
455
+ border:${borderWidth}px ${borderStyle} ${borderColor};
456
+ overflow:hidden;
457
+ mso-line-height-rule:exactly;
458
+ ">
459
+
460
+ <table role="presentation" cellspacing="0" cellpadding="0" border="0">
461
+ <tr>
462
+ <td align="center" valign="middle"
463
+ style="padding:${padTop}px ${padRight}px ${padBottom}px ${padLeft}px;">
464
+
465
+ <a href="${navigateToUrl}"
466
+ style="
467
+ display:inline-block;
468
+ color:${color};
469
+ text-decoration:none;
470
+ font-family:${fontFamily};
471
+ font-size:${fontSize}px;
472
+ font-weight:${fontWeight};
473
+ line-height:normal;
474
+ ">
475
+ ${text}
476
+ </a>
477
+
478
+ </td>
479
+ </tr>
480
+ </table>
481
+
482
+ </td>
483
+ </tr>
484
+ </table>
485
+ <![endif]-->
486
+ <!--[if !mso]><!-->
487
+ ${content}
488
+ <!--<![endif]-->`;
400
489
  }
401
490
  function convertButtonBlock(blockData) {
402
491
  const { style, props } = blockData.data;
403
492
  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,
493
+ const { fontFamily, fontSize, fontWeight, textAlign, borderColor, borderRadius, borderWidth, borderStyle, buttonPadding, color, buttonColor, width, height, alignment, padding, backgroundColor: containerBg, margin, } = style;
494
+ const pad = buttonPadding || {};
495
+ const padTop = Number.isFinite(pad.top) ? pad.top : 10;
496
+ const padBottom = Number.isFinite(pad.bottom) ? pad.bottom : 10;
497
+ const padLeft = Number.isFinite(pad.left) ? pad.left : 20;
498
+ const padRight = Number.isFinite(pad.right) ? pad.right : 20;
499
+ const fs = fontSize || 16;
500
+ // prevent layout break
501
+ const minHeight = padTop + padBottom + fs;
502
+ const finalHeight = typeof height === "number" && height > 0
503
+ ? Math.max(height, minHeight)
504
+ : null;
505
+ const safeFF = (0, fontFallback_1.withFontFallback)(fontFamily).replace(/"/g, "'");
506
+ const safeColor = color || "#ffffff";
507
+ const bgColor = buttonColor || "transparent";
508
+ const bdColor = borderColor || "transparent";
509
+ const bdStyle = borderStyle || "solid";
510
+ const bw = borderWidth || 0;
511
+ const br = borderRadius || 0;
512
+ const containerAlign = alignment || textAlign || "left";
513
+ const widthAttr = typeof width === "number"
514
+ ? `width="${width}"`
515
+ : "";
516
+ // ✅ FIX: no width=100% anywhere
517
+ const buttonTable = `
518
+ <table role="presentation" cellspacing="0" cellpadding="0" border="0"
519
+ style="display:inline-table; border-collapse:separate;"
520
+ ${widthAttr}>
521
+ <tr>
522
+ <td
523
+ align="center"
524
+ valign="middle"
525
+ ${finalHeight ? `height="${finalHeight}"` : ""}
526
+ style="
527
+ ${finalHeight ? `height:${finalHeight}px;` : ""}
528
+ background-color:${bgColor};
529
+ border-radius:${br}px;
530
+ border:${bw}px ${bdStyle} ${bdColor};
531
+ overflow:hidden;
532
+ mso-line-height-rule:exactly;
533
+ "
534
+ >
535
+
536
+ <table role="presentation" cellspacing="0" cellpadding="0" border="0">
537
+ <tr>
538
+ <td align="center" valign="middle"
539
+ style="padding:${padTop}px ${padRight}px ${padBottom}px ${padLeft}px;">
540
+
541
+ <a href="${navigateToUrl}"
542
+ style="
543
+ display:inline-block;
544
+ color:${safeColor};
545
+ text-decoration:none;
546
+ font-family:${safeFF};
547
+ font-size:${fs}px;
548
+ font-weight:${fontWeight || 400};
549
+ line-height:normal;
550
+ white-space:nowrap;
551
+ ">
552
+ ${text}
553
+ </a>
554
+
555
+ </td>
556
+ </tr>
557
+ </table>
558
+
559
+ </td>
560
+ </tr>
561
+ </table>
562
+ `;
563
+ const aligned = containerAlign === "center"
564
+ ? `<center>${buttonTable}</center>`
565
+ : `<div style="text-align:${containerAlign};">${buttonTable}</div>`;
566
+ const buttonWithOutlook = appendOutlookForButton(aligned, style, navigateToUrl, text);
567
+ return `
568
+ <table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
569
+ <tr>
570
+ <td align="${containerAlign}"
571
+ style="padding:${padding?.top || 0}px ${padding?.right || 0}px ${padding?.bottom || 0}px ${padding?.left || 0}px;
572
+ background-color:${containerBg || "transparent"};">
573
+ ${buttonWithOutlook}
574
+ </td>
575
+ </tr>
576
+ </table>
577
+ `;
578
+ }
579
+ // Words inside a gradient() that are NOT color names
580
+ const GRADIENT_KEYWORDS = new Set([
581
+ 'linear', 'radial', 'conic', 'gradient',
582
+ 'to', 'at', 'top', 'bottom', 'left', 'right', 'center',
583
+ 'closest', 'farthest', 'corner', 'side', 'circle', 'ellipse',
584
+ 'deg', 'turn', 'rad', 'grad', 'from', 'in',
585
+ 'url', // url() prefix sometimes appears when gradient is wrapped incorrectly
586
+ ]);
587
+ /** Extract the first color stop (hex, rgb, or named CSS color) from a gradient string. */
588
+ function firstGradientColor(gradient) {
589
+ const tokenRe = /#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|([a-zA-Z-]+)/g;
590
+ let m;
591
+ while ((m = tokenRe.exec(gradient)) !== null) {
592
+ const namedWord = m[1];
593
+ if (namedWord) {
594
+ if (!GRADIENT_KEYWORDS.has(namedWord.toLowerCase()))
595
+ return namedWord;
596
+ }
597
+ else {
598
+ return m[0]; // hex or rgb()
599
+ }
600
+ }
601
+ return '';
602
+ }
603
+ /**
604
+ * Extract the first solid-color stop from a CSS gradient in customCss.
605
+ * Used as a MSO/Outlook bgcolor fallback. Handles hex, rgb, and named colors.
606
+ */
607
+ function extractCssFallbackColor(customCss) {
608
+ if (!customCss)
609
+ return '';
610
+ const gradientMatch = customCss.match(/(?:linear|radial|conic)-gradient\(([^)]+(?:\([^)]*\)[^)]*)*)\)/);
611
+ if (!gradientMatch)
612
+ return '';
613
+ return firstGradientColor(gradientMatch[1]);
614
+ }
615
+ function parseGradient(gradient) {
616
+ if (!gradient)
617
+ return null;
618
+ const lower = gradient.toLowerCase();
619
+ // Determine angle from deg value or direction keyword
620
+ const degMatch = gradient.match(/(\d+(?:\.\d+)?)deg/);
621
+ let angle = 180;
622
+ if (degMatch) {
623
+ angle = parseFloat(degMatch[1]);
624
+ }
625
+ else if (lower.includes('to right'))
626
+ angle = 90;
627
+ else if (lower.includes('to left'))
628
+ angle = 270;
629
+ else if (lower.includes('to top'))
630
+ angle = 0;
631
+ // 'to bottom' and bare gradient() both default to 180
632
+ // Extract ALL color tokens: hex, rgb/rgba, and named CSS color words
633
+ const colors = [];
634
+ const tokenRe = /#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|([a-zA-Z-]+)/g;
635
+ let m;
636
+ while ((m = tokenRe.exec(gradient)) !== null) {
637
+ const namedWord = m[1];
638
+ if (namedWord) {
639
+ if (!GRADIENT_KEYWORDS.has(namedWord.toLowerCase()))
640
+ colors.push(namedWord);
641
+ }
642
+ else {
643
+ colors.push(m[0]);
644
+ }
645
+ }
646
+ return {
647
+ angle,
648
+ colors,
649
+ fallback: colors[0] || '#ffffff',
419
650
  };
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;
651
+ }
652
+ function cssAngleToVml(angle) {
653
+ return (angle + 90) % 360;
431
654
  }
432
655
  async function convertGridBlock(blockData, rootData, cellWidthInPx) {
433
656
  const { style = {}, childrenIds = [], props } = blockData.data;
434
657
  const { columns = 1, cellWidths = [], responsive = true } = props;
435
- const { columnGap = 0, ...restStyle } = style;
658
+ const { columnGap = 0, backgroundImage, backgroundColor, ...restStyle } = style;
436
659
  const gridVisibilityClass = (0, common_1.getVisibilityClass)(props);
437
- // FIX: avoid table-layout:fixed causes shrink in many clients
438
- const tableStyles = buildStyles(restStyle, {
660
+ // Detect gradient check both backgroundImage prop and customCss (gradient may land in
661
+ // customCss when the block was built via CSS shorthand or custom CSS input).
662
+ const bgImageStr = typeof backgroundImage === "string" ? backgroundImage : '';
663
+ const customCssStr = restStyle.customCss || '';
664
+ // Extract gradient string from customCss if not already in backgroundImage
665
+ const gradientInCustomCss = !bgImageStr.includes('gradient(') && customCssStr.includes('gradient(')
666
+ ? (customCssStr.match(/(?:linear|radial|conic)-gradient\([^)]+(?:\([^)]*\)[^)]*)*\)/)?.[0] || '')
667
+ : '';
668
+ const effectiveGradient = bgImageStr.includes('gradient(')
669
+ ? bgImageStr
670
+ : gradientInCustomCss;
671
+ const isGradient = Boolean(effectiveGradient);
672
+ const parsedGradient = isGradient ? parseGradient(effectiveGradient) : null;
673
+ const fallbackBgColor = backgroundColor ||
674
+ parsedGradient?.fallback ||
675
+ extractCssFallbackColor(customCssStr) ||
676
+ "#ffffff";
677
+ const rawBgImageUrl = !isGradient && bgImageStr
678
+ ? bgImageStr.replace(/^url\(['"]?/, "").replace(/['"]?\)$/, "")
679
+ : null;
680
+ // When gradient came from customCss, strip background-image from customCss so it
681
+ // doesn't duplicate into the inner table style (the outer <td> wrapper carries it).
682
+ const innerCustomCss = gradientInCustomCss
683
+ ? customCssStr.replace(/background-image\s*:[^;]+;?/gi, '').trim()
684
+ : customCssStr;
685
+ // Build inner table styles — when gradient/bg-image is on the outer wrapper, strip
686
+ // background props from the inner table so the outer <td> background shows through.
687
+ const innerRestStyle = (rawBgImageUrl || isGradient)
688
+ ? { ...restStyle, customCss: innerCustomCss, backgroundSize: undefined, backgroundPosition: undefined, backgroundRepeat: undefined }
689
+ : { ...restStyle, customCss: innerCustomCss };
690
+ const tableStyles = buildStyles({ backgroundColor: (rawBgImageUrl || isGradient) ? undefined : backgroundColor, ...innerRestStyle }, {
439
691
  perChanges: [],
440
692
  pxChanges: allPxAttributes,
441
693
  });
@@ -443,45 +695,58 @@ async function convertGridBlock(blockData, rootData, cellWidthInPx) {
443
695
  const visualRows = Math.ceil(total / columns);
444
696
  // OUTLOOK FIX: Use explicit pixel width for Old Outlook (Word engine)
445
697
  const msoTableWidth = Math.min(cellWidthInPx, 600);
446
- let html = `
447
- <!--[if mso]>
448
- <table border="0" cellpadding="0" cellspacing="${columnGap}" width="${msoTableWidth}"
449
- style="border-collapse:separate;border-spacing:${columnGap}px;width:${msoTableWidth}px;"
450
- class="${gridVisibilityClass}">
451
- <![endif]-->
452
- <!--[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;"
456
- class="${gridVisibilityClass}">
457
- <!--<![endif]-->
698
+ // When a background image/gradient is present, the background is applied on an outer
699
+ // wrapper <td> (see bottom of function). The inner grid tables must be clean.
700
+ // When no background, the MSO table gets bgcolor for solid-color sections.
701
+ const msoBgColor = !rawBgImageUrl && !isGradient
702
+ ? (backgroundColor || '')
703
+ : '';
704
+ const msoBgAttr = msoBgColor ? ` bgcolor="${msoBgColor}"` : '';
705
+ const msoBgStyle = msoBgColor ? `background-color:${msoBgColor};` : '';
706
+ // Inner tables must be explicitly transparent when outer <td> carries the background.
707
+ const innerBgTransparent = (rawBgImageUrl || isGradient)
708
+ ? 'background-color:transparent;'
709
+ : '';
710
+ const nonMsoBgAttr = !rawBgImageUrl && !isGradient && backgroundColor ? ` bgcolor="${backgroundColor}"` : '';
711
+ let html = `
712
+ <!--[if mso]>
713
+ <table border="0" cellpadding="0" cellspacing="0" width="${msoTableWidth}"${msoBgAttr}
714
+ style="border-collapse:collapse;width:${msoTableWidth}px;${msoBgStyle}${innerBgTransparent}"
715
+ class="${gridVisibilityClass}">
716
+ <![endif]-->
717
+ <!--[if !mso]><!-->
718
+ <table border="0" cellpadding="0" cellspacing="0" width="100%"
719
+ role="presentation"${nonMsoBgAttr}
720
+ style="border-collapse:collapse; ${innerBgTransparent}${tableStyles}; max-width:600px;"
721
+ class="${gridVisibilityClass}">
722
+ <!--<![endif]-->
458
723
  `;
459
724
  for (let r = 0; r < visualRows; r++) {
460
725
  html += "<tr>";
461
- // COUNT visible cells only
726
+ // COUNT visible cells and find last visible column index
462
727
  let visibleCells = 0;
728
+ let lastVisibleCol = 0;
463
729
  const rowIds = [];
464
730
  for (let c = 0; c < columns; c++) {
465
731
  const idx = r * columns + c;
466
732
  const id = childrenIds[idx] ?? null;
467
733
  rowIds.push(id);
468
- if (id) {
469
- const child = rootData[id];
470
- const isHidden = child?.data?.props?.hideOnDesktop;
471
- if (!isHidden)
472
- visibleCells++;
734
+ const child = id ? rootData[id] : null;
735
+ const isHidden = child?.data?.props?.hideOnDesktop;
736
+ if (!isHidden) {
737
+ visibleCells++;
738
+ lastVisibleCol = c;
473
739
  }
474
740
  }
475
- // OUTLOOK FIX: Calculate safe width based on visible cells
476
- // If we have visible cells, distribute 100% width evenly
477
741
  const safeWidth = visibleCells > 0 ? 100 / visibleCells : 100 / columns;
478
- // OUTLOOK FIX: Calculate total width used by all cells
742
+ // Reserve pixel space for spacer tds between visible cells (N-1 gaps for N visible cells)
743
+ const totalGapPx = columnGap * Math.max(visibleCells - 1, 0);
744
+ const adjustedTableWidth = Math.max(msoTableWidth - totalGapPx, 1);
479
745
  let totalWidth = 0;
480
746
  const cellWidthPercents = [];
481
747
  for (let c = 0; c < columns; c++) {
482
748
  const id = rowIds[c];
483
749
  let widthPercent = cellWidths[c] ?? safeWidth;
484
- // Validate width
485
750
  if (widthPercent <= 0 || widthPercent > 100) {
486
751
  widthPercent = safeWidth;
487
752
  }
@@ -494,62 +759,115 @@ async function convertGridBlock(blockData, rootData, cellWidthInPx) {
494
759
  }
495
760
  }
496
761
  }
497
- // OUTLOOK FIX: If total width < 100%, scale up to fill container
498
- // This prevents orphaned space that Outlook handles poorly
499
762
  const scaleFactor = totalWidth > 0 && totalWidth < 100 ? 100 / totalWidth : 1;
500
763
  for (let c = 0; c < columns; c++) {
501
- const idx = r * columns + c;
502
764
  const id = rowIds[c];
503
- // OUTLOOK FIX: Scale width to ensure cells fill 100% of container
504
765
  let widthPercent = cellWidthPercents[c] * scaleFactor;
505
- // OUTLOOK FIX: Ensure width doesn't exceed 100% after scaling
506
766
  widthPercent = Math.min(widthPercent, 100);
767
+ // Cell pixel width is a share of the gap-adjusted table width
768
+ const cellWidthPx = Math.round((widthPercent / 100) * adjustedTableWidth);
507
769
  if (id) {
508
770
  const child = rootData[id];
509
771
  const { style: cellStyle = {}, props: childProps = {} } = child.data;
510
772
  const verticalAlign = cellStyle.verticalAlign || "top";
511
773
  const childVisible = !childProps.hideOnDesktop;
512
774
  const visibilityClass = (0, common_1.getVisibilityClass)(childProps);
513
- // Only render if visible
514
775
  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);
518
- html += `
519
- <td
520
- width="${Math.round(widthPercent)}%"
521
- class="${[
522
- responsive ? "stack-column" : "",
523
- visibilityClass,
524
- ].filter(Boolean).join(" ")}"
525
- style="width:${cellWidthPx}px;vertical-align:${verticalAlign};word-break:break-word;${styles}"
526
- >
527
- ${childHtml}
776
+ const { html: childHtml, styles } = await convertGridCellBlock(child, rootData, widthPercent, adjustedTableWidth);
777
+ // bgcolor on the cell <td> ensures background-color survives Outlook
778
+ // compose paste (Word/Web editors strip CSS but keep bgcolor attribute).
779
+ const cellBgColor = cellStyle.backgroundColor || '';
780
+ const cellBgAttr = cellBgColor ? ` bgcolor="${cellBgColor}"` : '';
781
+ html += `
782
+ <td
783
+ width="${cellWidthPx}"${cellBgAttr}
784
+ class="${[responsive ? "stack-column" : "", visibilityClass].filter(Boolean).join(" ")}"
785
+ style="width:${cellWidthPx}px;vertical-align:${verticalAlign};word-break:break-word;${styles}"
786
+ >
787
+ ${childHtml}
528
788
  </td>`;
789
+ // Spacer td between columns — fixed pixel width, invisible to screen readers
790
+ if (columnGap > 0 && c !== lastVisibleCol) {
791
+ html += `<td width="${columnGap}" style="width:${columnGap}px;font-size:0;line-height:0;padding:0;" aria-hidden="true"></td>`;
792
+ }
529
793
  }
530
794
  }
531
795
  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
- html += `
536
- <td width="${Math.round(widthPercent)}%"
537
- ${responsive ? 'class="stack-column"' : ""}
538
- style="width:${cellWidthPx}px;vertical-align:top;">
796
+ html += `
797
+ <td width="${cellWidthPx}"
798
+ ${responsive ? 'class="stack-column"' : ""}
799
+ style="width:${cellWidthPx}px;vertical-align:top;">
539
800
  </td>`;
801
+ if (columnGap > 0 && c !== lastVisibleCol) {
802
+ html += `<td width="${columnGap}" style="width:${columnGap}px;font-size:0;line-height:0;padding:0;" aria-hidden="true"></td>`;
803
+ }
540
804
  }
541
805
  }
542
806
  html += "</tr>";
543
807
  }
544
808
  // Close both MSO and non-MSO tables
545
- html += `
546
- <!--[if mso]>
547
- </table>
548
- <![endif]-->
549
- <!--[if !mso]><!-->
550
- </table>
551
- <!--<![endif]-->
809
+ html += `
810
+ <!--[if mso]>
811
+ </table>
812
+ <![endif]-->
813
+ <!--[if !mso]><!-->
814
+ </table>
815
+ <!--<![endif]-->
552
816
  `;
817
+ // ── Background image: canonical multi-client approach ────────────────────
818
+ //
819
+ // Problem: `background-image` on a <table> element is stripped by:
820
+ // • New Outlook Mac / Windows (Chromium-based app)
821
+ // • Outlook.com
822
+ // • Old Outlook (Word engine) — ignores CSS entirely
823
+ //
824
+ // Solution: wrap the grid in an outer <table><tr><td> where the <td> carries
825
+ // the background. Different clients pick it up via different mechanisms:
826
+ //
827
+ // background="" attribute on <td> → Yahoo Mail, older webmail
828
+ // CSS background-image on <td> → Gmail, Apple Mail, new Outlook Mac ✓
829
+ // VML v:rect inside the <td> → Old Outlook (Word engine) ✓
830
+ //
831
+ // The inner grid tables have NO background so the outer <td> bg shows through.
832
+ if (rawBgImageUrl || isGradient) {
833
+ const vmlFill = isGradient
834
+ ? (() => {
835
+ const vmlAngle = cssAngleToVml(parsedGradient?.angle || 180);
836
+ const c1 = parsedGradient?.fallback || '#ffffff';
837
+ const c2 = parsedGradient?.colors[parsedGradient.colors.length - 1] || c1;
838
+ return `<v:fill type="gradient" color="${c1}" color2="${c2}" angle="${vmlAngle}" />`;
839
+ })()
840
+ : `<v:fill type="frame" src="${rawBgImageUrl}" color="${fallbackBgColor}" />`;
841
+ html = `
842
+ <table border="0" cellpadding="0" cellspacing="0" width="${msoTableWidth}" role="presentation"
843
+ style="border-collapse:collapse;width:${msoTableWidth}px;">
844
+ <tr>
845
+ <td width="${msoTableWidth}" bgcolor="${fallbackBgColor}" valign="top"
846
+ style="
847
+ width:${msoTableWidth}px;
848
+ background-color:${fallbackBgColor};
849
+ ${isGradient ? `background:${effectiveGradient};` : `background:url('${rawBgImageUrl}') center/cover no-repeat;`}
850
+ ">
851
+
852
+ <!--[if gte mso 9]>
853
+ <v:rect xmlns:v="urn:schemas-microsoft-com:vml"
854
+ fill="true" stroke="false"
855
+ style="width:${msoTableWidth}px;">
856
+ ${vmlFill}
857
+ <v:textbox inset="0,0,0,0">
858
+ <![endif]-->
859
+
860
+ ${html}
861
+
862
+ <!--[if gte mso 9]>
863
+ </v:textbox>
864
+ </v:rect>
865
+ <![endif]-->
866
+
867
+ </td>
868
+ </tr>
869
+ </table>`;
870
+ }
553
871
  return html;
554
872
  }
555
873
  async function convertGridCellBlock(blockData, rootData, cellWidthPercent, parentCellWidthPx) {
@@ -563,16 +881,39 @@ async function convertGridCellBlock(blockData, rootData, cellWidthPercent, paren
563
881
  // OUTLOOK FIX: Calculate the actual cell width in pixels based on percentage
564
882
  // If parent is 600px and cell is 50%, cell width should be 300px, not 600px
565
883
  const cellWidthPx = Math.round((cellWidthPercent / 100) * parentCellWidthPx);
884
+ // Subtract the cell's own padding so children receive the actual content-area width.
885
+ // Old Outlook honours explicit img/table width attributes — if a child is sized to the
886
+ // full column width (ignoring padding) it overflows and expands the column.
887
+ const cellPad = style?.padding || {};
888
+ const cellPadLeft = Number.isFinite(cellPad.left) ? cellPad.left : 0;
889
+ const cellPadRight = Number.isFinite(cellPad.right) ? cellPad.right : 0;
890
+ const contentWidthPx = Math.max(cellWidthPx - cellPadLeft - cellPadRight, 20);
566
891
  // OUTLOOK FIX: Ensure cell width is reasonable and capped at 600px
567
- const safeCellWidthPx = Math.min(Math.max(cellWidthPx, 20), 600);
892
+ const safeCellWidthPx = Math.min(contentWidthPx, 600);
568
893
  for (const childId of childrenIds) {
569
894
  const child = rootData[childId];
570
895
  if (child) {
571
896
  parts.push(await convertToHtml(child, rootData, safeCellWidthPx));
572
897
  }
573
898
  }
899
+ const borderRadius = style?.borderRadius || 0;
900
+ const bgColor = style?.backgroundColor || "transparent";
901
+ // IMPORTANT: radius only for non-Outlook
902
+ const wrapped = `
903
+ <!--[if !mso]><!-->
904
+ <div style="
905
+ border-radius:${borderRadius}px;
906
+ overflow:hidden;
907
+ background-color:${bgColor};
908
+ ">
909
+ <!--<![endif]-->
910
+ ${parts.join("")}
911
+ <!--[if !mso]><!-->
912
+ </div>
913
+ <!--<![endif]-->
914
+ `;
574
915
  return {
575
- html: parts.join(""),
916
+ html: wrapped,
576
917
  styles,
577
918
  };
578
919
  }
@@ -583,7 +924,9 @@ async function convertVideoBlock(blockData, cellWidthInPx) {
583
924
  const { hideOnDesktop } = props; // Get the hideOnDesktop prop
584
925
  const { videoUrl, youtubeVideoUrl, thumbnailUrl, altText } = props;
585
926
  const videoLink = youtubeVideoUrl || videoUrl || "#";
586
- let resolvedThumbnail = thumbnailUrl || "https://via.placeholder.com/480x360?text=No+Thumbnail";
927
+ // via.placeholder.com is defunct — use a data-URI grey box as the default thumbnail
928
+ const FALLBACK_THUMBNAIL = `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='480' height='360'%3E%3Crect width='480' height='360' fill='%23cccccc'/%3E%3C/svg%3E`;
929
+ let resolvedThumbnail = thumbnailUrl || FALLBACK_THUMBNAIL;
587
930
  if (youtubeVideoUrl) {
588
931
  const youtubeId = (0, common_1.extractYouTubeId)(youtubeVideoUrl);
589
932
  const vimeoId = (0, common_1.extractVimeoId)(youtubeVideoUrl);
@@ -637,116 +980,103 @@ async function convertVideoBlock(blockData, cellWidthInPx) {
637
980
  const vmlTop = calculatedHeight / 2 - playIconHeight / 2;
638
981
  const shouldHideInOutlook = hideOnDesktop;
639
982
  const outlookVideoContent = shouldHideInOutlook
640
- ? `<!--[if !mso]><!-->
641
- <v:group xmlns:v="urn:schemas-microsoft-com:vml"
642
- coordsize="${innerContainerWidth},${calculatedHeight}"
643
- href="${videoLink}"
644
- style="width:${innerContainerWidth}px;height:${calculatedHeight}px;">
645
- <v:rect fill="t" style="position:absolute;width:${innerContainerWidth}px;height:${calculatedHeight}px;" stroked="t"
646
- strokeweight="${borderWidth}px"
647
- strokecolor="${borderColor}"
648
- ${borderRadius > 0 ? `arcsize="${Math.min(borderRadius / calculatedHeight, 1).toFixed(2)}"` : ""}
649
- >
650
- <v:fill src="${resolvedThumbnail}" type="frame" color="${style?.backgroundColor || "#FFFFFF"}"/>
651
- </v:rect>
652
- <v:shape type="#_x0000_t75"
653
- style="position:absolute;
654
- left:${vmlLeft.toFixed(1)}px;
655
- top:${vmlTop.toFixed(1)}px;
656
- width:${playIconWidth}px;
657
- height:${playIconHeight}px;"
658
- alt="Play" href="${videoLink}" title="${altText || "Video"}"
659
- stroked="f" filled="t">
660
- <v:imagedata src="https://app-rsrc.getbee.io/public/resources/components/widgetBar/video-content-icon-sets/light/type-01.png" />
661
- </v:shape>
662
- </v:group>
983
+ ? `<!--[if !mso]><!-->
984
+ <v:group xmlns:v="urn:schemas-microsoft-com:vml"
985
+ coordsize="${innerContainerWidth},${calculatedHeight}"
986
+ href="${videoLink}"
987
+ style="width:${innerContainerWidth}px;height:${calculatedHeight}px;">
988
+ <v:rect fill="t" style="position:absolute;width:${innerContainerWidth}px;height:${calculatedHeight}px;"
989
+ ${borderWidth > 0 ? `stroked="t" strokeweight="${borderWidth}px" strokecolor="${borderColor}"` : `stroked="f"`}
990
+ ${borderRadius > 0 ? `arcsize="${Math.min(borderRadius / calculatedHeight, 1).toFixed(2)}"` : ""}
991
+ >
992
+ <v:fill src="${resolvedThumbnail}" type="frame" color="${style?.backgroundColor || "#FFFFFF"}"/>
993
+ </v:rect>
994
+ <v:shape type="#_x0000_t75"
995
+ style="position:absolute;
996
+ left:${vmlLeft.toFixed(1)}px;
997
+ top:${vmlTop.toFixed(1)}px;
998
+ width:${playIconWidth}px;
999
+ height:${playIconHeight}px;"
1000
+ alt="Play" href="${videoLink}" title="${altText || "Video"}"
1001
+ stroked="f" filled="t">
1002
+ <v:imagedata src="https://app-rsrc.getbee.io/public/resources/components/widgetBar/video-content-icon-sets/light/type-01.png" />
1003
+ </v:shape>
1004
+ </v:group>
663
1005
  <!--<![endif]-->`
664
- : `<!--[if mso]>
665
- <v:group xmlns:v="urn:schemas-microsoft-com:vml"
666
- coordsize="${innerContainerWidth},${calculatedHeight}"
667
- href="${videoLink}"
668
- style="width:${innerContainerWidth}px;height:${calculatedHeight}px;">
669
- <v:rect fill="t" style="position:absolute;width:${innerContainerWidth}px;height:${calculatedHeight}px; stroked="t"
670
- strokeweight="${borderWidth}px"
671
- strokecolor="${borderColor}"
672
- ${borderRadius > 0 ? `arcsize="${Math.min(borderRadius / calculatedHeight, 1).toFixed(2)}"` : ""}
673
- >
674
- <v:fill src="${resolvedThumbnail}" type="frame" color="${style?.backgroundColor || "#FFFFFF"}"/>
675
- </v:rect>
676
- <v:shape type="#_x0000_t75"
677
- style="position:absolute;
678
- left:${vmlLeft.toFixed(1)}px;
679
- top:${vmlTop.toFixed(1)}px;
680
- width:${playIconWidth}px;
681
- height:${playIconHeight}px;"
682
- alt="Play" href="${videoLink}" title="${altText || "Video"}"
683
- stroked="f" filled="t">
684
- <v:imagedata src="https://app-rsrc.getbee.io/public/resources/components/widgetBar/video-content-icon-sets/light/type-01.png" />
685
- </v:shape>
686
- </v:group>
1006
+ : `<!--[if mso]>
1007
+ <v:group xmlns:v="urn:schemas-microsoft-com:vml"
1008
+ coordsize="${innerContainerWidth},${calculatedHeight}"
1009
+ href="${videoLink}"
1010
+ style="width:${innerContainerWidth}px;height:${calculatedHeight}px;">
1011
+ <v:rect fill="t" style="position:absolute;width:${innerContainerWidth}px;height:${calculatedHeight}px;"
1012
+ ${borderWidth > 0 ? `stroked="t" strokeweight="${borderWidth}px" strokecolor="${borderColor}"` : `stroked="f"`}
1013
+ ${borderRadius > 0 ? `arcsize="${Math.min(borderRadius / calculatedHeight, 1).toFixed(2)}"` : ""}
1014
+ >
1015
+ <v:fill src="${resolvedThumbnail}" type="frame" color="${style?.backgroundColor || "#FFFFFF"}"/>
1016
+ </v:rect>
1017
+ <v:shape type="#_x0000_t75"
1018
+ style="position:absolute;
1019
+ left:${vmlLeft.toFixed(1)}px;
1020
+ top:${vmlTop.toFixed(1)}px;
1021
+ width:${playIconWidth}px;
1022
+ height:${playIconHeight}px;"
1023
+ alt="Play" href="${videoLink}" title="${altText || "Video"}"
1024
+ stroked="f" filled="t">
1025
+ <v:imagedata src="https://app-rsrc.getbee.io/public/resources/components/widgetBar/video-content-icon-sets/light/type-01.png" />
1026
+ </v:shape>
1027
+ </v:group>
687
1028
  <![endif]-->`;
688
- const nonOutlookVideoContent = `<!--[if !mso]><!-->
689
- <table
690
- width="${innerContainerWidth}"
691
- cellpadding="0"
692
- cellspacing="0"
693
- border="0"
694
- role="presentation"
695
- align="${style?.textAlign || "left"}"
696
- style="
697
- max-width: ${innerContainerWidth}px;
698
- width: 100%;
699
- height: ${calculatedHeight}px;
700
- background-color: ${style?.backgroundColor || "#FFFFFF"};
701
- background-image: url('${resolvedThumbnail}');
702
- background-size: contain;
703
- background-position: center;
704
- background-repeat: no-repeat;
705
- box-sizing: border-box;
706
- border: ${borderWidth}px ${style?.borderStyle || "solid"} ${borderColor};
707
- border-radius: ${borderRadius}px;
708
- "
709
- >
710
- <tr>
711
- <td style="padding: 0; height: ${calculatedHeight}px; text-align: center; vertical-align: middle;" valign="middle">
712
- <a href="${videoLink}" target="_blank" style="display:inline-block; border: 0; outline: none; text-decoration: none;">
713
- <img
714
- src="https://app-rsrc.getbee.io/public/resources/components/widgetBar/video-content-icon-sets/light/type-01.png"
715
- width="${playIconWidth}"
716
- alt="Play"
717
- style="display: block;
718
- border: 0;
719
- outline: none;
720
- text-decoration: none;
721
- height: auto;"
722
- />
723
- </a>
724
- </td>
725
- </tr>
726
- </table>
727
- <!--<![endif]-->`;
1029
+ // Non-Outlook: use a real <img> for the thumbnail so it renders in Gmail / Yahoo / webmail.
1030
+ // background-image on <table> is stripped by virtually every email client.
1031
+ // position:absolute for the play-button overlay is safe here because this block
1032
+ // is already inside <!--[if !mso]> — Outlook is handled separately via VML above.
1033
+ const thumbnailW = Math.round(innerContainerWidth);
1034
+ const thumbnailH = Math.round(calculatedHeight);
1035
+ const playMarginTop = -Math.round(playIconHeight / 2);
1036
+ const playMarginLeft = -Math.round(playIconWidth / 2);
1037
+ const borderAttr = borderWidth > 0 ? `border:${borderWidth}px ${style?.borderStyle || "solid"} ${borderColor};` : "border:0;";
1038
+ const radiusAttr = borderRadius > 0 ? `border-radius:${borderRadius}px; overflow:hidden;` : "";
1039
+ const nonOutlookVideoContent = `<!--[if !mso]><!-->
1040
+ <div style="display:block; width:100%; max-width:${thumbnailW}px; position:relative; line-height:0; font-size:0; ${borderAttr}${radiusAttr}">
1041
+ <a href="${videoLink}" target="_blank" style="display:block; text-decoration:none; line-height:0; font-size:0;">
1042
+ <img
1043
+ src="${resolvedThumbnail}"
1044
+ width="${thumbnailW}"
1045
+ height="${thumbnailH}"
1046
+ alt="${altText || "Video"}"
1047
+ style="display:block; width:100%; max-width:${thumbnailW}px; height:auto; border:0;"
1048
+ />
1049
+ <img
1050
+ src="https://app-rsrc.getbee.io/public/resources/components/widgetBar/video-content-icon-sets/light/type-01.png"
1051
+ width="${playIconWidth}"
1052
+ alt="Play"
1053
+ style="display:block; position:absolute; top:50%; left:50%; margin-top:${playMarginTop}px; margin-left:${playMarginLeft}px; border:0; outline:none;"
1054
+ />
1055
+ </a>
1056
+ </div>
1057
+ <!--<![endif]-->`;
728
1058
  const videoContent = `${outlookVideoContent}${nonOutlookVideoContent}`;
729
- const wrapperHtml = `
730
- <table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="margin:0; padding:0; border-collapse: collapse; max-width:600px;" class="${visibilityClass}">
731
- <tr>
732
- <td align="${style?.textAlign || "left"}" style="padding:0; ${outerContainerStyles}">
733
- <table border="0" cellpadding="0" cellspacing="0" role="presentation"
734
- align="${style?.textAlign || "left"}"
735
- style="
736
- margin:0;
737
- max-width:${cellWidthInPx}px;
738
- width:${percentWidth};
739
- border-collapse:collapse;
740
- ">
741
- <tr>
742
- <td align="${style?.textAlign || "left"}" style="text-align:${style?.textAlign || "left"}; padding:0;">
743
- ${videoContent}
744
- </td>
745
- </tr>
746
- </table>
747
- </td>
748
- </tr>
749
- </table>
1059
+ const wrapperHtml = `
1060
+ <table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="margin:0; padding:0; border-collapse: collapse; max-width:600px;" class="${visibilityClass}">
1061
+ <tr>
1062
+ <td align="${style?.textAlign || "left"}" style="padding:0; ${outerContainerStyles}">
1063
+ <table border="0" cellpadding="0" cellspacing="0" role="presentation"
1064
+ align="${style?.textAlign || "left"}"
1065
+ style="
1066
+ margin:0;
1067
+ max-width:${cellWidthInPx}px;
1068
+ width:${percentWidth};
1069
+ border-collapse:collapse;
1070
+ ">
1071
+ <tr>
1072
+ <td align="${style?.textAlign || "left"}" style="text-align:${style?.textAlign || "left"}; padding:0;">
1073
+ ${videoContent}
1074
+ </td>
1075
+ </tr>
1076
+ </table>
1077
+ </td>
1078
+ </tr>
1079
+ </table>
750
1080
  `;
751
1081
  return wrapperHtml;
752
1082
  }
@@ -828,48 +1158,48 @@ async function convertShapeBlock(blockData) {
828
1158
  let nonMsoContent = "";
829
1159
  // --- Case 1: Image + Text ---
830
1160
  if (imageUrl && text) {
831
- nonMsoContent = `
832
- <div style="display:inline-block;width:${resolvedWidthPx}px;height:${resolvedHeightPx}px;
833
- border:${borderWidth}px ${borderStyle} ${borderColor};
834
- border-radius:${resolvedBorderRadius};
835
- background:${finalBackgroundColor} url('${imageUrl}') center/cover no-repeat;
836
- overflow:hidden;${alignmentStyle}${customCss || ""}">
837
- <div style="width:100%;height:100%;display:flex;justify-content:${flexJustify};align-items:${flexAlign};overflow:hidden;padding:6px;box-sizing:border-box;">
838
- <div style="${textSizeStyle}text-align:${textAlignStyle};max-width:90%;overflow:hidden;">
839
- ${text}
840
- </div>
841
- </div>
1161
+ nonMsoContent = `
1162
+ <div style="display:inline-block;width:${resolvedWidthPx}px;height:${resolvedHeightPx}px;
1163
+ border:${borderWidth}px ${borderStyle} ${borderColor};
1164
+ border-radius:${resolvedBorderRadius};
1165
+ background:${finalBackgroundColor} url('${imageUrl}') center/cover no-repeat;
1166
+ overflow:hidden;${alignmentStyle}${customCss || ""}">
1167
+ <div style="width:100%;height:100%;display:flex;justify-content:${flexJustify};align-items:${flexAlign};overflow:hidden;padding:6px;box-sizing:border-box;">
1168
+ <div style="${textSizeStyle}text-align:${textAlignStyle};max-width:90%;overflow:hidden;">
1169
+ ${text}
1170
+ </div>
1171
+ </div>
842
1172
  </div>`;
843
1173
  }
844
1174
  // --- Case 2: Image only ---
845
1175
  else if (imageUrl) {
846
- nonMsoContent = `
847
- <div style="display:inline-block;width:${resolvedWidthPx}px;height:${resolvedHeightPx}px;
848
- border:${borderWidth}px ${borderStyle} ${borderColor};
849
- border-radius:${resolvedBorderRadius};
850
- overflow:hidden;${alignmentStyle}${customCss || ""}">
851
- <img src="${imageUrl}" alt="${text || "shape image"}"
852
- width="${resolvedWidthPx}" height="${resolvedHeightPx}"
853
- style="width:100%;height:100%;object-fit:cover;border-radius:${resolvedBorderRadius};display:block;" />
1176
+ nonMsoContent = `
1177
+ <div style="display:inline-block;width:${resolvedWidthPx}px;height:${resolvedHeightPx}px;
1178
+ border:${borderWidth}px ${borderStyle} ${borderColor};
1179
+ border-radius:${resolvedBorderRadius};
1180
+ overflow:hidden;${alignmentStyle}${customCss || ""}">
1181
+ <img src="${imageUrl}" alt="${text || "shape image"}"
1182
+ width="${resolvedWidthPx}" height="${resolvedHeightPx}"
1183
+ style="width:100%;height:100%;object-fit:cover;border-radius:${resolvedBorderRadius};display:block;" />
854
1184
  </div>`;
855
1185
  }
856
1186
  // --- Case 3: Text only ---
857
1187
  else {
858
- nonMsoContent = `
859
- <div style="display:inline-block;width:${resolvedWidthPx}px;height:${resolvedHeightPx}px;
860
- background:${finalBackgroundColor};
861
- border:${borderWidth}px ${borderStyle} ${borderColor};
862
- border-radius:${resolvedBorderRadius};
863
- overflow:hidden;${alignmentStyle}${customCss || ""}">
864
- <div style="width:100%;height:100%;display:flex;justify-content:${flexJustify};align-items:${flexAlign};padding:8px;box-sizing:border-box;">
865
- <div style="${textSizeStyle}text-align:${textAlignStyle};max-width:90%;overflow:hidden;">
866
- ${text || ""}
867
- </div>
868
- </div>
1188
+ nonMsoContent = `
1189
+ <div style="display:inline-block;width:${resolvedWidthPx}px;height:${resolvedHeightPx}px;
1190
+ background:${finalBackgroundColor};
1191
+ border:${borderWidth}px ${borderStyle} ${borderColor};
1192
+ border-radius:${resolvedBorderRadius};
1193
+ overflow:hidden;${alignmentStyle}${customCss || ""}">
1194
+ <div style="width:100%;height:100%;display:flex;justify-content:${flexJustify};align-items:${flexAlign};padding:8px;box-sizing:border-box;">
1195
+ <div style="${textSizeStyle}text-align:${textAlignStyle};max-width:90%;overflow:hidden;">
1196
+ ${text || ""}
1197
+ </div>
1198
+ </div>
869
1199
  </div>`;
870
1200
  }
871
1201
  // Outlook (VML) fallback
872
- const outlookContent = await appendOutlookForShape(nonMsoContent, resolvedWidthPx, resolvedWidthPx, {
1202
+ const outlookContent = appendOutlookForShape(nonMsoContent, resolvedWidthPx, resolvedWidthPx, {
873
1203
  shape,
874
1204
  imageUrl,
875
1205
  backgroundColor,
@@ -887,17 +1217,45 @@ async function convertShapeBlock(blockData) {
887
1217
  padding,
888
1218
  msoBakeImageWithText,
889
1219
  }, visibilityClass);
1220
+ // Embed block metadata so the HTML importer can reconstruct the Shape block exactly.
1221
+ const shapeProps = encodeBlockProps({
1222
+ shape,
1223
+ width: resolvedWidthPx,
1224
+ height: resolvedHeightPx,
1225
+ shapeColor: String(finalBackgroundColor || '#BEBEBE'),
1226
+ backgroundColor: String(finalBackgroundColor || '#BEBEBE'),
1227
+ borderRadius: borderRadius !== undefined ? borderRadius : 0,
1228
+ borderWidth: borderWidth || 0,
1229
+ borderColor: borderColor || 'transparent',
1230
+ borderStyle: borderStyle || 'solid',
1231
+ imageUrl: imageUrl || '',
1232
+ text: text || '',
1233
+ color: String(color || '#000000'),
1234
+ fontSize: fontSize || 14,
1235
+ textAlign: textAlignStyle,
1236
+ verticalAlign: verticalAlign || 'middle',
1237
+ alignment: alignment || 'left',
1238
+ padding: {
1239
+ top: padding.top || 0,
1240
+ right: padding.right || 0,
1241
+ bottom: padding.bottom || 0,
1242
+ left: padding.left || 0,
1243
+ },
1244
+ hideOnDesktop: Boolean(props.hideOnDesktop),
1245
+ hideOnMobile: Boolean(props.hideOnMobile),
1246
+ customCss: customCss || '',
1247
+ });
890
1248
  // Combine into table wrapper
891
- return `
892
- <table width="100%" style="border-collapse:collapse;table-layout:fixed;max-width:600px;" class="${visibilityClass}">
893
- <tr>
894
- <td style="padding:${padding.top || 0}px ${padding.right || 0}px ${padding.bottom || 0}px ${padding.left || 0}px;text-align:${alignment};">
895
- ${outlookContent}
896
- <!--[if !mso]><!-->
897
- ${nonMsoContent}
898
- <!--<![endif]-->
899
- </td>
900
- </tr>
1249
+ return `
1250
+ <table width="100%" style="border-collapse:collapse;table-layout:fixed;max-width:600px;" class="${visibilityClass}" data-block-type="shape" data-block-props="${shapeProps}">
1251
+ <tr>
1252
+ <td style="padding:${padding.top || 0}px ${padding.right || 0}px ${padding.bottom || 0}px ${padding.left || 0}px;text-align:${alignment};">
1253
+ ${outlookContent}
1254
+ <!--[if !mso]><!-->
1255
+ ${nonMsoContent}
1256
+ <!--<![endif]-->
1257
+ </td>
1258
+ </tr>
901
1259
  </table>`;
902
1260
  }
903
1261
  // ---------- Updated VML builder ----------
@@ -930,24 +1288,24 @@ function buildVMLShape({ shape, widthPx, heightPx, imageUrl, backgroundColor, bo
930
1288
  const safeFontSize = Math.max(Math.round(textSize), 10);
931
1289
  // Build the textbox with table/cell for reliable vertical centering in Outlook
932
1290
  const textboxMarkup = text && !msoHasBakedText
933
- ? `<v:textbox inset="6pt,6pt,6pt,6pt" style="mso-fit-shape-to-text:false;">
934
- <div style="display:table;width:100%;height:100%;">
935
- <div style="display:table-cell;vertical-align:${vAlign};text-align:${hAlign};padding:0 6px;">
936
- <div style="color:${textColor};font-family:Arial, sans-serif;font-size:${safeFontSize}px;line-height:1.3;word-wrap:break-word;">
937
- ${text}
938
- </div>
939
- </div>
940
- </div>
1291
+ ? `<v:textbox inset="6pt,6pt,6pt,6pt" style="mso-fit-shape-to-text:false;">
1292
+ <div style="display:table;width:100%;height:100%;">
1293
+ <div style="display:table-cell;vertical-align:${vAlign};text-align:${hAlign};padding:0 6px;">
1294
+ <div style="color:${textColor};font-family:Arial, sans-serif;font-size:${safeFontSize}px;line-height:1.3;word-wrap:break-word;">
1295
+ ${text}
1296
+ </div>
1297
+ </div>
1298
+ </div>
941
1299
  </v:textbox>`
942
1300
  : `<v:textbox inset="0,0,0,0"><div style="display:none;">.</div></v:textbox>`;
943
1301
  // Return VML shape
944
- return `
945
- <v:${tag} xmlns:v="urn:schemas-microsoft-com:vml"
946
- style="width:${widthPx}px;height:${heightPx}px;display:inline-block;"
947
- ${borderAttrs}
948
- fill="true" fillcolor="${fillColor}"${extraAttr}>
949
- ${fillMarkup}
950
- ${textboxMarkup}
1302
+ return `
1303
+ <v:${tag} xmlns:v="urn:schemas-microsoft-com:vml"
1304
+ style="width:${widthPx}px;height:${heightPx}px;display:inline-block;"
1305
+ ${borderAttrs}
1306
+ fill="true" fillcolor="${fillColor}"${extraAttr}>
1307
+ ${fillMarkup}
1308
+ ${textboxMarkup}
951
1309
  </v:${tag}>`;
952
1310
  }
953
1311
  function appendOutlookForShape(content, outerContainerWidth, innerContainerWidth, opts, visibilityClass) {
@@ -975,53 +1333,60 @@ function appendOutlookForShape(content, outerContainerWidth, innerContainerWidth
975
1333
  const shouldHideInOutlook = visibilityClass.includes("hide-desktop");
976
1334
  // Fix: Properly handle Outlook visibility with conditional comments
977
1335
  if (shouldHideInOutlook) {
978
- return `<!--[if !mso]><!-->
979
- <table align="${align}" border="0" cellpadding="0" cellspacing="0"
980
- style="width:${widthPx}px;height:${heightPx}px;border-collapse:collapse;" class="${visibilityClass}">
981
- <tr>
982
- <td valign="${valign}"
983
- style="padding:${pad.top || 0}px ${pad.right || 0}px ${pad.bottom || 0}px ${pad.left || 0}px;">
984
- ${vml}
985
- </td>
986
- </tr>
987
- </table>
1336
+ return `<!--[if !mso]><!-->
1337
+ <table align="${align}" border="0" cellpadding="0" cellspacing="0"
1338
+ style="width:${widthPx}px;height:${heightPx}px;border-collapse:collapse;" class="${visibilityClass}">
1339
+ <tr>
1340
+ <td valign="${valign}"
1341
+ style="padding:${pad.top || 0}px ${pad.right || 0}px ${pad.bottom || 0}px ${pad.left || 0}px;">
1342
+ ${vml}
1343
+ </td>
1344
+ </tr>
1345
+ </table>
988
1346
  <!--<![endif]-->`;
989
1347
  }
990
- return `<!--[if mso]>
991
- <table align="${align}" border="0" cellpadding="0" cellspacing="0"
992
- style="width:${widthPx}px;height:${heightPx}px;border-collapse:collapse;" class="${visibilityClass}">
993
- <tr>
994
- <td valign="${valign}"
995
- style="padding:${pad.top || 0}px ${pad.right || 0}px ${pad.bottom || 0}px ${pad.left || 0}px;">
996
- ${vml}
997
- </td>
998
- </tr>
999
- </table>
1348
+ return `<!--[if mso]>
1349
+ <table align="${align}" border="0" cellpadding="0" cellspacing="0"
1350
+ style="width:${widthPx}px;height:${heightPx}px;border-collapse:collapse;" class="${visibilityClass}">
1351
+ <tr>
1352
+ <td valign="${valign}"
1353
+ style="padding:${pad.top || 0}px ${pad.right || 0}px ${pad.bottom || 0}px ${pad.left || 0}px;">
1354
+ ${vml}
1355
+ </td>
1356
+ </tr>
1357
+ </table>
1000
1358
  <![endif]-->`;
1001
1359
  }
1002
1360
  function convertVerticalDividerBlockToHtml(blockData) {
1003
1361
  const { style, props } = blockData.data;
1004
- const { width, height, dividerColor, ...rest } = style;
1362
+ const { width, height, dividerColor, padding, backgroundColor } = style;
1005
1363
  const visibilityClass = (0, common_1.getVisibilityClass)(props);
1006
- // Convert other styles to inline-safe HTML attributes
1007
- const convertedStyle = buildStyles(rest, {
1364
+ const outerStyles = buildStyles({ padding, backgroundColor }, {
1008
1365
  perChanges: [],
1009
1366
  pxChanges: allPxAttributes,
1010
1367
  });
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);
1368
+ const vDividerProps = encodeBlockProps({
1369
+ width: width || 5,
1370
+ height: height || 100,
1371
+ dividerColor: dividerColor || '#808080',
1372
+ backgroundColor: backgroundColor || '',
1373
+ alignment: 'left',
1374
+ padding: padding || { top: 0, right: 0, bottom: 0, left: 0 },
1375
+ hideOnDesktop: Boolean(props.hideOnDesktop),
1376
+ hideOnMobile: Boolean(props.hideOnMobile),
1377
+ });
1378
+ return `
1379
+ <table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation"
1380
+ style="${exports.tableCommonStyle} max-width:600px;" class="${visibilityClass}" data-block-type="vdivider" data-block-props="${vDividerProps}">
1381
+ <tr>
1382
+ <td style="${outerStyles}; text-align:center; vertical-align:middle;">
1383
+ <!--[if mso | IE]>
1384
+ <v:rect xmlns:v="urn:schemas-microsoft-com:vml" fillcolor="${dividerColor}" style="width:${width}px;height:${height}px;" stroke="f"></v:rect>
1385
+ <![endif]-->
1386
+ <!--[if !mso]><!-->
1387
+ <div style="display:inline-block;width:${width}px;height:${height}px;background:${dividerColor};line-height:0;font-size:0;">&nbsp;</div>
1388
+ <!--<![endif]-->
1389
+ </td>
1390
+ </tr>
1391
+ </table>`;
1027
1392
  }