fable-editor 1.3.0 → 1.3.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.
@@ -1980,6 +1980,152 @@ function firstStrongDir(text) {
1980
1980
  }
1981
1981
  return null;
1982
1982
  }
1983
+ /* ---------------------------------------------------------- email-export table
1984
+ borders — used only by getContentForEmail(). Word describes a cell's borders
1985
+ as three parallel longhands (`border-color` / `border-style` / `border-width`),
1986
+ one value per side, and a browser re-copy of Word content keeps that split.
1987
+ Mail pipelines allowlist CSS one property at a time, and any of them that
1988
+ keeps `border-color` and `border-width` while dropping `border-style` erases
1989
+ the border outright — the initial `border-style` is `none`, so a width and a
1990
+ colour on their own draw nothing. Re-emitting every side as a single
1991
+ `border-<side>: <width> <style> <colour>` (collapsed to one `border:` when all
1992
+ four agree) makes a side survive or vanish as a unit instead. A side left with
1993
+ a width or a colour but no style is read back as `solid`, which also repairs
1994
+ content that was already flattened that way before it came back into the
1995
+ editor, and `windowtext` — a deprecated system colour Word still emits —
1996
+ becomes plain black. */
1997
+ const BORDER_SIDES = ['top', 'right', 'bottom', 'left'];
1998
+ const BORDER_STYLE_KEYWORD = /^(none|hidden|dotted|dashed|solid|double|groove|ridge|inset|outset)$/i;
1999
+ const BORDER_WIDTH_VALUE = /^(thin|medium|thick|[+-]?(\d+\.?\d*|\.\d+)(px|pt|em|rem|ex|ch|cm|mm|in|pc|q)?)$/i;
2000
+ const BORDER_ZERO_WIDTH = /^[+-]?0*(\.0*)?(px|pt|em|rem|ex|ch|cm|mm|in|pc|q)?$/i;
2001
+ /* border-* properties that describe something other than one of the four sides,
2002
+ plus the CSS-wide keywords a shorthand cannot express. */
2003
+ const BORDER_NOT_A_SIDE = /^border-(collapse|spacing|radius|image|block|inline|start|end)/i;
2004
+ const BORDER_SIDE_PROP = /^border(?:-(top|right|bottom|left))?(?:-(width|style|color))?$/;
2005
+ const CSS_WIDE_KEYWORD = /^(inherit|initial|unset|revert|revert-layer)$/i;
2006
+ /* Split on top-level whitespace, so `rgb(0, 0, 0) solid` stays two values. */
2007
+ function splitCssValues(value) {
2008
+ const out = [];
2009
+ let depth = 0;
2010
+ let cur = '';
2011
+ for (const ch of value) {
2012
+ if (ch === '(')
2013
+ depth++;
2014
+ else if (ch === ')')
2015
+ depth--;
2016
+ if (depth === 0 && /\s/.test(ch)) {
2017
+ if (cur)
2018
+ out.push(cur);
2019
+ cur = '';
2020
+ }
2021
+ else
2022
+ cur += ch;
2023
+ }
2024
+ if (cur)
2025
+ out.push(cur);
2026
+ return out;
2027
+ }
2028
+ /* 1–4 values in CSS box order → an explicit [top, right, bottom, left]. */
2029
+ function boxSides(parts) {
2030
+ const [t, r = t, b = t, l = r] = parts;
2031
+ return [t, r, b, l];
2032
+ }
2033
+ /* `1pt solid windowtext` in any order; whatever the author left out falls back
2034
+ to its initial value, exactly as the real shorthand resets it. */
2035
+ function parseBorderShorthand(value) {
2036
+ const side = { width: 'medium', style: 'none', color: '' };
2037
+ splitCssValues(value).forEach((part) => {
2038
+ if (BORDER_STYLE_KEYWORD.test(part))
2039
+ side.style = part.toLowerCase();
2040
+ else if (BORDER_WIDTH_VALUE.test(part))
2041
+ side.width = part;
2042
+ else
2043
+ side.color = part;
2044
+ });
2045
+ return side;
2046
+ }
2047
+ function emailBorderColor(color) {
2048
+ if (!color || /^currentcolor$/i.test(color))
2049
+ return '';
2050
+ return /^windowtext$/i.test(color) ? '#000000' : color;
2051
+ }
2052
+ /* One side's declaration value, or '' when that side was never mentioned. */
2053
+ function sideBorderValue(side) {
2054
+ if (side.width === null && side.style === null && side.color === null)
2055
+ return '';
2056
+ const style = side.style ?? 'solid';
2057
+ if (style === 'none' || style === 'hidden')
2058
+ return 'none';
2059
+ if (side.width && BORDER_ZERO_WIDTH.test(side.width))
2060
+ return 'none';
2061
+ const width = side.width && side.width !== 'medium' ? side.width : '';
2062
+ return [width, style, emailBorderColor(side.color || '')].filter(Boolean).join(' ');
2063
+ }
2064
+ function hardenTableBorders(root) {
2065
+ root.querySelectorAll('table,td,th').forEach((el) => {
2066
+ const raw = el.getAttribute('style') || '';
2067
+ if (!/border/i.test(raw))
2068
+ return;
2069
+ const sides = {
2070
+ top: { width: null, style: null, color: null },
2071
+ right: { width: null, style: null, color: null },
2072
+ bottom: { width: null, style: null, color: null },
2073
+ left: { width: null, style: null, color: null }
2074
+ };
2075
+ const rest = [];
2076
+ let sawBorder = false;
2077
+ let bail = false;
2078
+ raw.split(';').forEach((decl) => {
2079
+ const i = decl.indexOf(':');
2080
+ if (i < 1) {
2081
+ if (decl.trim())
2082
+ rest.push(decl.trim());
2083
+ return;
2084
+ }
2085
+ const prop = decl.slice(0, i).trim().toLowerCase();
2086
+ const value = decl.slice(i + 1).trim();
2087
+ const m = BORDER_SIDE_PROP.exec(prop);
2088
+ if (!m || BORDER_NOT_A_SIDE.test(prop)) {
2089
+ rest.push(prop + ':' + value);
2090
+ return;
2091
+ }
2092
+ /* `border-color:inherit` and friends only mean anything against a
2093
+ parent this export has no say over — leave the cell untouched. */
2094
+ if (CSS_WIDE_KEYWORD.test(value)) {
2095
+ bail = true;
2096
+ return;
2097
+ }
2098
+ sawBorder = true;
2099
+ const one = m[1];
2100
+ const part = m[2];
2101
+ if (!part) {
2102
+ /* `border` / `border-<side>` resets all three components. */
2103
+ const parsed = parseBorderShorthand(value);
2104
+ (one ? [one] : BORDER_SIDES.slice()).forEach((s) => {
2105
+ sides[s] = { ...parsed };
2106
+ });
2107
+ }
2108
+ else if (one) {
2109
+ sides[one][part] = value;
2110
+ }
2111
+ else {
2112
+ const per = boxSides(splitCssValues(value));
2113
+ BORDER_SIDES.forEach((s, n) => {
2114
+ sides[s][part] = per[n];
2115
+ });
2116
+ }
2117
+ });
2118
+ if (bail || !sawBorder)
2119
+ return;
2120
+ const values = BORDER_SIDES.map((s) => sideBorderValue(sides[s]));
2121
+ const out = rest.slice();
2122
+ if (values.every((v) => v && v === values[0]))
2123
+ out.push('border:' + values[0]);
2124
+ else
2125
+ BORDER_SIDES.forEach((s, n) => values[n] && out.push(`border-${s}:` + values[n]));
2126
+ el.setAttribute('style', out.join(';'));
2127
+ });
2128
+ }
1983
2129
  /* Text belonging to el itself, skipping subtrees rooted at a descendant that
1984
2130
  already carries its own explicit rtl/ltr dir — that subtree's direction is
1985
2131
  already independently decided and shouldn't sway el's own detection. */
