@seliseblocks/mailcraft 0.2.9 → 0.2.11

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.
@@ -29,6 +29,22 @@ function onlyChild(el, tag) {
29
29
  return el.children.length === 1 && el.firstElementChild.tagName === tag ? el.firstElementChild : null;
30
30
  }
31
31
 
32
+ /** Font stacks for equality only: CSSOM and the exporter disagree on quoting (`"DM Sans"` vs `'DM Sans'`), spacing and case, so families are compared with quotes stripped, whitespace collapsed and case folded. Never used as a value -- the original string is what gets stored. */
33
+ function fontKey(v) {
34
+ return String(v || '').replace(/["']/g, '').replace(/\s*,\s*/g, ',').replace(/\s+/g, ' ').trim().toLowerCase();
35
+ }
36
+
37
+ /** Whether `el` (its own style, or any styled descendant) declares a font family different from `family`. The guard that keeps a block-level font read from flattening a mixed-typography run: once a block owns a font, the renderer's overrideRichFont strips every descendant's family, so a run whose pieces genuinely differ must stay font-less at block level and keep its inline declarations instead. */
38
+ function mixedFamily(el, family) {
39
+ const key = fontKey(family);
40
+ const off = (v) => { const k = fontKey(v); return !!k && k !== key; };
41
+ if (el.style && off(el.style.fontFamily)) return true;
42
+ return !!el.querySelectorAll && Array.from(el.querySelectorAll('[style*="font-family"]')).some((d) => d.style && off(d.style.fontFamily));
43
+ }
44
+
45
+ /** The heading block's Condensed style ships as this exact stack (render/block-body.js); read back, it becomes the `font: 'condensed'` toggle again rather than an opaque per-block family. */
46
+ const CONDENSED_KEY = fontKey("'Arial Narrow', 'Helvetica Neue Condensed', Helvetica, Arial, sans-serif");
47
+
32
48
  function textAlignOf(el) {
33
49
  let n = el;
34
50
  while (n && n.nodeType === 1) {
@@ -135,14 +151,24 @@ function bgImageOf(el) {
135
151
  return (el.getAttribute && el.getAttribute('background')) || '';
136
152
  }
137
153
 
154
+ /** The exporter's own overlay idiom, read back: a section image ships as `linear-gradient(rgba(20,22,24,a),rgba(20,22,24,a)),url(...)` (core/export.js), and the alpha is the row's `overlay` percentage. Only that exact neutral-dark signature is folded back -- a foreign gradient says nothing about MailCraft's tint and stays out of the model, exactly as before. Without this the tint silently vanished on every save/reload while the photo survived. */
155
+ function overlayOf(el) {
156
+ const st = el.style;
157
+ if (!st) return 0;
158
+ const m = ((st.backgroundImage || '') + ' ' + (st.background || '')).match(/linear-gradient\(rgba\(20,\s*22,\s*24,\s*(0?\.\d+|1|0)\s*\)/);
159
+ return m ? Math.round(parseFloat(m[1]) * 100) : 0;
160
+ }
161
+
138
162
  /** Carries a wrapper's background image (hero photo sections) onto the rows it produced, mirroring applyBg -- fit/position/repeat come along when declared. */
139
163
  function applyBgImage(rows, el) {
140
164
  const url = bgImageOf(el);
141
165
  if (!url) return rows;
142
166
  const st = el.style || {};
167
+ const ov = overlayOf(el);
143
168
  rows.forEach((r) => {
144
169
  if (r.props.bgImage) return;
145
170
  r.props.bgImage = url;
171
+ if (ov) r.props.overlay = ov;
146
172
  if (st.backgroundSize) r.props.bgSize = st.backgroundSize;
147
173
  if (st.backgroundPosition) r.props.bgPos = st.backgroundPosition;
148
174
  if (st.backgroundRepeat) r.props.bgRepeat = st.backgroundRepeat;
@@ -212,12 +238,27 @@ function lineHeightRatio(raw, fontPx) {
212
238
  let ratio;
213
239
  if (raw.endsWith('%')) ratio = PX(raw) / 100;
214
240
  else if (raw.endsWith('em')) ratio = PX(raw); // already a multiplier of the font size, by definition
215
- else if (raw.endsWith('pt')) ratio = (PX(raw) * 4 / 3) / size;
216
- else if (raw.endsWith('px')) ratio = PX(raw) / size;
241
+ else if (raw.endsWith('pt')) ratio = snapRatio(PX(raw) * 4 / 3, size);
242
+ else if (raw.endsWith('px')) ratio = snapRatio(PX(raw), size);
217
243
  else ratio = parseFloat(raw);
218
244
  return Number.isFinite(ratio) && ratio > 0 && ratio <= 4 ? ratio : null;
219
245
  }
220
246
 
247
+ /** The exporter ships line-height as `round(ratio * size)` px (msoHarden needs a length Outlook will honour), so dividing back rarely lands on the ratio the slider actually held -- 1.6 at 16px ships as 26px and returned as 1.625, drifting further every save. Any ratio inside the half-pixel window reproduces the same px on the next export, so the round trip stays converged; the shortest-decimal one in that window is the value the slider can actually hold, i.e. the one the user chose. */
248
+ function snapRatio(px, size) {
249
+ if (!size || !px) return px / (size || 16);
250
+ const r = px / size;
251
+ // Coarsest grid first -- tenths, then the sliders' own 0.05 step, then
252
+ // hundredths -- because several grid values can sit inside the same
253
+ // half-pixel window (1.35 and 1.33 both ship as 36px at 27px type) and the
254
+ // coarser one is the value a slider actually lands on.
255
+ for (const step of [0.1, 0.05, 0.01]) {
256
+ const cand = Math.round(r / step) * step;
257
+ if (Math.abs(cand * size - px) < 0.5) return Math.round(cand * 100) / 100;
258
+ }
259
+ return r;
260
+ }
261
+
221
262
  function escapeText(s) {
222
263
  const d = document.createElement('div');
223
264
  d.textContent = s;
@@ -246,20 +287,27 @@ function classifyImage(el) {
246
287
  // <img style="width:100%" width="88">`), and trusting the percent first is
247
288
  // exactly what blew an 88px logo up to the full content width.
248
289
  const isPct = (v) => String(v || '').endsWith('%');
290
+ // A linked image carries its % width on the anchor, not the img: the
291
+ // renderer sizes the <a> and lets the img fill it (block-body.js explains
292
+ // why), so reading only the img saw `width:100%` and every linked image
293
+ // reloaded at full width.
294
+ const a = img.parentElement && img.parentElement.tagName === 'A' ? img.parentElement : null;
249
295
  const pxCandidates = [
250
296
  img.style.width, img.getAttribute('width'), img.style.maxWidth,
297
+ a && a.style ? a.style.width : '', a && a.style ? a.style.maxWidth : '',
251
298
  el !== img && el.style ? el.style.width : '', el !== img && el.style ? el.style.maxWidth : '',
252
299
  ];
253
300
  const pxHint = pxCandidates.find((v) => v && !isPct(v) && PX(v));
301
+ const pctHint = [a && a.style ? a.style.width : '', img.style.width].find((v) => isPct(v) && PX(v));
254
302
  let width = 100;
255
303
  if (pxHint) {
256
304
  // Convert to % of the nearest fixed-width ancestor.
257
305
  const colPx = ancestorPxWidth(img) || 600;
258
306
  width = Math.max(2, Math.min(100, Math.round((PX(pxHint) / colPx) * 100)));
259
- } else if (isPct(img.style.width) && PX(img.style.width)) {
260
- width = PX(img.style.width);
307
+ } else if (pctHint) {
308
+ width = PX(pctHint);
261
309
  }
262
- return blk('image', {
310
+ const over = {
263
311
  src: img.getAttribute('src') || '',
264
312
  alt: img.getAttribute('alt') || '',
265
313
  href,
@@ -268,7 +316,14 @@ function classifyImage(el) {
268
316
  // Always explicit: the block *default* is 10px, which quietly rounded
269
317
  // the corners of every imported image that had none.
270
318
  radius: PX(img.style.borderRadius) || 0,
271
- });
319
+ };
320
+ // The block's own spacing lives on the wrapper the exporter writes
321
+ // (`padding: py px`); unread, it reset to 0/0 on every save.
322
+ if (el !== img && el.style) {
323
+ const pd = paddingOf(el.style);
324
+ if (pd) { over.py = pd.py; over.px = pd.px; }
325
+ }
326
+ return blk('image', over);
272
327
  }
273
328
 
274
329
  function classifyButton(el) {
@@ -318,8 +373,11 @@ function classifyButton(el) {
318
373
  const over = {
319
374
  label: (a.textContent || '').trim() || 'Button',
320
375
  href: a.getAttribute('href') || '#',
321
- bg: st.backgroundColor || st.background || (pill.getAttribute && pill.getAttribute('bgcolor')) || '',
322
- color: a.style.color || st.color || '#ffffff',
376
+ // hexOf throughout: CSSOM serializes every hex the renderer wrote back as
377
+ // `rgb(...)`, and the raw string both fails the inspector's native picker
378
+ // and breaks the theme-equality folds' string comparisons.
379
+ bg: hexOf(st.backgroundColor || st.background || (pill.getAttribute && pill.getAttribute('bgcolor')) || ''),
380
+ color: hexOf(a.style.color || st.color || '#ffffff'),
323
381
  radius: radiusOf(st),
324
382
  py: (pad && pad.py) || 13,
325
383
  px: (pad && pad.px) || 26,
@@ -337,6 +395,11 @@ function classifyButton(el) {
337
395
  };
338
396
  const size = fontPx(st.fontSize) || fontPx(a.style.fontSize);
339
397
  if (size) over.size = size;
398
+ // The label's family sits on the anchor in this exporter's shape, on the
399
+ // pill in most foreign ones. The theme-equality fold in htmlToDoc turns a
400
+ // value that merely restates the document font back into "inherit".
401
+ const family = a.style.fontFamily || st.fontFamily || inheritedStyle(a, 'fontFamily');
402
+ if (family) over.fontFamily = family;
340
403
  // Outline buttons: transparent fill, the pill drawn by its border. The
341
404
  // border is looked for on the cell as well as the pill, because in the
342
405
  // one-cell shape the paint and the frame are on different elements -- the
@@ -353,9 +416,15 @@ const SOCIAL_HOSTS = /facebook|twitter|x\.com|instagram|linkedin|youtube|tiktok|
353
416
  /** A run of small image-links, mostly pointing at social networks (every footer's icon strip), becomes a native social block -- MailCraft draws its own icon art from the network name (alt text, or the link's hostname). Imported as loose images they'd neither line up nor be editable as a set. */
354
417
  function classifySocial(el) {
355
418
  if (!/^(DIV|TD|TABLE)$/.test(el.tagName)) return null;
356
- if ((el.textContent || '').trim()) return null;
357
419
  const anchors = Array.from(el.querySelectorAll('a'));
358
420
  if (anchors.length < 2) return null;
421
+ // Text is disqualifying only when it is *prose around* the icons. A strip
422
+ // with "Show network names" on carries each network's name in a span inside
423
+ // its anchor -- all of the wrapper's text -- and rejecting that shape sent
424
+ // the block to classifyMenu, which reloaded it as a menu of links.
425
+ const squash = (s) => String(s || '').replace(/\s+/g, '');
426
+ const ownText = squash(el.textContent);
427
+ if (ownText && ownText !== squash(anchors.map((a) => a.textContent).join(''))) return null;
359
428
  // `svg` alongside `img`: MailCraft's own social block renders inline-SVG
360
429
  // icons, so accepting both is what lets an exported strip round-trip back
361
430
  // into a social block instead of dissolving.
@@ -385,15 +454,37 @@ function classifySocial(el) {
385
454
  // closest match to how such strips actually look. The block *default*
386
455
  // (outlined boxes at 1.9x the icon size) both looked wrong and wrapped in
387
456
  // narrow footer columns.
388
- const aBg = a0.style && (a0.style.backgroundColor || a0.style.background);
457
+ // `background:transparent` is what the renderer writes on every NON-badge
458
+ // anchor -- it is the absence of a fill, not a fill. Reading it as painted
459
+ // flipped every Outline/Bare strip into filled Square badges on reload.
460
+ const paintOf = (a) => {
461
+ const v = (a.style && (a.style.backgroundColor || a.style.background)) || '';
462
+ return v === 'transparent' || /^rgba\(0,\s*0,\s*0,\s*0\)/.test(v) ? '' : v;
463
+ };
464
+ const aBg = paintOf(a0);
389
465
  const aBorder = a0.style ? PX(a0.style.borderWidth) || PX(a0.style.borderTopWidth) : 0;
390
466
  over.shape = aBg ? (String(a0.style.borderRadius || '').indexOf('50%') > -1 ? 'circle' : 'square') : (aBorder ? 'outline' : 'bare');
391
- const aColor = a0.style && a0.style.color;
392
- if (aColor) over.color = aColor; else over.palette = 'brand';
467
+ if (ownText) over.showLabel = true;
468
+ const label0 = a0.querySelector('span');
469
+ if (label0 && label0.style.fontFamily) over.fontFamily = label0.style.fontFamily;
470
+ // The block's one color: a badge's is its FILL (the anchor's text color is
471
+ // the auto-contrast ink painted over it -- reading that turned a blue badge
472
+ // strip white); outline/bare's is the icon color. Anchors that disagree
473
+ // with each other are the brand palette -- the custom palette paints every
474
+ // anchor alike, so per-network colors can only mean per-network brands.
475
+ const chromaOf = (a) => hexOf(aBg ? paintOf(a) : (a.style && a.style.color) || '');
476
+ const chroma = anchors.map(chromaOf).filter(Boolean);
477
+ if (chroma.length && chroma.some((c) => c !== chroma[0])) over.palette = 'brand';
478
+ else if (chroma[0]) over.color = chroma[0];
479
+ else over.palette = 'brand';
393
480
  // Icon spacing: inter-cell padding on image strips, anchor margins on
394
- // exported ones.
481
+ // exported ones. A cell only describes ICON spacing when it holds exactly
482
+ // one icon (the one-td-per-icon strip); a single cell around the whole
483
+ // strip is layout -- its padding is the row's gutter, and reading it here
484
+ // overwrote the block's own 18px with the row's 20 on every reload.
395
485
  const cell = a0.closest ? a0.closest('td') : null;
396
- const cellGap = cell && cell !== el && el.contains(cell) ? PX(cell.style.paddingRight) + PX(cell.style.paddingLeft) : 0;
486
+ const cellGap = cell && cell !== el && el.contains(cell) && cell.querySelectorAll('a').length === 1
487
+ ? PX(cell.style.paddingRight) + PX(cell.style.paddingLeft) : 0;
397
488
  const marginGap = a0.style ? PX(a0.style.marginRight) + PX(a0.style.marginLeft) : 0;
398
489
  const gap = cellGap || marginGap;
399
490
  if (gap) over.gap = gap;
@@ -403,7 +494,9 @@ function classifySocial(el) {
403
494
  /** A run of two or more sibling links with nothing else in the container (Beefree/MJML nav bars) is a menu block, not a text run -- as text, each link imports mid-paragraph with the wrapper's junk around it. */
404
495
  function classifyMenu(el) {
405
496
  if (el.tagName !== 'DIV' && el.tagName !== 'TD') return null;
406
- if (el.querySelector('img,table,div,p,input,h1,h2,h3,h4,h5,h6')) return null;
497
+ // `svg` in the blocker list: a labeled social strip is anchors of svg+span,
498
+ // and without it this classifier claimed the strip as a menu of links.
499
+ if (el.querySelector('img,svg,table,div,p,input,h1,h2,h3,h4,h5,h6')) return null;
407
500
  const anchors = Array.from(el.children).filter((c) => c.tagName === 'A');
408
501
  if (anchors.length < 2) return null;
409
502
  const linkText = anchors.map((a) => (a.textContent || '')).join('').replace(/\s+/g, '');
@@ -415,7 +508,13 @@ function classifyMenu(el) {
415
508
  const size = fontPx(inheritedStyle(anchors[0], 'fontSize'));
416
509
  if (size) over.size = size;
417
510
  const color = inheritedStyle(anchors[0], 'color');
418
- if (color) over.color = color;
511
+ if (color) over.color = hexOf(color);
512
+ const family = inheritedStyle(anchors[0], 'fontFamily');
513
+ if (family) over.fontFamily = family;
514
+ // Item spacing ships as symmetric horizontal margins on each anchor
515
+ // (`margin: 0 gap/2`); unread, a chosen gap snapped back to the default.
516
+ const mg = PX(anchors[0].style.marginLeft) + PX(anchors[0].style.marginRight);
517
+ if (mg) over.gap = mg;
419
518
  return blk('menu', over);
420
519
  }
421
520
 
@@ -425,7 +524,7 @@ function classifyDivider(el) {
425
524
  return blk('divider', {
426
525
  thickness: PX(el.style.height) || PX(el.style.borderTopWidth) || 1,
427
526
  lineStyle: borderStyleOf(el.style),
428
- color: el.style.backgroundColor || el.style.borderTopColor || el.style.borderColor || '#d9dade',
527
+ color: hexOf(el.style.backgroundColor || el.style.borderTopColor || el.style.borderColor) || '#d9dade',
429
528
  width: sw.endsWith('%') ? PX(sw) : 100,
430
529
  });
431
530
  }
@@ -436,7 +535,12 @@ function classifyDivider(el) {
436
535
  const bg = bar.style.backgroundColor || bar.style.background || bar.style.borderTopColor;
437
536
  if (!(h > 0 && h <= 10 && bg)) return null;
438
537
  const sw = bar.style.width || '';
439
- return blk('divider', { thickness: h, lineStyle: borderStyleOf(bar.style), color: bg, width: sw.endsWith('%') ? PX(sw) : 100 });
538
+ const over = { thickness: h, lineStyle: borderStyleOf(bar.style), color: hexOf(bg), width: sw.endsWith('%') ? PX(sw) : 100 };
539
+ // The wrapper's vertical padding is the block's own spacing (the exporter
540
+ // writes `padding: py 0` around the rule); a declared zero counts too, so
541
+ // a tightened divider does not spring back to the 14px default on reload.
542
+ if (bar !== el && el.style && el.style.paddingTop !== '') over.py = PX(el.style.paddingTop);
543
+ return blk('divider', over);
440
544
  }
441
545
 
442
546
  function classifySpacer(el) {
@@ -462,6 +566,23 @@ function classifyHeading(el) {
462
566
  const color = st.color || inheritedStyle(el.parentElement, 'color');
463
567
  if (color) over.color = hexOf(color);
464
568
  if (st.fontWeight) over.weight = st.fontWeight;
569
+ // The family, like color, may live on the section cell. The renderer's own
570
+ // Condensed stack folds back into the style toggle it came from; any other
571
+ // family is a per-block font (the theme-equality fold in htmlToDoc turns a
572
+ // restated document font back into "inherit").
573
+ const family = st.fontFamily || inheritedStyle(el.parentElement, 'fontFamily');
574
+ if (fontKey(family) === CONDENSED_KEY) over.font = 'condensed';
575
+ else if (family) over.fontFamily = family;
576
+ // Line spacing and the block's own padding, read the way size/color are --
577
+ // both have inspector sliders, and both silently reset to the defaults
578
+ // (1.12, 8/0) on every save before this.
579
+ const lh = lineHeightRatio(st.lineHeight || inheritedStyle(el.parentElement, 'lineHeight'), size);
580
+ if (lh) over.lh = lh;
581
+ const pd = paddingOf(st);
582
+ if (pd) { over.py = pd.py; over.px = pd.px; }
583
+ // A declared all-zero padding is a choice (paddingOf returns null for it);
584
+ // absence is what falls to the 8px default.
585
+ else if (st.paddingTop !== '') { over.py = 0; over.px = 0; }
465
586
  return blk('heading', over);
466
587
  }
467
588
 
@@ -473,10 +594,34 @@ function classifyList(el) {
473
594
  // and the export while every other path stripped them. The newline swap is
474
595
  // because `items` is a one-fragment-per-line string -- a linebreak inside a
475
596
  // source <li> would otherwise split it into two items on render.
476
- const items = Array.from(el.children).filter((c) => c.tagName === 'LI')
477
- .map((li) => cleanImportHtml(li.innerHTML).replace(/\n/g, ' '));
478
- if (!items.length) return null;
479
- return blk('list', { items: items.join('\n'), ordered: el.tagName === 'OL' });
597
+ const lis = Array.from(el.children).filter((c) => c.tagName === 'LI');
598
+ if (!lis.length) return null;
599
+ const over = { ordered: el.tagName === 'OL' };
600
+ // The list's own typography lives on the UL/OL (this exporter stamps it
601
+ // there; table emails may hang it on the cell above). Unread, a styled
602
+ // list reset to the 15px default ink on every save/reload.
603
+ const st = el.style;
604
+ const size = fontPx(st.fontSize) || fontPx(inheritedStyle(el.parentElement, 'fontSize'));
605
+ if (size) over.size = size;
606
+ const color = st.color || inheritedStyle(el.parentElement, 'color');
607
+ if (color) over.color = hexOf(color);
608
+ const lh = lineHeightRatio(st.lineHeight || inheritedStyle(el.parentElement, 'lineHeight'), size);
609
+ if (lh) over.lh = lh;
610
+ // Same mixed-run guard as a text block: a block-level family makes the
611
+ // renderer strip descendant families, so it is only claimed when no item
612
+ // declares a different one -- and a claimed family folds the items' own
613
+ // restated declarations away (same byte-stability reasoning as flush).
614
+ const family = st.fontFamily || inheritedStyle(el.parentElement, 'fontFamily');
615
+ if (family && !mixedFamily(el, family)) over.fontFamily = family;
616
+ over.items = lis
617
+ .map((li) => cleanImportHtml(li.innerHTML, over.fontFamily ? ['font-family'] : null).replace(/\n/g, ' '))
618
+ .join('\n');
619
+ // Declared zeros count: `padding-top:0` / `margin-bottom:0` are choices,
620
+ // absence is what falls to the defaults.
621
+ if (st.paddingTop !== '') over.py = PX(st.paddingTop);
622
+ const li0 = el.querySelector(':scope > li');
623
+ if (li0 && li0.style.marginBottom !== '') over.gap = PX(li0.style.marginBottom);
624
+ return blk('list', over);
480
625
  }
481
626
 
482
627
  function classifyTable(el) {
@@ -492,14 +637,98 @@ function classifyTable(el) {
492
637
  const cellStyle = firstCell && firstCell.style;
493
638
  const borderWidth = cellStyle ? borderSidesOf(cellStyle).width : 0;
494
639
  const borders = !!borderWidth;
495
- return blk('table', {
640
+ const over = {
496
641
  data, header, borders, borderWidth: borderWidth || 1,
497
642
  borderStyle: cellStyle ? borderStyleOf(cellStyle) : 'solid',
498
643
  lineColor: cellStyle ? (borderColorOf(cellStyle) || '#e2e8f0') : '#e2e8f0',
499
- });
644
+ };
645
+ // Presentation the renderer writes on the table and its cells, read back so
646
+ // a styled table stops resetting to the stock look on every save/reload.
647
+ const st = el.style;
648
+ const size = fontPx(st.fontSize);
649
+ if (size) over.size = size;
650
+ if (st.fontFamily) over.fontFamily = st.fontFamily;
651
+ const w = st.width || el.getAttribute('width') || '';
652
+ if (String(w).endsWith('%') && PX(w)) over.width = PX(w);
653
+ if (cellStyle) {
654
+ // The renderer's cell padding is `pad` on top, `pad*1.2` at the sides --
655
+ // the top edge is the stored prop. A declared zero is a choice.
656
+ if (cellStyle.paddingTop !== '') over.pad = PX(cellStyle.paddingTop);
657
+ if (cellStyle.textAlign) over.align = cellStyle.textAlign;
658
+ }
659
+ if (header) {
660
+ const hb = bgOf(rows[0]) || bgOf(firstCell);
661
+ if (hb && hb !== 'transparent') over.headBg = hb;
662
+ }
663
+ // Striping is claimed only from this renderer's own signature tint; with
664
+ // enough body rows to tell, its absence means the source is not striped --
665
+ // the old behaviour striped every imported table by default.
666
+ const stripeAt = header ? 2 : 0;
667
+ if (rows.length > stripeAt) {
668
+ over.striped = rows.some((r, i) => !(header && i === 0) && i % 2 === 0
669
+ && /rgba\(29,\s*31,\s*32/.test((r.style && (r.style.backgroundColor || r.style.background)) || ''));
670
+ }
671
+ return blk('table', over);
500
672
  }
501
673
 
502
- const CLASSIFIERS = [classifyImage, classifyButton, classifySocial, classifyMenu, classifyDivider, classifySpacer, classifyHeading, classifyList, classifyTable];
674
+ /** The svg block's export shape (core/export.js writes it directly, not via grab): a div whose only element child is an inline <svg> -- optionally through the width-carrying span the canvas draws -- and whose text is nothing but the svg's own. Read back as the block it was, code verbatim. Previously the sanitizer's whitelist had no SVG tags, so the run flushed empty and the block -- the whole row, when it stood alone -- silently vanished on every save. A bare <svg> at content level gets the same treatment. */
675
+ function classifySvg(el) {
676
+ const isSvg = (e) => !!e && String(e.tagName).toLowerCase() === 'svg';
677
+ const over = {};
678
+ let holder = el;
679
+ if (el.tagName === 'DIV' && el.children.length === 1) {
680
+ if (el.style.textAlign) over.align = el.style.textAlign;
681
+ if (el.style.paddingTop !== '') over.py = PX(el.style.paddingTop);
682
+ holder = el.firstElementChild;
683
+ if (holder.tagName === 'SPAN' && holder.children.length === 1 && isSvg(holder.firstElementChild)) {
684
+ const w = holder.style.width || '';
685
+ if (w.endsWith('%') && PX(w)) over.width = PX(w);
686
+ holder = holder.firstElementChild;
687
+ }
688
+ }
689
+ if (!isSvg(holder)) return null;
690
+ // Prose around the drawing means a text run with an svg glyph in it, not
691
+ // an svg block -- only the svg's own text (<text> labels etc.) may appear.
692
+ if ((el.textContent || '').trim() !== (holder.textContent || '').trim()) return null;
693
+ over.code = holder.outerHTML;
694
+ return blk('svg', over);
695
+ }
696
+
697
+ /**
698
+ * Fidelity markers (core/export.js): the few blocks whose rendered shape
699
+ * cannot be read back -- a countdown bakes its digits, a video is a linked
700
+ * image, a section box and a code sample are styled divs like any other, a
701
+ * raw-CSS block is a bare <style> -- ship `data-mc` (type) and `data-mcp`
702
+ * (the props the DOM itself does not carry). Trusted when present, with the
703
+ * content halves still read from the DOM and sanitized exactly like any
704
+ * import: a payload is data about a block, never markup to inject.
705
+ */
706
+ const MARKER_TYPES = { countdown: 1, video: 1, box: 1, codeblock: 1 };
707
+ function markerBlock(el) {
708
+ if (!el.getAttribute) return null;
709
+ const type = el.getAttribute('data-mc') || '';
710
+ if (type === 'css' && el.tagName === 'STYLE') {
711
+ const over = { code: el.textContent || '' };
712
+ const note = el.getAttribute('data-mcn');
713
+ if (note) over.note = note;
714
+ return blk('css', over);
715
+ }
716
+ if (!MARKER_TYPES[type]) return null;
717
+ let props = {};
718
+ try { props = JSON.parse(el.getAttribute('data-mcp') || '{}') || {}; } catch { props = {}; }
719
+ delete props.html; delete props.code; // content only ever comes from the DOM, sanitized
720
+ const inner = el.firstElementChild;
721
+ if (type === 'box') {
722
+ // The box's own display/margin pair is vouched for: its template writes
723
+ // `<strong style="display:block;margin-bottom:6px">` and the shared
724
+ // whitelist rightly refuses those from arbitrary paste.
725
+ props.html = cleanImportHtml(inner ? inner.innerHTML : '', null, ['display', 'margin', 'margin-top', 'margin-bottom']);
726
+ }
727
+ if (type === 'codeblock') props.code = (inner ? inner.textContent : el.textContent) || '';
728
+ return blk(type, props);
729
+ }
730
+
731
+ const CLASSIFIERS = [classifyImage, classifyButton, classifySocial, classifyMenu, classifyDivider, classifySpacer, classifySvg, classifyHeading, classifyList, classifyTable];
503
732
 
504
733
  function classifyNode(el) {
505
734
  for (const fn of CLASSIFIERS) {
@@ -524,7 +753,7 @@ function isStructural(el) {
524
753
  return /^(TABLE|FORM|IFRAME|SCRIPT|STYLE|VIDEO|OBJECT|EMBED)$/.test(el.tagName) || !!el.querySelector('table,form,iframe,script,video,object,embed');
525
754
  }
526
755
 
527
- /** `core/export.js` wraps every block in a column with `<div style="{boxCss(b.props)}">`, which for every block type shipped today (none define the `bBg/bBorder/bLine/bRadius/bPad` props `boxCss` reads) always resolves to a bare `<div style="margin:0">` -- a see-through spacing wrapper, not real content. Unwraps that one level so the classifiers see the block's own signature div directly; leaves any div that carries other styling (an intentionally-styled container someone pasted in) alone, since that's real content, not framework wrapper. */
756
+ /** `core/export.js` wraps every block in a column with `<div style="{boxCss(b.props)}">`, which resolves to a bare `<div style="margin:0">` for any block whose "Box & border" panel is untouched -- a see-through spacing wrapper, not real content. Unwraps that one level so the classifiers see the block's own signature div directly; leaves any div that carries other styling alone, since that's either a set box (read back by `boxPropsOf`) or an intentionally-styled container someone pasted in -- real content, not framework wrapper. */
528
757
  function unwrapBoxDiv(el) {
529
758
  if (el.tagName !== 'DIV' || el.children.length !== 1) return el;
530
759
  // A device-visibility wrapper is framework too, and the declarations that
@@ -571,6 +800,48 @@ function logicMarkersOf(text) {
571
800
  return out;
572
801
  }
573
802
 
803
+ /**
804
+ * The container styling of a `<div>` that paints a panel around its content.
805
+ *
806
+ * `cleanImportHtml`'s tag whitelist has no DIV (it never could have one --
807
+ * arbitrary paste brings div soup), so a `<div
808
+ * style="background-color:#eff6fc;border-radius:8px">` wrapping a run of
809
+ * text -- the verification-code panel every transactional template has --
810
+ * was flattened away on import and the box came back unpainted on the very
811
+ * first save. Read here instead, into the exact props `boxCss`/`boxStyle`
812
+ * already write for a block's own box, so the shape round-trips and the
813
+ * color lands under the inspector's "Box & border" controls where it can be
814
+ * edited.
815
+ *
816
+ * A color or a border is what makes a div a panel; a radius alone paints
817
+ * nothing, so it is picked up alongside but never claimed on its own.
818
+ * Padding is deliberately not read: it already reaches the block as its
819
+ * py/px run padding, which keeps the source's asymmetry (`18px 24px`) that
820
+ * the single-value `bPad` cannot express.
821
+ */
822
+ function boxPropsOf(el) {
823
+ if (!el || el.tagName !== 'DIV' || !el.style) return null;
824
+ const out = {};
825
+ const bg = bgOf(el);
826
+ // `background:transparent` is what the exporter writes on every styled
827
+ // column wrapper, and CSSOM hands the keyword back as `rgba(0, 0, 0, 0)`;
828
+ // claiming either as a box color paints nothing and only makes the run
829
+ // look like a panel.
830
+ if (bg && bg !== 'transparent' && !/^rgba\([^)]*,\s*0\s*\)$/.test(bg)) out.bBg = bg;
831
+ const frame = borderSidesOf(el.style);
832
+ if (frame.width) {
833
+ out.bBorder = frame.width;
834
+ out.bStyle = borderStyleOf(el.style);
835
+ out.bLine = borderColorOf(el.style) || '#e2e2e5';
836
+ out.bTop = frame.sides.top > 0; out.bRight = frame.sides.right > 0;
837
+ out.bBottom = frame.sides.bottom > 0; out.bLeft = frame.sides.left > 0;
838
+ }
839
+ if (!out.bBg && !out.bBorder) return null;
840
+ const radius = radiusOf(el.style);
841
+ if (radius) out.bRadius = radius;
842
+ return out;
843
+ }
844
+
574
845
  /** Whether an element (or the transparent single-child chain under it) carries its own padding -- the shape the exporter writes for a block's py/px, and a builder section's own spacing. Such a wrapper is one block, never part of a text run. */
575
846
  function hasOwnRunPad(el) {
576
847
  let e = el;
@@ -590,14 +861,34 @@ function blocksFromNodes(nodes) {
590
861
  const out = [];
591
862
  let buf = [];
592
863
  let bufFirstEl = null;
864
+ // Every element buffered into the current run, for the mixed-family check
865
+ // below: the first element alone cannot say whether a *later* piece of the
866
+ // run declares its own font.
867
+ let bufEls = [];
868
+ // The run's device visibility: the class sits on the box wrapper the
869
+ // exporter puts around a text block (never inside the sanitized html, which
870
+ // strips classes), so only this walk can carry it. `undefined` = not seen
871
+ // yet; '' = pieces disagreed, and a mixed run claims nothing rather than
872
+ // hide half its content along with the other half.
873
+ let bufVis;
874
+ const visUp = (start) => {
875
+ let e = start;
876
+ for (let i = 0; e && i < 4; i += 1) { const v = visibilityOf(e); if (v) return v; e = e.parentElement; }
877
+ return '';
878
+ };
593
879
  // The parent of a leading bare text node. A run with no element of its own
594
880
  // (`<td style="font-size:30px;...">{{Code}}</td>`) still has real
595
881
  // typography -- it lives on the ancestors, and with no bufFirstEl the
596
882
  // inheritedStyle reads below were skipped wholesale, so the run imported
597
883
  // at the theme default (a 30px/800 verification code became 16px plain).
598
884
  let bufTextEl = null;
885
+ // The container styling of the run's own wrapper (see `boxPropsOf`), kept
886
+ // out of the html because the sanitizer's whitelist has no DIV to hang it
887
+ // on.
888
+ let bufBox = null;
599
889
  const flush = () => {
600
- const html = cleanImportHtml(buf.join(''));
890
+ const raw = buf.join('');
891
+ const html = cleanImportHtml(raw);
601
892
  // A run with nothing visible in it -- no text beyond whitespace/hair
602
893
  // spaces, no image or line break (an `<nbsp;>` still counts as a blank
603
894
  // line someone wrote) -- is markup residue (`<p style="margin:0"></p>`),
@@ -613,6 +904,10 @@ function blocksFromNodes(nodes) {
613
904
  const weight = inheritedStyle(bufTextEl, 'fontWeight'); if (weight) over.weight = weight;
614
905
  const lh = lineHeightRatio(inheritedStyle(bufTextEl, 'lineHeight'), size);
615
906
  if (lh) over.lh = lh;
907
+ // A bare run has no inline tags to carry a family, so the ancestor's
908
+ // is the block's -- no mixed-run risk here.
909
+ const family = inheritedStyle(bufTextEl, 'fontFamily');
910
+ if (family) over.fontFamily = family;
616
911
  }
617
912
  if (bufFirstEl) {
618
913
  // The block's *base* style: the first buffered element when it's a
@@ -627,6 +922,7 @@ function blocksFromNodes(nodes) {
627
922
  // (it lands on the <p>), so a padded block came back at the default
628
923
  // 10px/0 on every save. First padding on the wrapper chain wins.
629
924
  let runPad = paddingOf(baseEl.style);
925
+ let padDeclared = !!baseEl.style && baseEl.style.paddingTop !== '';
630
926
  // Then descend through transparent single-child wrappers: builders
631
927
  // nest a `font-family:sans-serif` shim div around the div that
632
928
  // carries the real typography, and `inheritedStyle` below walks *up*
@@ -636,18 +932,45 @@ function blocksFromNodes(nodes) {
636
932
  baseEl.children.length === 1
637
933
  && !INLINE_TAGS.test(baseEl.firstElementChild.tagName)
638
934
  && (baseEl.textContent || '') === (baseEl.firstElementChild.textContent || '')
639
- ) { baseEl = baseEl.firstElementChild; if (!runPad) runPad = paddingOf(baseEl.style); }
935
+ ) { baseEl = baseEl.firstElementChild; if (!runPad) runPad = paddingOf(baseEl.style); padDeclared = padDeclared || baseEl.style.paddingTop !== ''; }
640
936
  if (runPad) { over.py = runPad.py; over.px = runPad.px; }
937
+ // A declared all-zero padding is a choice (paddingOf returns null for
938
+ // it); absence is what falls to the 10px default.
939
+ else if (padDeclared) { over.py = 0; over.px = 0; }
641
940
  const size = fontPx(inheritedStyle(baseEl, 'fontSize')); if (size) over.size = size;
642
941
  const color = inheritedStyle(baseEl, 'color'); if (color) over.color = hexOf(color);
643
942
  over.align = textAlignOf(baseEl);
644
943
  const weight = inheritedStyle(baseEl, 'fontWeight'); if (weight) over.weight = weight;
645
944
  const lh = lineHeightRatio(inheritedStyle(baseEl, 'lineHeight'), size);
646
945
  if (lh) over.lh = lh;
946
+ // The run's base family, read exactly like size/color -- but claimed
947
+ // at block level only when no piece of the run declares a different
948
+ // one, since the renderer strips descendant families the moment the
949
+ // block owns a font (overrideRichFont). A mixed run keeps its inline
950
+ // declarations instead, exactly as before. The theme-equality fold in
951
+ // htmlToDoc turns a restated document font back into "inherit".
952
+ const family = inheritedStyle(baseEl, 'fontFamily');
953
+ if (family && !bufEls.some((e) => mixedFamily(e, family))) {
954
+ over.fontFamily = family;
955
+ // Claimed means consumed: every family in the run restates the one
956
+ // just read (the mixed check above), so the inline declarations
957
+ // fold into the block prop instead of shipping twice. This is also
958
+ // what keeps export -> import -> export byte-stable -- left in the
959
+ // html, the renderer strips them from the live DOM (overrideRichFont)
960
+ // and the re-serialized attributes drift on the next save.
961
+ over.html = cleanImportHtml(raw, ['font-family']);
962
+ }
647
963
  }
964
+ // Same walk classifyNode does for recognized blocks; text runs never
965
+ // went through it, so a mobile-only paragraph reloaded visible
966
+ // everywhere.
967
+ if (bufVis) over.vis = bufVis;
968
+ // The wrapper's own box, last: these are block-level props, so nothing
969
+ // read off the content above can collide with them.
970
+ if (bufBox) Object.assign(over, bufBox);
648
971
  out.push(blk('text', over));
649
972
  }
650
- buf = []; bufFirstEl = null; bufTextEl = null;
973
+ buf = []; bufFirstEl = null; bufTextEl = null; bufEls = []; bufVis = undefined; bufBox = null;
651
974
  };
652
975
  nodes.forEach((n) => {
653
976
  if (n.nodeType === 3) {
@@ -655,12 +978,22 @@ function blocksFromNodes(nodes) {
655
978
  if (markers) { flush(); markers.forEach((mb) => out.push(mb)); return; }
656
979
  if (n.textContent && n.textContent.trim()) {
657
980
  if (!bufFirstEl && !bufTextEl) bufTextEl = n.parentElement;
981
+ const tv = visUp(n.parentElement);
982
+ bufVis = bufVis === undefined || bufVis === tv ? tv : '';
658
983
  buf.push(escapeText(n.textContent));
659
984
  }
660
985
  return;
661
986
  }
662
987
  if (n.nodeType !== 1) return;
663
988
  if (isHidden(n)) return;
989
+ const marked = markerBlock(n);
990
+ if (marked) {
991
+ flush();
992
+ const mv = visUp(n);
993
+ if (mv) marked.props.vis = mv;
994
+ out.push(marked);
995
+ return;
996
+ }
664
997
  const target = unwrapBoxDiv(n);
665
998
  const b = classifyNode(target);
666
999
  if (b) { flush(); out.push(b); return; }
@@ -690,10 +1023,29 @@ function blocksFromNodes(nodes) {
690
1023
  // run: the exporter writes every text block as exactly such a padded div,
691
1024
  // and buffering two of them together merged neighbouring blocks into one
692
1025
  // -- the second lost its padding, size, everything -- on every save.
693
- if (n.nodeType === 1 && !INLINE_TAGS.test(target.tagName) && hasOwnRunPad(target) && buf.length) flush();
1026
+ // A box-div unwrap (target !== n) marks a block boundary just as surely
1027
+ // as padding does: the exporter writes exactly one such wrapper per
1028
+ // block, and without this two zero-padded text blocks buffered into one
1029
+ // -- the second lost its size, weight, everything -- on every save.
1030
+ // A painted panel is one block, whatever it holds: its styling describes
1031
+ // the whole run, so it must neither merge with the prose around it (a
1032
+ // bg-only div carries no padding to make it a boundary on its own) nor
1033
+ // split, since only the first block of a split would keep the panel.
1034
+ const box = boxPropsOf(target);
1035
+ const boundary = n.nodeType === 1 && !INLINE_TAGS.test(target.tagName) && (hasOwnRunPad(target) || target !== n || !!box);
1036
+ if (boundary && buf.length) flush();
1037
+ // Claimed only when the panel opens the run, which after that flush is
1038
+ // always -- a box nested inside a longer run is content, and its color
1039
+ // must not be promoted to the block around it.
1040
+ if (box && !bufFirstEl) bufBox = box;
694
1041
  if (!bufFirstEl) bufFirstEl = target;
1042
+ bufEls.push(target);
1043
+ // Read off `n`, not `target`: the visibility class rides the wrapper
1044
+ // unwrapBoxDiv deliberately sees through.
1045
+ const ev = visUp(n);
1046
+ bufVis = bufVis === undefined || bufVis === ev ? ev : '';
695
1047
  buf.push(n.outerHTML);
696
- if (n.nodeType === 1 && !INLINE_TAGS.test(target.tagName) && hasOwnRunPad(target)) flush();
1048
+ if (boundary) flush();
697
1049
  });
698
1050
  flush();
699
1051
  return out;
@@ -868,7 +1220,10 @@ function unwrapNestedLayout(td) {
868
1220
  if (classifyButton(td) || classifySocial(only)) return null;
869
1221
  const trs = only.querySelectorAll(':scope > tbody > tr, :scope > tr');
870
1222
  if (trs.length !== 1) return null;
871
- if (only.querySelector('th')) return null;
1223
+ // Header cells veto the unwrap only on the candidate's OWN row: a th
1224
+ // anywhere deeper is some block's content (a data table in a column), and
1225
+ // vetoing on it left the gap cell around that block unread.
1226
+ if (only.querySelector(':scope > tbody > tr > th, :scope > tr > th')) return null;
872
1227
  const cells = Array.from(trs[0].children).filter((c) => c.tagName === 'TD' || c.tagName === 'TH');
873
1228
  return cells.length ? cells : null;
874
1229
  }
@@ -949,14 +1304,20 @@ function looksLikeContainer(el) {
949
1304
  return Array.from(el.children).some((c) => c.tagName === 'TABLE' || c.tagName === 'DIV' || c.tagName === 'CENTER');
950
1305
  }
951
1306
 
1307
+ /** A row holding nothing but condition/loop markers. The exporter emits only the tags at its position (no <tr> scaffolding), so wrapper styling must never stick to one -- a page background stamped onto a marker row painted a colored band in the canvas that no sent mail would ever show. */
1308
+ function isMarkerRow(r) {
1309
+ const blocks = r.cols.reduce((a, c) => a.concat(c.blocks), []);
1310
+ return blocks.length > 0 && blocks.every((b) => b.type === 'condition' || b.type === 'loop');
1311
+ }
1312
+
952
1313
  function applyBg(rows, bg) {
953
- if (bg) rows.forEach((r) => { if (!r.props.bg) r.props.bg = bg; });
1314
+ if (bg) rows.forEach((r) => { if (!r.props.bg && !isMarkerRow(r)) r.props.bg = bg; });
954
1315
  return rows;
955
1316
  }
956
1317
 
957
1318
  /** Rows built from a genuine content table (`rowsFromContentTable`) already carry real, per-line padding read off their own `<td>`; rows built from a plain buffered run of content (`collectRows`'s `flushBuf`) start at a neutral zero, since there's no source padding to point to at that level. Once one of those zeroed rows bubbles up through a passthrough table or container div that DOES carry padding (the common shape for a single-line MJML/ESP section), that's the closest real signal available, and gets applied -- but only to rows still at zero, so it never overwrites a more specific value a deeper table already set. */
958
1319
  function applyPad(rows, pad) {
959
- if (pad) rows.forEach((r) => { if (!r.props.py && !r.props.px && r.props.pt === undefined) setRowPad(r, pad); });
1320
+ if (pad) rows.forEach((r) => { if (!r.props.py && !r.props.px && r.props.pt === undefined && !isMarkerRow(r)) setRowPad(r, pad); });
960
1321
  return rows;
961
1322
  }
962
1323
 
@@ -1053,17 +1414,49 @@ function rowsFromContentTable(table) {
1053
1414
  }
1054
1415
  const outerCells = Array.from(tr.children).filter((c) => c.tagName === 'TD' || c.tagName === 'TH');
1055
1416
  if (!outerCells.length) return null;
1056
- const bgSource = outerCells[0];
1417
+ let bgSource = outerCells[0];
1418
+ // The margin / Max-width wrapper (core/export.js `boxed`): margins on a
1419
+ // <td> are inert in mail clients, so a row using either ships them on a
1420
+ // div inside the cell -- which also carries the row's paint and padding.
1421
+ // When it is there, IT is the row: everything below reads styles off
1422
+ // `bgSource`, so re-pointing here is the whole unhoist. Detected by a
1423
+ // real margin component or a %-cap, never by `margin:0` alone -- that is
1424
+ // the exporter's see-through box div around a block.
1425
+ let rowCap = 0;
1426
+ {
1427
+ const lone = onlyChild(bgSource, 'DIV');
1428
+ const mw = (lone && lone.style.maxWidth) || '';
1429
+ const capped = String(mw).endsWith('%') && PX(mw) > 0 && PX(mw) < 100;
1430
+ if (lone && (capped || PX(lone.style.marginTop) || PX(lone.style.marginBottom) || PX(lone.style.marginLeft) || PX(lone.style.marginRight))) {
1431
+ if (capped) rowCap = PX(mw);
1432
+ bgSource = lone;
1433
+ }
1434
+ }
1057
1435
  let cells = outerCells;
1058
- if (outerCells.length === 1) {
1436
+ // A flex/grid row: no table walker can re-shape one (its columns are
1437
+ // divs), so the exporter stamps the row's settings on the wrapper as
1438
+ // `data-mcr` and the columns are its child divs. Without the marker the
1439
+ // old collapse-to-one-column behaviour stands (foreign HTML, or a host
1440
+ // exporting with markers off).
1441
+ let layoutCfg = null;
1442
+ let layoutDiv = onlyChild(bgSource, 'DIV');
1443
+ if (layoutDiv && !(layoutDiv.getAttribute && layoutDiv.getAttribute('data-mcr'))) layoutDiv = null;
1444
+ if (layoutDiv) {
1445
+ try { layoutCfg = JSON.parse(layoutDiv.getAttribute('data-mcr')) || null; } catch { layoutCfg = null; }
1446
+ const colDivs = Array.from(layoutDiv.children).filter((k) => k.tagName === 'DIV');
1447
+ if (layoutCfg && colDivs.length) cells = colDivs;
1448
+ else { layoutCfg = null; layoutDiv = null; }
1449
+ }
1450
+ if (!layoutCfg && outerCells.length === 1) {
1059
1451
  // The table shape first, since it is unambiguous; the CSS-layout shape
1060
1452
  // only when there is no table row to read.
1061
- const nested = unwrapNestedLayout(outerCells[0]);
1453
+ const nested = unwrapNestedLayout(bgSource);
1062
1454
  if (nested) cells = nested;
1063
1455
  else {
1064
- const inline = inlineColumnGroup(outerCells[0].childNodes);
1456
+ const inline = inlineColumnGroup(bgSource.childNodes);
1065
1457
  if (inline) cells = inline;
1066
1458
  }
1459
+ if (cells === outerCells && bgSource !== outerCells[0]) cells = [bgSource];
1067
1460
  }
1068
1461
  // Spacer columns: a content-free `<td>` (often `class="column gap"`,
1069
1462
  // holding only an empty fixed-width table) between real columns exists
@@ -1092,16 +1485,74 @@ function rowsFromContentTable(table) {
1092
1485
  // column gap) describe a gap, not row padding. Recognizing it keeps
1093
1486
  // gap -> export -> import a fixed point instead of drifting into padding.
1094
1487
  let gutter = false;
1095
- if (!gapPx && cells.length > 1) {
1488
+ // Single-column rows carry the same gutter (the exporter writes
1489
+ // `padding:0 gap/2` on every cell, one column or four) -- but only a cell
1490
+ // an unwrap produced qualifies: a flat foreign `<td style="padding:0 24px">`
1491
+ // has always read as row padding, and visually the two are the same, so
1492
+ // that behaviour must not shift under existing imports.
1493
+ if (!gapPx && (cells.length > 1 || cells[0] !== bgSource)) {
1096
1494
  const pads = cells.map((c) => paddingOf(c.style));
1097
1495
  const p0 = pads[0];
1098
1496
  gutter = !!(p0 && !p0.t && !p0.b && p0.l > 0 && p0.l === p0.r && p0.l <= 60
1099
1497
  && pads.every((pp) => pp && !pp.t && !pp.b && pp.l === p0.l && pp.r === p0.r));
1100
1498
  if (gutter) gapPx = p0.l * 2;
1101
1499
  }
1500
+ // The unwrap can be refused on purpose (a social strip's layout table
1501
+ // must stay one block -- see unwrapNestedLayout), which also hides the
1502
+ // gap cell inside it. Peek at that one cell for the same pure-horizontal
1503
+ // signature; the strip itself is still classified by the cell walk below.
1504
+ // Never past a bulletproof button, whose padded cell is the pill, not a
1505
+ // gutter.
1506
+ if (!gapPx && cells.length === 1 && cells[0] === bgSource && !classifyButton(bgSource)) {
1507
+ const only = onlyChild(bgSource, 'TABLE');
1508
+ const innerTds = only ? Array.from(only.querySelectorAll(':scope > tbody > tr > td, :scope > tr > td')) : [];
1509
+ if (innerTds.length === 1) {
1510
+ const pd = paddingOf(innerTds[0].style);
1511
+ if (pd && !pd.t && !pd.b && pd.l > 0 && pd.l === pd.r && pd.l <= 60) gapPx = pd.l * 2;
1512
+ }
1513
+ }
1102
1514
  const spans = cells.length === 1 ? [100] : spansFromCells(cells, tableWidthPx);
1103
1515
  const row = mkRow(spans);
1104
1516
  row.props.py = 0; row.props.px = 0; row.props.gap = gapPx;
1517
+ // Vertical alignment ships as the attribute on every cell; it only means
1518
+ // something the cells agree on (a foreign row aligning each column its
1519
+ // own way has no single row value to take).
1520
+ const va = cells[0].getAttribute && cells[0].getAttribute('valign');
1521
+ if ((va === 'middle' || va === 'bottom') && cells.every((c) => c.getAttribute('valign') === va)) row.props.valign = va;
1522
+ // Mobile behaviour, from the classes the exporter's media query targets
1523
+ // (they survive css-cascade untouched -- @media rules are never folded).
1524
+ // Only ever set on a real multi-column row, and only from an explicit
1525
+ // class: `keep` exports NO class and so cannot be told apart from foreign
1526
+ // HTML, where stacking (the default) is the safer read.
1527
+ if (cells.length > 1) {
1528
+ const trCls = (cells[0].parentElement && cells[0].parentElement.getAttribute('class')) || '';
1529
+ if (/\bmc-2up\b/.test(trCls)) row.props.mobileCols = 2;
1530
+ if (/\bmc-rev\b/.test(trCls)) row.props.mobileOrder = 'reverse';
1531
+ // `keep` is inert-class-marked (mc-keep, no stylesheet rule): with no
1532
+ // explicit class it cannot be told apart from foreign HTML, where
1533
+ // stacking -- the default -- is the safer read.
1534
+ if (/\bmc-keep\b/.test(trCls)) row.props.mobileCols = 'keep';
1535
+ }
1536
+ if (layoutCfg) {
1537
+ Object.assign(row.props, {
1538
+ layout: layoutCfg.layout === 'grid' ? 'grid' : 'flex',
1539
+ flexDir: layoutCfg.flexDir || 'row', justify: layoutCfg.justify || 'flex-start',
1540
+ alignItems: layoutCfg.alignItems || 'stretch', wrap: layoutCfg.wrap !== false,
1541
+ gridCols: layoutCfg.gridCols || 2, gap: layoutCfg.gap || 0,
1542
+ });
1543
+ if (Array.isArray(layoutCfg.spans) && layoutCfg.spans.length === row.cols.length) {
1544
+ layoutCfg.spans.forEach((sp, i) => { if (Number(sp) > 0) row.cols[i].span = Number(sp); });
1545
+ }
1546
+ }
1547
+ // The row's outside margins, straight off the style the exporter writes.
1548
+ // Mail clients ignore margins on a <td> (a known export gap), but the
1549
+ // values are the user's -- dropping them zeroed the four sliders on
1550
+ // every reload.
1551
+ if (bgSource.style) {
1552
+ const mg = { mt: PX(bgSource.style.marginTop), mr: PX(bgSource.style.marginRight), mb: PX(bgSource.style.marginBottom), ml: PX(bgSource.style.marginLeft) };
1553
+ if (mg.mt || mg.mr || mg.mb || mg.ml) Object.assign(row.props, mg);
1554
+ }
1555
+ if (rowCap) row.props.maxW = rowCap;
1105
1556
  // Background and frame can live on the first cell OR on the table itself
1106
1557
  // (builders style `table.row-content`, not its tds) -- read the cell
1107
1558
  // first, the table as fallback. Cell-derived values only count when every
@@ -1180,11 +1631,20 @@ function rowsFromContentTable(table) {
1180
1631
  // its children, or the whole card collapses into one opaque text blob.
1181
1632
  let contentEl = cell;
1182
1633
  const lone = onlyChild(cell, 'DIV');
1183
- if (lone && (bgOf(lone) || radiusOf(lone.style)) && !classifyNode(lone)) {
1634
+ if (lone && (bgOf(lone) || radiusOf(lone.style) || borderSidesOf(lone.style).width) && !classifyNode(lone)) {
1184
1635
  const col = row.cols[i];
1185
1636
  if (col) {
1186
1637
  const cbg = bgOf(lone); if (cbg && !col.bg) col.bg = cbg;
1187
1638
  const crad = radiusOf(lone.style); if (crad && !col.radius) col.radius = crad;
1639
+ // The column's own border ships on this same wrapper (core/export.js
1640
+ // writes bg, border, radius and padding together); reading everything
1641
+ // but the border dropped a column frame on every save/reload.
1642
+ const cframe = borderSidesOf(lone.style);
1643
+ if (cframe.width && !col.border) {
1644
+ col.border = cframe.width;
1645
+ col.borderStyle = borderStyleOf(lone.style);
1646
+ col.lineColor = borderColorOf(lone.style) || '#e2e2e5';
1647
+ }
1188
1648
  const cpd = paddingOf(lone.style);
1189
1649
  if (cpd && col.padY === undefined) { col.padY = cpd.py; col.padX = cpd.px; }
1190
1650
  }
@@ -1263,7 +1723,17 @@ function collectRows(nodes) {
1263
1723
  };
1264
1724
  nodes.forEach((n) => {
1265
1725
  if (n.nodeType === 8) return; // HTML comments (Outlook/MSO conditionals) -- inert
1266
- if (n.nodeType === 1 && /^(SCRIPT|STYLE)$/.test(n.tagName)) return;
1726
+ if (n.nodeType === 1 && /^(SCRIPT|STYLE)$/.test(n.tagName)) {
1727
+ const mb = markerBlock(n);
1728
+ if (mb) {
1729
+ flushBuf();
1730
+ const row = mkRow([100]);
1731
+ row.props.py = 0; row.props.px = 0; row.props.gap = 0;
1732
+ row.cols[0].blocks = [mb];
1733
+ rows.push(row);
1734
+ }
1735
+ return;
1736
+ }
1267
1737
  if (n.nodeType === 1 && isHidden(n)) return;
1268
1738
  // A lone column: its blocks belong to one row, not one row each.
1269
1739
  if (n.nodeType === 1 && isColumnContainer(n) && !classifyNode(unwrapBoxDiv(n))) {
@@ -1277,7 +1747,15 @@ function collectRows(nodes) {
1277
1747
  return;
1278
1748
  }
1279
1749
  }
1280
- if (n.nodeType === 1 && (n.tagName === 'DIV' || n.tagName === 'CENTER') && looksLikeContainer(n) && !classifyNode(unwrapBoxDiv(n))) {
1750
+ // Container-ness is judged on the UNWRAPPED element where the unwrap
1751
+ // stays a div/center: a margin-only box div around one padded text div is
1752
+ // the exporter's block wrapper, and walking it as a section split every
1753
+ // block of a single-row document into its own row. An unwrap that lands
1754
+ // on something else (a table -- Beefree's divider ships as
1755
+ // div > 20%-table) keeps the old judgement of the wrapper itself.
1756
+ const seen = n.nodeType === 1 && (n.tagName === 'DIV' || n.tagName === 'CENTER') ? unwrapBoxDiv(n) : null;
1757
+ const containerish = seen && (seen.tagName === 'DIV' || seen.tagName === 'CENTER' ? looksLikeContainer(seen) : looksLikeContainer(n));
1758
+ if (seen && containerish && !classifyNode(seen)) {
1281
1759
  flushBuf();
1282
1760
  const inner = mergeBandRows(collectRows(Array.from(n.childNodes)), n);
1283
1761
  rows.push(...applyBgImage(applyFrame(applyPad(applyBg(inner, bgOf(n)), padOf(n)), n), n));
@@ -1497,14 +1975,21 @@ function themeFromParsedDoc(doc) {
1497
1975
  }
1498
1976
  }
1499
1977
  }
1500
- // Prefer the first *real* stack (has a comma or quotes) over a lone generic
1501
- // keyword: builders wrap everything in a `font-family:sans-serif` shim div
1502
- // with the actual `'DM Sans', Arial, ...` declared a level deeper.
1978
+ // The body's own family first: it is where this exporter writes the theme
1979
+ // font, and `querySelectorAll` never sees the body itself -- so the scan
1980
+ // below used to crown the *first block's* effective font instead, and one
1981
+ // custom-font heading at the top of a document flipped the whole theme on
1982
+ // reload. The element scan stays as the fallback for foreign emails that
1983
+ // declare nothing on the body, preferring the first *real* stack (has a
1984
+ // comma or quotes) over a lone generic keyword: builders wrap everything in
1985
+ // a `font-family:sans-serif` shim div with the actual `'DM Sans', Arial,
1986
+ // ...` declared a level deeper.
1987
+ const bodyFont = (body.style && body.style.fontFamily) || '';
1503
1988
  const fonts = Array.from(body.querySelectorAll('[style*="font-family"]'))
1504
1989
  .filter((el) => (el.textContent || '').trim())
1505
1990
  .map((el) => el.style.fontFamily)
1506
1991
  .filter(Boolean);
1507
- const font = fonts.find((v) => /[,"']/.test(v)) || fonts[0];
1992
+ const font = bodyFont || fonts.find((v) => /[,"']/.test(v)) || fonts[0];
1508
1993
  if (font) theme.font = font;
1509
1994
  // Link color: the most common inline anchor color -- skipping button
1510
1995
  // pills, whose (usually white) label color would otherwise dominate a
@@ -1515,8 +2000,16 @@ function themeFromParsedDoc(doc) {
1515
2000
  body.querySelectorAll('a[style*="color"]').forEach((a) => {
1516
2001
  const c = a.style.color;
1517
2002
  // Only text links vote -- icon links (social strips) carry an icon
1518
- // color, not the document's link color.
2003
+ // color, not the document's link color. An anchor holding an icon still
2004
+ // has text when its network name is shown beside the glyph, so the icon
2005
+ // check is structural, not textual. Menu items are navigation chrome in
2006
+ // the block's own color (every exported item carries the uppercase +
2007
+ // letter-spacing signature) -- counting them let a three-item menu
2008
+ // outvote the document's actual links and rewrite theme.link on every
2009
+ // reload.
1519
2010
  if (!c || c === 'inherit' || !(a.textContent || '').trim() || isPill(a)) return;
2011
+ if (a.querySelector('svg,img')) return;
2012
+ if (a.style.textTransform === 'uppercase' && a.style.letterSpacing) return;
1520
2013
  linkCounts[c] = (linkCounts[c] || 0) + 1;
1521
2014
  });
1522
2015
  const link = Object.keys(linkCounts).sort((a, b) => linkCounts[b] - linkCounts[a])[0];
@@ -1551,6 +2044,67 @@ function foldLogicWrappers(src) {
1551
2044
  return s;
1552
2045
  }
1553
2046
 
2047
+ /**
2048
+ * The exporter writes every inherited value as a concrete declaration
2049
+ * (`p.fontFamily || t.font`, `p.color || t.text`, a row's
2050
+ * `rp.bg || t.contentBg || 'transparent'`), so a round trip used to come back
2051
+ * with the theme stamped onto every block and row as an explicit override --
2052
+ * visually identical, but the inherit relationship was gone: a later theme
2053
+ * edit (font, text ink, content background) no longer reached anything. A
2054
+ * value that merely restates what the imported theme already says folds back
2055
+ * to "inherit"; anything genuinely different is a real override and stays.
2056
+ */
2057
+ /** Strips an anchor's inline color where it merely restates the document link color, so the stored html inherits again -- the exporter stamps `theme.link` on every colorless anchor (mail needs the value inline), and left in the reloaded html that stamp froze links at whatever the theme said on the day of the save; a later Link color edit never reached them. A genuinely different inline color is the user's and stays. */
2058
+ function foldLinkColor(html, linkKey, ckey) {
2059
+ const src = String(html || '');
2060
+ if (!linkKey || src.indexOf('<a') < 0 || src.indexOf('color') < 0) return html;
2061
+ const doc = new DOMParser().parseFromString('<div id="mc-fold">' + src + '</div>', 'text/html');
2062
+ const root = doc.getElementById('mc-fold');
2063
+ if (!root) return html;
2064
+ let hit = false;
2065
+ root.querySelectorAll('a[style]').forEach((a) => {
2066
+ if (a.style.color && ckey(a.style.color) === linkKey) {
2067
+ a.style.removeProperty('color');
2068
+ if (!(a.getAttribute('style') || '').trim()) a.removeAttribute('style');
2069
+ hit = true;
2070
+ }
2071
+ });
2072
+ return hit ? root.innerHTML : html;
2073
+ }
2074
+
2075
+ function foldThemeInherits(rows, theme) {
2076
+ const tFont = fontKey(theme.font);
2077
+ // Colors compare through hexOf on BOTH sides: CSSOM hands the walkers
2078
+ // `rgb(...)` for every hex the exporter wrote, so a bare string comparison
2079
+ // saw `#172033` != `rgb(23, 32, 51)` and the fold silently never fired.
2080
+ const ckey = (v) => String(hexOf(v) || '').toLowerCase();
2081
+ const tText = ckey(theme.text);
2082
+ const cBg = ckey(theme.contentBg);
2083
+ const tLink = ckey(theme.link);
2084
+ // Only the types whose renderer falls back `p.color || t.text` -- an empty
2085
+ // color means "theme ink" for exactly these; other blocks' colors are
2086
+ // structural (a button label, a divider line) and must stay explicit.
2087
+ const inheritsInk = { text: 1, heading: 1, list: 1 };
2088
+ rows.forEach((row) => {
2089
+ const bg = ckey(row.props.bg);
2090
+ if (bg && bg === cBg) row.props.bg = '';
2091
+ row.cols.forEach((col) => {
2092
+ // A see-through column wrapper is the exporter's own scaffolding
2093
+ // (`background: transparent` is always written on a styled column),
2094
+ // never a chosen paint.
2095
+ if (String(col.bg || '').toLowerCase() === 'transparent') col.bg = '';
2096
+ col.blocks.forEach((b) => {
2097
+ if (tFont && b.props.fontFamily && fontKey(b.props.fontFamily) === tFont) b.props.fontFamily = '';
2098
+ if (tText && inheritsInk[b.type] && ckey(b.props.color) === tText) b.props.color = '';
2099
+ if (tLink && theme.link && typeof b.props.html === 'string') b.props.html = foldLinkColor(b.props.html, tLink, ckey);
2100
+ if (tLink && theme.link && b.type === 'list' && b.props.items) {
2101
+ b.props.items = String(b.props.items).split('\n').map((l) => foldLinkColor(l, tLink, ckey)).join('\n');
2102
+ }
2103
+ });
2104
+ });
2105
+ });
2106
+ }
2107
+
1554
2108
  /** Full import entry point: the rows plus the theme patch read from the same source. `theme` only carries keys the source actually declared -- the caller merges it over the current theme so unspecified fields keep their values. */
1555
2109
  export function htmlToDoc(src) {
1556
2110
  let doc;
@@ -1562,7 +2116,9 @@ export function htmlToDoc(src) {
1562
2116
  // Theme first: themeFromParsedDoc consumes the styles it claims off the
1563
2117
  // scaffold nodes, and the row walker must see the cleaned DOM.
1564
2118
  const theme = themeFromParsedDoc(doc);
1565
- return { rows: collectRows(Array.from(doc.body.childNodes)), theme };
2119
+ const rows = collectRows(Array.from(doc.body.childNodes));
2120
+ foldThemeInherits(rows, theme);
2121
+ return { rows, theme };
1566
2122
  }
1567
2123
 
1568
2124
  export function htmlToRows(src) {