@templatical/import-html 0.46.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
@@ -4,12 +4,14 @@ import { isTag, isText } from "domhandler";
4
4
  //#region src/style-parser.ts
5
5
  /**
6
6
  * Parses a CSS `style="..."` attribute string into a flat key/value record.
7
- * Keys are lowercased; values are trimmed. Quotes around values are not stripped.
7
+ * Keys are lowercased; values are trimmed. Quotes around values are not
8
+ * stripped. A semicolon inside quotes or parentheses — `url("a;b")`,
9
+ * `url(data:image/png;base64,…)` — belongs to its value.
8
10
  */
9
11
  function parseStyleAttribute(styleAttr) {
10
12
  const result = {};
11
13
  if (!styleAttr) return result;
12
- for (const decl of styleAttr.split(";")) {
14
+ for (const decl of splitTopLevel(styleAttr, ";")) {
13
15
  const idx = decl.indexOf(":");
14
16
  if (idx === -1) continue;
15
17
  const key = decl.slice(0, idx).trim().toLowerCase();
@@ -19,6 +21,35 @@ function parseStyleAttribute(styleAttr) {
19
21
  return result;
20
22
  }
21
23
  /**
24
+ * Split `value` at each `separator` that sits outside quotes and parentheses.
25
+ * A value whose quote or parenthesis never closes is split at every
26
+ * `separator` instead, so a malformed declaration costs only itself and the
27
+ * ones after it still read.
28
+ */
29
+ function splitTopLevel(value, separator) {
30
+ const parts = [];
31
+ let depth = 0;
32
+ let quote = "";
33
+ let start = 0;
34
+ for (let i = 0; i < value.length; i++) {
35
+ const char = value[i];
36
+ if (quote) {
37
+ if (char === quote) quote = "";
38
+ continue;
39
+ }
40
+ if (char === "\"" || char === "'") quote = char;
41
+ else if (char === "(") depth++;
42
+ else if (char === ")") depth = Math.max(0, depth - 1);
43
+ else if (char === separator && depth === 0) {
44
+ parts.push(value.slice(start, i));
45
+ start = i + 1;
46
+ }
47
+ }
48
+ if (quote || depth > 0) return value.split(separator);
49
+ parts.push(value.slice(start));
50
+ return parts;
51
+ }
52
+ /**
22
53
  * Serializes a flat key/value record back to a `style` attribute string.
23
54
  */
24
55
  function serializeStyleAttribute(styles) {
@@ -233,6 +264,376 @@ function parseBorderShorthand(value) {
233
264
  color
234
265
  };
235
266
  }
267
+ const X_GRID = [
268
+ "left",
269
+ "center",
270
+ "right"
271
+ ];
272
+ const Y_GRID = [
273
+ "top",
274
+ "center",
275
+ "bottom"
276
+ ];
277
+ const PERCENT = /^(-?(?:\d+(?:\.\d+)?|\.\d+))%$/;
278
+ const LENGTH = /^-?(?:\d+(?:\.\d+)?|\.\d+)[a-z]*$/;
279
+ /**
280
+ * `background-size` as a keyword. A size with a side at 100%, or a px width
281
+ * spanning the box, fills it as `cover` does; any other length or percentage
282
+ * draws the image at a size of its own, which `auto` is closest to.
283
+ */
284
+ function backgroundSizeKeyword(raw, boxWidth) {
285
+ const parts = raw.toLowerCase().split(/\s+/);
286
+ const [first] = parts;
287
+ if (parts.length === 1 && (first === "cover" || first === "contain")) return {
288
+ size: first,
289
+ exact: true
290
+ };
291
+ if (parts.every((part) => part === "auto")) return {
292
+ size: "auto",
293
+ exact: true
294
+ };
295
+ const px = /^(\d+(?:\.\d+)?|\.\d+)px$/.exec(first);
296
+ return {
297
+ size: parts.includes("100%") || px !== null && boxWidth !== void 0 && Number(px[1]) >= boxWidth ? "cover" : "auto",
298
+ exact: false
299
+ };
300
+ }
301
+ /**
302
+ * `background-position` split into x and y the way MJML and CSS read it: a
303
+ * lone `top` / `bottom` names y and centres x, any other lone value is x
304
+ * with y centred, and a pair is x then y unless it leads with `top` /
305
+ * `bottom` (or with `center` before `left` / `right`). Three- and four-value
306
+ * edge offsets keep their edge keywords only.
307
+ */
308
+ function splitBackgroundPosition(raw) {
309
+ const parts = raw.toLowerCase().split(/\s+/);
310
+ if (parts.length === 1) {
311
+ const [only] = parts;
312
+ return only === "top" || only === "bottom" ? {
313
+ x: "center",
314
+ y: only,
315
+ exact: true
316
+ } : {
317
+ x: only,
318
+ y: "center",
319
+ exact: true
320
+ };
321
+ }
322
+ if (parts.length === 2) {
323
+ const [a, b] = parts;
324
+ return a === "top" || a === "bottom" || a === "center" && (b === "left" || b === "right") ? {
325
+ x: b,
326
+ y: a,
327
+ exact: true
328
+ } : {
329
+ x: a,
330
+ y: b,
331
+ exact: true
332
+ };
333
+ }
334
+ return {
335
+ x: parts.find((part) => part === "left" || part === "right") ?? "center",
336
+ y: parts.find((part) => part === "top" || part === "bottom") ?? "center",
337
+ exact: false
338
+ };
339
+ }
340
+ /**
341
+ * One axis as a grid point: its keyword; the point nearest a percentage,
342
+ * ties going to the centre; or the start edge for a length, which is an
343
+ * offset from it. `null` when the token is unreadable.
344
+ */
345
+ function backgroundAxisPoint(token, grid) {
346
+ const keyword = grid.find((point) => point === token);
347
+ if (keyword) return {
348
+ point: keyword,
349
+ exact: true
350
+ };
351
+ const percent = PERCENT.exec(token);
352
+ if (percent) {
353
+ const n = Number(percent[1]);
354
+ return {
355
+ point: n < 25 ? grid[0] : n > 75 ? grid[2] : grid[1],
356
+ exact: n === 0 || n === 50 || n === 100
357
+ };
358
+ }
359
+ if (LENGTH.test(token)) return {
360
+ point: grid[0],
361
+ exact: parseFloat(token) === 0
362
+ };
363
+ return null;
364
+ }
365
+ /**
366
+ * `background-repeat`: unrepeated only when every axis is `no-repeat`.
367
+ * Anything but plain `repeat` tiles too, inexactly.
368
+ */
369
+ function backgroundRepeatFlag(raw) {
370
+ const parts = raw.toLowerCase().split(/\s+/);
371
+ if (parts.every((part) => part === "no-repeat")) return {
372
+ repeat: false,
373
+ exact: true
374
+ };
375
+ return {
376
+ repeat: true,
377
+ exact: parts.every((part) => part === "repeat")
378
+ };
379
+ }
380
+ /** CSS's initial values: what a browser paints for a longhand the source leaves out. */
381
+ const CSS_BACKGROUND_DEFAULTS = {
382
+ size: "auto",
383
+ x: "left",
384
+ y: "top",
385
+ repeat: true
386
+ };
387
+ const IMAGE_FUNCTION = /^(?:url|[a-z-]*gradient|image-set)\(/;
388
+ const REPEAT_TOKENS = /* @__PURE__ */ new Set([
389
+ "repeat",
390
+ "no-repeat",
391
+ "repeat-x",
392
+ "repeat-y",
393
+ "round",
394
+ "space"
395
+ ]);
396
+ const POSITION_TOKENS = /* @__PURE__ */ new Set([
397
+ "left",
398
+ "right",
399
+ "top",
400
+ "bottom",
401
+ "center"
402
+ ]);
403
+ const NON_COLOR_TOKENS = /* @__PURE__ */ new Set([
404
+ "none",
405
+ "scroll",
406
+ "fixed",
407
+ "local",
408
+ "border-box",
409
+ "padding-box",
410
+ "content-box",
411
+ "text",
412
+ "cover",
413
+ "contain",
414
+ "auto"
415
+ ]);
416
+ const MEASURE = /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:%|[a-z]+)?$/i;
417
+ /**
418
+ * The URL inside the first CSS `url(…)`, unquoted and trimmed; "" when there
419
+ * is none or it is malformed. A forward scan rather than a regex, so a long
420
+ * run of unclosed `url(` can't make it backtrack.
421
+ */
422
+ function cssUrl(value) {
423
+ const text = value ?? "";
424
+ const start = text.search(/url\(/i);
425
+ if (start === -1) return "";
426
+ let i = start + 4;
427
+ while (i < text.length && /\s/.test(text[i])) i++;
428
+ const quote = text[i];
429
+ if (quote === "\"" || quote === "'") {
430
+ const close = text.indexOf(quote, i + 1);
431
+ if (close === -1) return "";
432
+ let j = close + 1;
433
+ while (j < text.length && /\s/.test(text[j])) j++;
434
+ return text[j] === ")" ? text.slice(i + 1, close).trim() : "";
435
+ }
436
+ const close = text.indexOf(")", i);
437
+ if (close === -1) return "";
438
+ const inner = text.slice(i, close);
439
+ return /["']/.test(inner) ? "" : inner.trim();
440
+ }
441
+ /**
442
+ * Whitespace-separated tokens, keeping a function's arguments together and
443
+ * making the `/` between position and size a token of its own.
444
+ */
445
+ function cssTokens(value) {
446
+ const tokens = [];
447
+ let depth = 0;
448
+ let quote = "";
449
+ let current = "";
450
+ const push = () => {
451
+ if (current) tokens.push(current);
452
+ current = "";
453
+ };
454
+ for (const char of value) {
455
+ if (quote) {
456
+ current += char;
457
+ if (char === quote) quote = "";
458
+ continue;
459
+ }
460
+ if (char === "\"" || char === "'") {
461
+ quote = char;
462
+ current += char;
463
+ continue;
464
+ }
465
+ if (char === "(") depth++;
466
+ if (char === ")") depth = Math.max(0, depth - 1);
467
+ if (depth === 0 && /\s/.test(char)) {
468
+ push();
469
+ continue;
470
+ }
471
+ if (depth === 0 && char === "/") {
472
+ push();
473
+ tokens.push("/");
474
+ continue;
475
+ }
476
+ current += char;
477
+ }
478
+ push();
479
+ return tokens;
480
+ }
481
+ function isColorToken(token) {
482
+ const lower = token.toLowerCase();
483
+ if (lower === "/" || IMAGE_FUNCTION.test(lower)) return false;
484
+ return !REPEAT_TOKENS.has(lower) && !POSITION_TOKENS.has(lower) && !NON_COLOR_TOKENS.has(lower) && !MEASURE.test(lower);
485
+ }
486
+ /**
487
+ * The parts of a `background` shorthand. The colour comes from the last
488
+ * layer, the only one CSS lets carry it; the image and its size, position
489
+ * and repeat come from the first layer with a `url()`.
490
+ */
491
+ function parseBackgroundShorthand(value) {
492
+ const layers = splitTopLevel(value, ",").map((layer) => layer.trim()).filter(Boolean);
493
+ const color = cssTokens(layers[layers.length - 1] ?? "").find(isColorToken) ?? "";
494
+ const imageLayer = layers.find((layer) => cssUrl(layer) !== "");
495
+ if (!imageLayer) return {
496
+ color,
497
+ url: "",
498
+ size: "",
499
+ position: "",
500
+ repeat: "",
501
+ layers: layers.length
502
+ };
503
+ const tokens = cssTokens(imageLayer);
504
+ const slash = tokens.indexOf("/");
505
+ const before = slash === -1 ? tokens : tokens.slice(0, slash);
506
+ const after = slash === -1 ? [] : tokens.slice(slash + 1);
507
+ const size = [];
508
+ for (const token of after) {
509
+ const lower = token.toLowerCase();
510
+ const isSize = lower === "cover" || lower === "contain" || lower === "auto" || MEASURE.test(lower);
511
+ if (size.length === 2 || !isSize) break;
512
+ size.push(token);
513
+ }
514
+ const rest = [...before, ...after.slice(size.length)];
515
+ return {
516
+ color,
517
+ url: cssUrl(imageLayer),
518
+ size: size.join(" "),
519
+ position: before.filter((token) => POSITION_TOKENS.has(token.toLowerCase()) || MEASURE.test(token)).join(" "),
520
+ repeat: rest.filter((token) => REPEAT_TOKENS.has(token.toLowerCase())).join(" "),
521
+ layers: layers.length
522
+ };
523
+ }
524
+ /**
525
+ * The colour a `background` shorthand paints, as written (`parseColor` it);
526
+ * "" when it states none. `background: #222 url(x) …` paints `#222`.
527
+ */
528
+ function backgroundShorthandColor(value) {
529
+ return value ? parseBackgroundShorthand(value).color : "";
530
+ }
531
+ /**
532
+ * A background image from a URL and its longhands. A longhand left out takes
533
+ * CSS's initial value; a value with no exact keyword takes the nearest one
534
+ * and adds a note naming it. `null` for a blank URL.
535
+ */
536
+ function buildBackgroundImage(url, longhands, label, boxWidth) {
537
+ const trimmedUrl = url.trim();
538
+ if (!trimmedUrl) return null;
539
+ const notes = [];
540
+ const lossy = (name, raw, shown) => {
541
+ notes.push(`${label} ${name} "${raw}" has no exact Templatical equivalent; imported as "${shown}".`);
542
+ };
543
+ let size = CSS_BACKGROUND_DEFAULTS.size;
544
+ const rawSize = longhands.size?.trim();
545
+ if (rawSize) {
546
+ const parsed = backgroundSizeKeyword(rawSize, boxWidth);
547
+ size = parsed.size;
548
+ if (!parsed.exact) lossy("background-size", rawSize, size);
549
+ }
550
+ let x = CSS_BACKGROUND_DEFAULTS.x;
551
+ let y = CSS_BACKGROUND_DEFAULTS.y;
552
+ let lossyX = false;
553
+ let lossyY = false;
554
+ const rawPosition = longhands.position?.trim();
555
+ if (rawPosition) {
556
+ const split = splitBackgroundPosition(rawPosition);
557
+ const readX = backgroundAxisPoint(split.x, X_GRID);
558
+ const readY = backgroundAxisPoint(split.y, Y_GRID);
559
+ x = readX?.point ?? x;
560
+ y = readY?.point ?? y;
561
+ lossyX = !split.exact || !readX?.exact;
562
+ lossyY = !split.exact || !readY?.exact;
563
+ }
564
+ const rawX = longhands.positionX?.trim();
565
+ const overrideX = rawX ? backgroundAxisPoint(rawX.toLowerCase(), X_GRID) : null;
566
+ if (overrideX) {
567
+ x = overrideX.point;
568
+ lossyX = false;
569
+ }
570
+ const rawY = longhands.positionY?.trim();
571
+ const overrideY = rawY ? backgroundAxisPoint(rawY.toLowerCase(), Y_GRID) : null;
572
+ if (overrideY) {
573
+ y = overrideY.point;
574
+ lossyY = false;
575
+ }
576
+ if (rawPosition && (lossyX || lossyY)) lossy("background-position", rawPosition, `${x} ${y}`);
577
+ if (rawX && !overrideX?.exact) lossy("background-position-x", rawX, x);
578
+ if (rawY && !overrideY?.exact) lossy("background-position-y", rawY, y);
579
+ let repeat = CSS_BACKGROUND_DEFAULTS.repeat;
580
+ const rawRepeat = longhands.repeat?.trim();
581
+ if (rawRepeat) {
582
+ const parsed = backgroundRepeatFlag(rawRepeat);
583
+ repeat = parsed.repeat;
584
+ if (!parsed.exact) lossy("background-repeat", rawRepeat, repeat ? "repeat" : "no-repeat");
585
+ }
586
+ return {
587
+ image: {
588
+ url: trimmedUrl,
589
+ size,
590
+ position: {
591
+ x,
592
+ y
593
+ },
594
+ repeat
595
+ },
596
+ notes
597
+ };
598
+ }
599
+ /**
600
+ * A background image from CSS declarations: `background-image`, else the
601
+ * `background` shorthand's image, with the size, position and repeat
602
+ * longhands winning over the shorthand's. A style map keeps no declaration
603
+ * order, so a longhand is taken to follow the shorthand, as it usually does.
604
+ * Of several layers the first image is kept, and a note says so. `null`
605
+ * when the declarations paint no image.
606
+ */
607
+ function readCssBackground(styles, label, boxWidth) {
608
+ const shorthand = styles.background !== void 0 ? parseBackgroundShorthand(styles.background) : null;
609
+ const imageLayers = styles["background-image"] !== void 0 ? splitTopLevel(styles["background-image"], ",").map((layer) => layer.trim()) : null;
610
+ const index = imageLayers ? imageLayers.findIndex((layer) => cssUrl(layer) !== "") : -1;
611
+ const url = imageLayers ? index === -1 ? "" : cssUrl(imageLayers[index]) : shorthand?.url ?? "";
612
+ if (!url) return null;
613
+ const layer = (value) => {
614
+ if (value === void 0) return void 0;
615
+ const parts = splitTopLevel(value, ",").map((part) => part.trim());
616
+ return parts[Math.max(index, 0)] ?? parts[0];
617
+ };
618
+ const read = buildBackgroundImage(url, {
619
+ size: layer(styles["background-size"]) ?? shorthand?.size,
620
+ position: layer(styles["background-position"]) ?? shorthand?.position,
621
+ positionX: styles["background-position-x"],
622
+ positionY: styles["background-position-y"],
623
+ repeat: layer(styles["background-repeat"]) ?? shorthand?.repeat
624
+ }, label, boxWidth);
625
+ const layers = imageLayers?.length ?? shorthand?.layers ?? 1;
626
+ if (read && layers > 1) read.notes.unshift(`${label} background has ${layers} layers; only the image "${url}" was imported.`);
627
+ return read;
628
+ }
629
+ /**
630
+ * The legacy `background="…"` attribute of a table, cell or body: a URL
631
+ * alone, tiled from the top left at the image's own size, as a browser
632
+ * draws it.
633
+ */
634
+ function readLegacyBackground(value, label, boxWidth) {
635
+ return buildBackgroundImage(value ?? "", {}, label, boxWidth);
636
+ }
236
637
  //#endregion
237
638
  //#region src/css-resolver.ts
238
639
  /**
@@ -277,11 +678,12 @@ function stripAtRules(css) {
277
678
  }
278
679
  /**
279
680
  * Parses a CSS declarations block (`color: red; font-size: 14px`) into
280
- * a flat record. `!important` markers are dropped.
681
+ * a flat record. `!important` markers are dropped. A semicolon inside quotes
682
+ * or parentheses — `url(data:image/png;base64,…)` — belongs to its value.
281
683
  */
282
684
  function parseDeclarations(text) {
283
685
  const result = {};
284
- for (const decl of text.split(";")) {
686
+ for (const decl of splitTopLevel(text, ";")) {
285
687
  const idx = decl.indexOf(":");
286
688
  if (idx === -1) continue;
287
689
  const key = decl.slice(0, idx).trim().toLowerCase();
@@ -1533,11 +1935,12 @@ function readHostPadding($el) {
1533
1935
  }
1534
1936
  /**
1535
1937
  * The colour an element paints behind its content: its CSS background, then
1536
- * its legacy `bgcolor` attribute, which CSS overrides.
1938
+ * its legacy `bgcolor` attribute, which CSS overrides. A `background`
1939
+ * shorthand contributes its colour token, image or not.
1537
1940
  */
1538
1941
  function fillOf($el) {
1539
1942
  const styles = getStyles($el);
1540
- return parseColor(styles["background-color"]) || parseColor(styles.background) || parseLegacyColor($el.attr("bgcolor"));
1943
+ return parseColor(styles["background-color"]) || parseColor(backgroundShorthandColor(styles.background)) || parseLegacyColor($el.attr("bgcolor"));
1541
1944
  }
1542
1945
  /**
1543
1946
  * Insets columns by the padding of the element that holds them, where that
@@ -1802,19 +2205,20 @@ function packagingTablesOf($cell, $) {
1802
2205
  *
1803
2206
  * - The cell's meaningful content must *be* the tables. Descending discards
1804
2207
  * the row, so a heading or an image beside the table would be dropped.
1805
- * - The row's style must set no background and no padding. The section it
1806
- * emits is the carrier for those, so descending past a styled row would
1807
- * drop the band it paints.
2208
+ * - The row's style must set no background (colour or image) and no padding.
2209
+ * The section it emits is the carrier for those, so descending past a
2210
+ * styled row would drop the band it paints.
1808
2211
  *
1809
- * A fill the descent does pass — the row's `bgcolor`, its cell's, its
1810
- * table's — is not lost: `processTable` hands it to the tables below, whose
1811
- * sections take it when nothing nearer paints them. Section counts depend on
1812
- * this gate, so a fill is carried down rather than made a reason to stop.
2212
+ * A fill or image the descent does pass — the row's `bgcolor`, its cell's,
2213
+ * its table's — is not lost: `processTable` hands it to the tables below,
2214
+ * whose sections take it when nothing nearer paints them. Section counts
2215
+ * depend on this gate, so a fill is carried down rather than made a reason
2216
+ * to stop.
1813
2217
  */
1814
2218
  function packagingRowTables($row, cells, $) {
1815
2219
  if (cells.length !== 1) return null;
1816
2220
  const rowStyles = getStyles($row);
1817
- if (parseColor(rowStyles["background-color"]) || parseColor(rowStyles.background)) return null;
2221
+ if (parseColor(rowStyles["background-color"]) || parseColor(backgroundShorthandColor(rowStyles.background)) || readCssBackground(rowStyles, "Section", void 0)) return null;
1818
2222
  const padding = readPaddingFromStyles(rowStyles);
1819
2223
  if (padding.top || padding.right || padding.bottom || padding.left) return null;
1820
2224
  return packagingTablesOf(cells[0], $);
@@ -1841,13 +2245,15 @@ function packagingRowTables($row, cells, $) {
1841
2245
  * three-into-one merge for a row that always stated one column.
1842
2246
  *
1843
2247
  * A background the section could not keep adds its own note after the
1844
- * layout's, so one entry names every loss the row had.
2248
+ * layout's, and so does a background-image value or column image it could not
2249
+ * keep, so one entry names every loss the row had.
1845
2250
  */
1846
- function sectionEntry(columnCount, slotCount, ratioNote, fillNote) {
2251
+ function sectionEntry(columnCount, slotCount, ratioNote, fillNote, imageNotes = []) {
1847
2252
  const notes = [];
1848
2253
  if (slotCount !== columnCount) notes.push(`Row of ${columnCount} columns was merged into a single column. Templatical sections hold at most 3 columns.`);
1849
2254
  else if (ratioNote) notes.push(ratioNote);
1850
2255
  if (fillNote) notes.push(fillNote);
2256
+ notes.push(...imageNotes);
1851
2257
  if (notes.length === 0) return {
1852
2258
  sourceTag: "tr",
1853
2259
  templaticalBlockType: "section",
@@ -1887,6 +2293,76 @@ function sectionFill($row, layoutCells, tableFill, $) {
1887
2293
  };
1888
2294
  }
1889
2295
  /**
2296
+ * The image an element paints: its CSS background, else the legacy
2297
+ * `background` attribute of a table or cell. A `background-image` or
2298
+ * `background` declaration overrides the attribute; a `background-color`
2299
+ * doesn't.
2300
+ */
2301
+ function imageOf($el, room) {
2302
+ const styles = getStyles($el);
2303
+ if (styles["background-image"] !== void 0 || styles.background !== void 0) return readCssBackground(styles, "Section", room);
2304
+ return $el.is("table, td, th") ? readLegacyBackground($el.attr("background"), "Section", room) : null;
2305
+ }
2306
+ function sameImage(a, b) {
2307
+ 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;
2308
+ }
2309
+ /** Each section gets its own image object, so editing one leaves the others alone. */
2310
+ function copyImage(image) {
2311
+ return {
2312
+ ...image,
2313
+ position: { ...image.position }
2314
+ };
2315
+ }
2316
+ function droppedColumnsNote(columns) {
2317
+ 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.`;
2318
+ }
2319
+ /**
2320
+ * The image a row's section paints, nearest first, as `sectionFill` reads
2321
+ * colour: the `<tr>`, or the cell around a column set, which spans the row;
2322
+ * then the columns' own images, moved up when there is one column or every
2323
+ * column shares the same image; then `carried`, the image of the table
2324
+ * holding the row or of the wrappers around it. Column images the section
2325
+ * can't keep are named in a note. A button cell's background is the
2326
+ * button's, so it is never read as a column image.
2327
+ */
2328
+ function sectionImage($row, layoutCells, hosts, carried, room, $) {
2329
+ const own = imageOf($row, room) ?? (hosts[0]?.kind === "container" ? imageOf(layoutCells[0], room) : null);
2330
+ const columns = hosts.map((host) => host.kind === "cell" && isButtonCell(host.$el, $).match ? null : imageOf(host.$el, room));
2331
+ const withImage = columns.flatMap((read, index) => read ? [index + 1] : []);
2332
+ const first = columns[0] ?? null;
2333
+ const shared = first !== null && columns.every((read) => read !== null && sameImage(read.image, first.image));
2334
+ const notes = [];
2335
+ let picked = own;
2336
+ if (withImage.length > 0) {
2337
+ if (!own && shared) picked = first;
2338
+ else notes.push(droppedColumnsNote(withImage));
2339
+ }
2340
+ if (picked) return {
2341
+ image: copyImage(picked.image),
2342
+ notes: [...picked.notes, ...notes],
2343
+ carried: null
2344
+ };
2345
+ if (carried) return {
2346
+ image: copyImage(carried.read.image),
2347
+ notes: [...carried.read.notes, ...notes],
2348
+ carried
2349
+ };
2350
+ return {
2351
+ image: void 0,
2352
+ notes,
2353
+ carried: null
2354
+ };
2355
+ }
2356
+ /** Note on each section a carried image was copied onto, once it spans more than one. */
2357
+ function noteSpread(carried) {
2358
+ if (carried.entries.length < 2) return;
2359
+ const note = `A background image behind ${carried.entries.length} sections was applied to each of them — Templatical has no multi-section band.`;
2360
+ for (const entry of carried.entries) {
2361
+ entry.status = "approximated";
2362
+ entry.note = entry.note ? `${entry.note} ${note}` : note;
2363
+ }
2364
+ }
2365
+ /**
1890
2366
  * The report entry for a nested row whose section wrapper was dropped, or
1891
2367
  * `null` when dropping it lost nothing.
1892
2368
  *
@@ -2048,8 +2524,11 @@ function hostRooms(rowRoom, layout, hostCount, setPadding) {
2048
2524
  * land: the template body's width for a table whose rows become sections,
2049
2525
  * the surrounding column's for a flattened one. Unknown, a divider keeps a
2050
2526
  * px width as stated.
2527
+ * @param enclosingImage - The image of the wrapper cells and tables this
2528
+ * table was reached through, which its sections take when nothing nearer
2529
+ * paints one.
2051
2530
  */
2052
- function processTable($table, $, entries, warnings, flattenInline = false, enclosingFill = "", room) {
2531
+ function processTable($table, $, entries, warnings, flattenInline = false, enclosingFill = "", room, enclosingImage = null) {
2053
2532
  if (!isLayoutTable($table, $)) {
2054
2533
  entries.push({
2055
2534
  sourceTag: "table",
@@ -2062,6 +2541,11 @@ function processTable($table, $, entries, warnings, flattenInline = false, enclo
2062
2541
  const rows = getDirectRows($table, $);
2063
2542
  if (rows.length === 0) return [];
2064
2543
  const tableFill = fillOf($table) || enclosingFill;
2544
+ const ownTableImage = imageOf($table, room);
2545
+ const tableImage = ownTableImage ? {
2546
+ read: ownTableImage,
2547
+ entries: []
2548
+ } : enclosingImage;
2065
2549
  const sections = [];
2066
2550
  for (const $row of rows) {
2067
2551
  const cells = getDirectCells($row, $);
@@ -2069,10 +2553,16 @@ function processTable($table, $, entries, warnings, flattenInline = false, enclo
2069
2553
  const packaging = packagingRowTables($row, cells, $);
2070
2554
  if (packaging) {
2071
2555
  const carried = fillOf($row) || fillOf(cells[0]) || tableFill;
2556
+ const cellImage = imageOf(cells[0], room);
2557
+ const carriedImage = cellImage ? {
2558
+ read: cellImage,
2559
+ entries: []
2560
+ } : tableImage;
2072
2561
  const wrapperPadding = readHostPadding(cells[0]);
2073
2562
  const innerRoom = flattenInline ? narrow(room, wrapperPadding) : room;
2074
2563
  const descended = [];
2075
- for (const $inner of packaging) descended.push(...processTable($inner, $, entries, warnings, flattenInline, carried, innerRoom));
2564
+ for (const $inner of packaging) descended.push(...processTable($inner, $, entries, warnings, flattenInline, carried, innerRoom, carriedImage));
2565
+ if (cellImage && carriedImage) noteSpread(carriedImage);
2076
2566
  if (flattenInline) insetColumns([descended], wrapperPadding);
2077
2567
  sections.push(...descended);
2078
2568
  continue;
@@ -2104,16 +2594,21 @@ function processTable($table, $, entries, warnings, flattenInline = false, enclo
2104
2594
  continue;
2105
2595
  }
2106
2596
  const fill = sectionFill($row, layoutCells, tableFill, $);
2107
- entries.push(sectionEntry(hosts.length, columnsBlocks.length, ratio.note, fill.note));
2597
+ const image = sectionImage($row, layoutCells, hosts, tableImage, room, $);
2598
+ const entry = sectionEntry(hosts.length, columnsBlocks.length, ratio.note, fill.note, image.notes);
2599
+ entries.push(entry);
2600
+ image.carried?.entries.push(entry);
2108
2601
  sections.push(createSectionBlock({
2109
2602
  columns: ratio.layout,
2110
2603
  children: columnsBlocks,
2111
2604
  styles: {
2112
2605
  padding,
2113
2606
  ...fill.color ? { backgroundColor: fill.color } : {}
2114
- }
2607
+ },
2608
+ ...image.image ? { backgroundImage: image.image } : {}
2115
2609
  }));
2116
2610
  }
2611
+ if (ownTableImage && tableImage) noteSpread(tableImage);
2117
2612
  return sections;
2118
2613
  }
2119
2614
  //#endregion
@@ -2142,7 +2637,7 @@ function extractSettings($, linkUnderline) {
2142
2637
  const $body = $("body");
2143
2638
  const bodyStyles = parseStyleAttribute($body.attr("style"));
2144
2639
  const fontFamily = canonicalFontStack(bodyStyles["font-family"]) || "Arial";
2145
- const backgroundColor = parseColor(bodyStyles["background-color"]) || parseColor(bodyStyles.background) || "#ffffff";
2640
+ const backgroundColor = parseColor(bodyStyles["background-color"]) || parseColor(backgroundShorthandColor(bodyStyles.background)) || "#ffffff";
2146
2641
  const $outerTable = $body.find("table").first();
2147
2642
  const widthAttr = parsePxValue($outerTable.attr("width"));
2148
2643
  const widthStyle = parsePxValue(parseStyleAttribute($outerTable.attr("style")).width);
@@ -2265,6 +2760,16 @@ function processBody($, entries, warnings, bodyWidth) {
2265
2760
  return blocks;
2266
2761
  }
2267
2762
  /**
2763
+ * The page's background image, which has no Templatical equivalent: the
2764
+ * body's CSS background, else its legacy `background` attribute.
2765
+ */
2766
+ function pageImageWarning($) {
2767
+ const $body = $("body");
2768
+ const styles = parseStyleAttribute($body.attr("style"));
2769
+ const read = styles["background-image"] !== void 0 || styles.background !== void 0 ? readCssBackground(styles, "Body", void 0) : readLegacyBackground($body.attr("background"), "Body", void 0);
2770
+ return read ? `The email background image "${read.image.url}" was dropped; Templatical has no email-level background image.` : null;
2771
+ }
2772
+ /**
2268
2773
  * Converts an HTML email template to Templatical TemplateContent.
2269
2774
  *
2270
2775
  * Designed for table-based marketing email HTML (output of MJML, Mailchimp,
@@ -2297,6 +2802,8 @@ function convertHtmlTemplate(html) {
2297
2802
  const entries = [];
2298
2803
  const warnings = [];
2299
2804
  const settings = extractSettings($, linkUnderline);
2805
+ const pageImage = pageImageWarning($);
2806
+ if (pageImage) warnings.push(pageImage);
2300
2807
  const blocks = processBody($, entries, warnings, settings.width);
2301
2808
  if (blocks.length === 0) warnings.push("No convertible content was found in the HTML. The email may use a non-table layout — modern HTML support is limited.");
2302
2809
  return {