@templatical/import-html 0.46.0 → 0.48.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();
@@ -821,29 +1223,77 @@ function convertInlineRun(nodes, $cell, $) {
821
1223
  };
822
1224
  }
823
1225
  /**
824
- * <img> → Image block.
1226
+ * An `<img>`'s shown width as `ImageBlock.width`: the `width` attribute, else
1227
+ * the `width` style. No width, or `100%`, is `"full"`, as is a px width that
1228
+ * reaches `room`, the column less the image's side padding, when the caller
1229
+ * knows it. A percentage under 100% becomes px of `room` and pushes a note,
1230
+ * since a px width keeps its size on a phone where a percentage shrinks with
1231
+ * the column; with no `room` to resolve against it is `"full"`.
1232
+ */
1233
+ function resolveImageWidth(values, room, notes) {
1234
+ for (const raw of values) {
1235
+ const value = (raw ?? "").trim();
1236
+ if (!value) continue;
1237
+ const percent = value.match(/^(\d+(?:\.\d+)?)\s*%$/);
1238
+ if (percent) {
1239
+ const share = parseFloat(percent[1]);
1240
+ if (share <= 0 || share >= 100) return "full";
1241
+ if (room === void 0) {
1242
+ notes.push(`Image width ${value} has no known column to resolve against; imported as full width.`);
1243
+ return "full";
1244
+ }
1245
+ const px = Math.round(room * share / 100);
1246
+ if (px >= room) return "full";
1247
+ notes.push(`Image width ${value} imported as ${px}px, which keeps its size on a phone.`);
1248
+ return px;
1249
+ }
1250
+ const px = parsePxValue(value);
1251
+ if (px > 0) return room !== void 0 && px >= room ? "full" : px;
1252
+ }
1253
+ return "full";
1254
+ }
1255
+ /**
1256
+ * <img> → Image block. `room` is the width its column leaves a line, when the
1257
+ * caller knows it.
825
1258
  */
