@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 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
- * Apply a container-level `text-align` to every `<p>` opening tag in `html`,
700
- * merging into an existing `style="…"` attribute when present. Tolerant of
701
- * any other attributes on the `<p>` (class/id/dir/…) — the previous narrow
702
- * `<p style="…">` + bare-`<p>` matchers silently dropped the alignment when
703
- * the inner `<p>` carried a non-style attribute.
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, (_match, attrs) => {
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: readPaddingFromStyles(styles) }
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
  * `&nbsp;` 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
- return createDividerBlock({
994
- lineStyle,
995
- color: border.color || "#e5e7eb",
996
- thickness: border.width || 1,
997
- width: 100,
998
- styles: { padding: readPaddingFromStyles(styles) }
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") return {
1146
- block: convertDivider($target),
1147
- entry: {
1148
- sourceTag: tag,
1149
- templaticalBlockType: "divider",
1150
- status: "converted"
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 carry no background and no padding. The section it emits is
1600
- * the only carrier for those, so descending past a styled row would drop
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
- if (slotCount !== columnCount) return {
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: "approximated",
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
- if (ratioNote) return {
1863
+ return {
1640
1864
  sourceTag: "tr",
1641
1865
  templaticalBlockType: "section",
1642
1866
  status: "approximated",
1643
- note: ratioNote
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
- sourceTag: "tr",
1647
- templaticalBlockType: "section",
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
- const inner = processTable($child, $, entries, warnings, true);
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
- function processTable($table, $, entries, warnings, flattenInline = false) {
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
- for (const $inner of packaging) sections.push(...processTable($inner, $, entries, warnings, flattenInline));
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 hosts = columnHostsOf(centringCells(cells) ?? cells, $);
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
- for (const host of hosts) merged.push(...extractHostBlocks(host, $, entries, warnings));
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 ratio = countedLayout === "1" ? {
1818
- layout: countedLayout,
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
- ...bgColor ? { backgroundColor: bgColor } : {}
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
- function extractSettings($) {
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: false,
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 blocks = processBody($, entries, warnings);
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: extractSettings($)
2313
+ settings
2014
2314
  },
2015
2315
  report: {
2016
2316
  entries,