@seliseblocks/mailcraft 0.2.16 → 0.2.19

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.
Files changed (78) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/DOCS.md +70 -67
  3. package/README.md +8 -8
  4. package/README.md.txt +8 -8
  5. package/dist/mailcraft-editor.bundle.js +89 -76
  6. package/dist/mailcraft-editor.bundle.js.map +3 -3
  7. package/examples/templates/activate-your-account.html +1 -1
  8. package/examples/templates/back-in-stock.html +4 -4
  9. package/examples/templates/cart-left-behind.html +3 -3
  10. package/examples/templates/community-giveaway.html +3 -3
  11. package/examples/templates/frontend-futures-invite.html +3 -3
  12. package/examples/templates/give-25-get-25.html +2 -2
  13. package/examples/templates/invoice-paid.html +3 -3
  14. package/examples/templates/meet-nova-launch.html +7 -7
  15. package/examples/templates/mega-weekend-sale.html +1 -1
  16. package/examples/templates/order-confirmed.html +6 -6
  17. package/examples/templates/rate-your-headphones.html +3 -3
  18. package/examples/templates/reset-your-password.html +1 -1
  19. package/examples/templates/thankyou-promo-code.html +3 -3
  20. package/examples/templates/the-sunday-brief.html +4 -4
  21. package/examples/templates/welcome-to-your-workspace.html +4 -4
  22. package/examples/templates/your-order-shipped.html +2 -2
  23. package/examples/templates/your-password-was-changed.html +2 -2
  24. package/examples/templates/your-signin-code.html +1 -1
  25. package/examples/vanilla.html +66 -66
  26. package/package.json +1 -1
  27. package/src/core/binder.js +10 -0
  28. package/src/core/blocks.js +3 -3
  29. package/src/core/editor-core.js +195 -6
  30. package/src/core/export.js +311 -22
  31. package/src/core/i18n/ar.js +2 -0
  32. package/src/core/i18n/bg.js +2 -0
  33. package/src/core/i18n/bn.js +2 -0
  34. package/src/core/i18n/ca.js +2 -0
  35. package/src/core/i18n/cs.js +2 -0
  36. package/src/core/i18n/da.js +2 -0
  37. package/src/core/i18n/de-CH.js +2 -0
  38. package/src/core/i18n/de.js +2 -0
  39. package/src/core/i18n/dz.js +2 -0
  40. package/src/core/i18n/el.js +2 -0
  41. package/src/core/i18n/en.js +2 -0
  42. package/src/core/i18n/es.js +2 -0
  43. package/src/core/i18n/et.js +2 -0
  44. package/src/core/i18n/fi.js +2 -0
  45. package/src/core/i18n/fr.js +2 -0
  46. package/src/core/i18n/hr.js +2 -0
  47. package/src/core/i18n/hu.js +2 -0
  48. package/src/core/i18n/index.js +83 -83
  49. package/src/core/i18n/it.js +2 -0
  50. package/src/core/i18n/lt.js +2 -0
  51. package/src/core/i18n/lv.js +2 -0
  52. package/src/core/i18n/nb.js +2 -0
  53. package/src/core/i18n/nl.js +2 -0
  54. package/src/core/i18n/pl.js +2 -0
  55. package/src/core/i18n/pt.js +2 -0
  56. package/src/core/i18n/ro.js +2 -0
  57. package/src/core/i18n/ru.js +2 -0
  58. package/src/core/i18n/sk.js +2 -0
  59. package/src/core/i18n/sl.js +2 -0
  60. package/src/core/i18n/sv.js +2 -0
  61. package/src/core/i18n/tr.js +2 -0
  62. package/src/core/i18n/uk.js +2 -0
  63. package/src/core/icons.js +17 -43
  64. package/src/core/ids.js +1 -1
  65. package/src/core/import-html.js +390 -20
  66. package/src/core/layout-style.js +25 -0
  67. package/src/core/parse.js +10 -10
  68. package/src/core/placeholder.js +15 -15
  69. package/src/core/sanitize.js +4 -1
  70. package/src/core/theme.js +22 -1
  71. package/src/core/variables.js +11 -11
  72. package/src/mailcraft-editor.js +11 -1
  73. package/src/render/block-body.js +129 -17
  74. package/src/render/canvas.js +35 -6
  75. package/src/render/fields.js +19 -0
  76. package/src/render/focus-preserve.js +158 -158
  77. package/src/render/screenshot.js +26 -3
  78. package/src/render/style.js +6 -1
@@ -87,6 +87,36 @@ export function msoHarden(html) {
87
87
  if (!/line-height:\s*[\d.]+px/.test(out)) return 'style="' + out + '"';
88
88
  return 'style="' + out + (out.trim().endsWith(';') ? '' : ';') + 'mso-line-height-rule:exactly;"';
89
89
  })