@@ -2559,6 +2705,19 @@ class FableEditor {
2559
2705
  if (!/(^|;)\s*height\s*:/i.test(img.getAttribute('style') || ''))
2560
2706
  setStyle(img, 'height:auto');
2561
2707
  });
2708
+ /* Borders last, so the per-side shorthands it writes are the final word on
2709
+ every cell. Also pin border-collapse inline and mirror it onto the legacy
2710
+ cellspacing attribute: a mail pipeline that drops border-collapse leaves
2711
+ the table in `separate` mode, where any cellspacing of its own would open
2712
+ gaps between Word's deliberately shared cell edges. */
2713
+ hardenTableBorders(box);
2714
+ box.querySelectorAll('table').forEach((tbl) => {
2715
+ const style = tbl.getAttribute('style') || '';
2716
+ if (!/border-collapse\s*:/i.test(style))
2717
+ setStyle(tbl, 'border-collapse:collapse');
2718
+ if (!/border-spacing\s*:/i.test(style) && !tbl.hasAttribute('cellspacing'))
2719
+ tbl.setAttribute('cellspacing', '0');
2720
+ });
2562
2721
  const dir = found || this.ed.getAttribute('dir') || this.dir();
2563
2722
  box.querySelectorAll('table:not([dir])').forEach((tbl) => {
2564
2723
  tbl.setAttribute('dir', dir);