email-builder-utils 1.1.42 → 1.1.45

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.
@@ -14,7 +14,12 @@ const addPxToAttributes = [
14
14
  ];
15
15
  const addPxOrPerToAttributes = ["width", "height"];
16
16
  const allPxAttributes = [...addPxToAttributes, ...addPxOrPerToAttributes];
17
- exports.tableCommonStyle = "border-collapse:collapse; table-layout:fixed;";
17
+ exports.tableCommonStyle = "border-collapse:collapse; table-layout:fixed";
18
+ function encodeBlockProps(props) {
19
+ return JSON.stringify(props)
20
+ .replace(/&/g, '&')
21
+ .replace(/"/g, '"');
22
+ }
18
23
  async function loadImageNaturalDimensions(imageUrl) {
19
24
  return new Promise((resolve, reject) => {
20
25
  const img = new Image();
@@ -23,6 +28,38 @@ async function loadImageNaturalDimensions(imageUrl) {
23
28
  img.src = imageUrl;
24
29
  });
25
30
  }
31
+ const GENERIC_FONT_FAMILIES = new Set([
32
+ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
33
+ 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace',
34
+ 'ui-rounded', 'emoji', 'math', 'fangsong',
35
+ ]);
36
+ /**
37
+ * Normalises a CSS font-family string so every multi-word family name is wrapped
38
+ * in single quotes — safe inside double-quoted HTML style attributes.
39
+ * Handles already-quoted names (single or double), generic keywords, and any
40
+ * number of comma-separated families.
41
+ */
42
+ function sanitizeFontFamily(fontFamily) {
43
+ if (!fontFamily)
44
+ return fontFamily;
45
+ return fontFamily
46
+ .split(',')
47
+ .map(font => {
48
+ const trimmed = font.trim();
49
+ // Strip any surrounding quotes (single or double) from either end
50
+ const unquoted = trimmed.replace(/^["']|["']$/g, '').trim();
51
+ if (!unquoted)
52
+ return '';
53
+ // Generic families and single-token names need no quotes
54
+ if (GENERIC_FONT_FAMILIES.has(unquoted.toLowerCase()) || !/\s/.test(unquoted)) {
55
+ return unquoted;
56
+ }
57
+ // Multi-word font name: wrap in single quotes, escaping any embedded single quotes
58
+ return `'${unquoted.replace(/'/g, "\\'")}'`;
59
+ })
60
+ .filter(Boolean)
61
+ .join(', ');
62
+ }
26
63
  function buildStyles(style, { pxChanges, perChanges }) {
27
64
  if (!style)
28
65
  style = {};
@@ -58,13 +95,13 @@ function buildStyles(style, { pxChanges, perChanges }) {
58
95
  };
59
96
  value = `${safePad.top}px ${safePad.right}px ${safePad.bottom}px ${safePad.left}px`;
60
97
  }
61
- // Sanitize fontFamily: replace double quotes with single quotes to avoid
62
- // breaking the surrounding style="..." HTML attribute
63
98
  if (key === "fontFamily" && typeof value === "string") {
64
- value = (0, fontFallback_1.withFontFallback)(value).replace(/"/g, "'");
99
+ value = sanitizeFontFamily((0, fontFallback_1.withFontFallback)(value));
65
100
  }
66
- // Wrap backgroundImage values in url() if not already wrapped
67
- if (key === "backgroundImage" && typeof value === "string" && !String(value).startsWith("url(")) {
101
+ // Wrap backgroundImage values in url() if not already wrapped — skip gradients
102
+ if (key === "backgroundImage" && typeof value === "string"
103
+ && !String(value).startsWith("url(")
104
+ && !String(value).toLowerCase().includes("gradient(")) {
68
105
  value = `url('${value}')`;
69
106
  }
70
107
  // lineHeight: values >= 4 are pixel values; smaller values are unitless multipliers (e.g. 1.5)
@@ -104,7 +141,7 @@ function buildStyles(style, { pxChanges, perChanges }) {
104
141
  .map(([k, v]) => `${k}:${v}`);
105
142
  if (style.customCss)
106
143
  parts.push(style.customCss);
107
- return parts.join('; ').trim();
144
+ return parts.join('; ').replace(/;\s*$/, '').trim();
108
145
  }
109
146
  async function convertToHtml(blockData, rootData, cellWidthInPx) {
110
147
  switch (blockData.type) {
@@ -134,43 +171,48 @@ function appendOutlookSupport(content, contentStyle, className, msoWidth) {
134
171
  const visibilityClass = className || "";
135
172
  const shouldHideInOutlook = visibilityClass.includes("hide-desktop");
136
173
  if (shouldHideInOutlook) {
137
- return `
138
- <!--[if !mso]><!-->
139
- <table width="100%" style="${exports.tableCommonStyle}" class="${visibilityClass}"><tr><td style="${contentStyle}">${content}</td></tr></table>
140
- <!--<![endif]-->
174
+ return `
175
+ <!--[if !mso]><!-->
176
+ <table width="100%" style="${exports.tableCommonStyle}" class="${visibilityClass}"><tr><td style="${contentStyle}">${content}</td></tr></table>
177
+ <!--<![endif]-->
141
178
  `;
142
179
  }
143
180
  // When an explicit pixel width is provided (e.g. inside a column cell), use dual MSO/non-MSO
144
181
  // tables. Old Outlook (Word engine) ignores max-width and can resolve width="100%" to the
145
182
  // full email width (600px) rather than the column width, causing images/buttons to expand.
146
183
  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]-->
184
+ return `
185
+ <!--[if mso]>
186
+ <table border="0" cellpadding="0" cellspacing="0" width="${msoWidth}" style="border-collapse:collapse;width:${msoWidth}px;"><tr><td width="${msoWidth}" style="${contentStyle}">
187
+ <![endif]-->
188
+ <!--[if !mso]><!-->
189
+ <table width="100%" border="0" cellpadding="0" cellspacing="0" style="${exports.tableCommonStyle}; max-width:600px;" class="${visibilityClass}"><tr><td width="100%" style="${contentStyle}">
190
+ <!--<![endif]-->
191
+ ${content}
192
+ <!--[if mso]></td></tr></table><![endif]-->
193
+ <!--[if !mso]><!-->
194
+ </td></tr></table>
195
+ <!--<![endif]-->
159
196
  `;
160
197
  }
161
- return `
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>
198
+ return `
199
+ <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>
163
200
  `;
164
201
  }
165
202
  function convertDividerBlockToHtml(blockData) {
166
203
  const { style, props } = blockData.data;
167
204
  const { hideOnMobile, hideOnDesktop } = props;
168
- const { thickness, dividerColor, width, ...rest } = style;
205
+ const { thickness, dividerColor, width, alignment, ...rest } = style;
169
206
  const convertedStyle = buildStyles(rest, {
170
207
  perChanges: [],
171
208
  pxChanges: allPxAttributes,
172
209
  });
173
- const dividerWidth = width || "100%";
210
+ const dividerWidth = width || 100;
211
+ const alignAttr = alignment === 'center' ? 'center' : alignment === 'right' ? 'right' : 'left';
212
+ // Append text-align so the import parser can recover alignment via inheritance
213
+ const contentStyle = convertedStyle
214
+ ? `${convertedStyle}; text-align:${alignAttr};`
215
+ : `text-align:${alignAttr};`;
174
216
  // Build class name based on visibility
175
217
  const visibilityClass = [
176
218
  hideOnMobile ? "hide-mobile" : "",
@@ -178,23 +220,24 @@ function convertDividerBlockToHtml(blockData) {
178
220
  ]
179
221
  .filter(Boolean)
180
222
  .join(" ");
181
- const dividerContent = `
182
- <table
183
- width="${dividerWidth}%"
184
- cellpadding="0"
185
- cellspacing="0"
186
- >
187
- <tr>
188
- <td
189
- height="${thickness}"
190
- style="font-size:1px; line-height:1px; background:${dividerColor}; width:${dividerWidth};"
191
- >
192
- &nbsp;
193
- </td>
194
- </tr>
195
- </table>
223
+ const dividerContent = `
224
+ <table
225
+ align="${alignAttr}"
226
+ width="${dividerWidth}%"
227
+ cellpadding="0"
228
+ cellspacing="0"
229
+ >
230
+ <tr>
231
+ <td
232
+ height="${thickness}"
233
+ style="font-size:1px; line-height:1px; background:${dividerColor}; width:${dividerWidth}%;"
234
+ >
235
+ &nbsp;
236
+ </td>
237
+ </tr>
238
+ </table>
196
239
  `;
197
- return appendOutlookSupport(dividerContent, convertedStyle, visibilityClass);
240
+ return appendOutlookSupport(dividerContent, contentStyle, visibilityClass);
198
241
  }
199
242
  function convertSpacerBlockToHtml(blockData) {
200
243
  const { style, props } = blockData.data;
@@ -237,13 +280,28 @@ function convertTextBlock(blockData, cellWidthInPx) {
237
280
  .replace(/<\/p>/gi, "</div>");
238
281
  const navigateToUrl = props.navigateToUrl || "";
239
282
  const fontSizeStyle = fontSize != null ? `font-size:${fontSize}px;` : "";
283
+ // Email clients apply `a { color: blue }` which overrides inherited color.
284
+ // Inject the block color directly onto <a> tags that don't already have one.
285
+ const blockTextColor = rest.color;
286
+ const processedText = blockTextColor
287
+ ? sanitizedText.replace(/<a(\s[^>]*)?>/gi, (match, attrs = '') => {
288
+ if (/style\s*=\s*["'][^"']*\bcolor\s*:/i.test(attrs))
289
+ return match;
290
+ if (/\bstyle\s*=/i.test(attrs)) {
291
+ return `<a${attrs.replace(/(\bstyle\s*=\s*["'])/, `$1color:${blockTextColor};`)}>`;
292
+ }
293
+ return `<a${attrs} style="color:${blockTextColor};">`;
294
+ })
295
+ : sanitizedText;
296
+ const colorStyle = blockTextColor ? `color:${blockTextColor};` : '';
240
297
  // Use display:block + width:100% so text fills the column naturally.
241
298
  // 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>`;
299
+ const convertedTextBox = `<div style="display:block; width:100%; box-sizing:border-box; ${colorStyle}${fontSizeStyle}${convertedTextStyle}">${processedText.replaceAll(/\n/g, "<br>")}</div>`;
243
300
  const safeCellWidth = cellWidthInPx ? Math.min(cellWidthInPx, 600) : undefined;
244
301
  const textContent = appendOutlookSupport(convertedTextBox, styles, visibilityClass, safeCellWidth);
302
+ const linkColorStyle = blockTextColor ? `color:${blockTextColor};` : 'color:inherit;';
245
303
  return navigateToUrl
246
- ? `<a href="${navigateToUrl}" rel="noreferrer noopener" style="color:inherit;text-decoration:none;cursor:pointer;">${textContent}</a>`
304
+ ? `<a href="${navigateToUrl}" rel="noreferrer noopener" style="${linkColorStyle}text-decoration:none;cursor:pointer;">${textContent}</a>`
247
305
  : textContent;
248
306
  }
249
307
  async function appendOutlookForImage(content, outerContainerWidth, innerContainerWidth, imageUrl, style = {}, finalWidth, finalHeight) {
@@ -287,21 +345,21 @@ async function appendOutlookForImage(content, outerContainerWidth, innerContaine
287
345
  if (useRoundRect && borderRadius > 0) {
288
346
  // Use VML for border radius - wrap in table to constrain width for Old Outlook (Word engine)
289
347
  // Use aspect="atmost" to prevent image from stretching beyond its bounds
290
- outlookImage = `<!--[if mso]>
291
- <table border="0" cellpadding="0" cellspacing="0" width="${vmlWidth}" style="width:${vmlWidth}px;">
292
- <tr>
293
- <td align="center" valign="top" width="${vmlWidth}" style="width:${vmlWidth}px;">
294
- <v:roundrect xmlns:v="urn:schemas-microsoft-com:vml"
295
- style="width:${vmlWidth}px;height:${vmlHeight}px;"
296
- ${borderAttributes}
297
- arcsize="${arcsize}"
298
- fill="true" fillcolor="none">
299
- <v:fill src="${imageUrl}" type="tile" aspect="atmost" />
300
- <v:textbox inset="0,0,0,0"><div style="display:none;">.</div></v:textbox>
301
- </v:roundrect>
302
- </td>
303
- </tr>
304
- </table>
348
+ outlookImage = `<!--[if mso]>
349
+ <table border="0" cellpadding="0" cellspacing="0" width="${vmlWidth}" style="width:${vmlWidth}px;">
350
+ <tr>
351
+ <td align="center" valign="top" width="${vmlWidth}" style="width:${vmlWidth}px;">
352
+ <v:roundrect xmlns:v="urn:schemas-microsoft-com:vml"
353
+ style="width:${vmlWidth}px;height:${vmlHeight}px;"
354
+ ${borderAttributes}
355
+ arcsize="${arcsize}"
356
+ fill="true" fillcolor="none">
357
+ <v:fill src="${imageUrl}" type="tile" aspect="atmost" />
358
+ <v:textbox inset="0,0,0,0"><div style="display:none;">.</div></v:textbox>
359
+ </v:roundrect>
360
+ </td>
361
+ </tr>
362
+ </table>
305
363
  <![endif]-->`;
306
364
  }
307
365
  else {
@@ -310,21 +368,21 @@ async function appendOutlookForImage(content, outerContainerWidth, innerContaine
310
368
  const borderStyleAttr = borderWidth > 0
311
369
  ? `border: ${borderWidth}px solid ${borderColor};`
312
370
  : '';
313
- outlookImage = `<!--[if mso]>
314
- <table border="0" cellpadding="0" cellspacing="0" width="${vmlWidth}" style="width:${vmlWidth}px;">
315
- <tr>
316
- <td align="center" valign="top" width="${vmlWidth}" style="width:${vmlWidth}px;">
317
- <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}" />
318
- </td>
319
- </tr>
320
- </table>
371
+ outlookImage = `<!--[if mso]>
372
+ <table border="0" cellpadding="0" cellspacing="0" width="${vmlWidth}" style="width:${vmlWidth}px;">
373
+ <tr>
374
+ <td align="center" valign="top" width="${vmlWidth}" style="width:${vmlWidth}px;">
375
+ <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}" />
376
+ </td>
377
+ </tr>
378
+ </table>
321
379
  <![endif]-->`;
322
380
  }
323
- return `
324
- ${outlookImage}
325
- <!--[if !mso]><!-->
326
- ${content}
327
- <!--<![endif]-->
381
+ return `
382
+ ${outlookImage}
383
+ <!--[if !mso]><!-->
384
+ ${content}
385
+ <!--<![endif]-->
328
386
  `;
329
387
  }
330
388
  async function computeScaledDimensions(imageUrl, maxContainerWidthPx) {
@@ -430,56 +488,56 @@ function appendOutlookForButton(content, buttonStyle, navigateToUrl, text) {
430
488
  const borderStyle = buttonStyle.borderStyle || "solid";
431
489
  const bgColor = buttonStyle.buttonColor || "transparent";
432
490
  const color = buttonStyle.color || "#ffffff";
433
- const fontFamily = (0, fontFallback_1.withFontFallback)(buttonStyle.fontFamily).replace(/"/g, "'");
491
+ const fontFamily = sanitizeFontFamily((0, fontFallback_1.withFontFallback)(buttonStyle.fontFamily));
434
492
  const fontWeight = buttonStyle.fontWeight || 400;
435
493
  const width = typeof buttonStyle.width === "number"
436
494
  ? `width="${buttonStyle.width}"`
437
495
  : "";
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>
480
- <![endif]-->
481
- <!--[if !mso]><!-->
482
- ${content}
496
+ return `<!--[if mso]>
497
+ <table role="presentation" cellspacing="0" cellpadding="0" border="0" style="display:inline-table;">
498
+ <tr>
499
+ <td align="center"
500
+ valign="middle"
501
+ ${width}
502
+ ${finalHeight ? `height="${finalHeight}"` : ""}
503
+ bgcolor="${bgColor}"
504
+ style="
505
+ ${finalHeight ? `height:${finalHeight}px;` : ""}
506
+ background-color:${bgColor};
507
+ border-radius:${borderRadius}px;
508
+ border:${borderWidth}px ${borderStyle} ${borderColor};
509
+ overflow:hidden;
510
+ mso-line-height-rule:exactly;
511
+ ">
512
+
513
+ <table role="presentation" cellspacing="0" cellpadding="0" border="0">
514
+ <tr>
515
+ <td align="center" valign="middle"
516
+ style="padding:${padTop}px ${padRight}px ${padBottom}px ${padLeft}px;">
517
+
518
+ <a href="${navigateToUrl}"
519
+ style="
520
+ display:inline-block;
521
+ color:${color};
522
+ text-decoration:none;
523
+ font-family:${fontFamily};
524
+ font-size:${fontSize}px;
525
+ font-weight:${fontWeight};
526
+ line-height:normal;
527
+ ">
528
+ ${text}
529
+ </a>
530
+
531
+ </td>
532
+ </tr>
533
+ </table>
534
+
535
+ </td>
536
+ </tr>
537
+ </table>
538
+ <![endif]-->
539
+ <!--[if !mso]><!-->
540
+ ${content}
483
541
  <!--<![endif]-->`;
484
542
  }
485
543
  function convertButtonBlock(blockData) {
@@ -497,7 +555,7 @@ function convertButtonBlock(blockData) {
497
555
  const finalHeight = typeof height === "number" && height > 0
498
556
  ? Math.max(height, minHeight)
499
557
  : null;
500
- const safeFF = (0, fontFallback_1.withFontFallback)(fontFamily).replace(/"/g, "'");
558
+ const safeFF = sanitizeFontFamily((0, fontFallback_1.withFontFallback)(fontFamily));
501
559
  const safeColor = color || "#ffffff";
502
560
  const bgColor = buttonColor || "transparent";
503
561
  const bdColor = borderColor || "transparent";
@@ -509,89 +567,139 @@ function convertButtonBlock(blockData) {
509
567
  ? `width="${width}"`
510
568
  : "";
511
569
  // ✅ 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>
570
+ const buttonTable = `
571
+ <table role="presentation" cellspacing="0" cellpadding="0" border="0"
572
+ style="display:inline-table; border-collapse:separate;"
573
+ ${widthAttr}>
574
+ <tr>
575
+ <td
576
+ align="center"
577
+ valign="middle"
578
+ ${finalHeight ? `height="${finalHeight}"` : ""}
579
+ style="
580
+ ${finalHeight ? `height:${finalHeight}px;` : ""}
581
+ background-color:${bgColor};
582
+ border-radius:${br}px;
583
+ border:${bw}px ${bdStyle} ${bdColor};
584
+ overflow:hidden;
585
+ mso-line-height-rule:exactly;
586
+ "
587
+ >
588
+
589
+ <table role="presentation" cellspacing="0" cellpadding="0" border="0">
590
+ <tr>
591
+ <td align="center" valign="middle"
592
+ style="padding:${padTop}px ${padRight}px ${padBottom}px ${padLeft}px;">
593
+
594
+ <a href="${navigateToUrl}"
595
+ style="
596
+ display:inline-block;
597
+ color:${safeColor};
598
+ text-decoration:none;
599
+ font-family:${safeFF};
600
+ font-size:${fs}px;
601
+ font-weight:${fontWeight || 400};
602
+ line-height:normal;
603
+ white-space:nowrap;
604
+ ">
605
+ ${text}
606
+ </a>
607
+
608
+ </td>
609
+ </tr>
610
+ </table>
611
+
612
+ </td>
613
+ </tr>
614
+ </table>
557
615
  `;
558
616
  const aligned = containerAlign === "center"
559
617
  ? `<center>${buttonTable}</center>`
560
618
  : `<div style="text-align:${containerAlign};">${buttonTable}</div>`;
561
619
  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>
620
+ return `
621
+ <table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
622
+ <tr>
623
+ <td align="${containerAlign}"
624
+ style="padding:${padding?.top || 0}px ${padding?.right || 0}px ${padding?.bottom || 0}px ${padding?.left || 0}px;
625
+ background-color:${containerBg || "transparent"};">
626
+ ${buttonWithOutlook}
627
+ </td>
628
+ </tr>
629
+ </table>
572
630
  `;
573
631
  }
632
+ // Words inside a gradient() that are NOT color names
633
+ const GRADIENT_KEYWORDS = new Set([
634
+ 'linear', 'radial', 'conic', 'gradient',
635
+ 'to', 'at', 'top', 'bottom', 'left', 'right', 'center',
636
+ 'closest', 'farthest', 'corner', 'side', 'circle', 'ellipse',
637
+ 'deg', 'turn', 'rad', 'grad', 'from', 'in',
638
+ 'url', // url() prefix sometimes appears when gradient is wrapped incorrectly
639
+ ]);
640
+ /** Extract the first color stop (hex, rgb, or named CSS color) from a gradient string. */
641
+ function firstGradientColor(gradient) {
642
+ const tokenRe = /#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|([a-zA-Z-]+)/g;
643
+ let m;
644
+ while ((m = tokenRe.exec(gradient)) !== null) {
645
+ const namedWord = m[1];
646
+ if (namedWord) {
647
+ if (!GRADIENT_KEYWORDS.has(namedWord.toLowerCase()))
648
+ return namedWord;
649
+ }
650
+ else {
651
+ return m[0]; // hex or rgb()
652
+ }
653
+ }
654
+ return '';
655
+ }
574
656
  /**
575
657
  * 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).
658
+ * Used as a MSO/Outlook bgcolor fallback. Handles hex, rgb, and named colors.
578
659
  */
579
660
  function extractCssFallbackColor(customCss) {
580
661
  if (!customCss)
581
662
  return '';
582
- const m = customCss.match(/(?:linear|radial|conic)-gradient\([^)]*?(#[0-9a-fA-F]{3,8}|rgb\([^)]+\)|rgba\([^)]+\))/);
583
- return m?.[1] ?? '';
663
+ const gradientMatch = customCss.match(/(?:linear|radial|conic)-gradient\(([^)]+(?:\([^)]*\)[^)]*)*)\)/);
664
+ if (!gradientMatch)
665
+ return '';
666
+ return firstGradientColor(gradientMatch[1]);
584
667
  }
585
668
  function parseGradient(gradient) {
586
669
  if (!gradient)
587
670
  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) || [];
671
+ const lower = gradient.toLowerCase();
672
+ // Determine angle from deg value or direction keyword
673
+ const degMatch = gradient.match(/(\d+(?:\.\d+)?)deg/);
674
+ let angle = 180;
675
+ if (degMatch) {
676
+ angle = parseFloat(degMatch[1]);
677
+ }
678
+ else if (lower.includes('to right'))
679
+ angle = 90;
680
+ else if (lower.includes('to left'))
681
+ angle = 270;
682
+ else if (lower.includes('to top'))
683
+ angle = 0;
684
+ // 'to bottom' and bare gradient() both default to 180
685
+ // Extract ALL color tokens: hex, rgb/rgba, and named CSS color words
686
+ const colors = [];
687
+ const tokenRe = /#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|([a-zA-Z-]+)/g;
688
+ let m;
689
+ while ((m = tokenRe.exec(gradient)) !== null) {
690
+ const namedWord = m[1];
691
+ if (namedWord) {
692
+ if (!GRADIENT_KEYWORDS.has(namedWord.toLowerCase()))
693
+ colors.push(namedWord);
694
+ }
695
+ else {
696
+ colors.push(m[0]);
697
+ }
698
+ }
591
699
  return {
592
700
  angle,
593
701
  colors,
594
- fallback: colors[0] || "#ffffff",
702
+ fallback: colors[0] || '#ffffff',
595
703
  };
596
704
  }
597
705
  function cssAngleToVml(angle) {
@@ -602,18 +710,56 @@ async function convertGridBlock(blockData, rootData, cellWidthInPx) {
602
710
  const { columns = 1, cellWidths = [], responsive = true } = props;
603
711
  const { columnGap = 0, backgroundImage, backgroundColor, ...restStyle } = style;
604
712
  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;
713
+ // Detect gradient check both backgroundImage prop and customCss (gradient may land in
714
+ // customCss when the block was built via CSS shorthand or custom CSS input).
715
+ const bgImageStr = typeof backgroundImage === "string" ? backgroundImage : '';
716
+ const customCssStr = restStyle.customCss || '';
717
+ // Extract gradient string from customCss if not already in backgroundImage
718
+ const gradientInCustomCss = !bgImageStr.includes('gradient(') && customCssStr.includes('gradient(')
719
+ ? (customCssStr.match(/(?:linear|radial|conic)-gradient\([^)]+(?:\([^)]*\)[^)]*)*\)/)?.[0] || '')
720
+ : '';
721
+ const effectiveGradient = bgImageStr.includes('gradient(')
722
+ ? bgImageStr
723
+ : gradientInCustomCss;
724
+ const isGradient = Boolean(effectiveGradient);
725
+ const parsedGradient = isGradient ? parseGradient(effectiveGradient) : null;
608
726
  const fallbackBgColor = backgroundColor ||
609
727
  parsedGradient?.fallback ||
610
- extractCssFallbackColor(restStyle.customCss) ||
728
+ extractCssFallbackColor(customCssStr) ||
611
729
  "#ffffff";
612
- const rawBgImageUrl = !isGradient && backgroundImage
613
- ? String(backgroundImage).replace(/^url\(['"]?/, "").replace(/['"]?\)$/, "")
730
+ const rawBgImageUrl = !isGradient && bgImageStr
731
+ ? bgImageStr.replace(/^url\(['"]?/, "").replace(/['"]?\)$/, "")
614
732
  : null;
615
- // FIX: avoid table-layout:fixed causes shrink in many clients
616
- const tableStyles = buildStyles({ backgroundColor, ...restStyle }, {
733
+ // When gradient came from customCss, strip background-image from customCss so it
734
+ // doesn't duplicate into the inner table style (the outer <td> wrapper carries it).
735
+ const innerCustomCss = gradientInCustomCss
736
+ ? customCssStr.replace(/background-image\s*:[^;]+;?/gi, '').trim()
737
+ : customCssStr;
738
+ // Build inner table styles — when gradient/bg-image is on the outer wrapper, strip
739
+ // background props from the inner table so the outer <td> background shows through.
740
+ const innerRestStyleRaw = (rawBgImageUrl || isGradient)
741
+ ? { ...restStyle, customCss: innerCustomCss, backgroundSize: undefined, backgroundPosition: undefined, backgroundRepeat: undefined }
742
+ : { ...restStyle, customCss: innerCustomCss };
743
+ // Extract border/radius props — applied via a div wrapper for non-MSO clients so that
744
+ // border-radius is honoured (Gmail/Outlook compose strip border-radius from <table>).
745
+ const { borderRadius, border, borderColor, borderWidth, borderStyle: borderStyleProp, ...innerRestStyle } = innerRestStyleRaw;
746
+ const divBorderParts = [];
747
+ if (borderRadius)
748
+ divBorderParts.push(`border-radius:${typeof borderRadius === 'number' ? borderRadius + 'px' : borderRadius};`, `overflow:hidden;`);
749
+ if (border) {
750
+ divBorderParts.push(`border:${border};`);
751
+ }
752
+ else if (borderWidth || borderColor || borderStyleProp) {
753
+ const bw = borderWidth ? (typeof borderWidth === 'number' ? borderWidth + 'px' : borderWidth) : '1px';
754
+ const bs = borderStyleProp || 'solid';
755
+ const bc = borderColor || '#000000';
756
+ divBorderParts.push(`border:${bw} ${bs} ${bc};`);
757
+ }
758
+ const divBorderStyle = divBorderParts.join(' ');
759
+ const tableBgForNonMso = divBorderStyle
760
+ ? 'transparent'
761
+ : ((rawBgImageUrl || isGradient) ? undefined : backgroundColor);
762
+ const tableStyles = buildStyles({ backgroundColor: tableBgForNonMso, ...innerRestStyle }, {
617
763
  perChanges: [],
618
764
  pxChanges: allPxAttributes,
619
765
  });
@@ -621,48 +767,39 @@ async function convertGridBlock(blockData, rootData, cellWidthInPx) {
621
767
  const visualRows = Math.ceil(total / columns);
622
768
  // OUTLOOK FIX: Use explicit pixel width for Old Outlook (Word engine)
623
769
  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))
770
+ // When a background image/gradient is present, the background is applied on an outer
771
+ // wrapper <td> (see bottom of function). The inner grid tables must be clean.
772
+ // When no background, the MSO table gets bgcolor for solid-color sections.
773
+ const msoBgColor = !rawBgImageUrl && !isGradient
774
+ ? (backgroundColor || '')
630
775
  : '';
631
776
  const msoBgAttr = msoBgColor ? ` bgcolor="${msoBgColor}"` : '';
632
777
  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).
778
+ // Inner tables must be explicitly transparent when outer <td> carries the background.
648
779
  const innerBgTransparent = (rawBgImageUrl || isGradient)
649
780
  ? 'background-color:transparent;'
650
781
  : '';
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}"` : '';
654
- let html = `
655
- <!--[if mso]>
656
- <table border="0" cellpadding="0" cellspacing="0" width="${msoTableWidth}"${msoBgAttr}
657
- style="border-collapse:collapse;width:${msoTableWidth}px;${msoBgStyle}${innerBgTransparent}"
658
- class="${gridVisibilityClass}">
659
- <![endif]-->
660
- <!--[if !mso]><!-->
661
- <table border="0" cellpadding="0" cellspacing="0" width="100%"
662
- role="presentation"${nonMsoBgAttr}
663
- style="border-collapse:collapse; ${innerBgTransparent}${innerNonMsoStyle}; max-width:600px;"
664
- class="${gridVisibilityClass}">
665
- <!--<![endif]-->
782
+ const nonMsoBgAttr = !rawBgImageUrl && !isGradient && backgroundColor && !divBorderStyle ? ` bgcolor="${backgroundColor}"` : '';
783
+ // When divBorderStyle is set the non-MSO <table> is transparent, so the Grid's
784
+ // backgroundColor must move onto the div wrapper otherwise it vanishes in modern clients.
785
+ // Skip this for bg-image/gradient blocks; they apply their background via a separate wrapper.
786
+ const divWrapBg = divBorderStyle && backgroundColor && !rawBgImageUrl && !isGradient
787
+ ? ` background-color:${backgroundColor};`
788
+ : '';
789
+ const divWrapOpen = divBorderStyle ? `<div style="${divBorderStyle}${divWrapBg}">` : '';
790
+ const divWrapClose = divBorderStyle ? `</div>` : '';
791
+ let html = `
792
+ <!--[if mso]>
793
+ <table border="0" cellpadding="0" cellspacing="0" width="${msoTableWidth}"${msoBgAttr}
794
+ style="border-collapse:collapse;width:${msoTableWidth}px;${msoBgStyle}${innerBgTransparent}"
795
+ class="${gridVisibilityClass}">
796
+ <![endif]-->
797
+ <!--[if !mso]><!-->
798
+ <table border="0" cellpadding="0" cellspacing="0" width="100%"
799
+ role="presentation"${nonMsoBgAttr}
800
+ style="border-collapse:collapse; ${innerBgTransparent}${tableStyles}; max-width:600px;"
801
+ class="${gridVisibilityClass}">
802
+ <!--<![endif]-->
666
803
  `;
667
804
  for (let r = 0; r < visualRows; r++) {
668
805
  html += "<tr>";
@@ -716,18 +853,18 @@ async function convertGridBlock(blockData, rootData, cellWidthInPx) {
716
853
  const childVisible = !childProps.hideOnDesktop;
717
854
  const visibilityClass = (0, common_1.getVisibilityClass)(childProps);
718
855
  if (childVisible) {
719
- const { html: childHtml, styles } = await convertGridCellBlock(child, rootData, widthPercent, adjustedTableWidth);
856
+ const { html: childHtml, styles } = await convertGridCellBlock(child, rootData, widthPercent, adjustedTableWidth, Boolean(divBorderStyle));
720
857
  // bgcolor on the cell <td> ensures background-color survives Outlook
721
858
  // compose paste (Word/Web editors strip CSS but keep bgcolor attribute).
722
859
  const cellBgColor = cellStyle.backgroundColor || '';
723
860
  const cellBgAttr = cellBgColor ? ` bgcolor="${cellBgColor}"` : '';
724
- html += `
725
- <td
726
- width="${cellWidthPx}"${cellBgAttr}
727
- class="${[responsive ? "stack-column" : "", visibilityClass].filter(Boolean).join(" ")}"
728
- style="width:${cellWidthPx}px;vertical-align:${verticalAlign};word-break:break-word;${styles}"
729
- >
730
- ${childHtml}
861
+ html += `
862
+ <td
863
+ width="${cellWidthPx}"${cellBgAttr}
864
+ class="${[responsive ? "stack-column" : "", visibilityClass].filter(Boolean).join(" ")}"
865
+ style="width:${cellWidthPx}px;vertical-align:${verticalAlign};word-break:break-word;${styles}"
866
+ >
867
+ ${childHtml}
731
868
  </td>`;
732
869
  // Spacer td between columns — fixed pixel width, invisible to screen readers
733
870
  if (columnGap > 0 && c !== lastVisibleCol) {
@@ -736,10 +873,10 @@ async function convertGridBlock(blockData, rootData, cellWidthInPx) {
736
873
  }
737
874
  }
738
875
  else {
739
- html += `
740
- <td width="${cellWidthPx}"
741
- ${responsive ? 'class="stack-column"' : ""}
742
- style="width:${cellWidthPx}px;vertical-align:top;">
876
+ html += `
877
+ <td width="${cellWidthPx}"
878
+ ${responsive ? 'class="stack-column"' : ""}
879
+ style="width:${cellWidthPx}px;vertical-align:top;">
743
880
  </td>`;
744
881
  if (columnGap > 0 && c !== lastVisibleCol) {
745
882
  html += `<td width="${columnGap}" style="width:${columnGap}px;font-size:0;line-height:0;padding:0;" aria-hidden="true"></td>`;
@@ -749,13 +886,13 @@ async function convertGridBlock(blockData, rootData, cellWidthInPx) {
749
886
  html += "</tr>";
750
887
  }
751
888
  // Close both MSO and non-MSO tables
752
- html += `
753
- <!--[if mso]>
754
- </table>
755
- <![endif]-->
756
- <!--[if !mso]><!-->
757
- </table>
758
- <!--<![endif]-->
889
+ html += `
890
+ <!--[if mso]>
891
+ </table>
892
+ <![endif]-->
893
+ <!--[if !mso]><!-->
894
+ </table>
895
+ <!--<![endif]-->
759
896
  `;
760
897
  // ── Background image: canonical multi-client approach ────────────────────
761
898
  //
@@ -781,42 +918,63 @@ async function convertGridBlock(blockData, rootData, cellWidthInPx) {
781
918
  return `<v:fill type="gradient" color="${c1}" color2="${c2}" angle="${vmlAngle}" />`;
782
919
  })()
783
920
  : `<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>
921
+ html = `
922
+ <table border="0" cellpadding="0" cellspacing="0" width="${msoTableWidth}" role="presentation"
923
+ style="border-collapse:collapse;width:${msoTableWidth}px;">
924
+ <tr>
925
+ <td width="${msoTableWidth}" bgcolor="${fallbackBgColor}" valign="top"
926
+ ${!isGradient && rawBgImageUrl ? `background="${rawBgImageUrl}"` : ""}
927
+ style="
928
+ width:${msoTableWidth}px;
929
+ background-color:${fallbackBgColor};
930
+ ${isGradient ? `background:${effectiveGradient};` : `background-image:url('${rawBgImageUrl}'); background-position:center center; background-size:cover; background-repeat:no-repeat;`}
931
+ ">
932
+
933
+ <!--[if gte mso 9]>
934
+ <v:rect xmlns:v="urn:schemas-microsoft-com:vml"
935
+ fill="true" stroke="false"
936
+ style="width:${msoTableWidth}px;">
937
+ ${vmlFill}
938
+ <v:textbox inset="0,0,0,0">
939
+ <![endif]-->
940
+
941
+ ${html}
942
+
943
+ <!--[if gte mso 9]>
944
+ </v:textbox>
945
+ </v:rect>
946
+ <![endif]-->
947
+
948
+ </td>
949
+ </tr>
812
950
  </table>`;
813
951
  }
952
+ // Wrap the entire grid (including any bg-image outer table) in a div when the block
953
+ // has border/radius. An unconditional <div> is used — not gated behind <!--[if !mso]>-->
954
+ // — so Gmail compose paste renders the border-radius reliably. Old Outlook ignores
955
+ // border-radius on <div> but still shows the rectangular border; new Outlook works fully.
956
+ if (divBorderStyle)
957
+ html = `${divWrapOpen}${html}${divWrapClose}`;
814
958
  return html;
815
959
  }
816
- async function convertGridCellBlock(blockData, rootData, cellWidthPercent, parentCellWidthPx) {
960
+ async function convertGridCellBlock(blockData, rootData, cellWidthPercent, parentCellWidthPx, parentGridHasBorder = false) {
817
961
  const { style = {}, childrenIds = [], props = {} } = blockData.data;
818
962
  const visibilityClass = (0, common_1.getVisibilityClass)(props);
819
- const styles = buildStyles(style, {
963
+ // Extract border + radius from style so they move to the div wrapper (not the <td>).
964
+ // Gmail strips border-radius from <td> but honours it on <div>. By putting border and
965
+ // radius on the same unconditional <div>, the rounded card border renders in all clients.
966
+ // The <td> keeps bgcolor (via attribute) for Old Outlook background fallback.
967
+ const { borderRadius: cellBorderRadius, borderWidth: cellBorderWidth, borderStyle: cellBorderStyleProp, borderColor: cellBorderColor, border: cellBorderShorthand, ...styleWithoutBorder } = style;
968
+ // backgroundColor must stay on the div wrapper (not the <td>) in two cases:
969
+ // 1. Cell has its own border-radius — the div's overflow:hidden clips the background.
970
+ // 2. Parent grid has a border div (divBorderStyle) — the grid's overflow:hidden clips it.
971
+ // In both cases, the rectangular <td> background bleeds through rounded corners if kept
972
+ // in CSS, creating visible corner squares. The bgcolor attribute stays for Outlook fallback.
973
+ const stripBgFromTd = Boolean(cellBorderRadius) || parentGridHasBorder;
974
+ const styleForTd = stripBgFromTd
975
+ ? { ...styleWithoutBorder, backgroundColor: 'transparent' }
976
+ : styleWithoutBorder;
977
+ const styles = buildStyles(styleForTd, {
820
978
  perChanges: [],
821
979
  pxChanges: allPxAttributes,
822
980
  });
@@ -827,7 +985,7 @@ async function convertGridCellBlock(blockData, rootData, cellWidthPercent, paren
827
985
  // Subtract the cell's own padding so children receive the actual content-area width.
828
986
  // Old Outlook honours explicit img/table width attributes — if a child is sized to the
829
987
  // full column width (ignoring padding) it overflows and expands the column.
830
- const cellPad = style?.padding || {};
988
+ const cellPad = styleWithoutBorder?.padding || {};
831
989
  const cellPadLeft = Number.isFinite(cellPad.left) ? cellPad.left : 0;
832
990
  const cellPadRight = Number.isFinite(cellPad.right) ? cellPad.right : 0;
833
991
  const contentWidthPx = Math.max(cellWidthPx - cellPadLeft - cellPadRight, 20);
@@ -839,22 +997,35 @@ async function convertGridCellBlock(blockData, rootData, cellWidthPercent, paren
839
997
  parts.push(await convertToHtml(child, rootData, safeCellWidthPx));
840
998
  }
841
999
  }
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
- `;
1000
+ const borderRadius = cellBorderRadius || 0;
1001
+ const bgColor = styleWithoutBorder?.backgroundColor || "transparent";
1002
+ // Build border CSS for the div wrapper.
1003
+ // When the parent grid already has a divBorderStyle wrapper (border + border-radius +
1004
+ // overflow:hidden), the cell must NOT duplicate the same border/radius — that causes
1005
+ // two concentric borders of the same colour (double-border). The grid's wrapper div
1006
+ // already provides the visual container; the cell div only needs background-color.
1007
+ const cellDivBorderParts = [];
1008
+ if (!parentGridHasBorder) {
1009
+ if (borderRadius)
1010
+ cellDivBorderParts.push(`border-radius:${typeof borderRadius === 'number' ? borderRadius + 'px' : borderRadius};`, `overflow:hidden;`);
1011
+ if (cellBorderShorthand) {
1012
+ cellDivBorderParts.push(`border:${cellBorderShorthand};`);
1013
+ }
1014
+ else if (cellBorderWidth || cellBorderColor || cellBorderStyleProp) {
1015
+ const bw = cellBorderWidth ? (typeof cellBorderWidth === 'number' ? cellBorderWidth + 'px' : cellBorderWidth) : '1px';
1016
+ const bs = cellBorderStyleProp || 'solid';
1017
+ const bc = cellBorderColor || '#000000';
1018
+ cellDivBorderParts.push(`border:${bw} ${bs} ${bc};`);
1019
+ }
1020
+ }
1021
+ const cellDivBorderStyle = cellDivBorderParts.join(' ');
1022
+ // Unconditional div — visible to all clients (Gmail, Outlook new/old, Apple Mail).
1023
+ // background-color on the div covers modern clients; bgcolor on <td> covers Old Outlook.
1024
+ const divStyleParts = [`background-color:${bgColor};`];
1025
+ if (cellDivBorderStyle)
1026
+ divStyleParts.push(cellDivBorderStyle);
1027
+ const divStyleStr = divStyleParts.join(' ');
1028
+ const wrapped = `<div style="${divStyleStr}">${parts.join("")}</div>`;
858
1029
  return {
859
1030
  html: wrapped,
860
1031
  styles,
@@ -867,7 +1038,9 @@ async function convertVideoBlock(blockData, cellWidthInPx) {
867
1038
  const { hideOnDesktop } = props; // Get the hideOnDesktop prop
868
1039
  const { videoUrl, youtubeVideoUrl, thumbnailUrl, altText } = props;
869
1040
  const videoLink = youtubeVideoUrl || videoUrl || "#";
870
- let resolvedThumbnail = thumbnailUrl || "https://via.placeholder.com/480x360?text=No+Thumbnail";
1041
+ // via.placeholder.com is defunct — use a data-URI grey box as the default thumbnail
1042
+ 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`;
1043
+ let resolvedThumbnail = thumbnailUrl || FALLBACK_THUMBNAIL;
871
1044
  if (youtubeVideoUrl) {
872
1045
  const youtubeId = (0, common_1.extractYouTubeId)(youtubeVideoUrl);
873
1046
  const vimeoId = (0, common_1.extractVimeoId)(youtubeVideoUrl);
@@ -921,117 +1094,103 @@ async function convertVideoBlock(blockData, cellWidthInPx) {
921
1094
  const vmlTop = calculatedHeight / 2 - playIconHeight / 2;
922
1095
  const shouldHideInOutlook = hideOnDesktop;
923
1096
  const outlookVideoContent = shouldHideInOutlook
924
- ? `<!--[if !mso]><!-->
925
- <v:group xmlns:v="urn:schemas-microsoft-com:vml"
926
- coordsize="${innerContainerWidth},${calculatedHeight}"
927
- href="${videoLink}"
928
- style="width:${innerContainerWidth}px;height:${calculatedHeight}px;">
929
- <v:rect fill="t" style="position:absolute;width:${innerContainerWidth}px;height:${calculatedHeight}px;" stroked="t"
930
- strokeweight="${borderWidth}px"
931
- strokecolor="${borderColor}"
932
- ${borderRadius > 0 ? `arcsize="${Math.min(borderRadius / calculatedHeight, 1).toFixed(2)}"` : ""}
933
- >
934
- <v:fill src="${resolvedThumbnail}" type="frame" color="${style?.backgroundColor || "#FFFFFF"}"/>
935
- </v:rect>
936
- <v:shape type="#_x0000_t75"
937
- style="position:absolute;
938
- left:${vmlLeft.toFixed(1)}px;
939
- top:${vmlTop.toFixed(1)}px;
940
- width:${playIconWidth}px;
941
- height:${playIconHeight}px;"
942
- alt="Play" href="${videoLink}" title="${altText || "Video"}"
943
- stroked="f" filled="t">
944
- <v:imagedata src="https://app-rsrc.getbee.io/public/resources/components/widgetBar/video-content-icon-sets/light/type-01.png" />
945
- </v:shape>
946
- </v:group>
1097
+ ? `<!--[if !mso]><!-->
1098
+ <v:group xmlns:v="urn:schemas-microsoft-com:vml"
1099
+ coordsize="${innerContainerWidth},${calculatedHeight}"
1100
+ href="${videoLink}"
1101
+ style="width:${innerContainerWidth}px;height:${calculatedHeight}px;">
1102
+ <v:rect fill="t" style="position:absolute;width:${innerContainerWidth}px;height:${calculatedHeight}px;"
1103
+ ${borderWidth > 0 ? `stroked="t" strokeweight="${borderWidth}px" strokecolor="${borderColor}"` : `stroked="f"`}
1104
+ ${borderRadius > 0 ? `arcsize="${Math.min(borderRadius / calculatedHeight, 1).toFixed(2)}"` : ""}
1105
+ >
1106
+ <v:fill src="${resolvedThumbnail}" type="frame" color="${style?.backgroundColor || "#FFFFFF"}"/>
1107
+ </v:rect>
1108
+ <v:shape type="#_x0000_t75"
1109
+ style="position:absolute;
1110
+ left:${vmlLeft.toFixed(1)}px;
1111
+ top:${vmlTop.toFixed(1)}px;
1112
+ width:${playIconWidth}px;
1113
+ height:${playIconHeight}px;"
1114
+ alt="Play" href="${videoLink}" title="${altText || "Video"}"
1115
+ stroked="f" filled="t">
1116
+ <v:imagedata src="https://app-rsrc.getbee.io/public/resources/components/widgetBar/video-content-icon-sets/light/type-01.png" />
1117
+ </v:shape>
1118
+ </v:group>
947
1119
  <!--<![endif]-->`
948
- : `<!--[if mso]>
949
- <v:group xmlns:v="urn:schemas-microsoft-com:vml"
950
- coordsize="${innerContainerWidth},${calculatedHeight}"
951
- href="${videoLink}"
952
- style="width:${innerContainerWidth}px;height:${calculatedHeight}px;">
953
- <v:rect fill="t" style="position:absolute;width:${innerContainerWidth}px;height:${calculatedHeight}px;"
954
- stroked="t"
955
- strokeweight="${borderWidth}px"
956
- strokecolor="${borderColor}"
957
- ${borderRadius > 0 ? `arcsize="${Math.min(borderRadius / calculatedHeight, 1).toFixed(2)}"` : ""}
958
- >
959
- <v:fill src="${resolvedThumbnail}" type="frame" color="${style?.backgroundColor || "#FFFFFF"}"/>
960
- </v:rect>
961
- <v:shape type="#_x0000_t75"
962
- style="position:absolute;
963
- left:${vmlLeft.toFixed(1)}px;
964
- top:${vmlTop.toFixed(1)}px;
965
- width:${playIconWidth}px;
966
- height:${playIconHeight}px;"
967
- alt="Play" href="${videoLink}" title="${altText || "Video"}"
968
- stroked="f" filled="t">
969
- <v:imagedata src="https://app-rsrc.getbee.io/public/resources/components/widgetBar/video-content-icon-sets/light/type-01.png" />
970
- </v:shape>
971
- </v:group>
1120
+ : `<!--[if mso]>
1121
+ <v:group xmlns:v="urn:schemas-microsoft-com:vml"
1122
+ coordsize="${innerContainerWidth},${calculatedHeight}"
1123
+ href="${videoLink}"
1124
+ style="width:${innerContainerWidth}px;height:${calculatedHeight}px;">
1125
+ <v:rect fill="t" style="position:absolute;width:${innerContainerWidth}px;height:${calculatedHeight}px;"
1126
+ ${borderWidth > 0 ? `stroked="t" strokeweight="${borderWidth}px" strokecolor="${borderColor}"` : `stroked="f"`}
1127
+ ${borderRadius > 0 ? `arcsize="${Math.min(borderRadius / calculatedHeight, 1).toFixed(2)}"` : ""}
1128
+ >
1129
+ <v:fill src="${resolvedThumbnail}" type="frame" color="${style?.backgroundColor || "#FFFFFF"}"/>
1130
+ </v:rect>
1131
+ <v:shape type="#_x0000_t75"
1132
+ style="position:absolute;
1133
+ left:${vmlLeft.toFixed(1)}px;
1134
+ top:${vmlTop.toFixed(1)}px;
1135
+ width:${playIconWidth}px;
1136
+ height:${playIconHeight}px;"
1137
+ alt="Play" href="${videoLink}" title="${altText || "Video"}"
1138
+ stroked="f" filled="t">
1139
+ <v:imagedata src="https://app-rsrc.getbee.io/public/resources/components/widgetBar/video-content-icon-sets/light/type-01.png" />
1140
+ </v:shape>
1141
+ </v:group>
972
1142
  <![endif]-->`;
973
- const nonOutlookVideoContent = `<!--[if !mso]><!-->
974
- <table
975
- width="${innerContainerWidth}"
976
- cellpadding="0"
977
- cellspacing="0"
978
- border="0"
979
- role="presentation"
980
- align="${style?.textAlign || "left"}"
981
- style="
982
- max-width: ${innerContainerWidth}px;
983
- width: 100%;
984
- height: ${calculatedHeight}px;
985
- background-color: ${style?.backgroundColor || "#FFFFFF"};
986
- background-image: url('${resolvedThumbnail}');
987
- background-size: contain;
988
- background-position: center;
989
- background-repeat: no-repeat;
990
- box-sizing: border-box;
991
- border: ${borderWidth}px ${style?.borderStyle || "solid"} ${borderColor};
992
- border-radius: ${borderRadius}px;
993
- "
994
- >
995
- <tr>
996
- <td style="padding: 0; height: ${calculatedHeight}px; text-align: center; vertical-align: middle;" valign="middle">
997
- <a href="${videoLink}" target="_blank" style="display:inline-block; border: 0; outline: none; text-decoration: none;">
998
- <img
999
- src="https://app-rsrc.getbee.io/public/resources/components/widgetBar/video-content-icon-sets/light/type-01.png"
1000
- width="${playIconWidth}"
1001
- alt="Play"
1002
- style="display: block;
1003
- border: 0;
1004
- outline: none;
1005
- text-decoration: none;
1006
- height: auto;"
1007
- />
1008
- </a>
1009
- </td>
1010
- </tr>
1011
- </table>
1012
- <!--<![endif]-->`;
1143
+ // Non-Outlook: use a real <img> for the thumbnail so it renders in Gmail / Yahoo / webmail.
1144
+ // background-image on <table> is stripped by virtually every email client.
1145
+ // position:absolute for the play-button overlay is safe here because this block
1146
+ // is already inside <!--[if !mso]> — Outlook is handled separately via VML above.
1147
+ const thumbnailW = Math.round(innerContainerWidth);
1148
+ const thumbnailH = Math.round(calculatedHeight);
1149
+ const playMarginTop = -Math.round(playIconHeight / 2);
1150
+ const playMarginLeft = -Math.round(playIconWidth / 2);
1151
+ const borderAttr = borderWidth > 0 ? `border:${borderWidth}px ${style?.borderStyle || "solid"} ${borderColor};` : "border:0;";
1152
+ const radiusAttr = borderRadius > 0 ? `border-radius:${borderRadius}px; overflow:hidden;` : "";
1153
+ const nonOutlookVideoContent = `<!--[if !mso]><!-->
1154
+ <div style="display:block; width:100%; max-width:${thumbnailW}px; position:relative; line-height:0; font-size:0; ${borderAttr}${radiusAttr}">
1155
+ <a href="${videoLink}" target="_blank" style="display:block; text-decoration:none; line-height:0; font-size:0;">
1156
+ <img
1157
+ src="${resolvedThumbnail}"
1158
+ width="${thumbnailW}"
1159
+ height="${thumbnailH}"
1160
+ alt="${altText || "Video"}"
1161
+ style="display:block; width:100%; max-width:${thumbnailW}px; height:auto; border:0;"
1162
+ />
1163
+ <img
1164
+ src="https://app-rsrc.getbee.io/public/resources/components/widgetBar/video-content-icon-sets/light/type-01.png"
1165
+ width="${playIconWidth}"
1166
+ alt="Play"
1167
+ style="display:block; position:absolute; top:50%; left:50%; margin-top:${playMarginTop}px; margin-left:${playMarginLeft}px; border:0; outline:none;"
1168
+ />
1169
+ </a>
1170
+ </div>
1171
+ <!--<![endif]-->`;
1013
1172
  const videoContent = `${outlookVideoContent}${nonOutlookVideoContent}`;
1014
- const wrapperHtml = `
1015
- <table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="margin:0; padding:0; border-collapse: collapse; max-width:600px;" class="${visibilityClass}">
1016
- <tr>
1017
- <td align="${style?.textAlign || "left"}" style="padding:0; ${outerContainerStyles}">
1018
- <table border="0" cellpadding="0" cellspacing="0" role="presentation"
1019
- align="${style?.textAlign || "left"}"
1020
- style="
1021
- margin:0;
1022
- max-width:${cellWidthInPx}px;
1023
- width:${percentWidth};
1024
- border-collapse:collapse;
1025
- ">
1026
- <tr>
1027
- <td align="${style?.textAlign || "left"}" style="text-align:${style?.textAlign || "left"}; padding:0;">
1028
- ${videoContent}
1029
- </td>
1030
- </tr>
1031
- </table>
1032
- </td>
1033
- </tr>
1034
- </table>
1173
+ const wrapperHtml = `
1174
+ <table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="margin:0; padding:0; border-collapse: collapse; max-width:600px;" class="${visibilityClass}">
1175
+ <tr>
1176
+ <td align="${style?.textAlign || "left"}" style="padding:0; ${outerContainerStyles}">
1177
+ <table border="0" cellpadding="0" cellspacing="0" role="presentation"
1178
+ align="${style?.textAlign || "left"}"
1179
+ style="
1180
+ margin:0;
1181
+ max-width:${cellWidthInPx}px;
1182
+ width:${percentWidth};
1183
+ border-collapse:collapse;
1184
+ ">
1185
+ <tr>
1186
+ <td align="${style?.textAlign || "left"}" style="text-align:${style?.textAlign || "left"}; padding:0;">
1187
+ ${videoContent}
1188
+ </td>
1189
+ </tr>
1190
+ </table>
1191
+ </td>
1192
+ </tr>
1193
+ </table>
1035
1194
  `;
1036
1195
  return wrapperHtml;
1037
1196
  }
@@ -1095,16 +1254,6 @@ async function convertShapeBlock(blockData) {
1095
1254
  justify: "justify",
1096
1255
  };
1097
1256
  const textAlignStyle = textAlignMap[textAlign] || "center";
1098
- const flexJustify = textAlign === "left"
1099
- ? "flex-start"
1100
- : textAlign === "right"
1101
- ? "flex-end"
1102
- : "center";
1103
- const flexAlign = verticalAlign === "top"
1104
- ? "flex-start"
1105
- : verticalAlign === "bottom"
1106
- ? "flex-end"
1107
- : "center";
1108
1257
  // --- Text styling ---
1109
1258
  const textSizeStyle = `font-size:${fontSize}px;line-height:1.3;word-break:break-word;overflow-wrap:break-word;color:${color};`;
1110
1259
  // ============================
@@ -1113,44 +1262,58 @@ async function convertShapeBlock(blockData) {
1113
1262
  let nonMsoContent = "";
1114
1263
  // --- Case 1: Image + Text ---
1115
1264
  if (imageUrl && text) {
1116
- nonMsoContent = `
1117
- <div style="display:inline-block;width:${resolvedWidthPx}px;height:${resolvedHeightPx}px;
1118
- border:${borderWidth}px ${borderStyle} ${borderColor};
1119
- border-radius:${resolvedBorderRadius};
1120
- background:${finalBackgroundColor} url('${imageUrl}') center/cover no-repeat;
1121
- overflow:hidden;${alignmentStyle}${customCss || ""}">
1122
- <div style="width:100%;height:100%;display:flex;justify-content:${flexJustify};align-items:${flexAlign};overflow:hidden;padding:6px;box-sizing:border-box;">
1123
- <div style="${textSizeStyle}text-align:${textAlignStyle};max-width:90%;overflow:hidden;">
1124
- ${text}
1125
- </div>
1126
- </div>
1265
+ nonMsoContent = `
1266
+ <div style="display:inline-block;width:${resolvedWidthPx}px;height:${resolvedHeightPx}px;
1267
+ border:${borderWidth}px ${borderStyle} ${borderColor};
1268
+ border-radius:${resolvedBorderRadius};
1269
+ background-color:${finalBackgroundColor};
1270
+ background-image:url('${imageUrl}');
1271
+ background-position:center center;
1272
+ background-size:cover;
1273
+ background-repeat:no-repeat;
1274
+ overflow:hidden;${alignmentStyle}${customCss || ""}">
1275
+ <table border="0" cellpadding="0" cellspacing="0" width="${resolvedWidthPx}"
1276
+ style="width:${resolvedWidthPx}px;height:${resolvedHeightPx}px;border-collapse:collapse;">
1277
+ <tr>
1278
+ <td align="${textAlignStyle}" valign="${verticalAlign}"
1279
+ width="${resolvedWidthPx}" height="${resolvedHeightPx}"
1280
+ style="padding:6px;vertical-align:${verticalAlign};text-align:${textAlignStyle};overflow:hidden;box-sizing:border-box;">
1281
+ <div style="${textSizeStyle}text-align:${textAlignStyle};max-width:90%;overflow:hidden;">${text}</div>
1282
+ </td>
1283
+ </tr>
1284
+ </table>
1127
1285
  </div>`;
1128
1286
  }
1129
1287
  // --- Case 2: Image only ---
1130
1288
  else if (imageUrl) {
1131
- nonMsoContent = `
1132
- <div style="display:inline-block;width:${resolvedWidthPx}px;height:${resolvedHeightPx}px;
1133
- border:${borderWidth}px ${borderStyle} ${borderColor};
1134
- border-radius:${resolvedBorderRadius};
1135
- overflow:hidden;${alignmentStyle}${customCss || ""}">
1136
- <img src="${imageUrl}" alt="${text || "shape image"}"
1137
- width="${resolvedWidthPx}" height="${resolvedHeightPx}"
1138
- style="width:100%;height:100%;object-fit:cover;border-radius:${resolvedBorderRadius};display:block;" />
1289
+ nonMsoContent = `
1290
+ <div style="display:inline-block;width:${resolvedWidthPx}px;height:${resolvedHeightPx}px;
1291
+ border:${borderWidth}px ${borderStyle} ${borderColor};
1292
+ border-radius:${resolvedBorderRadius};
1293
+ overflow:hidden;${alignmentStyle}${customCss || ""}">
1294
+ <img src="${imageUrl}" alt="${text || "shape image"}"
1295
+ width="${resolvedWidthPx}" height="${resolvedHeightPx}"
1296
+ style="width:100%;height:100%;object-fit:cover;border-radius:${resolvedBorderRadius};display:block;" />
1139
1297
  </div>`;
1140
1298
  }
1141
1299
  // --- Case 3: Text only ---
1142
1300
  else {
1143
- nonMsoContent = `
1144
- <div style="display:inline-block;width:${resolvedWidthPx}px;height:${resolvedHeightPx}px;
1145
- background:${finalBackgroundColor};
1146
- border:${borderWidth}px ${borderStyle} ${borderColor};
1147
- border-radius:${resolvedBorderRadius};
1148
- overflow:hidden;${alignmentStyle}${customCss || ""}">
1149
- <div style="width:100%;height:100%;display:flex;justify-content:${flexJustify};align-items:${flexAlign};padding:8px;box-sizing:border-box;">
1150
- <div style="${textSizeStyle}text-align:${textAlignStyle};max-width:90%;overflow:hidden;">
1151
- ${text || ""}
1152
- </div>
1153
- </div>
1301
+ nonMsoContent = `
1302
+ <div style="display:inline-block;width:${resolvedWidthPx}px;height:${resolvedHeightPx}px;
1303
+ background-color:${finalBackgroundColor};
1304
+ border:${borderWidth}px ${borderStyle} ${borderColor};
1305
+ border-radius:${resolvedBorderRadius};
1306
+ overflow:hidden;${alignmentStyle}${customCss || ""}">
1307
+ <table border="0" cellpadding="0" cellspacing="0" width="${resolvedWidthPx}"
1308
+ style="width:${resolvedWidthPx}px;height:${resolvedHeightPx}px;border-collapse:collapse;">
1309
+ <tr>
1310
+ <td align="${textAlignStyle}" valign="${verticalAlign}"
1311
+ width="${resolvedWidthPx}" height="${resolvedHeightPx}"
1312
+ style="padding:8px;vertical-align:${verticalAlign};text-align:${textAlignStyle};box-sizing:border-box;">
1313
+ <div style="${textSizeStyle}text-align:${textAlignStyle};max-width:90%;overflow:hidden;">${text || ""}</div>
1314
+ </td>
1315
+ </tr>
1316
+ </table>
1154
1317
  </div>`;
1155
1318
  }
1156
1319
  // Outlook (VML) fallback
@@ -1172,17 +1335,45 @@ async function convertShapeBlock(blockData) {
1172
1335
  padding,
1173
1336
  msoBakeImageWithText,
1174
1337
  }, visibilityClass);
1338
+ // Embed block metadata so the HTML importer can reconstruct the Shape block exactly.
1339
+ const shapeProps = encodeBlockProps({
1340
+ shape,
1341
+ width: resolvedWidthPx,
1342
+ height: resolvedHeightPx,
1343
+ shapeColor: String(finalBackgroundColor || '#BEBEBE'),
1344
+ backgroundColor: String(finalBackgroundColor || '#BEBEBE'),
1345
+ borderRadius: borderRadius !== undefined ? borderRadius : 0,
1346
+ borderWidth: borderWidth || 0,
1347
+ borderColor: borderColor || 'transparent',
1348
+ borderStyle: borderStyle || 'solid',
1349
+ imageUrl: imageUrl || '',
1350
+ text: text || '',
1351
+ color: String(color || '#000000'),
1352
+ fontSize: fontSize || 14,
1353
+ textAlign: textAlignStyle,
1354
+ verticalAlign: verticalAlign || 'middle',
1355
+ alignment: alignment || 'left',
1356
+ padding: {
1357
+ top: padding.top || 0,
1358
+ right: padding.right || 0,
1359
+ bottom: padding.bottom || 0,
1360
+ left: padding.left || 0,
1361
+ },
1362
+ hideOnDesktop: Boolean(props.hideOnDesktop),
1363
+ hideOnMobile: Boolean(props.hideOnMobile),
1364
+ customCss: customCss || '',
1365
+ });
1175
1366
  // Combine into table wrapper
1176
- return `
1177
- <table width="100%" style="border-collapse:collapse;table-layout:fixed;max-width:600px;" class="${visibilityClass}">
1178
- <tr>
1179
- <td style="padding:${padding.top || 0}px ${padding.right || 0}px ${padding.bottom || 0}px ${padding.left || 0}px;text-align:${alignment};">
1180
- ${outlookContent}
1181
- <!--[if !mso]><!-->
1182
- ${nonMsoContent}
1183
- <!--<![endif]-->
1184
- </td>
1185
- </tr>
1367
+ return `
1368
+ <table width="100%" style="border-collapse:collapse;table-layout:fixed;max-width:600px;" class="${visibilityClass}" data-block-type="shape" data-block-props="${shapeProps}">
1369
+ <tr>
1370
+ <td style="padding:${padding.top || 0}px ${padding.right || 0}px ${padding.bottom || 0}px ${padding.left || 0}px;text-align:${alignment};">
1371
+ ${outlookContent}
1372
+ <!--[if !mso]><!-->
1373
+ ${nonMsoContent}
1374
+ <!--<![endif]-->
1375
+ </td>
1376
+ </tr>
1186
1377
  </table>`;
1187
1378
  }
1188
1379
  // ---------- Updated VML builder ----------
@@ -1215,24 +1406,24 @@ function buildVMLShape({ shape, widthPx, heightPx, imageUrl, backgroundColor, bo
1215
1406
  const safeFontSize = Math.max(Math.round(textSize), 10);
1216
1407
  // Build the textbox with table/cell for reliable vertical centering in Outlook
1217
1408
  const textboxMarkup = text && !msoHasBakedText
1218
- ? `<v:textbox inset="6pt,6pt,6pt,6pt" style="mso-fit-shape-to-text:false;">
1219
- <div style="display:table;width:100%;height:100%;">
1220
- <div style="display:table-cell;vertical-align:${vAlign};text-align:${hAlign};padding:0 6px;">
1221
- <div style="color:${textColor};font-family:Arial, sans-serif;font-size:${safeFontSize}px;line-height:1.3;word-wrap:break-word;">
1222
- ${text}
1223
- </div>
1224
- </div>
1225
- </div>
1409
+ ? `<v:textbox inset="6pt,6pt,6pt,6pt" style="mso-fit-shape-to-text:false;">
1410
+ <div style="display:table;width:100%;height:100%;">
1411
+ <div style="display:table-cell;vertical-align:${vAlign};text-align:${hAlign};padding:0 6px;">
1412
+ <div style="color:${textColor};font-family:Arial, sans-serif;font-size:${safeFontSize}px;line-height:1.3;word-wrap:break-word;">
1413
+ ${text}
1414
+ </div>
1415
+ </div>
1416
+ </div>
1226
1417
  </v:textbox>`
1227
1418
  : `<v:textbox inset="0,0,0,0"><div style="display:none;">.</div></v:textbox>`;
1228
1419
  // Return VML shape
1229
- return `
1230
- <v:${tag} xmlns:v="urn:schemas-microsoft-com:vml"
1231
- style="width:${widthPx}px;height:${heightPx}px;display:inline-block;"
1232
- ${borderAttrs}
1233
- fill="true" fillcolor="${fillColor}"${extraAttr}>
1234
- ${fillMarkup}
1235
- ${textboxMarkup}
1420
+ return `
1421
+ <v:${tag} xmlns:v="urn:schemas-microsoft-com:vml"
1422
+ style="width:${widthPx}px;height:${heightPx}px;display:inline-block;"
1423
+ ${borderAttrs}
1424
+ fill="true" fillcolor="${fillColor}"${extraAttr}>
1425
+ ${fillMarkup}
1426
+ ${textboxMarkup}
1236
1427
  </v:${tag}>`;
1237
1428
  }
1238
1429
  function appendOutlookForShape(content, outerContainerWidth, innerContainerWidth, opts, visibilityClass) {
@@ -1260,28 +1451,28 @@ function appendOutlookForShape(content, outerContainerWidth, innerContainerWidth
1260
1451
  const shouldHideInOutlook = visibilityClass.includes("hide-desktop");
1261
1452
  // Fix: Properly handle Outlook visibility with conditional comments
1262
1453
  if (shouldHideInOutlook) {
1263
- return `<!--[if !mso]><!-->
1264
- <table align="${align}" border="0" cellpadding="0" cellspacing="0"
1265
- style="width:${widthPx}px;height:${heightPx}px;border-collapse:collapse;" class="${visibilityClass}">
1266
- <tr>
1267
- <td valign="${valign}"
1268
- style="padding:${pad.top || 0}px ${pad.right || 0}px ${pad.bottom || 0}px ${pad.left || 0}px;">
1269
- ${vml}
1270
- </td>
1271
- </tr>
1272
- </table>
1454
+ return `<!--[if !mso]><!-->
1455
+ <table align="${align}" border="0" cellpadding="0" cellspacing="0"
1456
+ style="width:${widthPx}px;height:${heightPx}px;border-collapse:collapse;" class="${visibilityClass}">
1457
+ <tr>
1458
+ <td valign="${valign}"
1459
+ style="padding:${pad.top || 0}px ${pad.right || 0}px ${pad.bottom || 0}px ${pad.left || 0}px;">
1460
+ ${vml}
1461
+ </td>
1462
+ </tr>
1463
+ </table>
1273
1464
  <!--<![endif]-->`;
1274
1465
  }
1275
- return `<!--[if mso]>
1276
- <table align="${align}" border="0" cellpadding="0" cellspacing="0"
1277
- style="width:${widthPx}px;height:${heightPx}px;border-collapse:collapse;" class="${visibilityClass}">
1278
- <tr>
1279
- <td valign="${valign}"
1280
- style="padding:${pad.top || 0}px ${pad.right || 0}px ${pad.bottom || 0}px ${pad.left || 0}px;">
1281
- ${vml}
1282
- </td>
1283
- </tr>
1284
- </table>
1466
+ return `<!--[if mso]>
1467
+ <table align="${align}" border="0" cellpadding="0" cellspacing="0"
1468
+ style="width:${widthPx}px;height:${heightPx}px;border-collapse:collapse;" class="${visibilityClass}">
1469
+ <tr>
1470
+ <td valign="${valign}"
1471
+ style="padding:${pad.top || 0}px ${pad.right || 0}px ${pad.bottom || 0}px ${pad.left || 0}px;">
1472
+ ${vml}
1473
+ </td>
1474
+ </tr>
1475
+ </table>
1285
1476
  <![endif]-->`;
1286
1477
  }
1287
1478
  function convertVerticalDividerBlockToHtml(blockData) {
@@ -1292,18 +1483,28 @@ function convertVerticalDividerBlockToHtml(blockData) {
1292
1483
  perChanges: [],
1293
1484
  pxChanges: allPxAttributes,
1294
1485
  });
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>
1486
+ const vDividerProps = encodeBlockProps({
1487
+ width: width || 5,
1488
+ height: height || 100,
1489
+ dividerColor: dividerColor || '#808080',
1490
+ backgroundColor: backgroundColor || '',
1491
+ alignment: 'left',
1492
+ padding: padding || { top: 0, right: 0, bottom: 0, left: 0 },
1493
+ hideOnDesktop: Boolean(props.hideOnDesktop),
1494
+ hideOnMobile: Boolean(props.hideOnMobile),
1495
+ });
1496
+ return `
1497
+ <table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation"
1498
+ style="${exports.tableCommonStyle}; max-width:600px;" class="${visibilityClass}" data-block-type="vdivider" data-block-props="${vDividerProps}">
1499
+ <tr>
1500
+ <td style="${outerStyles}; text-align:center; vertical-align:middle;">
1501
+ <!--[if mso | IE]>
1502
+ <v:rect xmlns:v="urn:schemas-microsoft-com:vml" fillcolor="${dividerColor}" style="width:${width}px;height:${height}px;" stroke="f"></v:rect>
1503
+ <![endif]-->
1504
+ <!--[if !mso]><!-->
1505
+ <div style="display:inline-block;width:${width}px;height:${height}px;background:${dividerColor};line-height:0;font-size:0;">&nbsp;</div>
1506
+ <!--<![endif]-->
1507
+ </td>
1508
+ </tr>
1308
1509
  </table>`;
1309
1510
  }