90
+ /*
91
+ * `mso-padding-alt` on the button cell. Word applies a cell's CSS padding
92
+ * inconsistently and ignores it on the anchor entirely, so the pill can
93
+ * shrink to bare text there; `mso-padding-alt` is the declaration it does
94
+ * honour, restating the same two values. Keyed on the exact shape the
95
+ * button renderer writes (block-body.js): a centred cell carrying a
96
+ * two-value padding AND a border-radius. Nothing else in the exporter
97
+ * produces that pair on a <td>.
98
+ */
99
+ .replace(/<td\b([^>]*)>/g, (m0, attrs) => {
100
+ // The whole tag, not just the style: the renderer writes align="center"
101
+ // AFTER the style attribute.
102
+ if (/mso-padding-alt/.test(attrs) || !/\balign="center"/.test(attrs) || !/\bborder-radius:/.test(attrs)) return m0;
103
+ const pm = attrs.match(/\bpadding:\s*(\d+(?:\.\d+)?px)\s+(\d+(?:\.\d+)?px)\s*;/);
104
+ if (!pm) return m0;
105
+ return '<td' + attrs.replace(/style="([^"]*)"/, (s0, css) => 'style="' + css + (css.trim().endsWith(';') ? '' : ';') + 'mso-padding-alt:' + pm[1] + ' ' + pm[2] + ';"') + '>';
106
+ })
107
+ /*
108
+ * `-ms-interpolation-mode:bicubic` on every image. Outlook's (and old
109
+ * IE's) default is nearest-neighbour, which turns a scaled photo into
110
+ * visible stair-steps; bicubic is the one switch that fixes it. Not a
111
+ * real CSS property, so CSSOM drops it on the canvas -- another string
112
+ * pass job, like the rest of this function.
113
+ */
114
+ .replace(/<img\b([^>]*)>/g, (m0, attrs) => {
115
+ if (/interpolation-mode/.test(attrs)) return m0;
116
+ return /style="/.test(attrs)
117
+ ? '<img' + attrs.replace(/style="([^"]*)"/, (s0, css) => 'style="' + css + (!css.trim() || css.trim().endsWith(';') ? '' : ';') + '-ms-interpolation-mode:bicubic;"') + '>'
118
+ : '<img' + attrs + ' style="-ms-interpolation-mode:bicubic;">';
119
+ })
90
120
  .replace(/<table\b([^>]*)>/g, (m0, attrs) => {
91
121
  if (/mso-table-lspace/.test(attrs)) return m0;
92
122
  const spacing = 'mso-table-lspace:0pt;mso-table-rspace:0pt;';
@@ -194,6 +224,9 @@ export function buildHtml(state, root, boxCss, opts) {
194
224
  * per block with no deduplication.
195
225
  */
196
226
  const need = { stack: false, twoUp: false, reverse: false, hideM: false, hideD: false };
227
+ // Set by the first row that emits a `v:rect`; decides whether the VML
228
+ // namespace is declared on <html> (see the msoHead comment below).
229
+ let usedVml = false;
197
230
  /**
198
231
  * One decision per row, made once: what goes on the row box (`<tr>`, or the
199
232
  * flex/grid wrapper) and what goes on each cell.
@@ -254,15 +287,84 @@ export function buildHtml(state, root, boxCss, opts) {
254
287
  if (b.type === 'condition' || b.type === 'loop') return logic.emit.get(b.id) || '';
255
288
  return '<div' + marker(b) + visClass(b.props) + ' style="' + boxCss(b.props) + '">' + grab(b.id) + '</div>';
256
289
  }).filter(Boolean).join('\n ') || '&nbsp;';
257
- const cells = r.cols.map((c) => {
290
+ /*
291
+ * A COLUMN's own background image -- the photo behind one half of a
292
+ * two-up row, which until now only the whole row could carry.
293
+ *
294
+ * Everything the row's image does (longhands not the shorthand, a solid
295
+ * rgba tint rather than a gradient layer, VML for Word) it does here for
296
+ * the same reasons, spelled out at the row's `bgUrl` below. What differs
297
+ * is where it lands: the painted element is the wrapper <div>, never the
298
+ * <td>, because the cell also carries the gutter padding and an image on
299
+ * it would run under the gap between the columns. That also rules out
300
+ * the `background=`/`bgcolor=` attribute pair -- those exist only on the
301
+ * cell -- so a column image is CSS plus VML, exactly like the row's
302
+ * `boxed` branch.
303
+ */
304
+ const colPadPx = (c) => (c.padY || 0) + 'px ' + (c.padX || 0) + 'px';
305
+ /** The column's own pixel width, for `v:rect` (which has no percentages). Content width less the row's side padding, split by span, less the gutter this cell gives up. */
306
+ const colPxOf = (c) => {
307
+ const padL = rp.pl ?? rp.px ?? 0;
308
+ const padR = rp.pr ?? rp.px ?? 0;
309
+ const innerPx = Math.max(20, (Number(t.width) || 620) - padL - padR);
310
+ return Math.max(20, Math.round(innerPx * ((c.span || 100) / 100)) - (rp.gap || 0));
311
+ };
312
+ /*
313
+ * One column's content inside its own paint.
314
+ *
315
+ * Used by all three layouts. The table path passes `allowVml` because a
316
+ * `<td>`'s pixel width is well defined there; a grid column is an equal
317
+ * fraction and a flex column can wrap, so neither offers the fixed
318
+ * number `v:rect` demands and both stay CSS-only -- Word still gets the
319
+ * ghost-cell table those layouts already emit.
320
+ *
321
+ * Before this was shared, the flex and grid branches called `colInner`
322
+ * raw: the canvas painted a column's background, border, radius and
323
+ * padding (render/canvas.js builds the same `host` wrapper for every
324
+ * layout) and the export dropped all four, so a two-card flex row was
325
+ * designed as cards and arrived as plain text.
326
+ */
327
+ const colPainted = (c, allowVml) => {
258
328
  // Column-level styling (bg/radius/inner padding) renders as a wrapper
259
329
  // <div> inside the cell so the gutter padding stays unpainted --
260
330
  // mirrors render/canvas.js's `host` wrapper.
261
- const inner = (c.bg || c.border || c.radius || c.padY || c.padX)
262
- ? '<div style="background:' + (c.bg || 'transparent') + ';' + (c.border ? 'border:' + c.border + 'px ' + (c.borderStyle || 'solid') + ' ' + (c.lineColor || '#e2e2e5') + ';' : '') + 'border-radius:' + (c.radius || 0) + 'px;padding:' + (c.padY || 0) + 'px ' + (c.padX || 0) + 'px">\n ' + colInner(c) + '\n </div>'
331
+ const cUrl = c.bgImage ? cssUrl(c.bgImage) : '';
332
+ const cPaint = cUrl
333
+ ? 'background-color:' + (c.bg || 'transparent') + ';'
334
+ + 'background-image:url(' + attrEsc(cUrl) + ');'
335
+ + 'background-size:' + (c.bgSize || 'cover') + ';'
336
+ + 'background-position:' + (c.bgPos || 'center') + ';'
337
+ + 'background-repeat:' + (c.bgRepeat || 'no-repeat') + ';'
338
+ : 'background:' + (c.bg || 'transparent') + ';';
339
+ const cOv = cUrl && c.overlay ? c.overlay / 100 : 0;
340
+ // The tint carries the padding for the same reason the row's does: the
341
+ // band a reader sees is the padded box, so a tint sized to the content
342
+ // would leave an untinted ring of bare photo around it.
343
+ const cBody = cOv
344
+ ? '<div style="background-color:rgba(20,22,24,' + cOv + ');padding:' + colPadPx(c) + ';">\n ' + colInner(c) + '\n </div>'
345
+ : colInner(c);
346
+ /*
347
+ * Word gets the column photo through VML -- but only where the ROW is
348
+ * not already painting one. Nesting a `v:rect` inside another shape's
349
+ * `v:textbox` is where Word's layout stops being predictable, and a
350
+ * row image plus a column image is a design that has already decided
351
+ * which one it wants on top. Every other client renders both.
352
+ */
353
+ let cPainted = cBody;
354
+ if (cUrl && allowVml && !rp.bgImage) {
355
+ usedVml = true;
356
+ const h = Math.max(120, (c.padY || 0) * 2 + Math.max(1, c.blocks.length) * 90);
357
+ cPainted = '<!--[if gte mso 9]><v:rect fill="true" stroke="false" style="width:' + colPxOf(c) + 'px;height:' + h + 'px;">'
358
+ + '<v:fill type="frame" src="' + attrEsc(cUrl) + '" color="' + (c.bg || '#ffffff') + '" />'
359
+ + '<v:textbox inset="0,0,0,0" style="mso-fit-shape-to-text:true"><![endif]-->'
360
+ + '\n ' + cBody
361
+ + '\n <!--[if gte mso 9]></v:textbox></v:rect><![endif]-->';
362
+ }
363
+ return (c.bg || c.bgImage || c.border || c.radius || c.padY || c.padX)
364
+ ? '<div style="' + cPaint + (c.border ? 'border:' + c.border + 'px ' + (c.borderStyle || 'solid') + ' ' + (c.lineColor || '#e2e2e5') + ';' : '') + 'border-radius:' + (c.radius || 0) + 'px;padding:' + (cOv ? '0px 0px' : colPadPx(c)) + '">\n ' + cPainted + '\n </div>'
263
365
  : colInner(c);
264
- return '<td' + (plan.cell ? ' class="' + plan.cell + '"' : '') + ' width="' + c.span + '%" valign="' + rp.valign + '" style="padding:0 ' + Math.round(rp.gap / 2) + 'px;">\n ' + inner + '\n </td>';
265
- }).join('\n ');
366
+ };
367
+ const cells = r.cols.map((c) => '<td' + (plan.cell ? ' class="' + plan.cell + '"' : '') + ' width="' + c.span + '%" valign="' + rp.valign + '" style="padding:0 ' + Math.round(rp.gap / 2) + 'px;">\n ' + colPainted(c, true) + '\n </td>').join('\n ');
266
368
  // The CSS-layout rows reach the same behaviour through their wrapper: one
267
369
  // class on the flex/grid container, so the markup stays exactly as it was
268
370
  // for every wide client.
@@ -271,20 +373,79 @@ export function buildHtml(state, root, boxCss, opts) {
271
373
  const mcr = markers && rp.layout && rp.layout !== 'columns'
272
374
  ? ' data-mcr="' + attrEsc(JSON.stringify({ layout: rp.layout, flexDir: rp.flexDir || 'row', justify: rp.justify || 'flex-start', alignItems: rp.alignItems || 'stretch', wrap: rp.wrap !== false, gridCols: rp.gridCols || 2, gap: rp.gap || 0, spans: r.cols.map((c) => c.span) })) + '"'
273
375
  : '';
376
+ /*
377
+ * Flex and grid rows ship as the div they are -- and Word, which knows
378
+ * neither, sees block children and stacks them. The ghost cells fix that
379
+ * without duplicating any content: each child is wrapped in an MSO-only
380
+ * <td>, so Word lays the same children out in a table row while every
381
+ * other client skips the conditional comments and gets the flex/grid.
382
+ * Grid breaks into a new ghost <tr> every `gridCols` children; a
383
+ * column-direction flex stacks (one child per ghost row), which is what
384
+ * it does everywhere else too. Comments are inert to the importer, so
385
+ * the round trip through `data-mcr` is unchanged.
386
+ */
387
+ const ghostCells = (children, widths, perRow) => {
388
+ let out = '<!--[if mso]><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><![endif]-->';
389
+ children.forEach((h, i) => {
390
+ if (i > 0 && i % perRow === 0) out += '<!--[if mso]></tr><tr><![endif]-->';
391
+ out += '<!--[if mso]><td width="' + widths[i] + '%" valign="top"><![endif]-->' + h + '<!--[if mso]></td><![endif]-->';
392
+ });
393
+ return out + '<!--[if mso]></tr></table><![endif]-->';
394
+ };
395
+ const gridN = Math.max(1, rp.gridCols || 2);
396
+ const stacksVertically = /column/.test(rp.flexDir || 'row');
397
+ const spanSum = r.cols.reduce((a, c) => a + (c.span || 0), 0) || 100;
274
398
  const cssBody = rp.layout === 'grid'
275
- ? '<div' + mcr + stackWrap + ' style="display:grid;grid-template-columns:repeat(' + (rp.gridCols || 2) + ',minmax(0,1fr));gap:' + rp.gap + 'px">\n ' + r.cols.map((c) => '<div>' + colInner(c) + '</div>').join('\n ') + '\n </div>'
276
- : '<div' + mcr + stackWrap + ' style="display:flex;flex-direction:' + (rp.flexDir || 'row') + ';justify-content:' + (rp.justify || 'flex-start') + ';align-items:' + (rp.alignItems || 'stretch') + ';flex-wrap:' + (rp.wrap ? 'wrap' : 'nowrap') + ';gap:' + rp.gap + 'px">\n ' + r.cols.map((c) => '<div style="flex:' + c.span + ' 1 auto;min-width:0">' + colInner(c) + '</div>').join('\n ') + '\n </div>';
399
+ ? '<div' + mcr + stackWrap + ' style="display:grid;grid-template-columns:repeat(' + gridN + ',minmax(0,1fr));gap:' + rp.gap + 'px">\n '
400
+ + ghostCells(r.cols.map((c) => '<div>' + colPainted(c, false) + '</div>'), r.cols.map(() => Math.floor(100 / gridN)), gridN)
401
+ + '\n </div>'
402
+ : '<div' + mcr + stackWrap + ' style="display:flex;flex-direction:' + (rp.flexDir || 'row') + ';justify-content:' + (rp.justify || 'flex-start') + ';align-items:' + (rp.alignItems || 'stretch') + ';flex-wrap:' + (rp.wrap ? 'wrap' : 'nowrap') + ';gap:' + rp.gap + 'px">\n '
403
+ + ghostCells(r.cols.map((c) => '<div style="flex:' + c.span + ' 1 auto;min-width:0">' + colPainted(c, false) + '</div>'),
404
+ r.cols.map((c) => (stacksVertically ? 100 : Math.round(((c.span || 0) / spanSum) * 100))), stacksVertically ? 1 : r.cols.length)
405
+ + '\n </div>';
277
406
  const body = rp.layout && rp.layout !== 'columns'
278
407
  ? cssBody
279
408
  // The `<tr>` is what becomes the flex container for two-up and reverse;
280
409
  // in the default one-up stack it carries no class at all, exactly as before.
281
410
  : '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr' + (plan.row ? ' class="' + plan.row + '"' : '') + '>\n ' + cells + '\n </tr></table>';
282
- const tdBg = rp.bgImage
283
- ? 'background-color:' + (rp.bg || t.contentBg || 'transparent') + ';background-image:' + (rp.overlay ? 'linear-gradient(rgba(20,22,24,' + (rp.overlay / 100) + '),rgba(20,22,24,' + (rp.overlay / 100) + ')),' : '') + 'url(&quot;' + cssUrl(rp.bgImage) + '&quot;);background-size:' + (rp.bgSize || 'cover') + ';background-position:' + (rp.bgPos || 'center') + ';background-repeat:' + (rp.bgRepeat || 'no-repeat') + ';'
411
+ /*
412
+ * A row's background image.
413
+ *
414
+ * Emitted as LONGHANDS, never the `background` shorthand, and never as a
415
+ * layered value. The old shape put the tint in front of the photo in one
416
+ * declaration --
417
+ * background-image:linear-gradient(rgba(20,22,24,.46),...),url("...")
418
+ * -- and outlook.com's sanitiser (which is what New Outlook for Windows
419
+ * and Outlook on the web both run) drops a declaration it cannot fully
420
+ * parse rather than salvaging the layers it understands. A comma list
421
+ * opening with `linear-gradient(...)` is exactly that, so the tint took
422
+ * the photo down with it and the row fell back to flat colour. The tint
423
+ * is now a solid rgba wrapper (`tinted` below), which every client that
424
+ * paints the image at all also paints.
425
+ *
426
+ * `background=` and `bgcolor=` restate the same two values as HTML
427
+ * attributes. They cost nothing, they outlive sanitisers that drop CSS
428
+ * wholesale, and the importer already reads `background` back
429
+ * (import-html.js `bgImageOf`). They can only ride the `<td>`: in the
430
+ * boxed branch the painted element is a `<div>`, where the attributes do
431
+ * not exist, so that branch stays CSS-only.
432
+ *
433
+ * The URL is unquoted. `cssUrl` percent-encodes quotes, parens, spaces
434
+ * and backslashes, so there is nothing left for a bare `url(...)` to trip
435
+ * over, and one less thing for a sanitiser to re-serialise wrongly.
436
+ */
437
+ const bgUrl = rp.bgImage ? cssUrl(rp.bgImage) : '';
438
+ const bgColor = rp.bg || t.contentBg || 'transparent';
439
+ const tdBg = bgUrl
440
+ ? 'background-color:' + bgColor + ';'
441
+ + 'background-image:url(' + attrEsc(bgUrl) + ');'
442
+ + 'background-size:' + (rp.bgSize || 'cover') + ';'
443
+ + 'background-position:' + (rp.bgPos || 'center') + ';'
444
+ + 'background-repeat:' + (rp.bgRepeat || 'no-repeat') + ';'
284
445
  // Falls all the way through to `transparent`: a row inherits the
285
446
  // content column's background, and that column may itself be unpainted
286
447
  // now that it can be -- 'background:;' is not a declaration.
287
- : 'background:' + (rp.bg || t.contentBg || 'transparent') + ';';
448
+ : 'background:' + bgColor + ';';
288
449
  const tdBorder = rowBorderCss(rp)
289
450
  + (rp.radius ? 'border-radius:' + rp.radius + 'px;' : '')
290
451
  + (rp.shadow ? 'box-shadow:' + rp.shadow + ';' : '');
@@ -296,13 +457,61 @@ export function buildHtml(state, root, boxCss, opts) {
296
457
  // the margin sits outside the painted box, exactly as the canvas draws
297
458
  // it. Rows using neither keep the old markup byte for byte.
298
459
  const boxed = rp.mt || rp.mr || rp.mb || rp.ml || rp.my || (rp.maxW && rp.maxW < 100);
460
+ /*
461
+ * The tint, as its own box. It has to carry the row's padding: the band a
462
+ * reader sees is the padded box, so a tint sized to the content alone
463
+ * would leave an untinted ring of bare photo around it. The painted
464
+ * element gives its padding up in exchange (`ownPad` below).
465
+ *
466
+ * `rgba()` is understood by every client that renders the image in the
467
+ * first place. Classic Outlook understands neither the CSS background nor
468
+ * rgba, and gets the photo through VML with no tint over it -- the one
469
+ * place the two paths differ.
470
+ */
471
+ const ov = bgUrl && rp.overlay ? rp.overlay / 100 : 0;
472
+ const ownPad = ov ? 'padding:0;' : 'padding:' + rowPad(rp) + ';';
473
+ const tinted = ov
474
+ ? '<div style="background-color:rgba(20,22,24,' + ov + ');padding:' + rowPad(rp) + ';">\n ' + body + '\n </div>'
475
+ : body;
476
+ /*
477
+ * VML, for the one engine that has never read a CSS background: Word,
478
+ * behind Outlook 2007-2021 and Classic Outlook for Microsoft 365. New
479
+ * Outlook skips conditional comments entirely, so none of this reaches
480
+ * the client this fix was written for -- it is the Classic half of the
481
+ * same feature, and it is free to carry.
482
+ *
483
+ * `v:rect` needs pixel dimensions, and a row's height is content-driven.
484
+ * The height here is therefore an ESTIMATE (padding plus a nominal 90px a
485
+ * block), which `mso-fit-shape-to-text` then treats as a floor rather
486
+ * than a clip: Word grows the shape to whatever the content actually
487
+ * needs. A row whose content is shorter than the estimate paints a taller
488
+ * photo band in Classic Outlook than elsewhere; that is the residual cost
489
+ * of not asking the author for a height.
490
+ */
491
+ const vmlWidth = boxed && rp.maxW && rp.maxW < 100 ? Math.round(t.width * (rp.maxW / 100)) : t.width;
492
+ const vmlOpen = () => {
493
+ usedVml = true;
494
+ const padT = rp.pt ?? rp.py ?? 0;
495
+ const padB = rp.pb ?? rp.py ?? 0;
496
+ const blockCount = r.cols.reduce((a, c) => a + c.blocks.length, 0);
497
+ const h = Math.max(120, padT + padB + Math.max(1, blockCount) * 90);
498
+ return '<!--[if gte mso 9]><v:rect fill="true" stroke="false" style="width:' + vmlWidth + 'px;height:' + h + 'px;">'
499
+ + '<v:fill type="frame" src="' + attrEsc(bgUrl) + '" color="' + (bgColor === 'transparent' ? '#ffffff' : bgColor) + '" />'
500
+ + '<v:textbox inset="0,0,0,0" style="mso-fit-shape-to-text:true"><![endif]-->';
501
+ };
502
+ const painted = bgUrl
503
+ ? vmlOpen() + '\n ' + tinted + '\n <!--[if gte mso 9]></v:textbox></v:rect><![endif]-->'
504
+ : tinted;
299
505
  if (boxed) {
300
506
  const cap = rp.maxW && rp.maxW < 100 ? 'max-width:' + rp.maxW + '%;' : '';
301
507
  // centerEmpty only when capped: an uncapped box spans the cell anyway,
302
508
  // and `auto` there would only read back as a lost slider value.
303
- return ' <tr>\n <td style="padding:0;">\n <div style="' + cap + 'margin:' + rowMargin(rp, !!cap) + ';padding:' + rowPad(rp) + ';' + tdBg + tdBorder + '">\n ' + body + '\n </div>\n </td>\n </tr>';
509
+ return ' <tr>\n <td style="padding:0;">\n <div style="' + cap + 'margin:' + rowMargin(rp, !!cap) + ';' + ownPad + tdBg + tdBorder + '">\n ' + painted + '\n </div>\n </td>\n </tr>';
304
510
  }
305
- return ' <tr>\n <td style="padding:' + rowPad(rp) + ';' + tdBg + tdBorder + '">\n ' + body + '\n </td>\n </tr>';
511
+ const tdAttrs = bgUrl
512
+ ? ' background="' + attrEsc(bgUrl) + '"' + (bgColor === 'transparent' ? '' : ' bgcolor="' + bgColor + '"')
513
+ : '';
514
+ return ' <tr>\n <td' + tdAttrs + ' style="' + ownPad + tdBg + tdBorder + '">\n ' + painted + '\n </td>\n </tr>';
306
515
  }).join('\n') + (logic.tail ? '\n ' + logic.tail : '');
307
516
  // The page section -- the full-width area the content column sits on. Its
308
517
  // padding is the band a mail client shows around the template, and `radius`
@@ -343,7 +552,36 @@ export function buildHtml(state, root, boxCss, opts) {
343
552
  */
344
553
  const ghostOpen = '<!--[if mso]><table role="presentation" width="' + t.width + '" cellpadding="0" cellspacing="0" border="0"><tr><td><![endif]-->';
345
554
  const ghostClose = '<!--[if mso]></td></tr></table><![endif]-->';
346
- const shell = '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%;max-width:' + t.width + 'px;background:' + (t.contentBg || 'transparent') + ';' + contentShape + '">\n' + rows + '\n </table>';
555
+ /*
556
+ * The content column's own paint. A background image here follows exactly
557
+ * the rules a row's does (see `tdBg` above): separate longhands, never the
558
+ * shorthand and never a layered value, an unquoted url, and the same two
559
+ * values repeated as `background=`/`bgcolor=` attributes for anything that
560
+ * drops CSS wholesale.
561
+ *
562
+ * This is the content column and not the page for one reason: it is already
563
+ * a <table>, the one element every client paints a background on. A page
564
+ * background has to ride <body>, and Gmail discards the body element
565
+ * outright -- so a page-level image is simply absent there, while this one
566
+ * renders everywhere `background-image` renders at all.
567
+ *
568
+ * No VML: `v:rect` needs a pixel height, and the content column's height is
569
+ * the whole email's. Classic Outlook gets `contentBg` flat, which is the
570
+ * same deal it has always had here.
571
+ */
572
+ const contentBgUrl = t.contentBgImage ? cssUrl(t.contentBgImage) : '';
573
+ const contentColor = t.contentBg || 'transparent';
574
+ const contentPaint = contentBgUrl
575
+ ? 'background-color:' + contentColor + ';'
576
+ + 'background-image:url(' + attrEsc(contentBgUrl) + ');'
577
+ + 'background-size:' + (t.contentBgSize || 'cover') + ';'
578
+ + 'background-position:' + (t.contentBgPos || 'center') + ';'
579
+ + 'background-repeat:' + (t.contentBgRepeat || 'no-repeat') + ';'
580
+ : 'background:' + contentColor + ';';
581
+ const contentAttrs = contentBgUrl
582
+ ? ' background="' + attrEsc(contentBgUrl) + '"' + (contentColor === 'transparent' ? '' : ' bgcolor="' + contentColor + '"')
583
+ : '';
584
+ const shell = '<table role="presentation"' + contentAttrs + ' width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%;max-width:' + t.width + 'px;' + contentPaint + contentShape + '">\n' + rows + '\n </table>';
347
585
  /*
348
586
  * The one embedded stylesheet in the document, and the only place the
349
587
  * exporter is not inline-styled -- a media query cannot be expressed
@@ -397,13 +635,17 @@ export function buildHtml(state, root, boxCss, opts) {
397
635
  + ' img { max-width:100% !important; height:auto !important; }\n'
398
636
  + '}\n</style>';
399
637
  /*
400
- * `xmlns:o` earns its place; `xmlns:v` does not. The Office namespace is
401
- * what makes `<o:OfficeDocumentSettings>` parse, and `PixelsPerInch` 96 is
402
- * a live bug fix rather than a legacy one: on a high-DPI Windows display
403
- * Outlook renders at 120dpi and scales the whole template about 25% larger
404
- * than authored. VML's namespace is deliberately absent -- nothing here
405
- * emits VML, and declaring a namespace for markup that never appears is
406
- * noise in every other client.
638
+ * `xmlns:o` always earns its place; `xmlns:v` earns it conditionally. The
639
+ * Office namespace is what makes `<o:OfficeDocumentSettings>` parse, and
640
+ * `PixelsPerInch` 96 is a live bug fix rather than a legacy one: on a
641
+ * high-DPI Windows display Outlook renders at 120dpi and scales the whole
642
+ * template about 25% larger than authored.
643
+ *
644
+ * VML's namespace ships only when a row actually emitted a `v:rect` for its
645
+ * background image. The old rule here -- "declaring a namespace for markup
646
+ * that never appears is noise in every other client" -- is unchanged; it is
647
+ * just that the markup now sometimes appears, so the namespace follows it
648
+ * rather than being absent on principle or present on every send.
407
649
  */
408
650
  const msoHead = '\n<!--[if mso]>\n<xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml>\n<![endif]-->';
409
651
  // `text-size-adjust` at 100%, never `none`: both stop a mobile client
@@ -429,6 +671,53 @@ export function buildHtml(state, root, boxCss, opts) {
429
671
  return '<a' + attrs + ' style="color:' + t.link + ';">';
430
672
  });
431
673
  };
