@templatical/import-stripo 0.45.0 → 0.47.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
@@ -62,10 +62,15 @@ const SIDE_LETTER = {
62
62
  b: "bottom",
63
63
  l: "left"
64
64
  };
65
+ /**
66
+ * A `style` attribute as a key/value map. A semicolon inside quotes or
67
+ * parentheses — `url("a;b")`, `url(data:image/png;base64,…)` — belongs to
68
+ * its value.
69
+ */
65
70
  function parseStyleAttribute(styleAttr) {
66
71
  const result = {};
67
72
  if (!styleAttr) return result;
68
- for (const decl of styleAttr.split(";")) {
73
+ for (const decl of splitTopLevel(styleAttr, ";")) {
69
74
  const idx = decl.indexOf(":");
70
75
  if (idx === -1) continue;
71
76
  const key = decl.slice(0, idx).trim().toLowerCase();
@@ -74,6 +79,35 @@ function parseStyleAttribute(styleAttr) {
74
79
  }
75
80
  return result;
76
81
  }
82
+ /**
83
+ * Split `value` at each `separator` that sits outside quotes and parentheses.
84
+ * A value whose quote or parenthesis never closes is split at every
85
+ * `separator` instead, so a malformed declaration costs only itself and the
86
+ * ones after it still read.
87
+ */
88
+ function splitTopLevel(value, separator) {
89
+ const parts = [];
90
+ let depth = 0;
91
+ let quote = "";
92
+ let start = 0;
93
+ for (let i = 0; i < value.length; i++) {
94
+ const char = value[i];
95
+ if (quote) {
96
+ if (char === quote) quote = "";
97
+ continue;
98
+ }
99
+ if (char === "\"" || char === "'") quote = char;
100
+ else if (char === "(") depth++;
101
+ else if (char === ")") depth = Math.max(0, depth - 1);
102
+ else if (char === separator && depth === 0) {
103
+ parts.push(value.slice(start, i));
104
+ start = i + 1;
105
+ }
106
+ }
107
+ if (quote || depth > 0) return value.split(separator);
108
+ parts.push(value.slice(start));
109
+ return parts;
110
+ }
77
111
  function channelHex(value) {
78
112
  return Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, "0");
79
113
  }
@@ -99,11 +133,6 @@ function colorFromPaint(styleAttr, bgcolor) {
99
133
  const fromShorthand = styles.background ? parseColor(styles.background.trim().split(/\s+/)[0]) : "";
100
134
  return parseColor(styles["background-color"]) || fromShorthand || parseColor(bgcolor) || "";
101
135
  }
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
136
  function parsePaddingShorthand(value) {
108
137
  if (!value) return { ...ZERO$3 };
109
138
  const values = value.trim().split(/\s+/).filter(Boolean).map((part) => parsePxValue(part));
@@ -302,6 +331,433 @@ function visibleLineBorder(styles) {
302
331
  }
303
332
  return null;
304
333
  }
334
+ const X_GRID = [
335
+ "left",
336
+ "center",
337
+ "right"
338
+ ];
339
+ const Y_GRID = [
340
+ "top",
341
+ "center",
342
+ "bottom"
343
+ ];
344
+ const PERCENT = /^(-?(?:\d+(?:\.\d+)?|\.\d+))%$/;
345
+ const LENGTH = /^-?(?:\d+(?:\.\d+)?|\.\d+)[a-z]*$/;
346
+ /**
347
+ * `background-size` as a keyword. A size with a side at 100%, or a px width
348
+ * spanning the box, fills it as `cover` does; any other length or percentage
349
+ * draws the image at a size of its own, which `auto` is closest to.
350
+ */
351
+ function backgroundSizeKeyword(raw, boxWidth) {
352
+ const parts = raw.toLowerCase().split(/\s+/);
353
+ const [first] = parts;
354
+ if (parts.length === 1 && (first === "cover" || first === "contain")) return {
355
+ size: first,
356
+ exact: true
357
+ };
358
+ if (parts.every((part) => part === "auto")) return {
359
+ size: "auto",
360
+ exact: true
361
+ };
362
+ const px = /^(\d+(?:\.\d+)?|\.\d+)px$/.exec(first);
363
+ return {
364
+ size: parts.includes("100%") || px !== null && boxWidth !== void 0 && Number(px[1]) >= boxWidth ? "cover" : "auto",
365
+ exact: false
366
+ };
367
+ }
368
+ /**
369
+ * `background-position` split into x and y the way MJML and CSS read it: a
370
+ * lone `top` / `bottom` names y and centres x, any other lone value is x
371
+ * with y centred, and a pair is x then y unless it leads with `top` /
372
+ * `bottom` (or with `center` before `left` / `right`). Three- and four-value
373
+ * edge offsets keep their edge keywords only.
374
+ */
375
+ function splitBackgroundPosition(raw) {
376
+ const parts = raw.toLowerCase().split(/\s+/);
377
+ if (parts.length === 1) {
378
+ const [only] = parts;
379
+ return only === "top" || only === "bottom" ? {
380
+ x: "center",
381
+ y: only,
382
+ exact: true
383
+ } : {
384
+ x: only,
385
+ y: "center",
386
+ exact: true
387
+ };
388
+ }
389
+ if (parts.length === 2) {
390
+ const [a, b] = parts;
391
+ return a === "top" || a === "bottom" || a === "center" && (b === "left" || b === "right") ? {
392
+ x: b,
393
+ y: a,
394
+ exact: true
395
+ } : {
396
+ x: a,
397
+ y: b,
398
+ exact: true
399
+ };
400
+ }
401
+ return {
402
+ x: parts.find((part) => part === "left" || part === "right") ?? "center",
403
+ y: parts.find((part) => part === "top" || part === "bottom") ?? "center",
404
+ exact: false
405
+ };
406
+ }
407
+ /**
408
+ * One axis as a grid point: its keyword; the point nearest a percentage,
409
+ * ties going to the centre; or the start edge for a length, which is an
410
+ * offset from it. `null` when the token is unreadable.
411
+ */
412
+ function backgroundAxisPoint(token, grid) {
413
+ const keyword = grid.find((point) => point === token);
414
+ if (keyword) return {
415
+ point: keyword,
416
+ exact: true
417
+ };
418
+ const percent = PERCENT.exec(token);
419
+ if (percent) {
420
+ const n = Number(percent[1]);
421
+ return {
422
+ point: n < 25 ? grid[0] : n > 75 ? grid[2] : grid[1],
423
+ exact: n === 0 || n === 50 || n === 100
424
+ };
425
+ }
426
+ if (LENGTH.test(token)) return {
427
+ point: grid[0],
428
+ exact: parseFloat(token) === 0
429
+ };
430
+ return null;
431
+ }
432
+ /**
433
+ * `background-repeat`: unrepeated only when every axis is `no-repeat`.
434
+ * Anything but plain `repeat` tiles too, inexactly.
435
+ */
436
+ function backgroundRepeatFlag(raw) {
437
+ const parts = raw.toLowerCase().split(/\s+/);
438
+ if (parts.every((part) => part === "no-repeat")) return {
439
+ repeat: false,
440
+ exact: true
441
+ };
442
+ return {
443
+ repeat: true,
444
+ exact: parts.every((part) => part === "repeat")
445
+ };
446
+ }
447
+ /** CSS's initial values: what a browser paints for a longhand the source leaves out. */
448
+ const CSS_BACKGROUND_DEFAULTS = {
449
+ size: "auto",
450
+ x: "left",
451
+ y: "top",
452
+ repeat: true
453
+ };
454
+ const IMAGE_FUNCTION = /^(?:url|[a-z-]*gradient|image-set)\(/;
455
+ const REPEAT_TOKENS = /* @__PURE__ */ new Set([
456
+ "repeat",
457
+ "no-repeat",
458
+ "repeat-x",
459
+ "repeat-y",
460
+ "round",
461
+ "space"
462
+ ]);
463
+ const POSITION_TOKENS = /* @__PURE__ */ new Set([
464
+ "left",
465
+ "right",
466
+ "top",
467
+ "bottom",
468
+ "center"
469
+ ]);
470
+ const NON_COLOR_TOKENS = /* @__PURE__ */ new Set([
471
+ "none",
472
+ "scroll",
473
+ "fixed",
474
+ "local",
475
+ "border-box",
476
+ "padding-box",
477
+ "content-box",
478
+ "text",
479
+ "cover",
480
+ "contain",
481
+ "auto"
482
+ ]);
483
+ const MEASURE = /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:%|[a-z]+)?$/i;
484
+ /**
485
+ * The URL inside the first CSS `url(…)`, unquoted and trimmed; "" when there
486
+ * is none or it is malformed. A forward scan rather than a regex, so a long
487
+ * run of unclosed `url(` can't make it backtrack.
488
+ */
489
+ function cssUrl(value) {
490
+ const text = value ?? "";
491
+ const start = text.search(/url\(/i);
492
+ if (start === -1) return "";
493
+ let i = start + 4;
494
+ while (i < text.length && /\s/.test(text[i])) i++;
495
+ const quote = text[i];
496
+ if (quote === "\"" || quote === "'") {
497
+ const close = text.indexOf(quote, i + 1);
498
+ if (close === -1) return "";
499
+ let j = close + 1;
500
+ while (j < text.length && /\s/.test(text[j])) j++;
501
+ return text[j] === ")" ? text.slice(i + 1, close).trim() : "";
502
+ }
503
+ const close = text.indexOf(")", i);
504
+ if (close === -1) return "";
505
+ const inner = text.slice(i, close);
506
+ return /["']/.test(inner) ? "" : inner.trim();
507
+ }
508
+ /**
509
+ * Whitespace-separated tokens, keeping a function's arguments together and
510
+ * making the `/` between position and size a token of its own.
511
+ */
512
+ function cssTokens(value) {
513
+ const tokens = [];
514
+ let depth = 0;
515
+ let quote = "";
516
+ let current = "";
517
+ const push = () => {
518
+ if (current) tokens.push(current);
519
+ current = "";
520
+ };
521
+ for (const char of value) {
522
+ if (quote) {
523
+ current += char;
524
+ if (char === quote) quote = "";
525
+ continue;
526
+ }
527
+ if (char === "\"" || char === "'") {
528
+ quote = char;
529
+ current += char;
530
+ continue;
531
+ }
532
+ if (char === "(") depth++;
533
+ if (char === ")") depth = Math.max(0, depth - 1);
534
+ if (depth === 0 && /\s/.test(char)) {
535
+ push();
536
+ continue;
537
+ }
538
+ if (depth === 0 && char === "/") {
539
+ push();
540
+ tokens.push("/");
541
+ continue;
542
+ }
543
+ current += char;
544
+ }
545
+ push();
546
+ return tokens;
547
+ }
548
+ function isColorToken(token) {
549
+ const lower = token.toLowerCase();
550
+ if (lower === "/" || IMAGE_FUNCTION.test(lower)) return false;
551
+ return !REPEAT_TOKENS.has(lower) && !POSITION_TOKENS.has(lower) && !NON_COLOR_TOKENS.has(lower) && !MEASURE.test(lower);
552
+ }
553
+ /**
554
+ * The parts of a `background` shorthand. The colour comes from the last
555
+ * layer, the only one CSS lets carry it; the image and its size, position
556
+ * and repeat come from the first layer with a `url()`.
557
+ */
558
+ function parseBackgroundShorthand(value) {
559
+ const layers = splitTopLevel(value, ",").map((layer) => layer.trim()).filter(Boolean);
560
+ const color = cssTokens(layers[layers.length - 1] ?? "").find(isColorToken) ?? "";
561
+ const imageLayer = layers.find((layer) => cssUrl(layer) !== "");
562
+ if (!imageLayer) return {
563
+ color,
564
+ url: "",
565
+ size: "",
566
+ position: "",
567
+ repeat: "",
568
+ layers: layers.length
569
+ };
570
+ const tokens = cssTokens(imageLayer);
571
+ const slash = tokens.indexOf("/");
572
+ const before = slash === -1 ? tokens : tokens.slice(0, slash);
573
+ const after = slash === -1 ? [] : tokens.slice(slash + 1);
574
+ const size = [];
575
+ for (const token of after) {
576
+ const lower = token.toLowerCase();
577
+ const isSize = lower === "cover" || lower === "contain" || lower === "auto" || MEASURE.test(lower);
578
+ if (size.length === 2 || !isSize) break;
579
+ size.push(token);
580
+ }
581
+ const rest = [...before, ...after.slice(size.length)];
582
+ return {
583
+ color,
584
+ url: cssUrl(imageLayer),
585
+ size: size.join(" "),
586
+ position: before.filter((token) => POSITION_TOKENS.has(token.toLowerCase()) || MEASURE.test(token)).join(" "),
587
+ repeat: rest.filter((token) => REPEAT_TOKENS.has(token.toLowerCase())).join(" "),
588
+ layers: layers.length
589
+ };
590
+ }
591
+ /**
592
+ * A background image from a URL and its longhands. A longhand left out takes
593
+ * CSS's initial value; a value with no exact keyword takes the nearest one
594
+ * and adds a note naming it. `null` for a blank URL.
595
+ */
596
+ function buildBackgroundImage(url, longhands, label, boxWidth) {
597
+ const trimmedUrl = url.trim();
598
+ if (!trimmedUrl) return null;
599
+ const notes = [];
600
+ const lossy = (name, raw, shown) => {
601
+ notes.push(`${label} ${name} "${raw}" has no exact Templatical equivalent; imported as "${shown}".`);
602
+ };
603
+ let size = CSS_BACKGROUND_DEFAULTS.size;
604
+ const rawSize = longhands.size?.trim();
605
+ if (rawSize) {
606
+ const parsed = backgroundSizeKeyword(rawSize, boxWidth);
607
+ size = parsed.size;
608
+ if (!parsed.exact) lossy("background-size", rawSize, size);
609
+ }
610
+ let x = CSS_BACKGROUND_DEFAULTS.x;
611
+ let y = CSS_BACKGROUND_DEFAULTS.y;
612
+ let lossyX = false;
613
+ let lossyY = false;
614
+ const rawPosition = longhands.position?.trim();
615
+ if (rawPosition) {
616
+ const split = splitBackgroundPosition(rawPosition);
617
+ const readX = backgroundAxisPoint(split.x, X_GRID);
618
+ const readY = backgroundAxisPoint(split.y, Y_GRID);
619
+ x = readX?.point ?? x;
620
+ y = readY?.point ?? y;
621
+ lossyX = !split.exact || !readX?.exact;
622
+ lossyY = !split.exact || !readY?.exact;
623
+ }
624
+ const rawX = longhands.positionX?.trim();
625
+ const overrideX = rawX ? backgroundAxisPoint(rawX.toLowerCase(), X_GRID) : null;
626
+ if (overrideX) {
627
+ x = overrideX.point;
628
+ lossyX = false;
629
+ }
630
+ const rawY = longhands.positionY?.trim();
631
+ const overrideY = rawY ? backgroundAxisPoint(rawY.toLowerCase(), Y_GRID) : null;
632
+ if (overrideY) {
633
+ y = overrideY.point;
634
+ lossyY = false;
635
+ }
636
+ if (rawPosition && (lossyX || lossyY)) lossy("background-position", rawPosition, `${x} ${y}`);
637
+ if (rawX && !overrideX?.exact) lossy("background-position-x", rawX, x);
638
+ if (rawY && !overrideY?.exact) lossy("background-position-y", rawY, y);
639
+ let repeat = CSS_BACKGROUND_DEFAULTS.repeat;
640
+ const rawRepeat = longhands.repeat?.trim();
641
+ if (rawRepeat) {
642
+ const parsed = backgroundRepeatFlag(rawRepeat);
643
+ repeat = parsed.repeat;
644
+ if (!parsed.exact) lossy("background-repeat", rawRepeat, repeat ? "repeat" : "no-repeat");
645
+ }
646
+ return {
647
+ image: {
648
+ url: trimmedUrl,
649
+ size,
650
+ position: {
651
+ x,
652
+ y
653
+ },
654
+ repeat
655
+ },
656
+ notes
657
+ };
658
+ }
659
+ /**
660
+ * A background image from CSS declarations: `background-image`, else the
661
+ * `background` shorthand's image, with the size, position and repeat
662
+ * longhands winning over the shorthand's. A style map keeps no declaration
663
+ * order, so a longhand is taken to follow the shorthand, as it usually does.
664
+ * Of several layers the first image is kept, and a note says so. `null`
665
+ * when the declarations paint no image.
666
+ */
667
+ function readCssBackground(styles, label, boxWidth) {
668
+ const shorthand = styles.background !== void 0 ? parseBackgroundShorthand(styles.background) : null;
669
+ const imageLayers = styles["background-image"] !== void 0 ? splitTopLevel(styles["background-image"], ",").map((layer) => layer.trim()) : null;
670
+ const index = imageLayers ? imageLayers.findIndex((layer) => cssUrl(layer) !== "") : -1;
671
+ const url = imageLayers ? index === -1 ? "" : cssUrl(imageLayers[index]) : shorthand?.url ?? "";
672
+ if (!url) return null;
673
+ const layer = (value) => {
674
+ if (value === void 0) return void 0;
675
+ const parts = splitTopLevel(value, ",").map((part) => part.trim());
676
+ return parts[Math.max(index, 0)] ?? parts[0];
677
+ };
678
+ const read = buildBackgroundImage(url, {
679
+ size: layer(styles["background-size"]) ?? shorthand?.size,
680
+ position: layer(styles["background-position"]) ?? shorthand?.position,
681
+ positionX: styles["background-position-x"],
682
+ positionY: styles["background-position-y"],
683
+ repeat: layer(styles["background-repeat"]) ?? shorthand?.repeat
684
+ }, label, boxWidth);
685
+ const layers = imageLayers?.length ?? shorthand?.layers ?? 1;
686
+ if (read && layers > 1) read.notes.unshift(`${label} background has ${layers} layers; only the image "${url}" was imported.`);
687
+ return read;
688
+ }
689
+ /**
690
+ * The image an element paints: its inline CSS background, else its legacy
691
+ * `background` attribute. A `background-image` or `background` declaration
692
+ * overrides the attribute; a `background-color` doesn't. The image twin of
693
+ * `colorFromPaint`.
694
+ */
695
+ function imageFromPaint(styleAttr, backgroundAttr, label, boxWidth) {
696
+ const styles = parseStyleAttribute(styleAttr);
697
+ if (styles["background-image"] !== void 0 || styles.background !== void 0) return readCssBackground(styles, label, boxWidth);
698
+ return buildBackgroundImage(backgroundAttr ?? "", {}, label, boxWidth);
699
+ }
700
+ function sameImage(a, b) {
701
+ return a.url === b.url && a.size === b.size && a.repeat === b.repeat && a.position.x === b.position.x && a.position.y === b.position.y;
702
+ }
703
+ function droppedColumnsNote(columns) {
704
+ return columns.length === 1 ? `The background image on column ${columns[0]} was dropped. A Templatical section has one background image.` : `Background images on columns ${columns.join(", ")} were dropped. A Templatical section has one background image.`;
705
+ }
706
+ /**
707
+ * The image a section paints, nearest first: the structure's own, then the
708
+ * body's, then the containers', moved up when there is one or all share the
709
+ * same image, then the stripe's while the body leaves it showing. An image
710
+ * that showed in the section but can't be kept is named in a note. A body
711
+ * image beneath the structure's own is covered in this section, so it isn't
712
+ * named; it still paints the stripe's other sections. The image is a copy,
713
+ * so sections that share a source don't share an object.
714
+ */
715
+ function resolveSectionImage(layers) {
716
+ const notes = [];
717
+ let picked = layers.structure ?? layers.body;
718
+ const withImage = layers.columns.flatMap((read, index) => read ? [index + 1] : []);
719
+ const first = layers.columns[0] ?? null;
720
+ const shared = first !== null && layers.columns.every((read) => read !== null && sameImage(read.image, first.image));
721
+ if (withImage.length > 0) {
722
+ if (!picked && shared) picked = {
723
+ read: first,
724
+ source: null
725
+ };
726
+ else notes.push(droppedColumnsNote(withImage));
727
+ }
728
+ if (layers.band) {
729
+ if (!picked && !layers.bodyPainted) {
730
+ picked = layers.band;
731
+ notes.push("The stripe's background image spans the full width in Stripo; the section paints it at the content width.");
732
+ } else notes.push("The stripe's background image outside the content width was dropped.");
733
+ }
734
+ if (!picked) return {
735
+ image: void 0,
736
+ notes,
737
+ source: null
738
+ };
739
+ return {
740
+ image: {
741
+ ...picked.read.image,
742
+ position: { ...picked.read.image.position }
743
+ },
744
+ notes: [...picked.read.notes, ...notes],
745
+ source: picked.source
746
+ };
747
+ }
748
+ /**
749
+ * The paint, minus a band colour that would become the section's wrapper
750
+ * colour, when the section has a background image. The wrapper has no
751
+ * padding, so the colour shows nowhere except Outlook desktop, which paints
752
+ * it over the section's image.
753
+ */
754
+ function withoutBandOverImage(paint, hasImage) {
755
+ if (!hasImage || !paint.wrapperColor) return paint;
756
+ return {
757
+ backgroundColor: paint.backgroundColor,
758
+ notes: [`The band colour ${paint.wrapperColor} was dropped: Outlook desktop paints a wrapper colour over the section's background image.`]
759
+ };
760
+ }
305
761
  //#endregion
306
762
  //#region src/fragment.ts
307
763
  /** Paint after `createSectionBlock` so `styles` keeps its required padding. */
@@ -319,10 +775,15 @@ function fragmentDocument(inner, css) {
319
775
  const safe = css ? css.replace(/<\/style/gi, "<\\/style") : "";
320
776
  return `<!DOCTYPE html><html>${safe ? `<head><style>${safe}</style></head>` : ""}<body>${inner}</body></html>`;
321
777
  }
778
+ /** The start of the HTML importer's page-image warning. */
779
+ const PAGE_IMAGE_WARNING = "The email background image ";
322
780
  /**
323
781
  * Run the generic HTML importer on a subtree and keep its blocks.
324
782
  * Section entries belong to the row the HTML importer invented; Stripo's own
325
- * section entries are recorded by the caller.
783
+ * section entries are recorded by the caller. A fragment's `<body>` is
784
+ * synthetic, so the page-image warning the HTML importer raises for a `body`
785
+ * background in the caller's CSS is dropped; Stripo reports the page image
786
+ * once, from `es-wrapper`.
326
787
  */
327
788
  function blocksFromHtml(inner, ctx) {
328
789
  const result = convertHtmlTemplate(fragmentDocument(inner, ctx.css));
@@ -330,16 +791,66 @@ function blocksFromHtml(inner, ctx) {
330
791
  if (entry.templaticalBlockType === "section") continue;
331
792
  ctx.entries.push(entry);
332
793
  }
333
- ctx.warnings.push(...result.report.warnings);
794
+ ctx.warnings.push(...result.report.warnings.filter((warning) => !warning.startsWith(PAGE_IMAGE_WARNING)));
334
795
  return flattenBlocks(result.content.blocks);
335
796
  }
336
797
  function pushEntry(ctx, sourceTag, templaticalBlockType, status, note) {
337
- ctx.entries.push({
798
+ const entry = {
338
799
  sourceTag,
339
800
  templaticalBlockType,
340
801
  status,
341
802
  ...note ? { note } : {}
342
- });
803
+ };
804
+ ctx.entries.push(entry);
805
+ return entry;
806
+ }
807
+ /** Record that a section's entry took the image `source` paints. */
808
+ function recordImageSpan(ctx, source, entry) {
809
+ if (!source) return;
810
+ ctx.imageSpans ??= /* @__PURE__ */ new Map();
811
+ const spanned = ctx.imageSpans.get(source) ?? [];
812
+ spanned.push(entry);
813
+ ctx.imageSpans.set(source, spanned);
814
+ }
815
+ /** Note on each entry whose section shares its image with another section. */
816
+ function noteImageSpans(ctx) {
817
+ for (const spanned of ctx.imageSpans?.values() ?? []) {
818
+ if (spanned.length < 2) continue;
819
+ const note = `A background image behind ${spanned.length} sections was applied to each of them — Templatical has no multi-section band.`;
820
+ for (const entry of spanned) {
821
+ entry.status = "approximated";
822
+ entry.note = entry.note ? `${entry.note} ${note}` : note;
823
+ }
824
+ }
825
+ }
826
+ /** The image an element paints: its inline CSS background, else its `background` attribute. */
827
+ function imageOf($el, label, boxWidth) {
828
+ return imageFromPaint($el.attr("style"), $el.attr("background"), label, boxWidth);
829
+ }
830
+ /** An element's image with that element as its source; `null` when there is no element or it paints none. */
831
+ function imageLayer($el, label, boxWidth) {
832
+ if (!$el) return null;
833
+ const read = imageOf($el, label, boxWidth);
834
+ return read ? {
835
+ read,
836
+ source: $el[0]
837
+ } : null;
838
+ }
839
+ /**
840
+ * The stripe's image: on the stripe, else on `$cell`, the cell Stripo paints
841
+ * instead of a table stripe, as the band colour is read. A `td` / `th` stripe
842
+ * is its own paint. The stripe names the image either way, since it is the
843
+ * element that spans the sections.
844
+ */
845
+ function bandImage($stripe, $cell, boxWidth) {
846
+ const own = imageLayer($stripe, "Stripe", boxWidth);
847
+ if (own) return own;
848
+ if ($stripe.is("td") || $stripe.is("th")) return null;
849
+ const cell = $cell.length > 0 ? imageOf($cell, "Stripe", boxWidth) : null;
850
+ return cell ? {
851
+ read: cell,
852
+ source: $stripe[0]
853
+ } : null;
343
854
  }
344
855
  //#endregion
345
856
  //#region src/platform.ts
@@ -621,7 +1132,6 @@ const STRIPE_TOKENS = [
621
1132
  const BODY_SELECTOR$1 = "[class~='es-header-body'], [class~='es-content-body'], [class~='es-footer-body']";
622
1133
  const BETWEEN_NOTE = "Content between columns was placed after them.";
623
1134
  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
1135
  function paintOf($el) {
626
1136
  return colorFromPaint($el.attr("style"), $el.attr("bgcolor"));
627
1137
  }
@@ -656,8 +1166,12 @@ function bandColor$1($stripe) {
656
1166
  const $cell = directCell($stripe);
657
1167
  return $cell.length > 0 ? paintOf($cell) : "";
658
1168
  }
659
- function imageNote$1(...styles) {
660
- return styles.some((style) => hasBackgroundImage(style)) ? IMAGE_NOTE$1 : void 0;
1169
+ /** A column's image: on its `es-left` / `es-right` table, else on that table's cell. */
1170
+ function columnImage($column, boxWidth) {
1171
+ const own = imageOf($column, "Column", boxWidth);
1172
+ if (own) return own;
1173
+ const $cell = directCell($column);
1174
+ return $cell.length > 0 ? imageOf($cell, "Column", boxWidth) : null;
661
1175
  }
662
1176
  function hasPadding(padding) {
663
1177
  return padding.top !== 0 || padding.right !== 0 || padding.bottom !== 0 || padding.left !== 0;
@@ -871,17 +1385,19 @@ function groupsFromPieces(pieces) {
871
1385
  });
872
1386
  return groups;
873
1387
  }
874
- function sectionsFromRow($row, $, ctx, token, paint, image, bodyWidth) {
1388
+ function sectionsFromRow($row, $, ctx, token, paint, images, bodyWidth) {
875
1389
  if (isHidden($row)) return [];
876
1390
  const pieces = [];
877
1391
  let padding = { ...ZERO$1 };
878
1392
  let sawCell = false;
1393
+ let $structure = null;
879
1394
  $row.children("td, th").each((_, cell) => {
880
1395
  const $cell = $(cell);
881
1396
  if (isHidden($cell)) return;
882
1397
  if (!sawCell) {
883
1398
  padding = readBoxPadding($cell.attr("class"), $cell.attr("style"));
884
1399
  sawCell = true;
1400
+ $structure = $cell;
885
1401
  }
886
1402
  pieces.push(...collectPieces($cell, $));
887
1403
  });
@@ -889,12 +1405,14 @@ function sectionsFromRow($row, $, ctx, token, paint, image, bodyWidth) {
889
1405
  const rendered = [];
890
1406
  for (const group of groupsFromPieces(pieces)) {
891
1407
  if (group.mode === "columns") {
892
- const built = columnChildren(group.pieces.flatMap((piece) => piece.kind === "column" ? [piece.el] : []), $, ctx, bodyWidth, padding);
1408
+ const cols = group.pieces.flatMap((piece) => piece.kind === "column" ? [piece.el] : []);
1409
+ const built = columnChildren(cols, $, ctx, bodyWidth, padding);
893
1410
  if (!built) continue;
894
1411
  rendered.push({
895
1412
  children: built.children,
896
1413
  layout: built.layout,
897
- extra: built.flattenNote
1414
+ extra: built.flattenNote,
1415
+ columnImages: cols.map((el) => columnImage($(el), bodyWidth))
898
1416
  });
899
1417
  continue;
900
1418
  }
@@ -903,25 +1421,37 @@ function sectionsFromRow($row, $, ctx, token, paint, image, bodyWidth) {
903
1421
  rendered.push({
904
1422
  children: [blocks],
905
1423
  layout: "1",
906
- extra: group.extra
1424
+ extra: group.extra,
1425
+ columnImages: []
907
1426
  });
908
1427
  }
909
1428
  if (rendered.length === 0) return [];
910
1429
  const seam = rendered.length > 1 && hasPadding(padding) ? SEAM_NOTE : void 0;
1430
+ const structure = imageLayer($structure, "Section", bodyWidth);
911
1431
  return rendered.map((group, index) => {
1432
+ const image = resolveSectionImage({
1433
+ structure,
1434
+ body: images.body,
1435
+ band: images.band,
1436
+ columns: group.columnImages,
1437
+ bodyPainted: images.bodyPainted
1438
+ });
1439
+ const groupPaint = withoutBandOverImage(paint, image.image !== void 0);
912
1440
  const notes = [
913
- ...paint.notes,
1441
+ ...groupPaint.notes,
914
1442
  group.extra,
915
- image,
1443
+ ...image.notes,
916
1444
  seam
917
1445
  ].filter((note) => Boolean(note));
918
- pushEntry(ctx, token, "section", notes.length > 0 ? "approximated" : "converted", notes.length > 0 ? notes.join(" ") : void 0);
1446
+ const entry = pushEntry(ctx, token, "section", notes.length > 0 ? "approximated" : "converted", notes.length > 0 ? notes.join(" ") : void 0);
1447
+ recordImageSpan(ctx, image.source, entry);
919
1448
  return withBackground(createSectionBlock({
920
1449
  columns: group.layout,
921
1450
  children: group.children,
922
1451
  styles: { padding: splitPadding(padding, rendered.length, index) },
923
- ...paint.wrapperColor ? { wrapper: { backgroundColor: paint.wrapperColor } } : {}
924
- }), paint.backgroundColor);
1452
+ ...groupPaint.wrapperColor ? { wrapper: { backgroundColor: groupPaint.wrapperColor } } : {},
1453
+ ...image.image ? { backgroundImage: image.image } : {}
1454
+ }), groupPaint.backgroundColor);
925
1455
  });
926
1456
  }
927
1457
  function sectionsFromStripe($stripe, $, ctx) {
@@ -929,12 +1459,16 @@ function sectionsFromStripe($stripe, $, ctx) {
929
1459
  const token = stripeToken($stripe) ?? "es-content";
930
1460
  const $body = $stripe.find(BODY_SELECTOR$1).first();
931
1461
  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
1462
  const $width = $body.length > 0 ? $body : $stripe;
935
1463
  const bodyWidth = emailBodyWidth($width.attr("width"), $width.attr("style"));
1464
+ const paint = resolveSectionPaint(ctx.pageBackground ?? "#ffffff", bandColor$1($stripe), $body.length > 0 ? paintOf($body) : "");
1465
+ const images = {
1466
+ body: imageLayer($body, "Section", bodyWidth),
1467
+ band: bandImage($stripe, directCell($stripe), bodyWidth),
1468
+ bodyPainted: $body.length > 0 && paintOf($body) !== ""
1469
+ };
936
1470
  const out = [];
937
- for (const $row of rows) out.push(...sectionsFromRow($row, $, ctx, token, paint, image, bodyWidth));
1471
+ for (const $row of rows) out.push(...sectionsFromRow($row, $, ctx, token, paint, images, bodyWidth));
938
1472
  return out;
939
1473
  }
940
1474
  function convertCompiled(html, ctx) {
@@ -1038,7 +1572,6 @@ const ZERO = {
1038
1572
  left: 0
1039
1573
  };
1040
1574
  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.";
1042
1575
  function columnLayout(count, ctx) {
1043
1576
  if (count <= 1) return { layout: "1" };
1044
1577
  if (count === 2) return { layout: "2" };
@@ -1091,13 +1624,17 @@ function enclosing($el, selector) {
1091
1624
  const $parent = $el.parents(selector).first();
1092
1625
  return $parent.length > 0 ? $parent : null;
1093
1626
  }
1627
+ /** A table stripe's first cell, which Stripo paints instead of the table. */
1628
+ function bandCell($stripe) {
1629
+ const $direct = $stripe.children("tr").children("td, th");
1630
+ const $nested = $stripe.children("tbody, thead").children("tr").children("td, th");
1631
+ return ($direct.length > 0 ? $direct : $nested).first();
1632
+ }
1094
1633
  function bandColor($stripe) {
1095
1634
  const own = colorFromPaint($stripe.attr("style"), $stripe.attr("bgcolor"));
1096
1635
  if (own) return own;
1097
1636
  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();
1637
+ const $cell = bandCell($stripe);
1101
1638
  return $cell.length > 0 ? colorFromPaint($cell.attr("style"), $cell.attr("bgcolor")) : "";
1102
1639
  }
1103
1640
  function paintFor($el, ctx) {
@@ -1105,9 +1642,6 @@ function paintFor($el, ctx) {
1105
1642
  const $body = enclosing($el, BODY_SELECTOR);
1106
1643
  return resolveSectionPaint(ctx.pageBackground ?? "#ffffff", $stripe ? bandColor($stripe) : "", $body ? colorFromPaint($body.attr("style"), $body.attr("bgcolor")) : "");
1107
1644
  }
1108
- function imageNote(...styles) {
1109
- return styles.some((style) => hasBackgroundImage(style)) ? IMAGE_NOTE : void 0;
1110
- }
1111
1645
  function sectionFromStructure($structure, $, ctx) {
1112
1646
  const frames = $structure.find("[class~='esd-container-frame']").toArray().map((n) => $(n)).filter(($el) => {
1113
1647
  if (isHidden($el)) return false;
@@ -1119,7 +1653,8 @@ function sectionFromStructure($structure, $, ctx) {
1119
1653
  const { layout, flattenNote } = columnLayout(frameCount === 0 ? 1 : frameCount, ctx);
1120
1654
  const padding = readBoxPadding($structure.attr("class"), $structure.attr("style"));
1121
1655
  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));
1656
+ const bodyWidth = emailBodyWidth($body?.attr("width"), $body?.attr("style"));
1657
+ const rooms = columnRooms(bodyWidth, layout, padding, layout === "1" ? 1 : Math.min(frameCount, 3));
1123
1658
  let children;
1124
1659
  if (layout === "1") {
1125
1660
  const merged = [];
@@ -1128,20 +1663,33 @@ function sectionFromStructure($structure, $, ctx) {
1128
1663
  else for (const $f of frames) merged.push(...blocksInContainer($f, $, ctx, room));
1129
1664
  children = [merged];
1130
1665
  } else children = frames.map(($f, index) => blocksInContainer($f, $, ctx, rooms[index]));
1131
- const paint = paintFor($structure, ctx);
1132
1666
  const $stripe = enclosing($structure, "[class~='esd-stripe']");
1667
+ const image = resolveSectionImage({
1668
+ structure: imageLayer($structure, "Section", bodyWidth),
1669
+ body: imageLayer($body, "Section", bodyWidth),
1670
+ band: $stripe ? bandImage($stripe, bandCell($stripe), bodyWidth) : null,
1671
+ columns: frames.map(($f) => imageOf($f, "Column", bodyWidth)),
1672
+ bodyPainted: $body ? colorFromPaint($body.attr("style"), $body.attr("bgcolor")) !== "" : false
1673
+ });
1674
+ const paint = withoutBandOverImage(paintFor($structure, ctx), image.image !== void 0);
1133
1675
  const notes = [
1134
1676
  ...paint.notes,
1135
1677
  flattenNote,
1136
- imageNote($stripe?.attr("style"), $body?.attr("style"))
1678
+ ...image.notes
1137
1679
  ].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");
1680
+ if (notes.length > 0) {
1681
+ const entry = pushEntry(ctx, "esd-structure", "section", "approximated", notes.join(" "));
1682
+ recordImageSpan(ctx, image.source, entry);
1683
+ } else if (frameCount <= 3) {
1684
+ const entry = pushEntry(ctx, "esd-structure", "section", "converted");
1685
+ recordImageSpan(ctx, image.source, entry);
1686
+ }
1140
1687
  return withBackground(createSectionBlock({
1141
1688
  columns: layout,
1142
1689
  children,
1143
1690
  styles: { padding },
1144
- ...paint.wrapperColor ? { wrapper: { backgroundColor: paint.wrapperColor } } : {}
1691
+ ...paint.wrapperColor ? { wrapper: { backgroundColor: paint.wrapperColor } } : {},
1692
+ ...image.image ? { backgroundImage: image.image } : {}
1145
1693
  }), paint.backgroundColor);
1146
1694
  }
1147
1695
  function convertEditor(html, ctx) {
@@ -1153,14 +1701,23 @@ function convertEditor(html, ctx) {
1153
1701
  return stripes.map(($stripe) => {
1154
1702
  const inner = $stripe.find("[class~='esd-structure']");
1155
1703
  if (inner.length > 0) return sectionFromStructure(inner.first(), $, ctx);
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);
1704
+ const image = resolveSectionImage({
1705
+ structure: null,
1706
+ body: null,
1707
+ band: bandImage($stripe, bandCell($stripe), 600),
1708
+ columns: [],
1709
+ bodyPainted: false
1710
+ });
1711
+ const paint = withoutBandOverImage(paintFor($stripe, ctx), image.image !== void 0);
1712
+ const notes = [...paint.notes, ...image.notes].filter((note) => Boolean(note));
1713
+ const entry = pushEntry(ctx, "esd-stripe", "section", notes.length > 0 ? "approximated" : "converted", notes.length > 0 ? notes.join(" ") : void 0);
1714
+ recordImageSpan(ctx, image.source, entry);
1159
1715
  return withBackground(createSectionBlock({
1160
1716
  columns: "1",
1161
1717
  children: [blocksInContainer($stripe, $, ctx, columnRooms(600, "1", ZERO, 1)[0])],
1162
1718
  styles: { padding: { ...ZERO } },
1163
- ...paint.wrapperColor ? { wrapper: { backgroundColor: paint.wrapperColor } } : {}
1719
+ ...paint.wrapperColor ? { wrapper: { backgroundColor: paint.wrapperColor } } : {},
1720
+ ...image.image ? { backgroundImage: image.image } : {}
1164
1721
  }), paint.backgroundColor);
1165
1722
  });
1166
1723
  }
@@ -1185,6 +1742,16 @@ function pageBackground($) {
1185
1742
  }
1186
1743
  return "#ffffff";
1187
1744
  }
1745
+ /** The page's background image, which has no Templatical equivalent: the first `es-wrapper-color` / `es-wrapper` that paints one. */
1746
+ function pageImageWarning($) {
1747
+ const nodes = $("[class~='es-wrapper-color'], [class~='es-wrapper']").toArray();
1748
+ for (const node of nodes) {
1749
+ const $el = $(node);
1750
+ const read = imageFromPaint($el.attr("style"), $el.attr("background"), "Page", void 0);
1751
+ if (read) return `The email background image "${read.image.url}" was dropped; Templatical has no email-level background image.`;
1752
+ }
1753
+ return null;
1754
+ }
1188
1755
  function summarize(ctx) {
1189
1756
  const summary = {
1190
1757
  total: ctx.entries.length,
@@ -1230,9 +1797,13 @@ function convertStripoTemplate(html, options) {
1230
1797
  warnings: [],
1231
1798
  css: options?.css
1232
1799
  };
1233
- const pageBg = pageBackground(load(prepared));
1800
+ const $page = load(prepared);
1801
+ const pageBg = pageBackground($page);
1802
+ const pageImage = pageImageWarning($page);
1803
+ if (pageImage) ctx.warnings.push(pageImage);
1234
1804
  ctx.pageBackground = pageBg;
1235
1805
  const blocks = kind === "editor" ? convertEditor(prepared, ctx) : convertCompiled(prepared, ctx);
1806
+ noteImageSpans(ctx);
1236
1807
  const defaults = createDefaultTemplateContent();
1237
1808
  return {
1238
1809
  content: {