@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 +528 -21
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
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
|
|
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
|
|
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
|
|
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.
|
|
1806
|
-
* emits is the carrier for those, so descending past a
|
|
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,
|
|
1810
|
-
* table's — is not lost: `processTable` hands it to the tables below,
|
|
1811
|
-
* sections take it when nothing nearer paints them. Section counts
|
|
1812
|
-
* this gate, so a fill is carried down rather than made a reason
|
|
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
|
|
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
|
-
|
|
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 {
|