432
- const bodyStyle = 'margin:0;padding:0;background:' + pageBg + ';font-family:' + t.font.replace(/"/g, "'") + ';color:' + t.text + ';-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:100%;text-size-adjust:100%;';
433
- return msoHarden(stampLinks('<!doctype html>\n<html lang="en" xmlns:o="urn:schemas-microsoft-com:office:office">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width,initial-scale=1">\n<meta name="color-scheme" content="light">\n<meta name="supported-color-schemes" content="light">\n<title>' + 'Email' + '</title>' + msoHead + stackCss + '\n</head>\n<body style="' + bodyStyle + '">\n<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:' + pageBg + ';">\n <tr><td align="center" style="padding:' + pagePad + ';">\n ' + ghostOpen + '\n ' + shell + '\n ' + ghostClose + '\n </td></tr>\n</table>\n</body>\n</html>'));
674
+ /*
675
+ * The preview line. Hidden by every trick the clients between them need
676
+ * (display:none for most, mso-hide for Word, zero size/opacity for the ones
677
+ * that ignore display), and padded out with zero-width joiners so a client
678
+ * that shows ~90 characters does not run on into the body copy after a
679
+ * short preheader. Escaped: it is author text in a markup context.
680
+ */
681
+ const preText = String(t.preheader || '').trim();
682
+ const preheader = preText
683
+ ? '\n<div style="display:none;font-size:1px;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;mso-hide:all;visibility:hidden;">'
684
+ + preText.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
685
+ + '&#847;&zwnj;&nbsp;'.repeat(40) + '</div>'
686
+ : '';
687
+ const docDir = t.dir === 'rtl' ? ' dir="rtl"' : '';
688
+ // `bgcolor` beside the CSS on both page wrappers, as the rows already carry:
689
+ // the attribute is what survives a client that drops the style attribute.
690
+ /*
691
+ * The page's paint, on <body> AND the full-width wrapper table. Same rules
692
+ * as every other background here: longhands, unquoted url, the two values
693
+ * repeated as attributes. The wrapper table is what makes an image render
694
+ * in Gmail, which discards the body element outright.
695
+ */
696
+ const pageBgUrl = t.bgImage ? cssUrl(t.bgImage) : '';
697
+ const pagePaint = pageBgUrl
698
+ ? 'background-color:' + pageBg + ';'
699
+ + 'background-image:url(' + attrEsc(pageBgUrl) + ');'
700
+ + 'background-size:' + (t.bgSize || 'cover') + ';'
701
+ + 'background-position:' + (t.bgPos || 'center') + ';'
702
+ + 'background-repeat:' + (t.bgRepeat || 'no-repeat') + ';'
703
+ : 'background:' + pageBg + ';';
704
+ const pageAttr = (pageBg && pageBg !== 'transparent' && !/^rgba\(/.test(pageBg) ? ' bgcolor="' + pageBg + '"' : '')
705
+ + (pageBgUrl ? ' background="' + attrEsc(pageBgUrl) + '"' : '');
706
+ /*
707
+ * The page image for Word, which reads neither the attribute nor the CSS.
708
+ * `v:background` is Word's own element for exactly this, and unlike the
709
+ * `v:rect` a row needs it takes NO dimensions -- it fills the page -- so
710
+ * there is no height to estimate. `frame` scales one copy to the page
711
+ * (the CSS `cover`); `tile` repeats it. First thing in the body, where
712
+ * Word expects it; every other client skips the conditional.
713
+ */
714
+ let pageVml = '';
715
+ if (pageBgUrl) {
716
+ usedVml = true;
717
+ const tile = (t.bgRepeat || 'no-repeat') !== 'no-repeat';
718
+ pageVml = '\n<!--[if gte mso 9]><v:background fill="true"><v:fill type="' + (tile ? 'tile' : 'frame') + '" src="' + attrEsc(pageBgUrl) + '"'
719
+ + (pageBg && pageBg !== 'transparent' && !/^rgba\(/.test(pageBg) ? ' color="' + pageBg + '"' : '') + ' /></v:background><![endif]-->';
720
+ }
721
+ const bodyStyle = 'margin:0;padding:0;' + pagePaint + 'font-family:' + t.font.replace(/"/g, "'") + ';color:' + t.text + ';-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:100%;text-size-adjust:100%;';
722
+ return msoHarden(stampLinks('<!doctype html>\n<html lang="en"' + docDir + ' xmlns:o="urn:schemas-microsoft-com:office:office"' + (usedVml ? ' xmlns:v="urn:schemas-microsoft-com:vml"' : '') + '>\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width,initial-scale=1">\n<meta name="color-scheme" content="light">\n<meta name="supported-color-schemes" content="light">\n<title>' + 'Email' + '</title>' + msoHead + stackCss + '\n</head>\n<body' + pageAttr + ' style="' + bodyStyle + '">' + pageVml + preheader + '\n<table role="presentation"' + pageAttr + ' width="100%" cellpadding="0" cellspacing="0" border="0" style="' + pagePaint + '">\n <tr><td align="center" style="padding:' + pagePad + ';">\n ' + ghostOpen + '\n ' + shell + '\n ' + ghostClose + '\n </td></tr>\n</table>\n</body>\n</html>'));
434
723
  }
@@ -193,6 +193,8 @@ export const AR = {
193
193
  'toast.imageReplaced': 'تم استبدال الصورة',
194
194
  'toast.imageAdded': 'تمت إضافة الصورة إلى اللوحة',
195
195
  'toast.htmlCopied': 'تم نسخ HTML إلى الحافظة',
196
+ 'toast.exportImageOne': 'صورة واحدة لن تظهر عند الإرسال — عنصر نائب أو مصدر data:/cid:',
197
+ 'toast.exportImageMany': '{count} صور لن تظهر عند الإرسال — عناصر نائبة أو مصادر data:/cid:',
196
198
  'toast.templateLoaded': 'تم تحميل {name}',
197
199
  'toast.snippetDefaultLabel': 'مقتطف',
198
200
  'toast.snippetInserted': 'تم إدراج {name}',
@@ -170,6 +170,8 @@ export const BG = {
170
170
  'toast.imageReplaced': 'Изображението е заменено',
171
171
  'toast.imageAdded': 'Изображението е добавено към платното',
172
172
  'toast.htmlCopied': 'HTML е копиран в клипборда',
173
+ 'toast.exportImageOne': '1 изображение няма да се показва при изпращане — заместващ или data:/cid: източник',
174
+ 'toast.exportImageMany': '{count} изображения няма да се показват при изпращане — заместващи или data:/cid: източници',
173
175
  'toast.templateLoaded': '{name} е зареден',
174
176
  'toast.snippetDefaultLabel': 'Откъс',
175
177
  'toast.snippetInserted': '{name} е вмъкнат',
@@ -192,6 +192,8 @@ export const BN = {
192
192
  'toast.imageReplaced': 'ইমেজ প্রতিস্থাপিত হয়েছে',
193
193
  'toast.imageAdded': 'ক্যানভাসে ইমেজ যোগ হয়েছে',
194
194
  'toast.htmlCopied': 'HTML ক্লিপবোর্ডে কপি হয়েছে',
195
+ 'toast.exportImageOne': '১টি ছবি পাঠানোর পর দেখা যাবে না — প্লেসহোল্ডার বা data:/cid: উৎস',
196
+ 'toast.exportImageMany': '{count}টি ছবি পাঠানোর পর দেখা যাবে না — প্লেসহোল্ডার বা data:/cid: উৎস',
195
197
  'toast.templateLoaded': '{name} লোড হয়েছে',
196
198
  'toast.snippetDefaultLabel': 'স্নিপেট',
197
199
  'toast.snippetInserted': '{name} বসানো হয়েছে',
@@ -170,6 +170,8 @@ export const CA = {
170
170
  'toast.imageReplaced': 'Imatge substituïda',
171
171
  'toast.imageAdded': 'Imatge afegida al llenç',
172
172
  'toast.htmlCopied': 'HTML copiat al porta-retalls',
173
+ 'toast.exportImageOne': '1 imatge no es mostrarà en enviar-se — origen de marcador de posició o data:/cid:',
174
+ 'toast.exportImageMany': '{count} imatges no es mostraran en enviar-se — orígens de marcador de posició o data:/cid:',
173
175
  'toast.templateLoaded': '{name} carregat',
174
176
  'toast.snippetDefaultLabel': 'Fragment',
175
177
  'toast.snippetInserted': '{name} inserit',
@@ -170,6 +170,8 @@ export const CS = {
170
170
  'toast.imageReplaced': 'Obrázek nahrazen',
171
171
  'toast.imageAdded': 'Obrázek přidán na plátno',
172
172
  'toast.htmlCopied': 'HTML zkopírováno do schránky',
173
+ 'toast.exportImageOne': '1 obrázek se po odeslání nezobrazí — zástupný nebo data:/cid: zdroj',
174
+ 'toast.exportImageMany': '{count} obrázků se po odeslání nezobrazí — zástupné nebo data:/cid: zdroje',
173
175
  'toast.templateLoaded': '{name} načteno',
174
176
  'toast.snippetDefaultLabel': 'Úryvek',
175
177
  'toast.snippetInserted': '{name} vloženo',
@@ -170,6 +170,8 @@ export const DA = {
170
170
  'toast.imageReplaced': 'Billede erstattet',
171
171
  'toast.imageAdded': 'Billede tilføjet til lærredet',
172
172
  'toast.htmlCopied': 'HTML kopieret til udklipsholder',
173
+ 'toast.exportImageOne': '1 billede vises ikke, når mailen sendes — pladsholder eller data:/cid:-kilde',
174
+ 'toast.exportImageMany': '{count} billeder vises ikke, når mailen sendes — pladsholder- eller data:/cid:-kilder',
173
175
  'toast.templateLoaded': '{name} indlæst',
174
176
  'toast.snippetDefaultLabel': 'Uddrag',
175
177
  'toast.snippetInserted': '{name} indsat',
@@ -170,6 +170,8 @@ export const DE_CH = {
170
170
  'toast.imageReplaced': 'Bild ersetzt',
171
171
  'toast.imageAdded': 'Bild zur Canvas hinzugefügt',
172
172
  'toast.htmlCopied': 'HTML in die Zwischenablage kopiert',
173
+ 'toast.exportImageOne': '1 Bild wird beim Versand nicht angezeigt — Platzhalter oder data:/cid:-Quelle',
174
+ 'toast.exportImageMany': '{count} Bilder werden beim Versand nicht angezeigt — Platzhalter- oder data:/cid:-Quellen',
173
175
  'toast.templateLoaded': '{name} geladen',
174
176
  'toast.snippetDefaultLabel': 'Snippet',
175
177
  'toast.snippetInserted': '{name} eingefügt',
@@ -170,6 +170,8 @@ export const DE = {
170
170
  'toast.imageReplaced': 'Bild ersetzt',
171
171
  'toast.imageAdded': 'Bild zur Canvas hinzugefügt',
172
172
  'toast.htmlCopied': 'HTML in die Zwischenablage kopiert',
173
+ 'toast.exportImageOne': '1 Bild wird beim Versand nicht angezeigt — Platzhalter oder data:/cid:-Quelle',
174
+ 'toast.exportImageMany': '{count} Bilder werden beim Versand nicht angezeigt — Platzhalter- oder data:/cid:-Quellen',
173
175
  'toast.templateLoaded': '{name} geladen',
174
176
  'toast.snippetDefaultLabel': 'Snippet',
175
177
  'toast.snippetInserted': '{name} eingefügt',
@@ -195,6 +195,8 @@ export const DZ = {
195
195
  'toast.imageReplaced': 'གཟུགས་བརྙན་ཚབ་བཙུགས་བྱུང',
196
196
  'toast.imageAdded': 'ཐིག་ཁྲམ་ལུ་གཟུགས་བརྙན་ཁ་སྣོན་བྱུང',
197
197
  'toast.htmlCopied': 'HTML ཀ་ལིཔ་བོརཌ་ལུ་འདྲ་བཤུས་བྱུང',
198
+ 'toast.exportImageOne': 'པར་ ༡ གཏང་བའི་སྐབས་ མི་སྟོན། — ཚབ་མའི་ ཡང་ན་ data:/cid: འབྱུང་ཁུངས།',
199
+ 'toast.exportImageMany': 'པར་ {count} གཏང་བའི་སྐབས་ མི་སྟོན། — ཚབ་མའི་ ཡང་ན་ data:/cid: འབྱུང་ཁུངས།',
198
200
  'toast.templateLoaded': '{name} འདྲེན་སྤྱོད་བྱུང',
199
201
  'toast.snippetDefaultLabel': 'ཡིག་ཐིག',
200
202
  'toast.snippetInserted': '{name} བཙུགས་བྱུང',
@@ -170,6 +170,8 @@ export const EL = {
170
170
  'toast.imageReplaced': 'Η εικόνα αντικαταστάθηκε',
171
171
  'toast.imageAdded': 'Η εικόνα προστέθηκε στον καμβά',
172
172
  'toast.htmlCopied': 'Το HTML αντιγράφηκε στο πρόχειρο',
173
+ 'toast.exportImageOne': '1 εικόνα δεν θα εμφανιστεί κατά την αποστολή — προσωρινή πηγή ή πηγή data:/cid:',
174
+ 'toast.exportImageMany': '{count} εικόνες δεν θα εμφανιστούν κατά την αποστολή — προσωρινές πηγές ή πηγές data:/cid:',
173
175
  'toast.templateLoaded': 'Το {name} φορτώθηκε',
174
176
  'toast.snippetDefaultLabel': 'Απόσπασμα',
175
177
  'toast.snippetInserted': 'Το {name} εισήχθη',
@@ -223,6 +223,8 @@ export const EN = {
223
223
  'toast.imageReplaced': 'Image replaced',
224
224
  'toast.imageAdded': 'Image added to canvas',
225
225
  'toast.htmlCopied': 'HTML copied to clipboard',
226
+ 'toast.exportImageOne': '1 image will not display when sent — placeholder or data:/cid: source',
227
+ 'toast.exportImageMany': '{count} images will not display when sent — placeholder or data:/cid: sources',
226
228
  'toast.templateLoaded': '{name} loaded',
227
229
  'toast.snippetDefaultLabel': 'Snippet',
228
230
  'toast.snippetInserted': '{name} inserted',
@@ -170,6 +170,8 @@ export const ES = {
170
170
  'toast.imageReplaced': 'Imagen reemplazada',
171
171
  'toast.imageAdded': 'Imagen añadida al lienzo',
172
172
  'toast.htmlCopied': 'HTML copiado al portapapeles',
173
+ 'toast.exportImageOne': '1 imagen no se mostrará al enviarse — origen de marcador de posición o data:/cid:',
174
+ 'toast.exportImageMany': '{count} imágenes no se mostrarán al enviarse — orígenes de marcador de posición o data:/cid:',
173
175
  'toast.templateLoaded': '{name} cargado',
174
176
  'toast.snippetDefaultLabel': 'Fragmento',
175
177
  'toast.snippetInserted': '{name} insertado',
@@ -170,6 +170,8 @@ export const ET = {
170
170
  'toast.imageReplaced': 'Pilt asendatud',
171
171
  'toast.imageAdded': 'Pilt lisatud lõuendile',
172
172
  'toast.htmlCopied': 'HTML kopeeritud lõikelauale',
173
+ 'toast.exportImageOne': '1 pilti ei kuvata saatmisel — kohatäite või data:/cid: allikas',
174
+ 'toast.exportImageMany': '{count} pilti ei kuvata saatmisel — kohatäite või data:/cid: allikad',
173
175
  'toast.templateLoaded': '{name} laaditud',
174
176
  'toast.snippetDefaultLabel': 'Katke',
175
177
  'toast.snippetInserted': '{name} lisatud',
@@ -170,6 +170,8 @@ export const FI = {
170
170
  'toast.imageReplaced': 'Kuva korvattu',
171
171
  'toast.imageAdded': 'Kuva lisätty kankaalle',
172
172
  'toast.htmlCopied': 'HTML kopioitu leikepöydälle',
173
+ 'toast.exportImageOne': '1 kuva ei näy lähetetyssä viestissä — paikkamerkki- tai data:/cid:-lähde',
174
+ 'toast.exportImageMany': '{count} kuvaa ei näy lähetetyssä viestissä — paikkamerkki- tai data:/cid:-lähteet',
173
175
  'toast.templateLoaded': '{name} ladattu',
174
176
  'toast.snippetDefaultLabel': 'Katkelma',
175
177
  'toast.snippetInserted': '{name} lisätty',
@@ -170,6 +170,8 @@ export const FR = {
170
170
  'toast.imageReplaced': 'Image remplacée',
171
171
  'toast.imageAdded': 'Image ajoutée au canevas',
172
172
  'toast.htmlCopied': 'HTML copié dans le presse-papiers',
173
+ 'toast.exportImageOne': '1 image ne s’affichera pas à l’envoi — source d’espace réservé ou data:/cid:',
174
+ 'toast.exportImageMany': '{count} images ne s’afficheront pas à l’envoi — sources d’espace réservé ou data:/cid:',
173
175
  'toast.templateLoaded': '{name} chargé',
174
176
  'toast.snippetDefaultLabel': 'Extrait',
175
177
  'toast.snippetInserted': '{name} inséré',
@@ -170,6 +170,8 @@ export const HR = {
170
170
  'toast.imageReplaced': 'Slika zamijenjena',
171
171
  'toast.imageAdded': 'Slika dodana na platno',
172
172
  'toast.htmlCopied': 'HTML kopiran u međuspremnik',
173
+ 'toast.exportImageOne': '1 slika neće biti prikazana pri slanju — rezervirano mjesto ili data:/cid: izvor',
174
+ 'toast.exportImageMany': '{count} slika neće biti prikazano pri slanju — rezervirana mjesta ili data:/cid: izvori',
173
175
  'toast.templateLoaded': '{name} učitano',
174
176
  'toast.snippetDefaultLabel': 'Isječak',
175
177
  'toast.snippetInserted': '{name} umetnuto',
@@ -170,6 +170,8 @@ export const HU = {
170
170
  'toast.imageReplaced': 'Kép kicserélve',
171
171
  'toast.imageAdded': 'Kép hozzáadva a rajzvászonhoz',
172
172
  'toast.htmlCopied': 'HTML a vágólapra másolva',
173
+ 'toast.exportImageOne': '1 kép nem jelenik meg az elküldött levélben — helyőrző vagy data:/cid: forrás',
174
+ 'toast.exportImageMany': '{count} kép nem jelenik meg az elküldött levélben — helyőrző vagy data:/cid: források',
173
175
  'toast.templateLoaded': '{name} betöltve',
174
176
  'toast.snippetDefaultLabel': 'Részlet',
175
177
  'toast.snippetInserted': '{name} beszúrva',