@seliseblocks/mailcraft 0.2.9 → 0.2.10

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) {
@@ -590,6 +819,21 @@ function blocksFromNodes(nodes) {
590
819
  const out = [];
591
820
  let buf = [];
592
821
  let bufFirstEl = null;
822
+ // Every element buffered into the current run, for the mixed-family check
823
+ // below: the first element alone cannot say whether a *later* piece of the
824
+ // run declares its own font.
825
+ let bufEls = [];
826
+ // The run's device visibility: the class sits on the box wrapper the
827
+ // exporter puts around a text block (never inside the sanitized html, which
828
+ // strips classes), so only this walk can carry it. `undefined` = not seen
829
+ // yet; '' = pieces disagreed, and a mixed run claims nothing rather than
830
+ // hide half its content along with the other half.
831
+ let bufVis;
832
+ const visUp = (start) => {
833
+ let e = start;
834
+ for (let i = 0; e && i < 4; i += 1) { const v = visibilityOf(e); if (v) return v; e = e.parentElement; }
835
+ return '';
836
+ };
593
837
  // The parent of a leading bare text node. A run with no element of its own
594
838
  // (`<td style="font-size:30px;...">{{Code}}</td>`) still has real
595
839
  // typography -- it lives on the ancestors, and with no bufFirstEl the
@@ -597,7 +841,8 @@ function blocksFromNodes(nodes) {
597
841
  // at the theme default (a 30px/800 verification code became 16px plain).
598
842
  let bufTextEl = null;
599
843
  const flush = () => {
600
- const html = cleanImportHtml(buf.join(''));
844
+ const raw = buf.join('');
845
+ const html = cleanImportHtml(raw);
601
846
  // A run with nothing visible in it -- no text beyond whitespace/hair
602
847
  // spaces, no image or line break (an `<nbsp;>` still counts as a blank
603
848
  // line someone wrote) -- is markup residue (`<p style="margin:0"></p>`),
@@ -613,6 +858,10 @@ function blocksFromNodes(nodes) {
613
858
  const weight = inheritedStyle(bufTextEl, 'fontWeight'); if (weight) over.weight = weight;
614
859
  const lh = lineHeightRatio(inheritedStyle(bufTextEl, 'lineHeight'), size);
615
860
  if (lh) over.lh = lh;
861
+ // A bare run has no inline tags to carry a family, so the ancestor's
862
+ // is the block's -- no mixed-run risk here.
863
+ const family = inheritedStyle(bufTextEl, 'fontFamily');
864
+ if (family) over.fontFamily = family;
616
865
  }
617
866
  if (bufFirstEl) {
618
867
  // The block's *base* style: the first buffered element when it's a
@@ -627,6 +876,7 @@ function blocksFromNodes(nodes) {
627
876
  // (it lands on the <p>), so a padded block came back at the default
628
877
  // 10px/0 on every save. First padding on the wrapper chain wins.
629
878
  let runPad = paddingOf(baseEl.style);
879
+ let padDeclared = !!baseEl.style && baseEl.style.paddingTop !== '';
630
880
  // Then descend through transparent single-child wrappers: builders
631
881
  // nest a `font-family:sans-serif` shim div around the div that
632
882
  // carries the real typography, and `inheritedStyle` below walks *up*
@@ -636,18 +886,42 @@ function blocksFromNodes(nodes) {
636
886
  baseEl.children.length === 1
637
887
  && !INLINE_TAGS.test(baseEl.firstElementChild.tagName)
638
888
  && (baseEl.textContent || '') === (baseEl.firstElementChild.textContent || '')
639
- ) { baseEl = baseEl.firstElementChild; if (!runPad) runPad = paddingOf(baseEl.style); }
889
+ ) { baseEl = baseEl.firstElementChild; if (!runPad) runPad = paddingOf(baseEl.style); padDeclared = padDeclared || baseEl.style.paddingTop !== ''; }
640
890
  if (runPad) { over.py = runPad.py; over.px = runPad.px; }
891
+ // A declared all-zero padding is a choice (paddingOf returns null for
892
+ // it); absence is what falls to the 10px default.
893
+ else if (padDeclared) { over.py = 0; over.px = 0; }
641
894
  const size = fontPx(inheritedStyle(baseEl, 'fontSize')); if (size) over.size = size;
642
895
  const color = inheritedStyle(baseEl, 'color'); if (color) over.color = hexOf(color);
643
896
  over.align = textAlignOf(baseEl);
644
897
  const weight = inheritedStyle(baseEl, 'fontWeight'); if (weight) over.weight = weight;
645
898
  const lh = lineHeightRatio(inheritedStyle(baseEl, 'lineHeight'), size);
646
899
  if (lh) over.lh = lh;
900
+ // The run's base family, read exactly like size/color -- but claimed
901
+ // at block level only when no piece of the run declares a different
902
+ // one, since the renderer strips descendant families the moment the
903
+ // block owns a font (overrideRichFont). A mixed run keeps its inline
904
+ // declarations instead, exactly as before. The theme-equality fold in
905
+ // htmlToDoc turns a restated document font back into "inherit".
906
+ const family = inheritedStyle(baseEl, 'fontFamily');
907
+ if (family && !bufEls.some((e) => mixedFamily(e, family))) {
908
+ over.fontFamily = family;
909
+ // Claimed means consumed: every family in the run restates the one
910
+ // just read (the mixed check above), so the inline declarations
911
+ // fold into the block prop instead of shipping twice. This is also
912
+ // what keeps export -> import -> export byte-stable -- left in the
913
+ // html, the renderer strips them from the live DOM (overrideRichFont)
914
+ // and the re-serialized attributes drift on the next save.
915
+ over.html = cleanImportHtml(raw, ['font-family']);
916
+ }
647
917
  }
918
+ // Same walk classifyNode does for recognized blocks; text runs never
919
+ // went through it, so a mobile-only paragraph reloaded visible
920
+ // everywhere.
921
+ if (bufVis) over.vis = bufVis;
648
922
  out.push(blk('text', over));
649
923
  }
650
- buf = []; bufFirstEl = null; bufTextEl = null;
924
+ buf = []; bufFirstEl = null; bufTextEl = null; bufEls = []; bufVis = undefined;
651
925
  };
652
926
  nodes.forEach((n) => {
653
927
  if (n.nodeType === 3) {
@@ -655,12 +929,22 @@ function blocksFromNodes(nodes) {
655
929
  if (markers) { flush(); markers.forEach((mb) => out.push(mb)); return; }
656
930
  if (n.textContent && n.textContent.trim()) {
657
931
  if (!bufFirstEl && !bufTextEl) bufTextEl = n.parentElement;
932
+ const tv = visUp(n.parentElement);
933
+ bufVis = bufVis === undefined || bufVis === tv ? tv : '';
658
934
  buf.push(escapeText(n.textContent));
659
935
  }
660
936
  return;
661
937
  }
662
938
  if (n.nodeType !== 1) return;
663
939
  if (isHidden(n)) return;
940
+ const marked = markerBlock(n);
941
+ if (marked) {
942
+ flush();
943
+ const mv = visUp(n);
944
+ if (mv) marked.props.vis = mv;
945
+ out.push(marked);
946
+ return;
947
+ }
664
948
  const target = unwrapBoxDiv(n);
665
949
  const b = classifyNode(target);
666
950
  if (b) { flush(); out.push(b); return; }
@@ -690,10 +974,20 @@ function blocksFromNodes(nodes) {
690
974
  // run: the exporter writes every text block as exactly such a padded div,
691
975
  // and buffering two of them together merged neighbouring blocks into one
692
976
  // -- 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();
977
+ // A box-div unwrap (target !== n) marks a block boundary just as surely
978
+ // as padding does: the exporter writes exactly one such wrapper per
979
+ // block, and without this two zero-padded text blocks buffered into one
980
+ // -- the second lost its size, weight, everything -- on every save.
981
+ const boundary = n.nodeType === 1 && !INLINE_TAGS.test(target.tagName) && (hasOwnRunPad(target) || target !== n);
982
+ if (boundary && buf.length) flush();
694
983
  if (!bufFirstEl) bufFirstEl = target;
984
+ bufEls.push(target);
985
+ // Read off `n`, not `target`: the visibility class rides the wrapper
986
+ // unwrapBoxDiv deliberately sees through.
987
+ const ev = visUp(n);
988
+ bufVis = bufVis === undefined || bufVis === ev ? ev : '';
695
989
  buf.push(n.outerHTML);
696
- if (n.nodeType === 1 && !INLINE_TAGS.test(target.tagName) && hasOwnRunPad(target)) flush();
990
+ if (boundary) flush();
697
991
  });
698
992
  flush();
699
993
  return out;
@@ -868,7 +1162,10 @@ function unwrapNestedLayout(td) {
868
1162
  if (classifyButton(td) || classifySocial(only)) return null;
869
1163
  const trs = only.querySelectorAll(':scope > tbody > tr, :scope > tr');
870
1164
  if (trs.length !== 1) return null;
871
- if (only.querySelector('th')) return null;
1165
+ // Header cells veto the unwrap only on the candidate's OWN row: a th
1166
+ // anywhere deeper is some block's content (a data table in a column), and
1167
+ // vetoing on it left the gap cell around that block unread.
1168
+ if (only.querySelector(':scope > tbody > tr > th, :scope > tr > th')) return null;
872
1169
  const cells = Array.from(trs[0].children).filter((c) => c.tagName === 'TD' || c.tagName === 'TH');
873
1170
  return cells.length ? cells : null;
874
1171
  }
@@ -949,14 +1246,20 @@ function looksLikeContainer(el) {
949
1246
  return Array.from(el.children).some((c) => c.tagName === 'TABLE' || c.tagName === 'DIV' || c.tagName === 'CENTER');
950
1247
  }
951
1248
 
1249
+ /** 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. */
1250
+ function isMarkerRow(r) {
1251
+ const blocks = r.cols.reduce((a, c) => a.concat(c.blocks), []);
1252
+ return blocks.length > 0 && blocks.every((b) => b.type === 'condition' || b.type === 'loop');
1253
+ }
1254
+
952
1255
  function applyBg(rows, bg) {
953
- if (bg) rows.forEach((r) => { if (!r.props.bg) r.props.bg = bg; });
1256
+ if (bg) rows.forEach((r) => { if (!r.props.bg && !isMarkerRow(r)) r.props.bg = bg; });
954
1257
  return rows;
955
1258
  }
956
1259
 
957
1260
  /** 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
1261
  function applyPad(rows, pad) {
959
- if (pad) rows.forEach((r) => { if (!r.props.py && !r.props.px && r.props.pt === undefined) setRowPad(r, pad); });
1262
+ if (pad) rows.forEach((r) => { if (!r.props.py && !r.props.px && r.props.pt === undefined && !isMarkerRow(r)) setRowPad(r, pad); });
960
1263
  return rows;
961
1264
  }
962
1265
 
@@ -1053,17 +1356,49 @@ function rowsFromContentTable(table) {
1053
1356
  }
1054
1357
  const outerCells = Array.from(tr.children).filter((c) => c.tagName === 'TD' || c.tagName === 'TH');
1055
1358
  if (!outerCells.length) return null;
1056
- const bgSource = outerCells[0];
1359
+ let bgSource = outerCells[0];
1360
+ // The margin / Max-width wrapper (core/export.js `boxed`): margins on a
1361
+ // <td> are inert in mail clients, so a row using either ships them on a
1362
+ // div inside the cell -- which also carries the row's paint and padding.
1363
+ // When it is there, IT is the row: everything below reads styles off
1364
+ // `bgSource`, so re-pointing here is the whole unhoist. Detected by a
1365
+ // real margin component or a %-cap, never by `margin:0` alone -- that is
1366
+ // the exporter's see-through box div around a block.
1367
+ let rowCap = 0;
1368
+ {
1369
+ const lone = onlyChild(bgSource, 'DIV');
1370
+ const mw = (lone && lone.style.maxWidth) || '';
1371
+ const capped = String(mw).endsWith('%') && PX(mw) > 0 && PX(mw) < 100;
1372
+ if (lone && (capped || PX(lone.style.marginTop) || PX(lone.style.marginBottom) || PX(lone.style.marginLeft) || PX(lone.style.marginRight))) {
1373
+ if (capped) rowCap = PX(mw);
1374
+ bgSource = lone;
1375
+ }
1376
+ }
1057
1377
  let cells = outerCells;
1058
- if (outerCells.length === 1) {
1378
+ // A flex/grid row: no table walker can re-shape one (its columns are
1379
+ // divs), so the exporter stamps the row's settings on the wrapper as
1380
+ // `data-mcr` and the columns are its child divs. Without the marker the
1381
+ // old collapse-to-one-column behaviour stands (foreign HTML, or a host
1382
+ // exporting with markers off).
1383
+ let layoutCfg = null;
1384
+ let layoutDiv = onlyChild(bgSource, 'DIV');
1385
+ if (layoutDiv && !(layoutDiv.getAttribute && layoutDiv.getAttribute('data-mcr'))) layoutDiv = null;
1386
+ if (layoutDiv) {
1387
+ try { layoutCfg = JSON.parse(layoutDiv.getAttribute('data-mcr')) || null; } catch { layoutCfg = null; }
1388
+ const colDivs = Array.from(layoutDiv.children).filter((k) => k.tagName === 'DIV');
1389
+ if (layoutCfg && colDivs.length) cells = colDivs;
1390
+ else { layoutCfg = null; layoutDiv = null; }
1391
+ }
1392
+ if (!layoutCfg && outerCells.length === 1) {
1059
1393
  // The table shape first, since it is unambiguous; the CSS-layout shape
1060
1394
  // only when there is no table row to read.
1061
- const nested = unwrapNestedLayout(outerCells[0]);
1395
+ const nested = unwrapNestedLayout(bgSource);
1062
1396
  if (nested) cells = nested;
1063
1397
  else {
1064
- const inline = inlineColumnGroup(outerCells[0].childNodes);
1398
+ const inline = inlineColumnGroup(bgSource.childNodes);
1065
1399
  if (inline) cells = inline;
1066
1400
  }
1401
+ if (cells === outerCells && bgSource !== outerCells[0]) cells = [bgSource];
1067
1402
  }
1068
1403
  // Spacer columns: a content-free `<td>` (often `class="column gap"`,
1069
1404
  // holding only an empty fixed-width table) between real columns exists
@@ -1092,16 +1427,74 @@ function rowsFromContentTable(table) {
1092
1427
  // column gap) describe a gap, not row padding. Recognizing it keeps
1093
1428
  // gap -> export -> import a fixed point instead of drifting into padding.
1094
1429
  let gutter = false;
1095
- if (!gapPx && cells.length > 1) {
1430
+ // Single-column rows carry the same gutter (the exporter writes
1431
+ // `padding:0 gap/2` on every cell, one column or four) -- but only a cell
1432
+ // an unwrap produced qualifies: a flat foreign `<td style="padding:0 24px">`
1433
+ // has always read as row padding, and visually the two are the same, so
1434
+ // that behaviour must not shift under existing imports.
1435
+ if (!gapPx && (cells.length > 1 || cells[0] !== bgSource)) {
1096
1436
  const pads = cells.map((c) => paddingOf(c.style));
1097
1437
  const p0 = pads[0];
1098
1438
  gutter = !!(p0 && !p0.t && !p0.b && p0.l > 0 && p0.l === p0.r && p0.l <= 60
1099
1439
  && pads.every((pp) => pp && !pp.t && !pp.b && pp.l === p0.l && pp.r === p0.r));
1100
1440
  if (gutter) gapPx = p0.l * 2;
1101
1441
  }
1442
+ // The unwrap can be refused on purpose (a social strip's layout table
1443
+ // must stay one block -- see unwrapNestedLayout), which also hides the
1444
+ // gap cell inside it. Peek at that one cell for the same pure-horizontal
1445
+ // signature; the strip itself is still classified by the cell walk below.
1446
+ // Never past a bulletproof button, whose padded cell is the pill, not a
1447
+ // gutter.
1448
+ if (!gapPx && cells.length === 1 && cells[0] === bgSource && !classifyButton(bgSource)) {
1449
+ const only = onlyChild(bgSource, 'TABLE');
1450
+ const innerTds = only ? Array.from(only.querySelectorAll(':scope > tbody > tr > td, :scope > tr > td')) : [];
1451
+ if (innerTds.length === 1) {
1452
+ const pd = paddingOf(innerTds[0].style);
1453
+ if (pd && !pd.t && !pd.b && pd.l > 0 && pd.l === pd.r && pd.l <= 60) gapPx = pd.l * 2;
1454
+ }
1455
+ }
1102
1456
  const spans = cells.length === 1 ? [100] : spansFromCells(cells, tableWidthPx);
1103
1457
  const row = mkRow(spans);
1104
1458
  row.props.py = 0; row.props.px = 0; row.props.gap = gapPx;
1459
+ // Vertical alignment ships as the attribute on every cell; it only means
1460
+ // something the cells agree on (a foreign row aligning each column its
1461
+ // own way has no single row value to take).
1462
+ const va = cells[0].getAttribute && cells[0].getAttribute('valign');
1463
+ if ((va === 'middle' || va === 'bottom') && cells.every((c) => c.getAttribute('valign') === va)) row.props.valign = va;
1464
+ // Mobile behaviour, from the classes the exporter's media query targets
1465
+ // (they survive css-cascade untouched -- @media rules are never folded).
1466
+ // Only ever set on a real multi-column row, and only from an explicit
1467
+ // class: `keep` exports NO class and so cannot be told apart from foreign
1468
+ // HTML, where stacking (the default) is the safer read.
1469
+ if (cells.length > 1) {
1470
+ const trCls = (cells[0].parentElement && cells[0].parentElement.getAttribute('class')) || '';
1471
+ if (/\bmc-2up\b/.test(trCls)) row.props.mobileCols = 2;
1472
+ if (/\bmc-rev\b/.test(trCls)) row.props.mobileOrder = 'reverse';
1473
+ // `keep` is inert-class-marked (mc-keep, no stylesheet rule): with no
1474
+ // explicit class it cannot be told apart from foreign HTML, where
1475
+ // stacking -- the default -- is the safer read.
1476
+ if (/\bmc-keep\b/.test(trCls)) row.props.mobileCols = 'keep';
1477
+ }
1478
+ if (layoutCfg) {
1479
+ Object.assign(row.props, {
1480
+ layout: layoutCfg.layout === 'grid' ? 'grid' : 'flex',
1481
+ flexDir: layoutCfg.flexDir || 'row', justify: layoutCfg.justify || 'flex-start',
1482
+ alignItems: layoutCfg.alignItems || 'stretch', wrap: layoutCfg.wrap !== false,
1483
+ gridCols: layoutCfg.gridCols || 2, gap: layoutCfg.gap || 0,
1484
+ });
1485
+ if (Array.isArray(layoutCfg.spans) && layoutCfg.spans.length === row.cols.length) {
1486
+ layoutCfg.spans.forEach((sp, i) => { if (Number(sp) > 0) row.cols[i].span = Number(sp); });
1487
+ }
1488
+ }
1489
+ // The row's outside margins, straight off the style the exporter writes.
1490
+ // Mail clients ignore margins on a <td> (a known export gap), but the
1491
+ // values are the user's -- dropping them zeroed the four sliders on
1492
+ // every reload.
1493
+ if (bgSource.style) {
1494
+ const mg = { mt: PX(bgSource.style.marginTop), mr: PX(bgSource.style.marginRight), mb: PX(bgSource.style.marginBottom), ml: PX(bgSource.style.marginLeft) };
1495
+ if (mg.mt || mg.mr || mg.mb || mg.ml) Object.assign(row.props, mg);
1496
+ }
1497
+ if (rowCap) row.props.maxW = rowCap;
1105
1498
  // Background and frame can live on the first cell OR on the table itself
1106
1499
  // (builders style `table.row-content`, not its tds) -- read the cell
1107
1500
  // first, the table as fallback. Cell-derived values only count when every
@@ -1180,11 +1573,20 @@ function rowsFromContentTable(table) {
1180
1573
  // its children, or the whole card collapses into one opaque text blob.
1181
1574
  let contentEl = cell;
1182
1575
  const lone = onlyChild(cell, 'DIV');
1183
- if (lone && (bgOf(lone) || radiusOf(lone.style)) && !classifyNode(lone)) {
1576
+ if (lone && (bgOf(lone) || radiusOf(lone.style) || borderSidesOf(lone.style).width) && !classifyNode(lone)) {
1184
1577
  const col = row.cols[i];
1185
1578
  if (col) {
1186
1579
  const cbg = bgOf(lone); if (cbg && !col.bg) col.bg = cbg;
1187
1580
  const crad = radiusOf(lone.style); if (crad && !col.radius) col.radius = crad;
1581
+ // The column's own border ships on this same wrapper (core/export.js
1582
+ // writes bg, border, radius and padding together); reading everything
1583
+ // but the border dropped a column frame on every save/reload.
1584
+ const cframe = borderSidesOf(lone.style);
1585
+ if (cframe.width && !col.border) {
1586
+ col.border = cframe.width;
1587
+ col.borderStyle = borderStyleOf(lone.style);
1588
+ col.lineColor = borderColorOf(lone.style) || '#e2e2e5';
1589
+ }
1188
1590
  const cpd = paddingOf(lone.style);
1189
1591
  if (cpd && col.padY === undefined) { col.padY = cpd.py; col.padX = cpd.px; }
1190
1592
  }
@@ -1263,7 +1665,17 @@ function collectRows(nodes) {
1263
1665
  };
1264
1666
  nodes.forEach((n) => {
1265
1667
  if (n.nodeType === 8) return; // HTML comments (Outlook/MSO conditionals) -- inert
1266
- if (n.nodeType === 1 && /^(SCRIPT|STYLE)$/.test(n.tagName)) return;
1668
+ if (n.nodeType === 1 && /^(SCRIPT|STYLE)$/.test(n.tagName)) {
1669
+ const mb = markerBlock(n);
1670
+ if (mb) {
1671
+ flushBuf();
1672
+ const row = mkRow([100]);
1673
+ row.props.py = 0; row.props.px = 0; row.props.gap = 0;
1674
+ row.cols[0].blocks = [mb];
1675
+ rows.push(row);
1676
+ }
1677
+ return;
1678
+ }
1267
1679
  if (n.nodeType === 1 && isHidden(n)) return;
1268
1680
  // A lone column: its blocks belong to one row, not one row each.
1269
1681
  if (n.nodeType === 1 && isColumnContainer(n) && !classifyNode(unwrapBoxDiv(n))) {
@@ -1277,7 +1689,15 @@ function collectRows(nodes) {
1277
1689
  return;
1278
1690
  }
1279
1691
  }
1280
- if (n.nodeType === 1 && (n.tagName === 'DIV' || n.tagName === 'CENTER') && looksLikeContainer(n) && !classifyNode(unwrapBoxDiv(n))) {
1692
+ // Container-ness is judged on the UNWRAPPED element where the unwrap
1693
+ // stays a div/center: a margin-only box div around one padded text div is
1694
+ // the exporter's block wrapper, and walking it as a section split every
1695
+ // block of a single-row document into its own row. An unwrap that lands
1696
+ // on something else (a table -- Beefree's divider ships as
1697
+ // div > 20%-table) keeps the old judgement of the wrapper itself.
1698
+ const seen = n.nodeType === 1 && (n.tagName === 'DIV' || n.tagName === 'CENTER') ? unwrapBoxDiv(n) : null;
1699
+ const containerish = seen && (seen.tagName === 'DIV' || seen.tagName === 'CENTER' ? looksLikeContainer(seen) : looksLikeContainer(n));
1700
+ if (seen && containerish && !classifyNode(seen)) {
1281
1701
  flushBuf();
1282
1702
  const inner = mergeBandRows(collectRows(Array.from(n.childNodes)), n);
1283
1703
  rows.push(...applyBgImage(applyFrame(applyPad(applyBg(inner, bgOf(n)), padOf(n)), n), n));
@@ -1497,14 +1917,21 @@ function themeFromParsedDoc(doc) {
1497
1917
  }
1498
1918
  }
1499
1919
  }
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.
1920
+ // The body's own family first: it is where this exporter writes the theme
1921
+ // font, and `querySelectorAll` never sees the body itself -- so the scan
1922
+ // below used to crown the *first block's* effective font instead, and one
1923
+ // custom-font heading at the top of a document flipped the whole theme on
1924
+ // reload. The element scan stays as the fallback for foreign emails that
1925
+ // declare nothing on the body, preferring the first *real* stack (has a
1926
+ // comma or quotes) over a lone generic keyword: builders wrap everything in
1927
+ // a `font-family:sans-serif` shim div with the actual `'DM Sans', Arial,
1928
+ // ...` declared a level deeper.
1929
+ const bodyFont = (body.style && body.style.fontFamily) || '';
1503
1930
  const fonts = Array.from(body.querySelectorAll('[style*="font-family"]'))
1504
1931
  .filter((el) => (el.textContent || '').trim())
1505
1932
  .map((el) => el.style.fontFamily)
1506
1933
  .filter(Boolean);
1507
- const font = fonts.find((v) => /[,"']/.test(v)) || fonts[0];
1934
+ const font = bodyFont || fonts.find((v) => /[,"']/.test(v)) || fonts[0];
1508
1935
  if (font) theme.font = font;
1509
1936
  // Link color: the most common inline anchor color -- skipping button
1510
1937
  // pills, whose (usually white) label color would otherwise dominate a
@@ -1515,8 +1942,16 @@ function themeFromParsedDoc(doc) {
1515
1942
  body.querySelectorAll('a[style*="color"]').forEach((a) => {
1516
1943
  const c = a.style.color;
1517
1944
  // Only text links vote -- icon links (social strips) carry an icon
1518
- // color, not the document's link color.
1945
+ // color, not the document's link color. An anchor holding an icon still
1946
+ // has text when its network name is shown beside the glyph, so the icon
1947
+ // check is structural, not textual. Menu items are navigation chrome in
1948
+ // the block's own color (every exported item carries the uppercase +
1949
+ // letter-spacing signature) -- counting them let a three-item menu
1950
+ // outvote the document's actual links and rewrite theme.link on every
1951
+ // reload.
1519
1952
  if (!c || c === 'inherit' || !(a.textContent || '').trim() || isPill(a)) return;
1953
+ if (a.querySelector('svg,img')) return;
1954
+ if (a.style.textTransform === 'uppercase' && a.style.letterSpacing) return;
1520
1955
  linkCounts[c] = (linkCounts[c] || 0) + 1;
1521
1956
  });
1522
1957
  const link = Object.keys(linkCounts).sort((a, b) => linkCounts[b] - linkCounts[a])[0];
@@ -1551,6 +1986,67 @@ function foldLogicWrappers(src) {
1551
1986
  return s;
1552
1987
  }
1553
1988
 
1989
+ /**
1990
+ * The exporter writes every inherited value as a concrete declaration
1991
+ * (`p.fontFamily || t.font`, `p.color || t.text`, a row's
1992
+ * `rp.bg || t.contentBg || 'transparent'`), so a round trip used to come back
1993
+ * with the theme stamped onto every block and row as an explicit override --
1994
+ * visually identical, but the inherit relationship was gone: a later theme
1995
+ * edit (font, text ink, content background) no longer reached anything. A
1996
+ * value that merely restates what the imported theme already says folds back
1997
+ * to "inherit"; anything genuinely different is a real override and stays.
1998
+ */
1999
+ /** 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. */
2000
+ function foldLinkColor(html, linkKey, ckey) {
2001
+ const src = String(html || '');
2002
+ if (!linkKey || src.indexOf('<a') < 0 || src.indexOf('color') < 0) return html;
2003
+ const doc = new DOMParser().parseFromString('<div id="mc-fold">' + src + '</div>', 'text/html');
2004
+ const root = doc.getElementById('mc-fold');
2005
+ if (!root) return html;
2006
+ let hit = false;
2007
+ root.querySelectorAll('a[style]').forEach((a) => {
2008
+ if (a.style.color && ckey(a.style.color) === linkKey) {
2009
+ a.style.removeProperty('color');
2010
+ if (!(a.getAttribute('style') || '').trim()) a.removeAttribute('style');
2011
+ hit = true;
2012
+ }
2013
+ });
2014
+ return hit ? root.innerHTML : html;
2015
+ }
2016
+
2017
+ function foldThemeInherits(rows, theme) {
2018
+ const tFont = fontKey(theme.font);
2019
+ // Colors compare through hexOf on BOTH sides: CSSOM hands the walkers
2020
+ // `rgb(...)` for every hex the exporter wrote, so a bare string comparison
2021
+ // saw `#172033` != `rgb(23, 32, 51)` and the fold silently never fired.
2022
+ const ckey = (v) => String(hexOf(v) || '').toLowerCase();
2023
+ const tText = ckey(theme.text);
2024
+ const cBg = ckey(theme.contentBg);
2025
+ const tLink = ckey(theme.link);
2026
+ // Only the types whose renderer falls back `p.color || t.text` -- an empty
2027
+ // color means "theme ink" for exactly these; other blocks' colors are
2028
+ // structural (a button label, a divider line) and must stay explicit.
2029
+ const inheritsInk = { text: 1, heading: 1, list: 1 };
2030
+ rows.forEach((row) => {
2031
+ const bg = ckey(row.props.bg);
2032
+ if (bg && bg === cBg) row.props.bg = '';
2033
+ row.cols.forEach((col) => {
2034
+ // A see-through column wrapper is the exporter's own scaffolding
2035
+ // (`background: transparent` is always written on a styled column),
2036
+ // never a chosen paint.
2037
+ if (String(col.bg || '').toLowerCase() === 'transparent') col.bg = '';
2038
+ col.blocks.forEach((b) => {
2039
+ if (tFont && b.props.fontFamily && fontKey(b.props.fontFamily) === tFont) b.props.fontFamily = '';
2040
+ if (tText && inheritsInk[b.type] && ckey(b.props.color) === tText) b.props.color = '';
2041
+ if (tLink && theme.link && typeof b.props.html === 'string') b.props.html = foldLinkColor(b.props.html, tLink, ckey);
2042
+ if (tLink && theme.link && b.type === 'list' && b.props.items) {
2043
+ b.props.items = String(b.props.items).split('\n').map((l) => foldLinkColor(l, tLink, ckey)).join('\n');
2044
+ }
2045
+ });
2046
+ });
2047
+ });
2048
+ }
2049
+
1554
2050
  /** 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
2051
  export function htmlToDoc(src) {
1556
2052
  let doc;
@@ -1562,7 +2058,9 @@ export function htmlToDoc(src) {
1562
2058
  // Theme first: themeFromParsedDoc consumes the styles it claims off the
1563
2059
  // scaffold nodes, and the row walker must see the cleaned DOM.
1564
2060
  const theme = themeFromParsedDoc(doc);
1565
- return { rows: collectRows(Array.from(doc.body.childNodes)), theme };
2061
+ const rows = collectRows(Array.from(doc.body.childNodes));
2062
+ foldThemeInherits(rows, theme);
2063
+ return { rows, theme };
1566
2064
  }
1567
2065
 
1568
2066
  export function htmlToRows(src) {