@templatical/import-stripo 0.42.1 → 0.43.0

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.
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { load } from "cheerio";
2
2
  import { convertHtmlTemplate } from "@templatical/import-html";
3
- import { createButtonBlock, createDefaultTemplateContent, createHtmlBlock, createMenuBlock, createSectionBlock, createSocialIconsBlock, createSpacerBlock, generateId } from "@templatical/types";
3
+ import { createButtonBlock, createDefaultTemplateContent, createDividerBlock, createHtmlBlock, createMenuBlock, createSectionBlock, createSocialIconsBlock, createSpacerBlock, generateId } from "@templatical/types";
4
4
  //#region src/classes.ts
5
5
  function classTokens($el) {
6
6
  return ($el.attr("class") ?? "").trim().split(/\s+/).filter(Boolean);
@@ -39,10 +39,29 @@ function isHidden($el) {
39
39
  }
40
40
  //#endregion
41
41
  //#region src/css.ts
42
- /**
43
- * Tiny CSS readers for paint that compiled Stripo HTML already inlines.
44
- * Hex (3/6) and a bare bgcolor hex; "transparent" is unset.
45
- */
42
+ const ZERO$3 = {
43
+ top: 0,
44
+ right: 0,
45
+ bottom: 0,
46
+ left: 0
47
+ };
48
+ /** Column shares of the body width. The third column takes the rounding remainder. */
49
+ const COLUMN_SHARES = {
50
+ "1": [100],
51
+ "2": [50, 50],
52
+ "3": [
53
+ 33.33,
54
+ 33.33,
55
+ 33.34
56
+ ]
57
+ };
58
+ const DIVIDER_WIDTH = /^(-?\d+(?:\.\d+)?)\s*(%|px)?$/i;
59
+ const SIDE_LETTER = {
60
+ t: "top",
61
+ r: "right",
62
+ b: "bottom",
63
+ l: "left"
64
+ };
46
65
  function parseStyleAttribute(styleAttr) {
47
66
  const result = {};
48
67
  if (!styleAttr) return result;
@@ -55,13 +74,18 @@ function parseStyleAttribute(styleAttr) {
55
74
  }
56
75
  return result;
57
76
  }
77
+ function channelHex(value) {
78
+ return Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, "0");
79
+ }
58
80
  function parseColor(value) {
59
81
  if (!value) return "";
60
82
  const trimmed = value.trim().toLowerCase();
61
83
  if (trimmed === "transparent" || trimmed === "inherit" || trimmed === "none") return "";
62
- const hex = trimmed.startsWith("#") ? trimmed : /^[0-9a-f]{3}$|^[0-9a-f]{6}$/.test(trimmed) ? `#${trimmed}` : trimmed;
84
+ const hex = trimmed.startsWith("#") ? trimmed : /^[0-9a-f]{3}$|^[0-9a-f]{6}$/.test(trimmed) ? `#${trimmed}` : "";
63
85
  if (/^#[0-9a-f]{6}$/.test(hex)) return hex;
64
86
  if (/^#[0-9a-f]{3}$/.test(hex)) return `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`;
87
+ const rgb = trimmed.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+\s*)?\)$/);
88
+ if (rgb) return `#${channelHex(Number(rgb[1]))}${channelHex(Number(rgb[2]))}${channelHex(Number(rgb[3]))}`;
65
89
  return "";
66
90
  }
