@templatical/import-html 0.45.0 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +533 -33
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { load } from "cheerio";
|
|
2
|
-
import { createButtonBlock, createDefaultTemplateContent, createDividerBlock, createHtmlBlock, createImageBlock, createParagraphBlock, createSectionBlock, createSpacerBlock, createTitleBlock } from "@templatical/types";
|
|
2
|
+
import { canonicalFontStack, createButtonBlock, createDefaultTemplateContent, createDividerBlock, createHtmlBlock, createImageBlock, createParagraphBlock, createSectionBlock, createSpacerBlock, createTitleBlock } from "@templatical/types";
|
|
3
3
|
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) {
|
|
@@ -175,13 +206,6 @@ function readPaddingFromStyles(styles) {
|
|
|
175
206
|
};
|
|
176
207
|
}
|
|
177
208
|
/**
|
|
178
|
-
* Strips quotes and returns the first font in a font-family stack.
|
|
179
|
-
*/
|
|
180
|
-
function parseFontFamily(value) {
|
|
181
|
-
if (!value) return "";
|
|
182
|
-
return value.split(",")[0].trim().replace(/^['"]|['"]$/g, "");
|
|
183
|
-
}
|
|
184
|
-
/**
|
|
185
209
|
* Normalizes a font-weight value to a string CSS keyword/number that
|
|
186
210
|
* the editor accepts. Returns "" when the value is the default (normal/400).
|
|
187
211
|
*/
|
|
@@ -240,6 +264,376 @@ function parseBorderShorthand(value) {
|
|
|
240
264
|
color
|
|
241
265
|
};
|
|
242
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
|
+
}
|
|
243
637
|
//#endregion
|
|
244
638
|
//#region src/css-resolver.ts
|
|
245
639
|
/**
|
|
@@ -284,11 +678,12 @@ function stripAtRules(css) {
|
|
|
284
678
|
}
|
|
285
679
|
/**
|
|
286
680
|
* Parses a CSS declarations block (`color: red; font-size: 14px`) into
|
|
287
|
-
* 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.
|
|
288
683
|
*/
|
|
289
684
|
function parseDeclarations(text) {
|
|
290
685
|
const result = {};
|
|
291
|
-
for (const decl of text
|
|
686
|
+
for (const decl of splitTopLevel(text, ";")) {
|
|
292
687
|
const idx = decl.indexOf(":");
|
|
293
688
|
if (idx === -1) continue;
|
|
294
689
|
const key = decl.slice(0, idx).trim().toLowerCase();
|
|
@@ -724,7 +1119,7 @@ function convertHeading($el, styles = getStyles$1($el)) {
|
|
|
724
1119
|
level,
|
|
725
1120
|
color: parseColor(styles.color) || "#1a1a1a",
|
|
726
1121
|
textAlign: parseAlignment(styles["text-align"]),
|
|
727
|
-
fontFamily:
|
|
1122
|
+
fontFamily: canonicalFontStack(styles["font-family"]) || void 0,
|
|
728
1123
|
styles: { padding: readPaddingFromStyles(styles) }
|
|
729
1124
|
});
|
|
730
1125
|
}
|
|
@@ -774,7 +1169,7 @@ function buildParagraph(innerHtml, styles, padding = readPaddingFromStyles(style
|
|
|
774
1169
|
if (color && color !== "#1a1a1a") fontParts.push(`color: ${color}`);
|
|
775
1170
|
const fontWeight = parseFontWeight(styles["font-weight"]);
|
|
776
1171
|
if (fontWeight) fontParts.push(`font-weight: ${fontWeight}`);
|
|
777
|
-
const fontFamily =
|
|
1172
|
+
const fontFamily = canonicalFontStack(styles["font-family"]);
|
|
778
1173
|
if (fontFamily) fontParts.push(`font-family: ${fontFamily}`);
|
|
779
1174
|
const textAlign = styles["text-align"];
|
|
780
1175
|
let result = wrapped;
|
|
@@ -1026,7 +1421,7 @@ function convertButton($el) {
|
|
|
1026
1421
|
textColor: parseColor(styles.color) || "#ffffff",
|
|
1027
1422
|
borderRadius: parsePxValue(styles["border-radius"]),
|
|
1028
1423
|
fontSize: parsePxValue(styles["font-size"]) || 16,
|
|
1029
|
-
fontFamily:
|
|
1424
|
+
fontFamily: canonicalFontStack(styles["font-family"]) || void 0,
|
|
1030
1425
|
align: readButtonAlign($el),
|
|
1031
1426
|
buttonPadding: readPaddingFromStyles(styles),
|
|
1032
1427
|
styles: { padding: emptyPadding$2() }
|
|
@@ -1540,11 +1935,12 @@ function readHostPadding($el) {
|
|
|
1540
1935
|
}
|
|
1541
1936
|
/**
|
|
1542
1937
|
* The colour an element paints behind its content: its CSS background, then
|
|
1543
|
-
* 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.
|
|
1544
1940
|
*/
|
|
1545
1941
|
function fillOf($el) {
|
|
1546
1942
|
const styles = getStyles($el);
|
|
1547
|
-
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"));
|
|
1548
1944
|
}
|
|
1549
1945
|
/**
|
|
1550
1946
|
* Insets columns by the padding of the element that holds them, where that
|
|
@@ -1809,19 +2205,20 @@ function packagingTablesOf($cell, $) {
|
|
|
1809
2205
|
*
|
|
1810
2206
|
* - The cell's meaningful content must *be* the tables. Descending discards
|
|
1811
2207
|
* the row, so a heading or an image beside the table would be dropped.
|
|
1812
|
-
* - The row's style must set no background and no padding.
|
|
1813
|
-
* emits is the carrier for those, so descending past a
|
|
1814
|
-
* 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.
|
|
1815
2211
|
*
|
|
1816
|
-
* A fill the descent does pass — the row's `bgcolor`, its cell's,
|
|
1817
|
-
* table's — is not lost: `processTable` hands it to the tables below,
|
|
1818
|
-
* sections take it when nothing nearer paints them. Section counts
|
|
1819
|
-
* 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.
|
|
1820
2217
|
*/
|
|
1821
2218
|
function packagingRowTables($row, cells, $) {
|
|
1822
2219
|
if (cells.length !== 1) return null;
|
|
1823
2220
|
const rowStyles = getStyles($row);
|
|
1824
|
-
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;
|
|
1825
2222
|
const padding = readPaddingFromStyles(rowStyles);
|
|
1826
2223
|
if (padding.top || padding.right || padding.bottom || padding.left) return null;
|
|
1827
2224
|
return packagingTablesOf(cells[0], $);
|
|
@@ -1848,13 +2245,15 @@ function packagingRowTables($row, cells, $) {
|
|
|
1848
2245
|
* three-into-one merge for a row that always stated one column.
|
|
1849
2246
|
*
|
|
1850
2247
|
* A background the section could not keep adds its own note after the
|
|
1851
|
-
* 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.
|
|
1852
2250
|
*/
|
|
1853
|
-
function sectionEntry(columnCount, slotCount, ratioNote, fillNote) {
|
|
2251
|
+
function sectionEntry(columnCount, slotCount, ratioNote, fillNote, imageNotes = []) {
|
|
1854
2252
|
const notes = [];
|
|
1855
2253
|
if (slotCount !== columnCount) notes.push(`Row of ${columnCount} columns was merged into a single column. Templatical sections hold at most 3 columns.`);
|
|
1856
2254
|
else if (ratioNote) notes.push(ratioNote);
|
|
1857
2255
|
if (fillNote) notes.push(fillNote);
|
|
2256
|
+
notes.push(...imageNotes);
|
|
1858
2257
|
if (notes.length === 0) return {
|
|
1859
2258
|
sourceTag: "tr",
|
|
1860
2259
|
templaticalBlockType: "section",
|
|
@@ -1894,6 +2293,76 @@ function sectionFill($row, layoutCells, tableFill, $) {
|
|
|
1894
2293
|
};
|
|
1895
2294
|
}
|
|
1896
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
|
+
/**
|
|
1897
2366
|
* The report entry for a nested row whose section wrapper was dropped, or
|
|
1898
2367
|
* `null` when dropping it lost nothing.
|
|
1899
2368
|
*
|
|
@@ -2055,8 +2524,11 @@ function hostRooms(rowRoom, layout, hostCount, setPadding) {
|
|
|
2055
2524
|
* land: the template body's width for a table whose rows become sections,
|
|
2056
2525
|
* the surrounding column's for a flattened one. Unknown, a divider keeps a
|
|
2057
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.
|
|
2058
2530
|
*/
|
|
2059
|
-
function processTable($table, $, entries, warnings, flattenInline = false, enclosingFill = "", room) {
|
|
2531
|
+
function processTable($table, $, entries, warnings, flattenInline = false, enclosingFill = "", room, enclosingImage = null) {
|
|
2060
2532
|
if (!isLayoutTable($table, $)) {
|
|
2061
2533
|
entries.push({
|
|
2062
2534
|
sourceTag: "table",
|
|
@@ -2069,6 +2541,11 @@ function processTable($table, $, entries, warnings, flattenInline = false, enclo
|
|
|
2069
2541
|
const rows = getDirectRows($table, $);
|
|
2070
2542
|
if (rows.length === 0) return [];
|
|
2071
2543
|
const tableFill = fillOf($table) || enclosingFill;
|
|
2544
|
+
const ownTableImage = imageOf($table, room);
|
|
2545
|
+
const tableImage = ownTableImage ? {
|
|
2546
|
+
read: ownTableImage,
|
|
2547
|
+
entries: []
|
|
2548
|
+
} : enclosingImage;
|
|
2072
2549
|
const sections = [];
|
|
2073
2550
|
for (const $row of rows) {
|
|
2074
2551
|
const cells = getDirectCells($row, $);
|
|
@@ -2076,10 +2553,16 @@ function processTable($table, $, entries, warnings, flattenInline = false, enclo
|
|
|
2076
2553
|
const packaging = packagingRowTables($row, cells, $);
|
|
2077
2554
|
if (packaging) {
|
|
2078
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;
|
|
2079
2561
|
const wrapperPadding = readHostPadding(cells[0]);
|
|
2080
2562
|
const innerRoom = flattenInline ? narrow(room, wrapperPadding) : room;
|
|
2081
2563
|
const descended = [];
|
|
2082
|
-
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);
|
|
2083
2566
|
if (flattenInline) insetColumns([descended], wrapperPadding);
|
|
2084
2567
|
sections.push(...descended);
|
|
2085
2568
|
continue;
|
|
@@ -2111,16 +2594,21 @@ function processTable($table, $, entries, warnings, flattenInline = false, enclo
|
|
|
2111
2594
|
continue;
|
|
2112
2595
|
}
|
|
2113
2596
|
const fill = sectionFill($row, layoutCells, tableFill, $);
|
|
2114
|
-
|
|
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);
|
|
2115
2601
|
sections.push(createSectionBlock({
|
|
2116
2602
|
columns: ratio.layout,
|
|
2117
2603
|
children: columnsBlocks,
|
|
2118
2604
|
styles: {
|
|
2119
2605
|
padding,
|
|
2120
2606
|
...fill.color ? { backgroundColor: fill.color } : {}
|
|
2121
|
-
}
|
|
2607
|
+
},
|
|
2608
|
+
...image.image ? { backgroundImage: image.image } : {}
|
|
2122
2609
|
}));
|
|
2123
2610
|
}
|
|
2611
|
+
if (ownTableImage && tableImage) noteSpread(tableImage);
|
|
2124
2612
|
return sections;
|
|
2125
2613
|
}
|
|
2126
2614
|
//#endregion
|
|
@@ -2148,8 +2636,8 @@ function readPreheader($) {
|
|
|
2148
2636
|
function extractSettings($, linkUnderline) {
|
|
2149
2637
|
const $body = $("body");
|
|
2150
2638
|
const bodyStyles = parseStyleAttribute($body.attr("style"));
|
|
2151
|
-
const fontFamily =
|
|
2152
|
-
const backgroundColor = parseColor(bodyStyles["background-color"]) || parseColor(bodyStyles.background) || "#ffffff";
|
|
2639
|
+
const fontFamily = canonicalFontStack(bodyStyles["font-family"]) || "Arial";
|
|
2640
|
+
const backgroundColor = parseColor(bodyStyles["background-color"]) || parseColor(backgroundShorthandColor(bodyStyles.background)) || "#ffffff";
|
|
2153
2641
|
const $outerTable = $body.find("table").first();
|
|
2154
2642
|
const widthAttr = parsePxValue($outerTable.attr("width"));
|
|
2155
2643
|
const widthStyle = parsePxValue(parseStyleAttribute($outerTable.attr("style")).width);
|
|
@@ -2272,6 +2760,16 @@ function processBody($, entries, warnings, bodyWidth) {
|
|
|
2272
2760
|
return blocks;
|
|
2273
2761
|
}
|
|
2274
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
|
+
/**
|
|
2275
2773
|
* Converts an HTML email template to Templatical TemplateContent.
|
|
2276
2774
|
*
|
|
2277
2775
|
* Designed for table-based marketing email HTML (output of MJML, Mailchimp,
|
|
@@ -2304,6 +2802,8 @@ function convertHtmlTemplate(html) {
|
|
|
2304
2802
|
const entries = [];
|
|
2305
2803
|
const warnings = [];
|
|
2306
2804
|
const settings = extractSettings($, linkUnderline);
|
|
2805
|
+
const pageImage = pageImageWarning($);
|
|
2806
|
+
if (pageImage) warnings.push(pageImage);
|
|
2307
2807
|
const blocks = processBody($, entries, warnings, settings.width);
|
|
2308
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.");
|
|
2309
2809
|
return {
|