@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 +612 -61
- 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();
|
|
@@ -821,29 +1223,77 @@ function convertInlineRun(nodes, $cell, $) {
|
|
|
821
1223
|
};
|
|
822
1224
|
}
|
|
823
1225
|
/**
|
|
824
|
-
*
|
|
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
|
|
831
|
-
const
|
|
832
|
-
const
|
|
833
|
-
const width =
|
|
834
|
-
const
|
|
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
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
|
864
|
-
|
|
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.
|
|
1217
|
-
*
|
|
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")
|
|
1235
|
-
block
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
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.
|
|
1806
|
-
* emits is the carrier for those, so descending past a
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|