@templatical/import-html 0.42.1 → 0.43.1
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 +375 -75
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -113,6 +113,17 @@ function parseColor(value) {
|
|
|
113
113
|
return "";
|
|
114
114
|
}
|
|
115
115
|
/**
|
|
116
|
+
* Normalizes a legacy `bgcolor` attribute value the way `parseColor` does a
|
|
117
|
+
* CSS one, also accepting hex without its `#` (`bgcolor="f4f4f4"`), which
|
|
118
|
+
* browsers render and CSS rejects.
|
|
119
|
+
*/
|
|
120
|
+
function parseLegacyColor(value) {
|
|
121
|
+
const color = parseColor(value);
|
|
122
|
+
if (color) return color;
|
|
123
|
+
const bare = (value ?? "").trim();
|
|
124
|
+
return /^(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(bare) ? parseColor(`#${bare}`) : "";
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
116
127
|
* Parses a CSS `padding` shorthand (1-4 values) into a SpacingValue.
|
|
117
128
|
*/
|
|
118
129
|
function parsePaddingShorthand(value) {
|
|
@@ -323,6 +334,28 @@ function parseStyleSheet(css) {
|
|
|
323
334
|
return rules;
|
|
324
335
|
}
|
|
325
336
|
/**
|
|
337
|
+
* Whether the document underlines its links, from a `<style>` rule whose
|
|
338
|
+
* selector is a bare `a`, or `undefined` when no such rule sets
|
|
339
|
+
* `text-decoration`.
|
|
340
|
+
*
|
|
341
|
+
* Only a rule that reaches every link states the document's default: a
|
|
342
|
+
* pseudo-class, a scoped selector and an `@media` rule each cover some links
|
|
343
|
+
* some of the time. A later rule wins, as in the cascade. Read it before
|
|
344
|
+
* `resolveCssStyles`, which removes the `<style>` tags.
|
|
345
|
+
*/
|
|
346
|
+
function readLinkUnderline($) {
|
|
347
|
+
let underline;
|
|
348
|
+
$("style").each((_, el) => {
|
|
349
|
+
for (const rule of parseStyleSheet($(el).text())) {
|
|
350
|
+
if (!rule.selectors.some((selector) => selector.toLowerCase() === "a")) continue;
|
|
351
|
+
const decoration = rule.declarations["text-decoration"] ?? rule.declarations["text-decoration-line"];
|
|
352
|
+
if (decoration === void 0) continue;
|
|
353
|
+
underline = /\bunderline\b/i.test(decoration);
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
return underline;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
326
359
|
* Reads all `<style>` tags from the document, parses them into rules,
|
|
327
360
|
* applies each rule's declarations to matching elements (merging with
|
|
328
361
|
* existing inline `style=""` attributes — inline always wins), and removes
|
|
@@ -696,16 +729,27 @@ function convertHeading($el, styles = getStyles$1($el)) {
|
|
|
696
729
|
});
|
|
697
730
|
}
|
|
698
731
|
/**
|
|
699
|
-
*
|
|
700
|
-
*
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
732
|
+
* Whether a `style` attribute states an alignment of its own. `inherit` does
|
|
733
|
+
* not: it takes the container's.
|
|
734
|
+
*/
|
|
735
|
+
function statesOwnAlignment(style) {
|
|
736
|
+
const own = parseStyleAttribute(style)["text-align"];
|
|
737
|
+
return own !== void 0 && own.trim().toLowerCase() !== "inherit";
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Apply a container-level `text-align` to every `<p>` opening tag in `html`
|
|
741
|
+
* that states no alignment of its own, merging into an existing `style="…"`
|
|
742
|
+
* attribute when present. Any other attribute on the `<p>` (class/id/dir/…)
|
|
743
|
+
* is kept, and does not stop the alignment from applying.
|
|
744
|
+
*
|
|
745
|
+
* A `<p>`'s own `text-align` wins, as it does in a browser, where the
|
|
746
|
+
* container's value only reaches a paragraph by inheritance.
|
|
704
747
|
*/
|
|
705
748
|
function applyTextAlignToParagraphs(html, textAlign) {
|
|
706
|
-
return html.replace(/<p\b([^>]*)>/gi, (
|
|
749
|
+
return html.replace(/<p\b([^>]*)>/gi, (match, attrs) => {
|
|
707
750
|
const styleMatch = /\sstyle\s*=\s*"([^"]*)"/i.exec(attrs);
|
|
708
751
|
if (styleMatch) {
|
|
752
|
+
if (statesOwnAlignment(styleMatch[1])) return match;
|
|
709
753
|
const existing = styleMatch[1].trim().replace(/;\s*$/, "");
|
|
710
754
|
const merged = existing ? `${existing}; text-align: ${textAlign}` : `text-align: ${textAlign}`;
|
|
711
755
|
return `<p${attrs.slice(0, styleMatch.index) + ` style="${merged}"` + attrs.slice(styleMatch.index + styleMatch[0].length)}>`;
|
|
@@ -716,8 +760,12 @@ function applyTextAlignToParagraphs(html, textAlign) {
|
|
|
716
760
|
/**
|
|
717
761
|
* Builds a Paragraph block from a fragment of inline markup, styled by the
|
|
718
762
|
* element that supplied `styles`.
|
|
763
|
+
*
|
|
764
|
+
* `padding` defaults to that element's own. A caller building from a host's
|
|
765
|
+
* styles passes none: a host insets every block it holds, and the cell walk
|
|
766
|
+
* applies that inset once, to all of them.
|
|
719
767
|
*/
|
|
720
|
-
function buildParagraph(innerHtml, styles) {
|
|
768
|
+
function buildParagraph(innerHtml, styles, padding = readPaddingFromStyles(styles)) {
|
|
721
769
|
const wrapped = ensureParagraphWrapped(innerHtml);
|
|
722
770
|
const fontParts = [];
|
|
723
771
|
const fontSize = parsePxValue(styles["font-size"]);
|
|
@@ -737,7 +785,7 @@ function buildParagraph(innerHtml, styles) {
|
|
|
737
785
|
}
|
|
738
786
|
return createParagraphBlock({
|
|
739
787
|
content: result,
|
|
740
|
-
styles: { padding
|
|
788
|
+
styles: { padding }
|
|
741
789
|
});
|
|
742
790
|
}
|
|
743
791
|
/**
|
|
@@ -761,7 +809,8 @@ function isInlineContent(node) {
|
|
|
761
809
|
*
|
|
762
810
|
* `$cell` supplies the styling: a bare run has no element of its own to read
|
|
763
811
|
* a colour, size or alignment from, and table-based email puts all three on
|
|
764
|
-
* the cell.
|
|
812
|
+
* the cell. Its padding stays out: the cell walk insets every block the cell
|
|
813
|
+
* holds by it, so reading it here too would count it twice.
|
|
765
814
|
*
|
|
766
815
|
* Returns `null` for a run carrying no text — a cell holding nothing but
|
|
767
816
|
* ` ` and `<br>` has no content, the same reading `convertElement`
|
|
@@ -770,7 +819,7 @@ function isInlineContent(node) {
|
|
|
770
819
|
function convertInlineRun(nodes, $cell, $) {
|
|
771
820
|
if (!nodes.map((node) => $(node).text()).join("").trim()) return null;
|
|
772
821
|
return {
|
|
773
|
-
block: buildParagraph(nodes.map((node) => $.html(node)).join(""), getStyles$1($cell)),
|
|
822
|
+
block: buildParagraph(nodes.map((node) => $.html(node)).join(""), getStyles$1($cell), emptyPadding$2()),
|
|
774
823
|
entry: {
|
|
775
824
|
sourceTag: tagOf($cell[0]),
|
|
776
825
|
templaticalBlockType: "paragraph",
|
|
@@ -842,7 +891,7 @@ function splitMixedImageAnchor($anchor, $host, $) {
|
|
|
842
891
|
$clone.find("img").remove();
|
|
843
892
|
if (($clone.text() ?? "").trim() === "") return results;
|
|
844
893
|
results.push({
|
|
845
|
-
block: buildParagraph($.html($clone) ?? "", getStyles$1($host)),
|
|
894
|
+
block: buildParagraph($.html($clone) ?? "", getStyles$1($host), emptyPadding$2()),
|
|
846
895
|
entry: {
|
|
847
896
|
sourceTag: tagOf($host[0]),
|
|
848
897
|
templaticalBlockType: "paragraph",
|
|
@@ -983,20 +1032,91 @@ function convertButton($el) {
|
|
|
983
1032
|
styles: { padding: emptyPadding$2() }
|
|
984
1033
|
});
|
|
985
1034
|
}
|
|
1035
|
+
/** A width as a number and an optional unit; a unitless value is px. */
|
|
1036
|
+
const DIVIDER_WIDTH = /^(-?\d+(?:\.\d+)?)\s*(%|px)?$/i;
|
|
1037
|
+
/**
|
|
1038
|
+
* The width an `<hr>` spans, from its `width` style, then its `width`
|
|
1039
|
+
* attribute, with a note in `notes` for whatever the result does not keep.
|
|
1040
|
+
*
|
|
1041
|
+
* No width, `auto` and `100%` are the whole column, which is how a
|
|
1042
|
+
* block-level `<hr>` renders. Any other percentage stays a share of the
|
|
1043
|
+
* column, clamped to 0–100% and rounded to two decimals, which keeps it inside
|
|
1044
|
+
* `DividerPercentWidth`'s pattern. A px width stays px until it reaches
|
|
1045
|
+
* `room` less the divider's own side padding, which is the width `mj-divider`
|
|
1046
|
+
* draws 100% across; past that it is the whole column, and it keeps narrowing
|
|
1047
|
+
* with the column on a phone. An unknown `room` keeps every px width.
|
|
1048
|
+
*/
|
|
1049
|
+
function readDividerWidth($el, room, notes) {
|
|
1050
|
+
const styles = getStyles$1($el);
|
|
1051
|
+
const raw = (styles.width ?? $el.attr("width") ?? "").trim();
|
|
1052
|
+
if (raw === "" || raw.toLowerCase() === "auto") return "full";
|
|
1053
|
+
const match = raw.match(DIVIDER_WIDTH);
|
|
1054
|
+
if (!match) {
|
|
1055
|
+
notes.push(`Divider width "${raw}" could not be read; imported as full width.`);
|
|
1056
|
+
return "full";
|
|
1057
|
+
}
|
|
1058
|
+
const value = parseFloat(match[1]);
|
|
1059
|
+
if (match[2] === "%") {
|
|
1060
|
+
const percent = Math.min(100, Math.max(0, value));
|
|
1061
|
+
if (percent !== value) notes.push(`Divider width ${raw} was clamped to ${percent}%.`);
|
|
1062
|
+
const share = Math.round(percent * 100) / 100;
|
|
1063
|
+
return share === 100 ? "full" : `${share}%`;
|
|
1064
|
+
}
|
|
1065
|
+
const px = Math.max(0, Math.round(value));
|
|
1066
|
+
if (value < 0) notes.push(`Divider width ${raw} was clamped to 0px.`);
|
|
1067
|
+
if (room === void 0) return px;
|
|
1068
|
+
const own = readPaddingFromStyles(styles);
|
|
1069
|
+
return px >= room - own.left - own.right ? "full" : px;
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* Where an `<hr>` sits in its column. Each side margin starts at the
|
|
1073
|
+
* browser's `auto`, the legacy `align` attribute sets them next, and CSS
|
|
1074
|
+
* margins override both, side by side. Auto on both sides centres the line;
|
|
1075
|
+
* auto on the left alone pushes it right; anything else leaves it at the
|
|
1076
|
+
* start of the column.
|
|
1077
|
+
*/
|
|
1078
|
+
function dividerAlign($el) {
|
|
1079
|
+
let left = "auto";
|
|
1080
|
+
let right = "auto";
|
|
1081
|
+
const align = ($el.attr("align") ?? "").trim().toLowerCase();
|
|
1082
|
+
if (align === "left") left = "0";
|
|
1083
|
+
if (align === "right") right = "0";
|
|
1084
|
+
const styles = getStyles$1($el);
|
|
1085
|
+
const margin = (styles.margin ?? "").trim().split(/\s+/).filter(Boolean);
|
|
1086
|
+
if (margin.length > 0) {
|
|
1087
|
+
right = margin[1] ?? margin[0];
|
|
1088
|
+
left = margin.length === 4 ? margin[3] : right;
|
|
1089
|
+
}
|
|
1090
|
+
left = styles["margin-left"] ?? left;
|
|
1091
|
+
right = styles["margin-right"] ?? right;
|
|
1092
|
+
const isAuto = (value) => value.trim().toLowerCase() === "auto";
|
|
1093
|
+
if (isAuto(left) && isAuto(right)) return "center";
|
|
1094
|
+
return isAuto(left) ? "right" : "left";
|
|
1095
|
+
}
|
|
986
1096
|
/**
|
|
987
|
-
* <hr> → Divider block.
|
|
1097
|
+
* <hr> → Divider block, with a note for each thing the block does not keep.
|
|
1098
|
+
*
|
|
1099
|
+
* Templatical centres every divider, so a partial one the source places at
|
|
1100
|
+
* either side is reported. A full-width line has no placement to lose.
|
|
988
1101
|
*/
|
|
989
|
-
function convertDivider($el) {
|
|
1102
|
+
function convertDivider($el, room) {
|
|
990
1103
|
const styles = getStyles$1($el);
|
|
991
1104
|
const border = parseBorderShorthand(styles["border-top"] ?? styles.border);
|
|
992
1105
|
const lineStyle = border.style === "dashed" || border.style === "dotted" ? border.style : "solid";
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1106
|
+
const notes = [];
|
|
1107
|
+
const width = readDividerWidth($el, room, notes);
|
|
1108
|
+
const align = dividerAlign($el);
|
|
1109
|
+
if (width !== "full" && align !== "center") notes.push(`The source aligns this divider ${align}; Templatical centres every divider.`);
|
|
1110
|
+
return {
|
|
1111
|
+
block: createDividerBlock({
|
|
1112
|
+
lineStyle,
|
|
1113
|
+
color: border.color || "#e5e7eb",
|
|
1114
|
+
thickness: border.width || 1,
|
|
1115
|
+
width,
|
|
1116
|
+
styles: { padding: readPaddingFromStyles(styles) }
|
|
1117
|
+
}),
|
|
1118
|
+
notes
|
|
1119
|
+
};
|
|
1000
1120
|
}
|
|
1001
1121
|
/**
|
|
1002
1122
|
* Wraps the element's outerHTML in an HTML block (the lossless fallback).
|
|
@@ -1099,10 +1219,14 @@ function isButtonCell($el, $) {
|
|
|
1099
1219
|
* for every other branch the resolved element is the one handed in, so its own
|
|
1100
1220
|
* styles are what `styles` already holds.
|
|
1101
1221
|
*
|
|
1222
|
+
* `room` is the width, in px, that a line in the element's column can span
|
|
1223
|
+
* before the element's own padding, when the caller knows it. Only a divider
|
|
1224
|
+
* reads it.
|
|
1225
|
+
*
|
|
1102
1226
|
* Returns `null` for elements that do not contain any meaningful content
|
|
1103
1227
|
* (the caller should skip them).
|
|
1104
1228
|
*/
|
|
1105
|
-
function convertElement($el,
|
|
1229
|
+
function convertElement($el, $, room) {
|
|
1106
1230
|
const { $el: $target, styles } = resolveWrappedBlock($el, $);
|
|
1107
1231
|
const tag = tagOf($target[0]);
|
|
1108
1232
|
if (!tag) return null;
|
|
@@ -1142,14 +1266,18 @@ function convertElement($el, $) {
|
|
|
1142
1266
|
}
|
|
1143
1267
|
};
|
|
1144
1268
|
}
|
|
1145
|
-
if (tag === "hr")
|
|
1146
|
-
block
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1269
|
+
if (tag === "hr") {
|
|
1270
|
+
const { block, notes } = convertDivider($target, room);
|
|
1271
|
+
return {
|
|
1272
|
+
block,
|
|
1273
|
+
entry: {
|
|
1274
|
+
sourceTag: tag,
|
|
1275
|
+
templaticalBlockType: "divider",
|
|
1276
|
+
status: notes.length > 0 ? "approximated" : "converted",
|
|
1277
|
+
...notes.length > 0 ? { note: notes.join(" ") } : {}
|
|
1278
|
+
}
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1153
1281
|
if (TEXT_TAGS.has(tag)) {
|
|
1154
1282
|
if (!hasRenderedContent($target)) return null;
|
|
1155
1283
|
return {
|
|
@@ -1389,6 +1517,91 @@ function emptyPadding$1() {
|
|
|
1389
1517
|
function getStyles($el) {
|
|
1390
1518
|
return parseStyleAttribute($el.attr("style"));
|
|
1391
1519
|
}
|
|
1520
|
+
/**
|
|
1521
|
+
* The padding an element insets its content by.
|
|
1522
|
+
*
|
|
1523
|
+
* A table cell falls back to its table's `cellpadding`, side by side: the
|
|
1524
|
+
* attribute pads every cell of the table, and a side the cell's own CSS
|
|
1525
|
+
* declares overrides it, as in a browser.
|
|
1526
|
+
*/
|
|
1527
|
+
function readHostPadding($el) {
|
|
1528
|
+
const styles = getStyles($el);
|
|
1529
|
+
const own = readPaddingFromStyles(styles);
|
|
1530
|
+
if (!$el.is("td, th")) return own;
|
|
1531
|
+
const cellpadding = parsePxValue($el.closest("table").attr("cellpadding"));
|
|
1532
|
+
if (!(cellpadding > 0)) return own;
|
|
1533
|
+
const declares = (side) => styles.padding !== void 0 || styles[`padding-${side}`] !== void 0;
|
|
1534
|
+
return {
|
|
1535
|
+
top: declares("top") ? own.top : cellpadding,
|
|
1536
|
+
right: declares("right") ? own.right : cellpadding,
|
|
1537
|
+
bottom: declares("bottom") ? own.bottom : cellpadding,
|
|
1538
|
+
left: declares("left") ? own.left : cellpadding
|
|
1539
|
+
};
|
|
1540
|
+
}
|
|
1541
|
+
/**
|
|
1542
|
+
* The colour an element paints behind its content: its CSS background, then
|
|
1543
|
+
* its legacy `bgcolor` attribute, which CSS overrides.
|
|
1544
|
+
*/
|
|
1545
|
+
function fillOf($el) {
|
|
1546
|
+
const styles = getStyles($el);
|
|
1547
|
+
return parseColor(styles["background-color"]) || parseColor(styles.background) || parseLegacyColor($el.attr("bgcolor"));
|
|
1548
|
+
}
|
|
1549
|
+
/**
|
|
1550
|
+
* Insets columns by the padding of the element that holds them, where that
|
|
1551
|
+
* padding places them: the top on each column's first block, the bottom on
|
|
1552
|
+
* each column's last, the left on the first column and the right on the
|
|
1553
|
+
* last. A single column takes all four sides.
|
|
1554
|
+
*
|
|
1555
|
+
* Blocks are the carrier because nothing else is: a column has no padding of
|
|
1556
|
+
* its own, and a cell's blocks flatten into whichever column the cell lands
|
|
1557
|
+
* in.
|
|
1558
|
+
*/
|
|
1559
|
+
function insetColumns(columns, padding) {
|
|
1560
|
+
if (!padding.top && !padding.right && !padding.bottom && !padding.left) return;
|
|
1561
|
+
columns.forEach((column, columnIndex) => {
|
|
1562
|
+
const left = columnIndex === 0 ? padding.left : 0;
|
|
1563
|
+
const right = columnIndex === columns.length - 1 ? padding.right : 0;
|
|
1564
|
+
column.forEach((block, blockIndex) => {
|
|
1565
|
+
insetBlock(block, {
|
|
1566
|
+
top: blockIndex === 0 ? padding.top : 0,
|
|
1567
|
+
right,
|
|
1568
|
+
bottom: blockIndex === column.length - 1 ? padding.bottom : 0,
|
|
1569
|
+
left
|
|
1570
|
+
});
|
|
1571
|
+
});
|
|
1572
|
+
});
|
|
1573
|
+
}
|
|
1574
|
+
/**
|
|
1575
|
+
* `room` less the side padding of something inside it, or `undefined` while
|
|
1576
|
+
* the room is unknown.
|
|
1577
|
+
*/
|
|
1578
|
+
function narrow(room, padding) {
|
|
1579
|
+
return room === void 0 ? void 0 : room - padding.left - padding.right;
|
|
1580
|
+
}
|
|
1581
|
+
/**
|
|
1582
|
+
* Adds `by` to a block's own padding.
|
|
1583
|
+
*
|
|
1584
|
+
* A spacer renders at its height and ignores its padding, in the canvas and
|
|
1585
|
+
* the export alike, so the top and bottom it is inset by are added to its
|
|
1586
|
+
* height instead. Held as padding, the space a spacer at a cell's edge
|
|
1587
|
+
* carries would vanish on export.
|
|
1588
|
+
*/
|
|
1589
|
+
function insetBlock(block, by) {
|
|
1590
|
+
if (block.type === "spacer") {
|
|
1591
|
+
block.height += by.top + by.bottom;
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1594
|
+
const own = block.styles.padding;
|
|
1595
|
+
block.styles = {
|
|
1596
|
+
...block.styles,
|
|
1597
|
+
padding: {
|
|
1598
|
+
top: own.top + by.top,
|
|
1599
|
+
right: own.right + by.right,
|
|
1600
|
+
bottom: own.bottom + by.bottom,
|
|
1601
|
+
left: own.left + by.left
|
|
1602
|
+
}
|
|
1603
|
+
};
|
|
1604
|
+
}
|
|
1392
1605
|
function buildCellButton($cell, $anchor) {
|
|
1393
1606
|
const cellStyles = getStyles($cell);
|
|
1394
1607
|
const aStyles = getStyles($anchor);
|
|
@@ -1403,7 +1616,7 @@ function buildCellButton($cell, $anchor) {
|
|
|
1403
1616
|
text,
|
|
1404
1617
|
url,
|
|
1405
1618
|
openInNewTab: target === "_blank" || void 0,
|
|
1406
|
-
backgroundColor: parseColor(merged["background-color"]) || parseColor(merged.background) || "#4f46e5",
|
|
1619
|
+
backgroundColor: parseColor(merged["background-color"]) || parseColor(merged.background) || parseLegacyColor($cell.attr("bgcolor")) || "#4f46e5",
|
|
1407
1620
|
textColor: parseColor(merged.color) || "#ffffff",
|
|
1408
1621
|
borderRadius: parsePxValue(merged["border-radius"]),
|
|
1409
1622
|
fontSize: parsePxValue(merged["font-size"]) || 16,
|
|
@@ -1596,9 +1809,14 @@ function packagingTablesOf($cell, $) {
|
|
|
1596
1809
|
*
|
|
1597
1810
|
* - The cell's meaningful content must *be* the tables. Descending discards
|
|
1598
1811
|
* the row, so a heading or an image beside the table would be dropped.
|
|
1599
|
-
* - The row must
|
|
1600
|
-
* the
|
|
1601
|
-
* the band it paints.
|
|
1812
|
+
* - The row's style must set no background and no padding. The section it
|
|
1813
|
+
* emits is the carrier for those, so descending past a styled row would
|
|
1814
|
+
* drop the band it paints.
|
|
1815
|
+
*
|
|
1816
|
+
* A fill the descent does pass — the row's `bgcolor`, its cell's, its
|
|
1817
|
+
* table's — is not lost: `processTable` hands it to the tables below, whose
|
|
1818
|
+
* sections take it when nothing nearer paints them. Section counts depend on
|
|
1819
|
+
* this gate, so a fill is carried down rather than made a reason to stop.
|
|
1602
1820
|
*/
|
|
1603
1821
|
function packagingRowTables($row, cells, $) {
|
|
1604
1822
|
if (cells.length !== 1) return null;
|
|
@@ -1628,24 +1846,51 @@ function packagingRowTables($row, cells, $) {
|
|
|
1628
1846
|
* column `<div>`s a single cell holds — not every cell the row has: a
|
|
1629
1847
|
* centring row's gutters were never columns, so counting them would report a
|
|
1630
1848
|
* three-into-one merge for a row that always stated one column.
|
|
1849
|
+
*
|
|
1850
|
+
* A background the section could not keep adds its own note after the
|
|
1851
|
+
* layout's, so one entry names every loss the row had.
|
|
1631
1852
|
*/
|
|
1632
|
-
function sectionEntry(columnCount, slotCount, ratioNote) {
|
|
1633
|
-
|
|
1853
|
+
function sectionEntry(columnCount, slotCount, ratioNote, fillNote) {
|
|
1854
|
+
const notes = [];
|
|
1855
|
+
if (slotCount !== columnCount) notes.push(`Row of ${columnCount} columns was merged into a single column. Templatical sections hold at most 3 columns.`);
|
|
1856
|
+
else if (ratioNote) notes.push(ratioNote);
|
|
1857
|
+
if (fillNote) notes.push(fillNote);
|
|
1858
|
+
if (notes.length === 0) return {
|
|
1634
1859
|
sourceTag: "tr",
|
|
1635
1860
|
templaticalBlockType: "section",
|
|
1636
|
-
status: "
|
|
1637
|
-
note: `Row of ${columnCount} columns was merged into a single column. Templatical sections hold at most 3 columns.`
|
|
1861
|
+
status: "converted"
|
|
1638
1862
|
};
|
|
1639
|
-
|
|
1863
|
+
return {
|
|
1640
1864
|
sourceTag: "tr",
|
|
1641
1865
|
templaticalBlockType: "section",
|
|
1642
1866
|
status: "approximated",
|
|
1643
|
-
note:
|
|
1867
|
+
note: notes.join(" ")
|
|
1644
1868
|
};
|
|
1869
|
+
}
|
|
1870
|
+
/**
|
|
1871
|
+
* The background a row's section takes, with a note when a cell renders on
|
|
1872
|
+
* a colour the section does not keep.
|
|
1873
|
+
*
|
|
1874
|
+
* Nearest first: the `<tr>`, then its layout cells, then the tables around
|
|
1875
|
+
* it (`tableFill`, which a descended wrapper hands down). The cells count
|
|
1876
|
+
* when they share one fill, which covers the row's only cell as well as a
|
|
1877
|
+
* row painted alike across its columns. Cells that differ leave the section
|
|
1878
|
+
* to the table's fill, and the note names what each cell rendered on.
|
|
1879
|
+
*
|
|
1880
|
+
* A button cell is left out: its colour is the button's own, which
|
|
1881
|
+
* `buildCellButton` carries, and read as the section's it would band the
|
|
1882
|
+
* whole row in the button's colour.
|
|
1883
|
+
*/
|
|
1884
|
+
function sectionFill($row, layoutCells, tableFill, $) {
|
|
1885
|
+
const rowFill = fillOf($row);
|
|
1886
|
+
const cellFills = layoutCells.filter(($cell) => !isButtonCell($cell, $).match).map(fillOf);
|
|
1887
|
+
const shared = cellFills.length > 0 && cellFills.every((fill) => fill === cellFills[0]) ? cellFills[0] : "";
|
|
1888
|
+
const color = rowFill || shared || tableFill;
|
|
1889
|
+
const rendered = cellFills.map((fill) => fill || rowFill || tableFill);
|
|
1890
|
+
if (rendered.every((fill) => fill === color)) return { color };
|
|
1645
1891
|
return {
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
status: "converted"
|
|
1892
|
+
color,
|
|
1893
|
+
note: `Cell backgrounds ${rendered.map((fill) => fill || "none").join(" / ")} differ from ${color ? `the section background ${color}` : "the section, which has no background"}. A Templatical section has one background colour.`
|
|
1649
1894
|
};
|
|
1650
1895
|
}
|
|
1651
1896
|
/**
|
|
@@ -1710,14 +1955,14 @@ function columnHostsOf(layoutCells, $) {
|
|
|
1710
1955
|
* column whose content is one link into a single button block and drop
|
|
1711
1956
|
* everything the column's own table holds.
|
|
1712
1957
|
*/
|
|
1713
|
-
function extractHostBlocks(host, $, entries, warnings) {
|
|
1714
|
-
return host.kind === "cell" ? extractCellBlocks(host.$el, $, entries, warnings) : extractContentBlocks(host.$el, $, entries, warnings);
|
|
1958
|
+
function extractHostBlocks(host, $, entries, warnings, room) {
|
|
1959
|
+
return host.kind === "cell" ? extractCellBlocks(host.$el, $, entries, warnings, room) : extractContentBlocks(host.$el, $, entries, warnings, room);
|
|
1715
1960
|
}
|
|
1716
1961
|
/** The width an element declares, from the strongest signal it carries. */
|
|
1717
1962
|
function readDeclaredWidth($el) {
|
|
1718
1963
|
return readColumnWidth($el.attr("class"), getStyles($el), $el.attr("width"));
|
|
1719
1964
|
}
|
|
1720
|
-
function extractCellBlocks($cell, $, entries, warnings) {
|
|
1965
|
+
function extractCellBlocks($cell, $, entries, warnings, room) {
|
|
1721
1966
|
if (isSpacerCell($cell)) {
|
|
1722
1967
|
entries.push({
|
|
1723
1968
|
sourceTag: "td",
|
|
@@ -1735,7 +1980,7 @@ function extractCellBlocks($cell, $, entries, warnings) {
|
|
|
1735
1980
|
});
|
|
1736
1981
|
return [buildCellButton($cell, btn.anchor)];
|
|
1737
1982
|
}
|
|
1738
|
-
return extractContentBlocks($cell, $, entries, warnings);
|
|
1983
|
+
return extractContentBlocks($cell, $, entries, warnings, room);
|
|
1739
1984
|
}
|
|
1740
1985
|
/**
|
|
1741
1986
|
* The blocks an element's child nodes produce, for an element that holds
|
|
@@ -1748,38 +1993,70 @@ function extractCellBlocks($cell, $, entries, warnings) {
|
|
|
1748
1993
|
* `converter.ts`. What is left here is the half that differs: inside a cell a
|
|
1749
1994
|
* nested table flattens into the surrounding column, where at body level it
|
|
1750
1995
|
* becomes a section of its own.
|
|
1996
|
+
*
|
|
1997
|
+
* `room` is the width a line has across the host's column, before the host's
|
|
1998
|
+
* own padding narrows it for everything inside.
|
|
1751
1999
|
*/
|
|
1752
|
-
function extractContentBlocks($host, $, entries, warnings) {
|
|
2000
|
+
function extractContentBlocks($host, $, entries, warnings, room) {
|
|
1753
2001
|
const blocks = [];
|
|
2002
|
+
const padding = readHostPadding($host);
|
|
2003
|
+
const inner = narrow(room, padding);
|
|
1754
2004
|
walkContentNodes($host, $, ({ block, entry }) => {
|
|
1755
2005
|
entries.push(entry);
|
|
1756
2006
|
blocks.push(block);
|
|
1757
2007
|
}, ($child, tag) => {
|
|
1758
2008
|
if (tag === "table") {
|
|
1759
|
-
|
|
1760
|
-
blocks.push(...inner);
|
|
2009
|
+
blocks.push(...processTable($child, $, entries, warnings, true, "", inner));
|
|
1761
2010
|
return;
|
|
1762
2011
|
}
|
|
1763
2012
|
if (isTableContainer($child, tag)) {
|
|
1764
|
-
blocks.push(...extractContentBlocks($child, $, entries, warnings));
|
|
2013
|
+
blocks.push(...extractContentBlocks($child, $, entries, warnings, inner));
|
|
1765
2014
|
return;
|
|
1766
2015
|
}
|
|
1767
|
-
const r = convertElement($child,
|
|
2016
|
+
const r = convertElement($child, $, inner);
|
|
1768
2017
|
if (r) {
|
|
1769
2018
|
entries.push(r.entry);
|
|
1770
2019
|
blocks.push(r.block);
|
|
1771
2020
|
}
|
|
1772
2021
|
});
|
|
2022
|
+
insetColumns([blocks], padding);
|
|
1773
2023
|
return blocks;
|
|
1774
2024
|
}
|
|
1775
2025
|
/**
|
|
2026
|
+
* The room each of a row's hosts has, from the room across the row.
|
|
2027
|
+
*
|
|
2028
|
+
* `layout` is the one the hosts land in, `"1"` when they stack, and each
|
|
2029
|
+
* host takes its column's share of the row. A column set's cell pads the
|
|
2030
|
+
* row's edges, so its left side narrows the first column and its right the
|
|
2031
|
+
* last; stacked, every host sits between both.
|
|
2032
|
+
*/
|
|
2033
|
+
function hostRooms(rowRoom, layout, hostCount, setPadding) {
|
|
2034
|
+
const shares = LAYOUT_SHARES[layout];
|
|
2035
|
+
const edges = setPadding ?? emptyPadding$1();
|
|
2036
|
+
return Array.from({ length: hostCount }, (_, index) => {
|
|
2037
|
+
if (rowRoom === void 0) return void 0;
|
|
2038
|
+
if (layout === "1") return rowRoom - edges.left - edges.right;
|
|
2039
|
+
const share = rowRoom * shares[index] / 100;
|
|
2040
|
+
const left = index === 0 ? edges.left : 0;
|
|
2041
|
+
const right = index === hostCount - 1 ? edges.right : 0;
|
|
2042
|
+
return share - left - right;
|
|
2043
|
+
});
|
|
2044
|
+
}
|
|
2045
|
+
/**
|
|
1776
2046
|
* Walk a `<table>` and produce Section blocks (one per row).
|
|
1777
2047
|
*
|
|
1778
2048
|
* @param flattenInline - When true (used for nested tables), drop the section
|
|
1779
2049
|
* wrapper and return the flat block list. Templatical sections cannot nest,
|
|
1780
2050
|
* so nested layout-tables are merged into their parent cell.
|
|
1781
|
-
|
|
1782
|
-
|
|
2051
|
+
* @param enclosingFill - The fill of the wrapper rows, cells and tables this
|
|
2052
|
+
* table was reached through, which its sections take when nothing nearer
|
|
2053
|
+
* paints them.
|
|
2054
|
+
* @param room - The width, in px, a line can span where the table's blocks
|
|
2055
|
+
* land: the template body's width for a table whose rows become sections,
|
|
2056
|
+
* the surrounding column's for a flattened one. Unknown, a divider keeps a
|
|
2057
|
+
* px width as stated.
|
|
2058
|
+
*/
|
|
2059
|
+
function processTable($table, $, entries, warnings, flattenInline = false, enclosingFill = "", room) {
|
|
1783
2060
|
if (!isLayoutTable($table, $)) {
|
|
1784
2061
|
entries.push({
|
|
1785
2062
|
sourceTag: "table",
|
|
@@ -1791,43 +2068,56 @@ function processTable($table, $, entries, warnings, flattenInline = false) {
|
|
|
1791
2068
|
}
|
|
1792
2069
|
const rows = getDirectRows($table, $);
|
|
1793
2070
|
if (rows.length === 0) return [];
|
|
2071
|
+
const tableFill = fillOf($table) || enclosingFill;
|
|
1794
2072
|
const sections = [];
|
|
1795
2073
|
for (const $row of rows) {
|
|
1796
2074
|
const cells = getDirectCells($row, $);
|
|
1797
2075
|
if (cells.length === 0) continue;
|
|
1798
2076
|
const packaging = packagingRowTables($row, cells, $);
|
|
1799
2077
|
if (packaging) {
|
|
1800
|
-
|
|
2078
|
+
const carried = fillOf($row) || fillOf(cells[0]) || tableFill;
|
|
2079
|
+
const wrapperPadding = readHostPadding(cells[0]);
|
|
2080
|
+
const innerRoom = flattenInline ? narrow(room, wrapperPadding) : room;
|
|
2081
|
+
const descended = [];
|
|
2082
|
+
for (const $inner of packaging) descended.push(...processTable($inner, $, entries, warnings, flattenInline, carried, innerRoom));
|
|
2083
|
+
if (flattenInline) insetColumns([descended], wrapperPadding);
|
|
2084
|
+
sections.push(...descended);
|
|
1801
2085
|
continue;
|
|
1802
2086
|
}
|
|
1803
|
-
const
|
|
2087
|
+
const layoutCells = centringCells(cells) ?? cells;
|
|
2088
|
+
const hosts = columnHostsOf(layoutCells, $);
|
|
1804
2089
|
const countedLayout = resolveColumnLayout(hosts.length, warnings);
|
|
2090
|
+
const ratio = countedLayout === "1" || flattenInline ? {
|
|
2091
|
+
layout: countedLayout,
|
|
2092
|
+
note: void 0
|
|
2093
|
+
} : resolveColumnRatio(hosts.map((host) => readDeclaredWidth(host.$el)), countedLayout);
|
|
2094
|
+
const padding = readPaddingFromStyles(getStyles($row));
|
|
2095
|
+
const setPadding = hosts[0]?.kind === "container" ? readHostPadding(layoutCells[0]) : void 0;
|
|
2096
|
+
const stacked = countedLayout === "1" || flattenInline;
|
|
2097
|
+
const rooms = hostRooms(flattenInline ? room : narrow(room, padding), stacked ? "1" : ratio.layout, hosts.length, setPadding);
|
|
1805
2098
|
let columnsBlocks;
|
|
1806
2099
|
if (countedLayout === "1") {
|
|
1807
2100
|
const merged = [];
|
|
1808
|
-
|
|
2101
|
+
hosts.forEach((host, index) => {
|
|
2102
|
+
merged.push(...extractHostBlocks(host, $, entries, warnings, rooms[index]));
|
|
2103
|
+
});
|
|
1809
2104
|
columnsBlocks = [merged];
|
|
1810
|
-
} else columnsBlocks = hosts.map((host) => extractHostBlocks(host, $, entries, warnings));
|
|
2105
|
+
} else columnsBlocks = hosts.map((host, index) => extractHostBlocks(host, $, entries, warnings, rooms[index]));
|
|
2106
|
+
if (setPadding) insetColumns(flattenInline ? [columnsBlocks.flat()] : columnsBlocks, setPadding);
|
|
1811
2107
|
if (flattenInline) {
|
|
1812
2108
|
const dropped = flattenedRowEntry(hosts.length);
|
|
1813
2109
|
if (dropped) entries.push(dropped);
|
|
1814
2110
|
for (const col of columnsBlocks) sections.push(...col);
|
|
1815
2111
|
continue;
|
|
1816
2112
|
}
|
|
1817
|
-
const
|
|
1818
|
-
|
|
1819
|
-
note: void 0
|
|
1820
|
-
} : resolveColumnRatio(hosts.map((host) => readDeclaredWidth(host.$el)), countedLayout);
|
|
1821
|
-
const rowStyles = getStyles($row);
|
|
1822
|
-
const bgColor = parseColor(rowStyles["background-color"]) || parseColor(rowStyles.background);
|
|
1823
|
-
const padding = readPaddingFromStyles(rowStyles);
|
|
1824
|
-
entries.push(sectionEntry(hosts.length, columnsBlocks.length, ratio.note));
|
|
2113
|
+
const fill = sectionFill($row, layoutCells, tableFill, $);
|
|
2114
|
+
entries.push(sectionEntry(hosts.length, columnsBlocks.length, ratio.note, fill.note));
|
|
1825
2115
|
sections.push(createSectionBlock({
|
|
1826
2116
|
columns: ratio.layout,
|
|
1827
2117
|
children: columnsBlocks,
|
|
1828
2118
|
styles: {
|
|
1829
2119
|
padding,
|
|
1830
|
-
...
|
|
2120
|
+
...fill.color ? { backgroundColor: fill.color } : {}
|
|
1831
2121
|
}
|
|
1832
2122
|
}));
|
|
1833
2123
|
}
|
|
@@ -1850,7 +2140,12 @@ function readPreheader($) {
|
|
|
1850
2140
|
if (candidates.length === 0) return void 0;
|
|
1851
2141
|
return $(candidates[0]).text().trim() || void 0;
|
|
1852
2142
|
}
|
|
1853
|
-
|
|
2143
|
+
/**
|
|
2144
|
+
* `linkUnderline` is what the source's stylesheet states for every link, as
|
|
2145
|
+
* `readLinkUnderline` reads it. A source stating nothing underlines its links,
|
|
2146
|
+
* which is the browser's default.
|
|
2147
|
+
*/
|
|
2148
|
+
function extractSettings($, linkUnderline) {
|
|
1854
2149
|
const $body = $("body");
|
|
1855
2150
|
const bodyStyles = parseStyleAttribute($body.attr("style"));
|
|
1856
2151
|
const fontFamily = parseFontFamily(bodyStyles["font-family"]) || "Arial";
|
|
@@ -1864,7 +2159,7 @@ function extractSettings($) {
|
|
|
1864
2159
|
width,
|
|
1865
2160
|
backgroundColor,
|
|
1866
2161
|
textColor: "#1a1a1a",
|
|
1867
|
-
linkUnderline:
|
|
2162
|
+
linkUnderline: linkUnderline ?? true,
|
|
1868
2163
|
fontFamily,
|
|
1869
2164
|
locale: "en",
|
|
1870
2165
|
...preheaderText ? { preheaderText } : {}
|
|
@@ -1902,8 +2197,11 @@ function wrapInSection(blocks, entries) {
|
|
|
1902
2197
|
* level and inside a layout container reach a rich-text block. A walk over
|
|
1903
2198
|
* `children()` visits neither, which drops copy the source email displays —
|
|
1904
2199
|
* `Lead<h2>H</h2>Trailing` imported as the heading alone.
|
|
2200
|
+
*
|
|
2201
|
+
* `bodyWidth` is the room a line has across a top-level section, which is
|
|
2202
|
+
* what a divider's px width is measured against.
|
|
1905
2203
|
*/
|
|
1906
|
-
function processBody($, entries, warnings) {
|
|
2204
|
+
function processBody($, entries, warnings, bodyWidth) {
|
|
1907
2205
|
const blocks = [];
|
|
1908
2206
|
const $body = $("body");
|
|
1909
2207
|
let pendingLoose = [];
|
|
@@ -1937,14 +2235,14 @@ function processBody($, entries, warnings) {
|
|
|
1937
2235
|
walkContentNodes($container, $, collectLoose, ($inner, innerTag) => {
|
|
1938
2236
|
if (innerTag === "table") {
|
|
1939
2237
|
flushLoose();
|
|
1940
|
-
blocks.push(...processTable($inner, $, entries, warnings, false));
|
|
2238
|
+
blocks.push(...processTable($inner, $, entries, warnings, false, "", bodyWidth));
|
|
1941
2239
|
return;
|
|
1942
2240
|
}
|
|
1943
2241
|
if (isTableContainer($inner, innerTag)) {
|
|
1944
2242
|
walkContainer($inner);
|
|
1945
2243
|
return;
|
|
1946
2244
|
}
|
|
1947
|
-
const r = convertElement($inner,
|
|
2245
|
+
const r = convertElement($inner, $, bodyWidth);
|
|
1948
2246
|
if (r) {
|
|
1949
2247
|
entries.push(r.entry);
|
|
1950
2248
|
pendingLoose.push(r.block);
|
|
@@ -1954,7 +2252,7 @@ function processBody($, entries, warnings) {
|
|
|
1954
2252
|
walkContentNodes($body, $, collectLoose, ($child, tag) => {
|
|
1955
2253
|
if (tag === "table") {
|
|
1956
2254
|
flushLoose();
|
|
1957
|
-
blocks.push(...processTable($child, $, entries, warnings, false));
|
|
2255
|
+
blocks.push(...processTable($child, $, entries, warnings, false, "", bodyWidth));
|
|
1958
2256
|
return;
|
|
1959
2257
|
}
|
|
1960
2258
|
if ((parseStyleAttribute($child.attr("style")).display ?? "").toLowerCase() === "none") return;
|
|
@@ -1964,7 +2262,7 @@ function processBody($, entries, warnings) {
|
|
|
1964
2262
|
flushLoose();
|
|
1965
2263
|
return;
|
|
1966
2264
|
}
|
|
1967
|
-
const r = convertElement($child,
|
|
2265
|
+
const r = convertElement($child, $, bodyWidth);
|
|
1968
2266
|
if (r) {
|
|
1969
2267
|
entries.push(r.entry);
|
|
1970
2268
|
pendingLoose.push(r.block);
|
|
@@ -2000,17 +2298,19 @@ function convertHtmlTemplate(html) {
|
|
|
2000
2298
|
if (typeof html !== "string") throw new Error("Invalid HTML template: expected a string. Pass the raw HTML source as a string.");
|
|
2001
2299
|
if (html.trim().length === 0) throw new Error("Invalid HTML template: input is empty. Pass the raw HTML source of an email.");
|
|
2002
2300
|
const $ = load(html);
|
|
2301
|
+
const linkUnderline = readLinkUnderline($);
|
|
2003
2302
|
resolveCssStyles($);
|
|
2004
2303
|
$("script, noscript, link, meta, title").remove();
|
|
2005
2304
|
const entries = [];
|
|
2006
2305
|
const warnings = [];
|
|
2007
|
-
const
|
|
2306
|
+
const settings = extractSettings($, linkUnderline);
|
|
2307
|
+
const blocks = processBody($, entries, warnings, settings.width);
|
|
2008
2308
|
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.");
|
|
2009
2309
|
return {
|
|
2010
2310
|
content: {
|
|
2011
2311
|
...createDefaultTemplateContent(),
|
|
2012
2312
|
blocks,
|
|
2013
|
-
settings
|
|
2313
|
+
settings
|
|
2014
2314
|
},
|
|
2015
2315
|
report: {
|
|
2016
2316
|
entries,
|