67
91
  function parsePxValue(value) {
@@ -75,6 +99,209 @@ function colorFromPaint(styleAttr, bgcolor) {
75
99
  const fromShorthand = styles.background ? parseColor(styles.background.trim().split(/\s+/)[0]) : "";
76
100
  return parseColor(styles["background-color"]) || fromShorthand || parseColor(bgcolor) || "";
77
101
  }
102
+ function hasBackgroundImage(styleAttr) {
103
+ if (!styleAttr) return false;
104
+ const styles = parseStyleAttribute(styleAttr);
105
+ return /url\s*\(/i.test(styles["background-image"] ?? "") || /url\s*\(/i.test(styles.background ?? "");
106
+ }
107
+ function parsePaddingShorthand(value) {
108
+ if (!value) return { ...ZERO$3 };
109
+ const values = value.trim().split(/\s+/).filter(Boolean).map((part) => parsePxValue(part));
110
+ if (values.length === 0) return { ...ZERO$3 };
111
+ if (values.length === 1) return {
112
+ top: values[0],
113
+ right: values[0],
114
+ bottom: values[0],
115
+ left: values[0]
116
+ };
117
+ if (values.length === 2) return {
118
+ top: values[0],
119
+ right: values[1],
120
+ bottom: values[0],
121
+ left: values[1]
122
+ };
123
+ if (values.length === 3) return {
124
+ top: values[0],
125
+ right: values[1],
126
+ bottom: values[2],
127
+ left: values[1]
128
+ };
129
+ return {
130
+ top: values[0],
131
+ right: values[1],
132
+ bottom: values[2],
133
+ left: values[3]
134
+ };
135
+ }
136
+ /**
137
+ * Longhand wins when it is present, including an explicit 0.
138
+ * A missing longhand falls back to the shorthand, then 0.
139
+ */
140
+ function readPadding(styles) {
141
+ const shorthand = styles.padding !== void 0 ? parsePaddingShorthand(styles.padding) : ZERO$3;
142
+ const side = (name) => {
143
+ const key = `padding-${name}`;
144
+ if (styles[key] !== void 0) return parsePxValue(styles[key]);
145
+ return shorthand[name];
146
+ };
147
+ return {
148
+ top: side("top"),
149
+ right: side("right"),
150
+ bottom: side("bottom"),
151
+ left: side("left")
152
+ };
153
+ }
154
+ /** `es-p20` sets every side; `es-p10t` / `r` / `b` / `l` then override one side. */
155
+ function paddingFromClasses(classAttr) {
156
+ const pad = { ...ZERO$3 };
157
+ const tokens = (classAttr ?? "").split(/\s+/).filter(Boolean);
158
+ for (const token of tokens) {
159
+ const all = /^es-p(\d+)$/.exec(token);
160
+ if (!all) continue;
161
+ const n = Number(all[1]);
162
+ pad.top = n;
163
+ pad.right = n;
164
+ pad.bottom = n;
165
+ pad.left = n;
166
+ }
167
+ for (const token of tokens) {
168
+ const side = /^es-p(\d+)([trbl])$/.exec(token);
169
+ if (!side) continue;
170
+ pad[SIDE_LETTER[side[2]]] = Number(side[1]);
171
+ }
172
+ return pad;
173
+ }
174
+ /**
175
+ * Class padding first, then inline padding per side.
176
+ * A shorthand replaces every side; a longhand replaces only that side.
177
+ */
178
+ function readBoxPadding(classAttr, styleAttr) {
179
+ const base = paddingFromClasses(classAttr);
180
+ const styles = parseStyleAttribute(styleAttr);
181
+ const hasShorthand = styles.padding !== void 0;
182
+ const shorthand = hasShorthand ? parsePaddingShorthand(styles.padding) : base;
183
+ const side = (name) => {
184
+ const key = `padding-${name}`;
185
+ if (styles[key] !== void 0) return parsePxValue(styles[key]);
186
+ if (hasShorthand) return shorthand[name];
187
+ return base[name];
188
+ };
189
+ return {
190
+ top: side("top"),
191
+ right: side("right"),
192
+ bottom: side("bottom"),
193
+ left: side("left")
194
+ };
195
+ }
196
+ /**
197
+ * Body fill paints the section. A band that is neither the page nor the body
198
+ * becomes the section wrapper. A transparent body leaves the band on the section.
199
+ */
200
+ function resolveSectionPaint(pageBackground, band, body) {
201
+ const page = pageBackground || "#ffffff";
202
+ if (body) {
203
+ if (band && band !== page && band !== body) return {
204
+ backgroundColor: body,
205
+ wrapperColor: band,
206
+ notes: ["The band colour was imported as a section wrapper."]
207
+ };
208
+ return {
209
+ backgroundColor: body,
210
+ notes: []
211
+ };
212
+ }
213
+ return {
214
+ backgroundColor: band,
215
+ notes: []
216
+ };
217
+ }
218
+ /** `es-*-body` width in px, or 600 when the document does not state one. */
219
+ function emailBodyWidth(widthAttr, styleAttr) {
220
+ const n = parsePxValue((widthAttr || parseStyleAttribute(styleAttr).width || "").trim());
221
+ return n > 0 ? n : 600;
222
+ }
223
+ /**
224
+ * Room each column has for a line, before the line's own side padding.
225
+ * The share is of the body width; the first column then loses the section's
226
+ * left padding and the last loses the right. One column loses both.
227
+ * A room that is not positive is unknown — the caller keeps the stated px.
228
+ */
229
+ function columnRooms(bodyWidth, layout, padding, slotCount) {
230
+ const shares = COLUMN_SHARES[layout];
231
+ const count = layout === "1" ? 1 : slotCount;
232
+ const width = bodyWidth > 0 ? bodyWidth : 600;
233
+ return Array.from({ length: count }, (_, index) => {
234
+ const span = layout === "1" ? width : width * shares[index] / 100;
235
+ const left = layout === "1" || index === 0 ? padding.left : 0;
236
+ const right = layout === "1" || index === count - 1 ? padding.right : 0;
237
+ const room = span - left - right;
238
+ return room > 0 ? room : void 0;
239
+ });
240
+ }
241
+ /**
242
+ * No width, `auto` and `100%` span the column. Another percentage stays a
243
+ * share, clamped to 0–100 and rounded to two decimals. A px width stays px
244
+ * until it reaches `room` less the divider's own side padding.
245
+ */
246
+ function readDividerWidth(raw, room, ownLeft, ownRight, notes) {
247
+ const trimmed = raw.trim();
248
+ if (trimmed === "" || trimmed.toLowerCase() === "auto" || trimmed === "100%") return "full";
249
+ const match = trimmed.match(DIVIDER_WIDTH);
250
+ if (!match) {
251
+ notes.push(`Divider width "${trimmed}" could not be read; imported as full width.`);
252
+ return "full";
253
+ }
254
+ const value = parseFloat(match[1]);
255
+ if ((match[2] ?? "").toLowerCase() === "%") {
256
+ const percent = Math.min(100, Math.max(0, value));
257
+ if (percent !== value) notes.push(`Divider width ${trimmed} was clamped to ${percent}%.`);
258
+ const share = Math.round((percent + Number.EPSILON) * 100) / 100;
259
+ return share === 100 ? "full" : `${share}%`;
260
+ }
261
+ const px = Math.max(0, Math.round(value));
262
+ if (value < 0) notes.push(`Divider width ${trimmed} was clamped to 0px.`);
263
+ if (room === void 0 || room <= 0) return px;
264
+ return px >= room - ownLeft - ownRight ? "full" : px;
265
+ }
266
+ const BORDER_STYLES = /* @__PURE__ */ new Set([
267
+ "none",
268
+ "hidden",
269
+ "dotted",
270
+ "dashed",
271
+ "solid",
272
+ "double",
273
+ "groove",
274
+ "ridge",
275
+ "inset",
276
+ "outset"
277
+ ]);
278
+ /** A `border-bottom` / `border-top` shorthand. Color is empty when absent. */
279
+ function parseBorderShorthand(value) {
280
+ const parsed = {
281
+ width: 0,
282
+ style: "solid",
283
+ color: ""
284
+ };
285
+ if (!value) return parsed;
286
+ for (const token of value.trim().split(/\s+/)) {
287
+ const lower = token.toLowerCase();
288
+ if (BORDER_STYLES.has(lower)) parsed.style = lower;
289
+ else if (/^-?\d+(?:\.\d+)?(?:px)?$/i.test(lower)) parsed.width = parsePxValue(lower);
290
+ else {
291
+ const color = parseColor(lower);
292
+ if (color) parsed.color = color;
293
+ }
294
+ }
295
+ return parsed;
296
+ }
297
+ function visibleLineBorder(styles) {
298
+ for (const key of ["border-bottom", "border-top"]) {
299
+ if (styles[key] === void 0) continue;
300
+ const parsed = parseBorderShorthand(styles[key]);
301
+ if (parsed.width > 0 && parsed.style !== "none" && parsed.style !== "hidden") return parsed;
302
+ }
303
+ return null;
304
+ }
78
305
  //#endregion
79
306
  //#region src/fragment.ts
80
307
  /** Paint after `createSectionBlock` so `styles` keeps its required padding. */
@@ -88,11 +315,21 @@ function flattenBlocks(blocks) {
88
315
  else out.push(b);
89
316
  return out;
90
317
  }
91
- /** Run the generic HTML importer on a subtree and keep its blocks, not its sections. */
318
+ function fragmentDocument(inner, css) {
319
+ const safe = css ? css.replace(/<\/style/gi, "<\\/style") : "";
320
+ return `<!DOCTYPE html><html>${safe ? `<head><style>${safe}</style></head>` : ""}<body>${inner}</body></html>`;
321
+ }
322
+ /**
323
+ * Run the generic HTML importer on a subtree and keep its blocks.
324
+ * Section entries belong to the row the HTML importer invented; Stripo's own
325
+ * section entries are recorded by the caller.
326
+ */
92
327
  function blocksFromHtml(inner, ctx) {
93
- const wrapped = `<!DOCTYPE html><html><body>${inner}</body></html>`;
94
- const result = convertHtmlTemplate(wrapped);
95
- ctx.entries.push(...result.report.entries);
328
+ const result = convertHtmlTemplate(fragmentDocument(inner, ctx.css));
329
+ for (const entry of result.report.entries) {
330
+ if (entry.templaticalBlockType === "section") continue;
331
+ ctx.entries.push(entry);
332
+ }
96
333
  ctx.warnings.push(...result.report.warnings);
97
334
  return flattenBlocks(result.content.blocks);
98
335
  }
@@ -152,6 +389,28 @@ function normalizePlatform(name, src, alt) {
152
389
  }
153
390
  //#endregion
154
391
  //#region src/labelled.ts
392
+ const ZERO$2 = {
393
+ top: 0,
394
+ right: 0,
395
+ bottom: 0,
396
+ left: 0
397
+ };
398
+ /** Tables whose padding is the section's padding. A divider must not copy it. */
399
+ const STRUCTURE_TABLES = [
400
+ "es-header-body",
401
+ "es-content-body",
402
+ "es-footer-body",
403
+ "es-header",
404
+ "es-content",
405
+ "es-footer"
406
+ ];
407
+ const MAPPED_LINE_STYLES = /* @__PURE__ */ new Set([
408
+ "double",
409
+ "groove",
410
+ "ridge",
411
+ "inset",
412
+ "outset"
413
+ ]);
155
414
  function firstAnchor($el) {
156
415
  return $el.is("a") ? $el : $el.find("a").first();
157
416
  }
@@ -175,13 +434,123 @@ function buttonFrom($el, sourceTag, ctx) {
175
434
  ...borderRadius > 0 ? { borderRadius } : {}
176
435
  });
177
436
  }
178
- function spacerFrom($el, sourceTag, ctx) {
179
- const style = $el.attr("style") ?? "";
180
- const h = /(?:^|;)\s*height\s*:\s*(\d+)px/i.exec(style);
181
- const attrH = $el.attr("height");
182
- const height = h ? Number(h[1]) : attrH ? Number(attrH) : 24;
183
- pushEntry(ctx, sourceTag, "spacer", "converted");
184
- return createSpacerBlock({ height: Number.isFinite(height) && height > 0 ? height : 24 });
437
+ function declaresPadding($el) {
438
+ const styles = parseStyleAttribute($el.attr("style"));
439
+ return styles.padding !== void 0 || styles["padding-top"] !== void 0 || styles["padding-right"] !== void 0 || styles["padding-bottom"] !== void 0 || styles["padding-left"] !== void 0;
440
+ }
441
+ /**
442
+ * The structure cell's padding becomes the section's padding. Reading it again
443
+ * as the divider's own padding shortens the line twice.
444
+ */
445
+ function isStructureCell($cell) {
446
+ if (!$cell.is("td") && !$cell.is("th")) return false;
447
+ if (hasToken($cell, "esd-structure")) return true;
448
+ let $table = $cell.parent().parent();
449
+ if ($table.is("tbody") || $table.is("thead") || $table.is("tfoot")) $table = $table.parent();
450
+ return $table.is("table") && hasAnyToken($table, STRUCTURE_TABLES);
451
+ }
452
+ function spacerElement($root) {
453
+ if (hasToken($root, "es-spacer")) return $root;
454
+ const $inner = $root.find("[class~='es-spacer']").first();
455
+ return $inner.length ? $inner : $root;
456
+ }
457
+ function lineBorder($root, $spacer, $) {
458
+ const own = visibleLineBorder(parseStyleAttribute($root.attr("style")));
459
+ if (own) return own;
460
+ const onSpacer = visibleLineBorder(parseStyleAttribute($spacer.attr("style")));
461
+ if (onSpacer) return onSpacer;
462
+ let found = null;
463
+ $spacer.find("td, th").each((_, cell) => {
464
+ if (found) return;
465
+ found = visibleLineBorder(parseStyleAttribute($(cell).attr("style")));
466
+ });
467
+ return found;
468
+ }
469
+ function dividerPadding($root, $spacer) {
470
+ if (declaresPadding($root)) return readPadding(parseStyleAttribute($root.attr("style")));
471
+ if ($spacer[0] !== $root[0] && declaresPadding($spacer)) return readPadding(parseStyleAttribute($spacer.attr("style")));
472
+ const $parent = $spacer.parent();
473
+ if (($parent.is("td") || $parent.is("th")) && !isStructureCell($parent)) return readPadding(parseStyleAttribute($parent.attr("style")));
474
+ return { ...ZERO$2 };
475
+ }
476
+ /**
477
+ * No align and no margin is unset. A partial Stripo line with neither sits on
478
+ * the left; the caller supplies that default.
479
+ */
480
+ function specifiedAlign($el) {
481
+ const styles = parseStyleAttribute($el.attr("style"));
482
+ const alignAttr = ($el.attr("align") ?? "").trim().toLowerCase();
483
+ const alignKnown = alignAttr === "left" || alignAttr === "center" || alignAttr === "right";
484
+ const hasMargin = styles.margin !== void 0 || styles["margin-left"] !== void 0 || styles["margin-right"] !== void 0;
485
+ if (!alignKnown && !hasMargin) return void 0;
486
+ let left = "auto";
487
+ let right = "auto";
488
+ if (alignAttr === "left") left = "0";
489
+ if (alignAttr === "right") right = "0";
490
+ const margin = (styles.margin ?? "").trim().split(/\s+/).filter(Boolean);
491
+ if (margin.length > 0) {
492
+ right = margin[1] ?? margin[0];
493
+ left = margin.length === 4 ? margin[3] : right;
494
+ }
495
+ if (styles["margin-left"] !== void 0) left = styles["margin-left"];
496
+ if (styles["margin-right"] !== void 0) right = styles["margin-right"];
497
+ const isAuto = (value) => value.trim().toLowerCase() === "auto";
498
+ if (isAuto(left) && isAuto(right)) return "center";
499
+ return isAuto(left) ? "right" : "left";
500
+ }
501
+ function placementOf($spacer, $root) {
502
+ const own = specifiedAlign($spacer);
503
+ if (own) return own;
504
+ for (const $candidate of [$spacer.parent(), $root.parent()]) {
505
+ if (!$candidate.is("td") && !$candidate.is("th")) continue;
506
+ const fromCell = specifiedAlign($candidate);
507
+ if (fromCell) return fromCell;
508
+ }
509
+ return "left";
510
+ }
511
+ function spacerHeight($el) {
512
+ const styles = parseStyleAttribute($el.attr("style"));
513
+ const fromStyle = parsePxValue(styles.height);
514
+ if (fromStyle > 0) return fromStyle;
515
+ const fromAttr = parsePxValue($el.attr("height"));
516
+ if (fromAttr > 0) return fromAttr;
517
+ const pad = readPadding(styles);
518
+ const vertical = pad.top + pad.bottom;
519
+ return vertical > 0 ? vertical : 24;
520
+ }
521
+ function dividerBlock(border, padding, width) {
522
+ const lineStyle = border.style === "dashed" || border.style === "dotted" ? border.style : "solid";
523
+ return createDividerBlock({
524
+ lineStyle,
525
+ color: border.color || "#e5e7eb",
526
+ thickness: border.width,
527
+ width,
528
+ styles: { padding }
529
+ });
530
+ }
531
+ /**
532
+ * A bordered spacer is a divider. `room` is the column width the line can
533
+ * span, after the section padding that column owns.
534
+ */
535
+ function spacerFrom($el, sourceTag, ctx, $, room) {
536
+ const $spacer = spacerElement($el);
537
+ const border = lineBorder($el, $spacer, $);
538
+ if (!border) {
539
+ const height = spacerHeight($spacer);
540
+ pushEntry(ctx, sourceTag, "spacer", "converted");
541
+ return createSpacerBlock({ height });
542
+ }
543
+ const padding = dividerPadding($el, $spacer);
544
+ const raw = (parseStyleAttribute($spacer.attr("style")).width ?? $spacer.attr("width") ?? "").trim();
545
+ const notes = [];
546
+ const width = readDividerWidth(raw, room, padding.left, padding.right, notes);
547
+ if (MAPPED_LINE_STYLES.has(border.style)) notes.push(`Divider border style "${border.style}" was imported as solid.`);
548
+ if (width !== "full") {
549
+ const align = placementOf($spacer, $el);
550
+ if (align !== "center") notes.push(`The source aligns this divider ${align}; Templatical centres every divider.`);
551
+ }
552
+ pushEntry(ctx, sourceTag, "divider", notes.length > 0 ? "approximated" : "converted", notes.length > 0 ? notes.join(" ") : void 0);
553
+ return dividerBlock(border, padding, width);
185
554
  }
186
555
  function menuItems($table, $) {
187
556
  const items = [];
@@ -237,157 +606,344 @@ function labelledInnerBlocks($el, $, ctx) {
237
606
  }
238
607
  //#endregion
239
608
  //#region src/compiled.ts
240
- function paintOf($el) {
241
- return colorFromPaint($el.attr("style"), $el.attr("bgcolor"));
242
- }
243
- /**
244
- * The 600px band (`es-*-body`) carries the fill; the outer stripe is often
245
- * `transparent`. Style wins over `bgcolor` (a white bgcolor with a mint
246
- * `background-color` is the compiled password-reset shape).
247
- */
248
- function stripeFill($stripe) {
249
- const $body = $stripe.find("[class~='es-header-body'], [class~='es-content-body'], [class~='es-footer-body']").first();
250
- if ($body.length) {
251
- const fromBody = paintOf($body);
252
- if (fromBody) return fromBody;
253
- }
254
- return paintOf($stripe);
255
- }
609
+ const ZERO$1 = {
610
+ top: 0,
611
+ right: 0,
612
+ bottom: 0,
613
+ left: 0
614
+ };
615
+ const COLUMN_TOKENS = ["es-left", "es-right"];
256
616
  const STRIPE_TOKENS = [
257
617
  "es-header",
258
618
  "es-content",
259
619
  "es-footer"
260
620
  ];
621
+ const BODY_SELECTOR$1 = "[class~='es-header-body'], [class~='es-content-body'], [class~='es-footer-body']";
622
+ const BETWEEN_NOTE = "Content between columns was placed after them.";
623
+ const SEAM_NOTE = "This row's padding was split across more than one section.";
624
+ const IMAGE_NOTE$1 = "A background image was not imported.";
625
+ function paintOf($el) {
626
+ return colorFromPaint($el.attr("style"), $el.attr("bgcolor"));
627
+ }
261
628
  function stripeToken($el) {
262
- return STRIPE_TOKENS.find((t) => hasToken($el, t));
629
+ return STRIPE_TOKENS.find((token) => hasToken($el, token));
263
630
  }
264
631
  function topStripes($) {
265
632
  const out = [];
266
633
  $("[class]").each((_, node) => {
267
634
  const $el = $(node);
268
635
  if (!stripeToken($el)) return;
269
- if ($el.parents().toArray().some((p) => hasAnyToken($(p), STRIPE_TOKENS))) return;
636
+ if ($el.parents().toArray().some((parent) => hasAnyToken($(parent), STRIPE_TOKENS))) return;
270
637
  out.push($el);
271
638
  });
272
639
  return out;
273
640
  }
274
- function floatedColumns($stripe, $) {
641
+ function directRows($table, $) {
642
+ const $direct = $table.children("tr");
643
+ const $nested = $table.children("tbody, thead, tfoot").children("tr");
644
+ return ($direct.length > 0 ? $direct : $nested).toArray().map((node) => $(node));
645
+ }
646
+ function directCell($table) {
647
+ const $direct = $table.children("tr").children("td, th");
648
+ const $nested = $table.children("tbody, thead, tfoot").children("tr").children("td, th");
649
+ return ($direct.length > 0 ? $direct : $nested).first();
650
+ }
651
+ /** Paint on the stripe table, else on its cell. A `td` stripe is its own paint. */
652
+ function bandColor$1($stripe) {
653
+ const own = paintOf($stripe);
654
+ if (own) return own;
655
+ if ($stripe.is("td") || $stripe.is("th")) return "";
656
+ const $cell = directCell($stripe);
657
+ return $cell.length > 0 ? paintOf($cell) : "";
658
+ }
659
+ function imageNote$1(...styles) {
660
+ return styles.some((style) => hasBackgroundImage(style)) ? IMAGE_NOTE$1 : void 0;
661
+ }
662
+ function hasPadding(padding) {
663
+ return padding.top !== 0 || padding.right !== 0 || padding.bottom !== 0 || padding.left !== 0;
664
+ }
665
+ function splitPadding(padding, count, index) {
666
+ if (count <= 1) return { ...padding };
667
+ return {
668
+ top: index === 0 ? padding.top : 0,
669
+ right: padding.right,
670
+ bottom: index === count - 1 ? padding.bottom : 0,
671
+ left: padding.left
672
+ };
673
+ }
674
+ function isColumn($el) {
675
+ return $el.is("table") && hasAnyToken($el, COLUMN_TOKENS);
676
+ }
677
+ function containsColumn($el, $) {
678
+ const stop = $el[0];
679
+ let found = false;
680
+ $el.find("table").each((_, node) => {
681
+ if (found) return;
682
+ const $n = $(node);
683
+ if (!hasAnyToken($n, COLUMN_TOKENS)) return;
684
+ let $parent = $n.parent();
685
+ while ($parent.length > 0 && $parent[0] !== stop) {
686
+ if (hasAnyToken($parent, COLUMN_TOKENS)) return;
687
+ $parent = $parent.parent();
688
+ }
689
+ found = true;
690
+ });
691
+ return found;
692
+ }
693
+ /**
694
+ * Columns in one cell are one section. Flow before them, between them and
695
+ * after them becomes its own one-column section, in that order.
696
+ */
697
+ function collectPieces($root, $) {
698
+ const pieces = [];
699
+ const walk = ($parent) => {
700
+ const flow = [];
701
+ const flush = () => {
702
+ if (flow.length === 0) return;
703
+ pieces.push({
704
+ kind: "flow",
705
+ nodes: flow.splice(0, flow.length)
706
+ });
707
+ };
708
+ for (const node of $parent.contents().toArray()) {
709
+ if (node.type === "text") {
710
+ if (node.data.trim()) flow.push(node);
711
+ continue;
712
+ }
713
+ if (node.type !== "tag") continue;
714
+ const $el = $(node);
715
+ if (isHidden($el)) continue;
716
+ if (isColumn($el)) {
717
+ flush();
718
+ pieces.push({
719
+ kind: "column",
720
+ el: node
721
+ });
722
+ continue;
723
+ }
724
+ if (containsColumn($el, $)) {
725
+ flush();
726
+ walk($el);
727
+ continue;
728
+ }
729
+ flow.push(node);
730
+ }
731
+ flush();
732
+ };
733
+ walk($root);
734
+ return pieces;
735
+ }
736
+ function widgetKind($el) {
737
+ if (hasToken($el, "es-menu")) {
738
+ if (!$el.is("table") && $el.find("table").length > 0) return null;
739
+ return "menu";
740
+ }
741
+ if (hasToken($el, "es-social")) return "social";
742
+ if (hasToken($el, "es-button")) {
743
+ if ($el.is("a") || $el.find("a").length > 0) return "button";
744
+ return null;
745
+ }
746
+ if (hasToken($el, "es-spacer")) return "spacer";
747
+ return null;
748
+ }
749
+ function containsWidget($el, $) {
750
+ let found = false;
751
+ $el.find("[class]").each((_, node) => {
752
+ if (found) return;
753
+ if (widgetKind($(node))) found = true;
754
+ });
755
+ return found;
756
+ }
757
+ function widgetBlocks($el, kind, $, ctx, room) {
758
+ if (kind === "button") return [buttonFrom($el, "es-button", ctx)];
759
+ if (kind === "spacer") return [spacerFrom($el, "es-spacer", ctx, $, room)];
760
+ if (kind === "social") return [socialFrom($el, $, "es-social", ctx)];
761
+ const menu = menuFrom($el, $, "es-menu", ctx);
762
+ if (menu) return [menu];
763
+ return blocksFromHtml($.html($el) ?? "", ctx);
764
+ }
765
+ /** Widgets stay where they sit. A run of everything else goes through HTML import. */
766
+ function convertRegion(nodes, $, ctx, room) {
275
767
  const out = [];
276
- $stripe.find("[class~='es-left'], [class~='es-right']").each((_, node) => {
768
+ let pending = "";
769
+ const flush = () => {
770
+ if (!pending.trim()) {
771
+ pending = "";
772
+ return;
773
+ }
774
+ out.push(...blocksFromHtml(pending, ctx));
775
+ pending = "";
776
+ };
777
+ const visit = (node) => {
778
+ if (node.type === "text") {
779
+ if (node.data.trim()) pending += node.data;
780
+ return;
781
+ }
782
+ if (node.type !== "tag") return;
277
783
  const $el = $(node);
784
+ if ($el.is("head, style, script")) return;
278
785
  if (isHidden($el)) return;
279
- if ($el.parents("[class~='es-left'], [class~='es-right']").filter((_, p) => {
280
- return $stripe.find(p).length > 0 || $(p).closest($stripe).length > 0;
281
- }).length > 0) return;
282
- if ($el.parents().toArray().some((p) => {
283
- if (p === $stripe[0]) return false;
284
- return hasAnyToken($(p), STRIPE_TOKENS);
285
- })) return;
286
- out.push($el);
287
- });
786
+ const kind = widgetKind($el);
787
+ if (kind) {
788
+ flush();
789
+ out.push(...widgetBlocks($el, kind, $, ctx, room));
790
+ return;
791
+ }
792
+ if (containsWidget($el, $)) {
793
+ for (const child of $el.contents().toArray()) visit(child);
794
+ return;
795
+ }
796
+ pending += $.html(node) ?? "";
797
+ };
798
+ for (const node of nodes) visit(node);
799
+ flush();
288
800
  return out;
289
801
  }
290
- function columnLayout$1(count, ctx, sourceTag) {
291
- if (count <= 1) return {
292
- layout: "1",
293
- flattenExtra: false
294
- };
295
- if (count === 2) return {
296
- layout: "2",
297
- flattenExtra: false
298
- };
299
- if (count === 3) return {
300
- layout: "3",
301
- flattenExtra: false
802
+ function regionFromElement($el, $, ctx, room) {
803
+ return convertRegion($el.contents().toArray(), $, ctx, room);
804
+ }
805
+ function hasBlocks(children) {
806
+ return children.some((column) => column.length > 0);
807
+ }
808
+ function columnChildren(cols, $, ctx, bodyWidth, padding) {
809
+ const count = cols.length;
810
+ const layout = count >= 3 ? "3" : count === 2 ? "2" : "1";
811
+ const rooms = columnRooms(bodyWidth, count >= 4 ? "3" : layout, padding, count >= 4 ? 3 : layout === "1" ? 1 : count);
812
+ let children;
813
+ if (count >= 4) {
814
+ const rest = [];
815
+ for (const el of cols.slice(2)) rest.push(...regionFromElement($(el), $, ctx, rooms[2]));
816
+ children = [
817
+ regionFromElement($(cols[0]), $, ctx, rooms[0]),
818
+ regionFromElement($(cols[1]), $, ctx, rooms[1]),
819
+ rest
820
+ ];
821
+ } else children = cols.map((el, index) => regionFromElement($(el), $, ctx, rooms[index]));
822
+ if (!hasBlocks(children)) return null;
823
+ if (count < 4) return {
824
+ children,
825
+ layout: count >= 3 ? "3" : layout
302
826
  };
303
- pushEntry(ctx, sourceTag, "section", "approximated", `Row with ${count} columns was flattened; extra columns merged into the third slot. Templatical supports up to 3 columns per section.`);
304
- ctx.warnings.push(`Row with ${count} columns was flattened; extra columns merged into the third slot. Templatical supports up to 3 columns per section.`);
827
+ const flattenNote = `Row with ${count} columns was flattened; extra columns merged into the third slot. Templatical supports up to 3 columns per section.`;
828
+ ctx.warnings.push(flattenNote);
305
829
  return {
830
+ children,
306
831
  layout: "3",
307
- flattenExtra: true
832
+ flattenNote
308
833
  };
309
834
  }
310
- function convertSubtree($el, $, ctx) {
311
- const $clone = $el.clone();
312
- const replacements = [];
313
- const take = ($node, block) => {
314
- if (!block) return;
315
- const marker = `stripo-keep-${replacements.length}`;
316
- replacements.push([block]);
317
- $node.replaceWith(`<div data-stripo-keep="${marker}"></div>`);
318
- };
319
- $clone.find("table.es-menu, [class~='es-menu']").each((_, node) => {
320
- const $n = $(node);
321
- if (!$n.is("table") && $n.find("table").length) return;
322
- const menu = menuFrom($n, $, "es-menu", ctx);
323
- take($n, menu);
835
+ function flowNodes(pieces) {
836
+ return pieces.flatMap((piece) => piece.kind === "flow" ? piece.nodes : []);
837
+ }
838
+ function groupsFromPieces(pieces) {
839
+ const columns = pieces.filter((piece) => piece.kind === "column");
840
+ if (columns.length === 0) return pieces.length > 0 ? [{
841
+ mode: "flow",
842
+ pieces
843
+ }] : [];
844
+ let first = -1;
845
+ let last = -1;
846
+ pieces.forEach((piece, index) => {
847
+ if (piece.kind !== "column") return;
848
+ if (first === -1) first = index;
849
+ last = index;
324
850
  });
325
- $clone.find("[class~='es-social']").each((_, node) => {
326
- const $n = $(node);
327
- take($n, socialFrom($n, $, "es-social", ctx));
851
+ const groups = [];
852
+ const leading = pieces.slice(0, first);
853
+ if (leading.length > 0) groups.push({
854
+ mode: "flow",
855
+ pieces: leading
328
856
  });
329
- $clone.find("a.es-button, [class~='es-button']").each((_, node) => {
330
- const $n = $(node);
331
- if (!$n.is("a") && $n.find("a").length === 0) return;
332
- take($n, buttonFrom($n, "es-button", ctx));
857
+ groups.push({
858
+ mode: "columns",
859
+ pieces: columns
333
860
  });
334
- $clone.find("[class~='es-spacer']").each((_, node) => {
335
- const $n = $(node);
336
- take($n, spacerFrom($n, "es-spacer", ctx));
861
+ const between = pieces.slice(first + 1, last).filter((piece) => piece.kind === "flow");
862
+ if (between.length > 0) groups.push({
863
+ mode: "flow",
864
+ pieces: between,
865
+ extra: BETWEEN_NOTE
337
866
  });
338
- const generic = blocksFromHtml($.html($clone) ?? "", ctx);
339
- const out = [];
340
- const used = /* @__PURE__ */ new Set();
341
- const walk = (blocks) => {
342
- for (const b of blocks) {
343
- if (b.type === "html" && /data-stripo-keep="stripo-keep-(\d+)"/.test(b.content)) {
344
- const m = /data-stripo-keep="stripo-keep-(\d+)"/.exec(b.content);
345
- const i = m ? Number(m[1]) : -1;
346
- if (i >= 0 && replacements[i] && !used.has(i)) {
347
- used.add(i);
348
- out.push(...replacements[i]);
349
- continue;
350
- }
351
- }
352
- if (b.type === "paragraph" && /stripo-keep-/.test(b.content ?? "")) {
353
- const m = /stripo-keep-(\d+)/.exec(b.content);
354
- const i = m ? Number(m[1]) : -1;
355
- if (i >= 0 && replacements[i] && !used.has(i)) {
356
- used.add(i);
357
- out.push(...replacements[i]);
358
- continue;
359
- }
360
- }
361
- out.push(b);
867
+ const trailing = pieces.slice(last + 1);
868
+ if (trailing.length > 0) groups.push({
869
+ mode: "flow",
870
+ pieces: trailing
871
+ });
872
+ return groups;
873
+ }
874
+ function sectionsFromRow($row, $, ctx, token, paint, image, bodyWidth) {
875
+ if (isHidden($row)) return [];
876
+ const pieces = [];
877
+ let padding = { ...ZERO$1 };
878
+ let sawCell = false;
879
+ $row.children("td, th").each((_, cell) => {
880
+ const $cell = $(cell);
881
+ if (isHidden($cell)) return;
882
+ if (!sawCell) {
883
+ padding = readBoxPadding($cell.attr("class"), $cell.attr("style"));
884
+ sawCell = true;
362
885
  }
363
- };
364
- walk(generic);
365
- for (let i = 0; i < replacements.length; i++) if (!used.has(i)) out.push(...replacements[i]);
886
+ pieces.push(...collectPieces($cell, $));
887
+ });
888
+ if (!sawCell || pieces.length === 0) return [];
889
+ const rendered = [];
890
+ for (const group of groupsFromPieces(pieces)) {
891
+ if (group.mode === "columns") {
892
+ const built = columnChildren(group.pieces.flatMap((piece) => piece.kind === "column" ? [piece.el] : []), $, ctx, bodyWidth, padding);
893
+ if (!built) continue;
894
+ rendered.push({
895
+ children: built.children,
896
+ layout: built.layout,
897
+ extra: built.flattenNote
898
+ });
899
+ continue;
900
+ }
901
+ const blocks = convertRegion(flowNodes(group.pieces), $, ctx, columnRooms(bodyWidth, "1", padding, 1)[0]);
902
+ if (blocks.length === 0) continue;
903
+ rendered.push({
904
+ children: [blocks],
905
+ layout: "1",
906
+ extra: group.extra
907
+ });
908
+ }
909
+ if (rendered.length === 0) return [];
910
+ const seam = rendered.length > 1 && hasPadding(padding) ? SEAM_NOTE : void 0;
911
+ return rendered.map((group, index) => {
912
+ const notes = [
913
+ ...paint.notes,
914
+ group.extra,
915
+ image,
916
+ seam
917
+ ].filter((note) => Boolean(note));
918
+ pushEntry(ctx, token, "section", notes.length > 0 ? "approximated" : "converted", notes.length > 0 ? notes.join(" ") : void 0);
919
+ return withBackground(createSectionBlock({
920
+ columns: group.layout,
921
+ children: group.children,
922
+ styles: { padding: splitPadding(padding, rendered.length, index) },
923
+ ...paint.wrapperColor ? { wrapper: { backgroundColor: paint.wrapperColor } } : {}
924
+ }), paint.backgroundColor);
925
+ });
926
+ }
927
+ function sectionsFromStripe($stripe, $, ctx) {
928
+ if (isHidden($stripe)) return [];
929
+ const token = stripeToken($stripe) ?? "es-content";
930
+ const $body = $stripe.find(BODY_SELECTOR$1).first();
931
+ const rows = $body.length > 0 ? directRows($body, $) : directRows($stripe, $);
932
+ const paint = resolveSectionPaint(ctx.pageBackground ?? "#ffffff", bandColor$1($stripe), $body.length > 0 ? paintOf($body) : "");
933
+ const image = imageNote$1($stripe.attr("style"), directCell($stripe).attr("style"), $body.attr("style"));
934
+ const $width = $body.length > 0 ? $body : $stripe;
935
+ const bodyWidth = emailBodyWidth($width.attr("width"), $width.attr("style"));
936
+ const out = [];
937
+ for (const $row of rows) out.push(...sectionsFromRow($row, $, ctx, token, paint, image, bodyWidth));
366
938
  return out;
367
939
  }
368
940
  function convertCompiled(html, ctx) {
369
941
  const $ = load(html);
370
942
  const stripes = topStripes($);
371
- if (stripes.length === 0) return convertSubtree($.root(), $, ctx);
372
- return stripes.map(($stripe) => {
373
- const token = stripeToken($stripe) ?? "es-content";
374
- const cols = floatedColumns($stripe, $);
375
- const { layout, flattenExtra } = columnLayout$1(cols.length || 1, ctx, token);
376
- let children;
377
- if (cols.length <= 1) children = [convertSubtree($stripe, $, ctx)];
378
- else if (flattenExtra) {
379
- const slots = cols.slice(0, 2).map(($c) => convertSubtree($c, $, ctx));
380
- const rest = [];
381
- for (const $c of cols.slice(2)) rest.push(...convertSubtree($c, $, ctx));
382
- slots.push(rest);
383
- children = slots;
384
- } else children = cols.map(($c) => convertSubtree($c, $, ctx));
385
- if (!flattenExtra) pushEntry(ctx, token, "section", "converted");
386
- return withBackground(createSectionBlock({
387
- columns: layout,
388
- children
389
- }), stripeFill($stripe));
390
- });
943
+ if (stripes.length === 0) return convertRegion($.root().contents().toArray(), $, ctx);
944
+ const out = [];
945
+ for (const $stripe of stripes) out.push(...sectionsFromStripe($stripe, $, ctx));
946
+ return out;
391
947
  }
392
948
  //#endregion
393
949
  //#region src/detect.ts
@@ -475,23 +1031,34 @@ function isStripoHtml(html) {
475
1031
  }
476
1032
  //#endregion
477
1033
  //#region src/editor.ts
1034
+ const ZERO = {
1035
+ top: 0,
1036
+ right: 0,
1037
+ bottom: 0,
1038
+ left: 0
1039
+ };
1040
+ const BODY_SELECTOR = "[class~='es-header-body'], [class~='es-content-body'], [class~='es-footer-body']";
1041
+ const IMAGE_NOTE = "A background image was not imported.";
478
1042
  function columnLayout(count, ctx) {
479
- if (count <= 1) return "1";
480
- if (count === 2) return "2";
481
- if (count === 3) return "3";
482
- pushEntry(ctx, "esd-structure", "section", "approximated", `Row with ${count} columns was flattened to a single column. Templatical supports up to 3 columns per section.`);
483
- ctx.warnings.push(`Row with ${count} columns was flattened to a single column. Templatical supports up to 3 columns per section.`);
484
- return "1";
1043
+ if (count <= 1) return { layout: "1" };
1044
+ if (count === 2) return { layout: "2" };
1045
+ if (count === 3) return { layout: "3" };
1046
+ const flattenNote = `Row with ${count} columns was flattened to a single column. Templatical supports up to 3 columns per section.`;
1047
+ ctx.warnings.push(flattenNote);
1048
+ return {
1049
+ layout: "1",
1050
+ flattenNote
1051
+ };
485
1052
  }
486
1053
  function esdBlockKind($el) {
487
1054
  const token = tokenStartingWith($el, "esd-block-");
488
1055
  return token ? token.slice(10) : void 0;
489
1056
  }
490
- function convertEsdBlock($el, $, ctx) {
1057
+ function convertEsdBlock($el, $, ctx, room) {
491
1058
  const kind = esdBlockKind($el);
492
1059
  if (!kind) return labelledInnerBlocks($el, $, ctx);
493
1060
  if (kind === "button") return [buttonFrom($el, "esd-block-button", ctx)];
494
- if (kind === "spacer") return [spacerFrom($el, "esd-block-spacer", ctx)];
1061
+ if (kind === "spacer") return [spacerFrom($el, "esd-block-spacer", ctx, $, room)];
495
1062
  if (kind === "social") return [socialFrom($el, $, "esd-block-social", ctx)];
496
1063
  if (kind === "menu") {
497
1064
  const menu = menuFrom($el, $, "esd-block-menu", ctx);
@@ -505,7 +1072,7 @@ function convertEsdBlock($el, $, ctx) {
505
1072
  pushEntry(ctx, `esd-block-${kind}`, kind === "image" ? "image" : "paragraph", "converted");
506
1073
  return labelledInnerBlocks($el, $, ctx);
507
1074
  }
508
- function blocksInContainer($frame, $, ctx) {
1075
+ function blocksInContainer($frame, $, ctx, room) {
509
1076
  const out = [];
510
1077
  const seen = /* @__PURE__ */ new Set();
511
1078
  $frame.find("[class]").each((_, node) => {
@@ -514,11 +1081,33 @@ function blocksInContainer($frame, $, ctx) {
514
1081
  if ($el.parents().toArray().some((p) => tokenStartingWith($(p), "esd-block-"))) return;
515
1082
  if (seen.has(node)) return;
516
1083
  seen.add(node);
517
- out.push(...convertEsdBlock($el, $, ctx));
1084
+ out.push(...convertEsdBlock($el, $, ctx, room));
518
1085
  });
519
1086
  if (out.length === 0) return labelledInnerBlocks($frame, $, ctx);
520
1087
  return out;
521
1088
  }
1089
+ function enclosing($el, selector) {
1090
+ if ($el.is(selector)) return $el;
1091
+ const $parent = $el.parents(selector).first();
1092
+ return $parent.length > 0 ? $parent : null;
1093
+ }
1094
+ function bandColor($stripe) {
1095
+ const own = colorFromPaint($stripe.attr("style"), $stripe.attr("bgcolor"));
1096
+ if (own) return own;
1097
+ if ($stripe.is("td") || $stripe.is("th")) return "";
1098
+ const $direct = $stripe.children("tr").children("td, th");
1099
+ const $nested = $stripe.children("tbody, thead").children("tr").children("td, th");
1100
+ const $cell = ($direct.length > 0 ? $direct : $nested).first();
1101
+ return $cell.length > 0 ? colorFromPaint($cell.attr("style"), $cell.attr("bgcolor")) : "";
1102
+ }
1103
+ function paintFor($el, ctx) {
1104
+ const $stripe = enclosing($el, "[class~='esd-stripe']");
1105
+ const $body = enclosing($el, BODY_SELECTOR);
1106
+ return resolveSectionPaint(ctx.pageBackground ?? "#ffffff", $stripe ? bandColor($stripe) : "", $body ? colorFromPaint($body.attr("style"), $body.attr("bgcolor")) : "");
1107
+ }
1108
+ function imageNote(...styles) {
1109
+ return styles.some((style) => hasBackgroundImage(style)) ? IMAGE_NOTE : void 0;
1110
+ }
522
1111
  function sectionFromStructure($structure, $, ctx) {
523
1112
  const frames = $structure.find("[class~='esd-container-frame']").toArray().map((n) => $(n)).filter(($el) => {
524
1113
  if (isHidden($el)) return false;
@@ -526,19 +1115,34 @@ function sectionFromStructure($structure, $, ctx) {
526
1115
  return $(p).closest($structure).length > 0;
527
1116
  }).length === 0;
528
1117
  });
529
- const layout = columnLayout(frames.length || 1, ctx);
1118
+ const frameCount = frames.length;
1119
+ const { layout, flattenNote } = columnLayout(frameCount === 0 ? 1 : frameCount, ctx);
1120
+ const padding = readBoxPadding($structure.attr("class"), $structure.attr("style"));
1121
+ const $body = enclosing($structure, BODY_SELECTOR);
1122
+ const rooms = columnRooms(emailBodyWidth($body?.attr("width"), $body?.attr("style")), layout, padding, layout === "1" ? 1 : Math.min(frameCount, 3));
530
1123
  let children;
531
1124
  if (layout === "1") {
532
1125
  const merged = [];
533
- if (frames.length === 0) merged.push(...blocksInContainer($structure, $, ctx));
534
- else for (const $f of frames) merged.push(...blocksInContainer($f, $, ctx));
1126
+ const room = rooms[0];
1127
+ if (frames.length === 0) merged.push(...blocksInContainer($structure, $, ctx, room));
1128
+ else for (const $f of frames) merged.push(...blocksInContainer($f, $, ctx, room));
535
1129
  children = [merged];
536
- } else children = frames.map(($f) => blocksInContainer($f, $, ctx));
537
- if (layout !== "1" || frames.length <= 3) pushEntry(ctx, "esd-structure", "section", "converted");
1130
+ } else children = frames.map(($f, index) => blocksInContainer($f, $, ctx, rooms[index]));
1131
+ const paint = paintFor($structure, ctx);
1132
+ const $stripe = enclosing($structure, "[class~='esd-stripe']");
1133
+ const notes = [
1134
+ ...paint.notes,
1135
+ flattenNote,
1136
+ imageNote($stripe?.attr("style"), $body?.attr("style"))
1137
+ ].filter((note) => Boolean(note));
1138
+ if (notes.length > 0) pushEntry(ctx, "esd-structure", "section", "approximated", notes.join(" "));
1139
+ else if (frameCount <= 3) pushEntry(ctx, "esd-structure", "section", "converted");
538
1140
  return withBackground(createSectionBlock({
539
1141
  columns: layout,
540
- children
541
- }), colorFromPaint($structure.attr("style"), $structure.attr("bgcolor")));
1142
+ children,
1143
+ styles: { padding },
1144
+ ...paint.wrapperColor ? { wrapper: { backgroundColor: paint.wrapperColor } } : {}
1145
+ }), paint.backgroundColor);
542
1146
  }
543
1147
  function convertEditor(html, ctx) {
544
1148
  const $ = load(html);
@@ -549,11 +1153,15 @@ function convertEditor(html, ctx) {
549
1153
  return stripes.map(($stripe) => {
550
1154
  const inner = $stripe.find("[class~='esd-structure']");
551
1155
  if (inner.length > 0) return sectionFromStructure(inner.first(), $, ctx);
552
- pushEntry(ctx, "esd-stripe", "section", "converted");
1156
+ const paint = paintFor($stripe, ctx);
1157
+ const notes = [...paint.notes, imageNote($stripe.attr("style"))].filter((note) => Boolean(note));
1158
+ pushEntry(ctx, "esd-stripe", "section", notes.length > 0 ? "approximated" : "converted", notes.length > 0 ? notes.join(" ") : void 0);
553
1159
  return withBackground(createSectionBlock({
554
1160
  columns: "1",
555
- children: [blocksInContainer($stripe, $, ctx)]
556
- }), colorFromPaint($stripe.attr("style"), $stripe.attr("bgcolor")));
1161
+ children: [blocksInContainer($stripe, $, ctx, columnRooms(600, "1", ZERO, 1)[0])],
1162
+ styles: { padding: { ...ZERO } },
1163
+ ...paint.wrapperColor ? { wrapper: { backgroundColor: paint.wrapperColor } } : {}
1164
+ }), paint.backgroundColor);
557
1165
  });
558
1166
  }
559
1167
  //#endregion
@@ -567,6 +1175,16 @@ function injectCss(html, css) {
567
1175
  $("head").append($style);
568
1176
  return $.html() ?? html;
569
1177
  }
1178
+ /** First painted `es-wrapper-color` / `es-wrapper`, or white when neither is set. */
1179
+ function pageBackground($) {
1180
+ const nodes = $("[class~='es-wrapper-color'], [class~='es-wrapper']").toArray();
1181
+ for (const node of nodes) {
1182
+ const $el = $(node);
1183
+ const color = colorFromPaint($el.attr("style"), $el.attr("bgcolor"));
1184
+ if (color) return color;
1185
+ }
1186
+ return "#ffffff";
1187
+ }
570
1188
  function summarize(ctx) {
571
1189
  const summary = {
572
1190
  total: ctx.entries.length,
@@ -609,14 +1227,23 @@ function convertStripoTemplate(html, options) {
609
1227
  };
610
1228
  const ctx = {
611
1229
  entries: [],
612
- warnings: []
1230
+ warnings: [],
1231
+ css: options?.css
613
1232
  };
1233
+ const pageBg = pageBackground(load(prepared));
1234
+ ctx.pageBackground = pageBg;
614
1235
  const blocks = kind === "editor" ? convertEditor(prepared, ctx) : convertCompiled(prepared, ctx);
1236
+ const defaults = createDefaultTemplateContent();
615
1237
  return {
616
1238
  content: {
617
- ...createDefaultTemplateContent(),
1239
+ ...defaults,
618
1240
  ...fallback.content,
619
- blocks
1241
+ blocks,
1242
+ settings: {
1243
+ ...defaults.settings,
1244
+ ...fallback.content.settings,
1245
+ backgroundColor: pageBg
1246
+ }
620
1247
  },
621
1248
  report: summarize(ctx)
622
1249
  };