826
- function convertImage($el, link) {
1259
+ function convertImage($el, room, link) {
827
1260
  const styles = getStyles$1($el);
828
1261
  const src = $el.attr("src") ?? "";
829
1262
  const alt = $el.attr("alt") ?? "";
830
- const widthAttr = $el.attr("width");
831
- const widthStyle = styles.width;
832
- const readWidth = parsePxValue(widthAttr) || parsePxValue(widthStyle) || void 0;
833
- const width = readWidth ?? 600;
834
- const height = parsePxValue($el.attr("height")) || parsePxValue(styles.height) || void 0;
1263
+ const padding = readPaddingFromStyles(styles);
1264
+ const imageRoom = room === void 0 ? void 0 : room - padding.left - padding.right;
1265
+ const notes = [];
1266
+ const width = resolveImageWidth([$el.attr("width"), styles.width], imageRoom, notes);
1267
+ const heightStyle = (styles.height ?? "").trim();
1268
+ const height = (heightStyle ? parsePxValue(heightStyle) : parsePxValue($el.attr("height"))) || void 0;
835
1269
  const href = (link?.url ?? "").trim();
836
- return createImageBlock({
837
- src,
838
- alt,
839
- width,
840
- height,
841
- borderRadius: parseImageBorderRadius(styles["border-radius"], readWidth, height),
842
- align: parseAlignment(styles["text-align"], "center"),
843
- styles: { padding: readPaddingFromStyles(styles) },
844
- ...href ? { linkUrl: href } : {},
845
- ...href && link?.openInNewTab ? { linkOpenInNewTab: true } : {}
846
- });
1270
+ const stated = Boolean(($el.attr("width") ?? "").trim() || (styles.width ?? "").trim());
1271
+ const shownWidth = width === "full" ? imageRoom : width;
1272
+ return {
1273
+ block: createImageBlock({
1274
+ src,
1275
+ alt,
1276
+ width,
1277
+ height,
1278
+ borderRadius: parseImageBorderRadius(styles["border-radius"], stated ? shownWidth : void 0, height),
1279
+ align: parseAlignment(styles["text-align"], "center"),
1280
+ styles: { padding },
1281
+ ...href ? { linkUrl: href } : {},
1282
+ ...href && link?.openInNewTab ? { linkOpenInNewTab: true } : {}
1283
+ }),
1284
+ notes
1285
+ };
1286
+ }
1287
+ /**
1288
+ * The report entry for an image `convertImage` produced from `sourceTag`.
1289
+ */
1290
+ function imageEntry(sourceTag, notes) {
1291
+ return {
1292
+ sourceTag,
1293
+ templaticalBlockType: "image",
1294
+ status: notes.length > 0 ? "approximated" : "converted",
1295
+ ...notes.length > 0 ? { note: notes.join(" ") } : {}
1296
+ };
847
1297
  }
848
1298
  /**
849
1299
  * Whether an `<a>` wraps an image. An anchor wrapping only an image carries
@@ -858,17 +1308,14 @@ function isImageAnchor($el) {
858
1308
  function isImageOnlyAnchor($el) {
859
1309
  return isImageAnchor($el) && ($el.text() ?? "").trim() === "";
860
1310
  }
861
- function convertLinkedImage($anchor, $img) {
1311
+ function convertLinkedImage($anchor, $img, room) {
1312
+ const { block, notes } = convertImage($img, room, {
1313
+ url: $anchor.attr("href") ?? "",
1314
+ openInNewTab: $anchor.attr("target") === "_blank"
1315
+ });
862
1316
  return {
863
- block: convertImage($img, {
864
- url: $anchor.attr("href") ?? "",
865
- openInNewTab: $anchor.attr("target") === "_blank"
866
- }),
867
- entry: {
868
- sourceTag: "a",
869
- templaticalBlockType: "image",
870
- status: "converted"
871
- }
1317
+ block,
1318
+ entry: imageEntry("a", notes)
872
1319
  };
873
1320
  }
874
1321
  /**
@@ -879,7 +1326,7 @@ function convertLinkedImage($anchor, $img) {
879
1326
  */
880
1327
  function splitMixedImageAnchor($anchor, $host, $) {
881
1328
  const results = [];
882
- for (const img of $anchor.find("img").toArray()) results.push(convertLinkedImage($anchor, $(img)));
1329
+ for (const img of $anchor.find("img").toArray()) results.push(convertLinkedImage($anchor, $(img), void 0));
883
1330
  const $clone = $anchor.clone();
884
1331
  $clone.find("img").remove();
885
1332
  if (($clone.text() ?? "").trim() === "") return results;
@@ -1213,8 +1660,8 @@ function isButtonCell($el, $) {
1213
1660
  * styles are what `styles` already holds.
1214
1661
  *
1215
1662
  * `room` is the width, in px, that a line in the element's column can span
1216
- * before the element's own padding, when the caller knows it. Only a divider
1217
- * reads it.
1663
+ * before the element's own padding, when the caller knows it. A divider and an
1664
+ * image read it.
1218
1665
  *
1219
1666
  * Returns `null` for elements that do not contain any meaningful content
1220
1667
  * (the caller should skip them).
@@ -1231,16 +1678,15 @@ function convertElement($el, $, room) {
1231
1678
  status: "converted"
1232
1679
  }
1233
1680
  };
1234
- if (tag === "img") return {
1235
- block: convertImage($target),
1236
- entry: {
1237
- sourceTag: tag,
1238
- templaticalBlockType: "image",
1239
- status: "converted"
1240
- }
1241
- };
1681
+ if (tag === "img") {
1682
+ const { block, notes } = convertImage($target, room);
1683
+ return {
1684
+ block,
1685
+ entry: imageEntry(tag, notes)
1686
+ };
1687
+ }
1242
1688
  if (tag === "a") {
1243
- if (isImageOnlyAnchor($target)) return convertLinkedImage($target, $($target.find("img")[0]));
1689
+ if (isImageOnlyAnchor($target)) return convertLinkedImage($target, $($target.find("img")[0]), room);
1244
1690
  if (looksLikeButton(styles)) return {
1245
1691
  block: convertButton($target),
1246
1692
  entry: {
@@ -1533,11 +1979,12 @@ function readHostPadding($el) {
1533
1979
  }
1534
1980
  /**
1535
1981
  * The colour an element paints behind its content: its CSS background, then
1536
- * its legacy `bgcolor` attribute, which CSS overrides.
1982
+ * its legacy `bgcolor` attribute, which CSS overrides. A `background`
1983
+ * shorthand contributes its colour token, image or not.
1537
1984
  */
1538
1985
  function fillOf($el) {
1539
1986
  const styles = getStyles($el);
1540
- return parseColor(styles["background-color"]) || parseColor(styles.background) || parseLegacyColor($el.attr("bgcolor"));
1987
+ return parseColor(styles["background-color"]) || parseColor(backgroundShorthandColor(styles.background)) || parseLegacyColor($el.attr("bgcolor"));
1541
1988
  }
1542
1989
  /**
1543
1990
  * Insets columns by the padding of the element that holds them, where that
@@ -1802,19 +2249,20 @@ function packagingTablesOf($cell, $) {
1802
2249
  *
1803
2250
  * - The cell's meaningful content must *be* the tables. Descending discards
1804
2251
  * 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.
2252
+ * - The row's style must set no background (colour or image) and no padding.
2253
+ * The section it emits is the carrier for those, so descending past a
2254
+ * styled row would drop the band it paints.
1808
2255
  *
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.
2256
+ * A fill or image the descent does pass — the row's `bgcolor`, its cell's,
2257
+ * its table's — is not lost: `processTable` hands it to the tables below,
2258
+ * whose sections take it when nothing nearer paints them. Section counts
2259
+ * depend on this gate, so a fill is carried down rather than made a reason
2260
+ * to stop.
1813
2261
  */
1814
2262
  function packagingRowTables($row, cells, $) {
1815
2263
  if (cells.length !== 1) return null;
1816
2264
  const rowStyles = getStyles($row);
1817
- if (parseColor(rowStyles["background-color"]) || parseColor(rowStyles.background)) return null;
2265
+ if (parseColor(rowStyles["background-color"]) || parseColor(backgroundShorthandColor(rowStyles.background)) || readCssBackground(rowStyles, "Section", void 0)) return null;
1818
2266
  const padding = readPaddingFromStyles(rowStyles);
1819
2267
  if (padding.top || padding.right || padding.bottom || padding.left) return null;
1820
2268
  return packagingTablesOf(cells[0], $);
@@ -1841,13 +2289,15 @@ function packagingRowTables($row, cells, $) {
1841
2289
  * three-into-one merge for a row that always stated one column.
1842
2290
  *
1843
2291
  * 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.
2292
+ * layout's, and so does a background-image value or column image it could not
2293
+ * keep, so one entry names every loss the row had.
1845
2294
  */
1846
- function sectionEntry(columnCount, slotCount, ratioNote, fillNote) {
2295
+ function sectionEntry(columnCount, slotCount, ratioNote, fillNote, imageNotes = []) {
1847
2296
  const notes = [];
1848
2297
  if (slotCount !== columnCount) notes.push(`Row of ${columnCount} columns was merged into a single column. Templatical sections hold at most 3 columns.`);
1849
2298
  else if (ratioNote) notes.push(ratioNote);
1850
2299
  if (fillNote) notes.push(fillNote);
2300
+ notes.push(...imageNotes);
1851
2301
  if (notes.length === 0) return {
1852
2302
  sourceTag: "tr",
1853
2303
  templaticalBlockType: "section",
@@ -1887,6 +2337,76 @@ function sectionFill($row, layoutCells, tableFill, $) {
1887
2337
  };
1888
2338
  }
1889
2339
  /**
2340
+ * The image an element paints: its CSS background, else the legacy
2341
+ * `background` attribute of a table or cell. A `background-image` or
2342
+ * `background` declaration overrides the attribute; a `background-color`
2343
+ * doesn't.
2344
+ */
2345
+ function imageOf($el, room) {
2346
+ const styles = getStyles($el);
2347
+ if (styles["background-image"] !== void 0 || styles.background !== void 0) return readCssBackground(styles, "Section", room);
2348
+ return $el.is("table, td, th") ? readLegacyBackground($el.attr("background"), "Section", room) : null;
2349
+ }
2350
+ function sameImage(a, b) {
2351
+ 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;
2352
+ }
2353
+ /** Each section gets its own image object, so editing one leaves the others alone. */
2354
+ function copyImage(image) {
2355
+ return {
2356
+ ...image,
2357
+ position: { ...image.position }
2358
+ };
2359
+ }
2360
+ function droppedColumnsNote(columns) {
2361
+ 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.`;
2362
+ }
2363
+ /**
2364
+ * The image a row's section paints, nearest first, as `sectionFill` reads
2365
+ * colour: the `<tr>`, or the cell around a column set, which spans the row;
2366
+ * then the columns' own images, moved up when there is one column or every
2367
+ * column shares the same image; then `carried`, the image of the table
2368
+ * holding the row or of the wrappers around it. Column images the section
2369
+ * can't keep are named in a note. A button cell's background is the
2370
+ * button's, so it is never read as a column image.
2371
+ */
2372
+ function sectionImage($row, layoutCells, hosts, carried, room, $) {
2373
+ const own = imageOf($row, room) ?? (hosts[0]?.kind === "container" ? imageOf(layoutCells[0], room) : null);
2374
+ const columns = hosts.map((host) => host.kind === "cell" && isButtonCell(host.$el, $).match ? null : imageOf(host.$el, room));
2375
+ const withImage = columns.flatMap((read, index) => read ? [index + 1] : []);
2376
+ const first = columns[0] ?? null;
2377
+ const shared = first !== null && columns.every((read) => read !== null && sameImage(read.image, first.image));
2378
+ const notes = [];
2379
+ let picked = own;
2380
+ if (withImage.length > 0) {
2381
+ if (!own && shared) picked = first;
2382
+ else notes.push(droppedColumnsNote(withImage));
2383
+ }
2384
+ if (picked) return {
2385
+ image: copyImage(picked.image),
2386
+ notes: [...picked.notes, ...notes],
2387
+ carried: null
2388
+ };
2389
+ if (carried) return {
2390
+ image: copyImage(carried.read.image),
2391
+ notes: [...carried.read.notes, ...notes],
2392
+ carried
2393
+ };
2394
+ return {
2395
+ image: void 0,
2396
+ notes,
2397
+ carried: null
2398
+ };
2399
+ }
2400
+ /** Note on each section a carried image was copied onto, once it spans more than one. */
2401
+ function noteSpread(carried) {
2402
+ if (carried.entries.length < 2) return;
2403
+ const note = `A background image behind ${carried.entries.length} sections was applied to each of them — Templatical has no multi-section band.`;
2404
+ for (const entry of carried.entries) {
2405
+ entry.status = "approximated";
2406
+ entry.note = entry.note ? `${entry.note} ${note}` : note;
2407
+ }
2408
+ }
2409
+ /**
1890
2410
  * The report entry for a nested row whose section wrapper was dropped, or
1891
2411
  * `null` when dropping it lost nothing.
1892
2412
  *
@@ -2048,8 +2568,11 @@ function hostRooms(rowRoom, layout, hostCount, setPadding) {
2048
2568
  * land: the template body's width for a table whose rows become sections,
2049
2569
  * the surrounding column's for a flattened one. Unknown, a divider keeps a
2050
2570
  * px width as stated.
2571
+ * @param enclosingImage - The image of the wrapper cells and tables this
2572
+ * table was reached through, which its sections take when nothing nearer
2573
+ * paints one.
2051
2574
  */
2052
- function processTable($table, $, entries, warnings, flattenInline = false, enclosingFill = "", room) {
2575
+ function processTable($table, $, entries, warnings, flattenInline = false, enclosingFill = "", room, enclosingImage = null) {
2053
2576
  if (!isLayoutTable($table, $)) {
2054
2577
  entries.push({
2055
2578
  sourceTag: "table",
@@ -2062,6 +2585,11 @@ function processTable($table, $, entries, warnings, flattenInline = false, enclo
2062
2585
  const rows = getDirectRows($table, $);
2063
2586
  if (rows.length === 0) return [];
2064
2587
  const tableFill = fillOf($table) || enclosingFill;
2588
+ const ownTableImage = imageOf($table, room);
2589
+ const tableImage = ownTableImage ? {
2590
+ read: ownTableImage,
2591
+ entries: []
2592
+ } : enclosingImage;
2065
2593
  const sections = [];
2066
2594
  for (const $row of rows) {
2067
2595
  const cells = getDirectCells($row, $);
@@ -2069,10 +2597,16 @@ function processTable($table, $, entries, warnings, flattenInline = false, enclo
2069
2597
  const packaging = packagingRowTables($row, cells, $);
2070
2598
  if (packaging) {
2071
2599
  const carried = fillOf($row) || fillOf(cells[0]) || tableFill;
2600
+ const cellImage = imageOf(cells[0], room);
2601
+ const carriedImage = cellImage ? {
2602
+ read: cellImage,
2603
+ entries: []
2604
+ } : tableImage;
2072
2605
  const wrapperPadding = readHostPadding(cells[0]);
2073
2606
  const innerRoom = flattenInline ? narrow(room, wrapperPadding) : room;
2074
2607
  const descended = [];
2075
- for (const $inner of packaging) descended.push(...processTable($inner, $, entries, warnings, flattenInline, carried, innerRoom));
2608
+ for (const $inner of packaging) descended.push(...processTable($inner, $, entries, warnings, flattenInline, carried, innerRoom, carriedImage));
2609
+ if (cellImage && carriedImage) noteSpread(carriedImage);
2076
2610
  if (flattenInline) insetColumns([descended], wrapperPadding);
2077
2611
  sections.push(...descended);
2078
2612
  continue;
@@ -2104,16 +2638,21 @@ function processTable($table, $, entries, warnings, flattenInline = false, enclo
2104
2638
  continue;
2105
2639
  }
2106
2640
  const fill = sectionFill($row, layoutCells, tableFill, $);
2107
- entries.push(sectionEntry(hosts.length, columnsBlocks.length, ratio.note, fill.note));
2641
+ const image = sectionImage($row, layoutCells, hosts, tableImage, room, $);
2642
+ const entry = sectionEntry(hosts.length, columnsBlocks.length, ratio.note, fill.note, image.notes);
2643
+ entries.push(entry);
2644
+ image.carried?.entries.push(entry);
2108
2645
  sections.push(createSectionBlock({
2109
2646
  columns: ratio.layout,
2110
2647
  children: columnsBlocks,
2111
2648
  styles: {
2112
2649
  padding,
2113
2650
  ...fill.color ? { backgroundColor: fill.color } : {}
2114
- }
2651
+ },
2652
+ ...image.image ? { backgroundImage: image.image } : {}
2115
2653
  }));
2116
2654
  }
2655
+ if (ownTableImage && tableImage) noteSpread(tableImage);
2117
2656
  return sections;
2118
2657
  }
2119
2658
  //#endregion
@@ -2142,7 +2681,7 @@ function extractSettings($, linkUnderline) {
2142
2681
  const $body = $("body");
2143
2682
  const bodyStyles = parseStyleAttribute($body.attr("style"));
2144
2683
  const fontFamily = canonicalFontStack(bodyStyles["font-family"]) || "Arial";
2145
- const backgroundColor = parseColor(bodyStyles["background-color"]) || parseColor(bodyStyles.background) || "#ffffff";
2684
+ const backgroundColor = parseColor(bodyStyles["background-color"]) || parseColor(backgroundShorthandColor(bodyStyles.background)) || "#ffffff";
2146
2685
  const $outerTable = $body.find("table").first();
2147
2686
  const widthAttr = parsePxValue($outerTable.attr("width"));
2148
2687
  const widthStyle = parsePxValue(parseStyleAttribute($outerTable.attr("style")).width);
@@ -2265,6 +2804,16 @@ function processBody($, entries, warnings, bodyWidth) {
2265
2804
  return blocks;
2266
2805
  }
2267
2806
  /**
2807
+ * The page's background image, which has no Templatical equivalent: the
2808
+ * body's CSS background, else its legacy `background` attribute.
2809
+ */
2810
+ function pageImageWarning($) {
2811
+ const $body = $("body");
2812
+ const styles = parseStyleAttribute($body.attr("style"));
2813
+ const read = styles["background-image"] !== void 0 || styles.background !== void 0 ? readCssBackground(styles, "Body", void 0) : readLegacyBackground($body.attr("background"), "Body", void 0);
2814
+ return read ? `The email background image "${read.image.url}" was dropped; Templatical has no email-level background image.` : null;
2815
+ }
2816
+ /**
2268
2817
  * Converts an HTML email template to Templatical TemplateContent.
2269
2818
  *
2270
2819
  * Designed for table-based marketing email HTML (output of MJML, Mailchimp,
@@ -2297,6 +2846,8 @@ function convertHtmlTemplate(html) {
2297
2846
  const entries = [];
2298
2847
  const warnings = [];
2299
2848
  const settings = extractSettings($, linkUnderline);
2849
+ const pageImage = pageImageWarning($);
2850
+ if (pageImage) warnings.push(pageImage);
2300
2851
  const blocks = processBody($, entries, warnings, settings.width);
2301
2852
  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
2853
  return {