Skip to content

Commit 9c8dc8b

Browse files
authored
chore: consolidate table cell formatting (#345)
* chore: consolidate table cell formatting * fix: satisfy formatting provenance lint * fix: model optional formatting inputs explicitly
1 parent b2f3885 commit 9c8dc8b

4 files changed

Lines changed: 419 additions & 163 deletions

File tree

.changeset/five-otters-bet.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import type { Theme } from "../../types/document";
4+
import { resolveEffectiveTableCellFormatting } from "./effectiveTableCellFormatting";
5+
6+
type ResolveOptions = Parameters<typeof resolveEffectiveTableCellFormatting>[0];
7+
8+
const resolveFormatting = (overrides: Partial<ResolveOptions>) =>
9+
resolveEffectiveTableCellFormatting({
10+
directFormatting: undefined,
11+
styleFormatting: undefined,
12+
tableBorders: undefined,
13+
position: {},
14+
gridWidthPercent: undefined,
15+
defaultMargins: undefined,
16+
theme: undefined,
17+
...overrides,
18+
});
19+
20+
const theme: Theme = {
21+
colorScheme: {
22+
dk1: "000000",
23+
lt1: "FFFFFF",
24+
dk2: "44546A",
25+
lt2: "E7E6E6",
26+
accent1: "4472C4",
27+
accent2: "ED7D31",
28+
accent3: "A5A5A5",
29+
accent4: "FFC000",
30+
accent5: "5B9BD5",
31+
accent6: "70AD47",
32+
hlink: "0563C1",
33+
folHlink: "954F72",
34+
},
35+
};
36+
37+
describe("resolveEffectiveTableCellFormatting", () => {
38+
test("keeps a direct no-fill distinct from an absent or inherited background", () => {
39+
const result = resolveFormatting({
40+
directFormatting: { shading: { pattern: "nil" } },
41+
styleFormatting: {
42+
shading: { pattern: "clear", fill: { themeColor: "accent1" } },
43+
},
44+
theme,
45+
});
46+
47+
expect(result.background).toEqual({ type: "none", source: "direct" });
48+
});
49+
50+
test("records whether width came from the authored cell or table grid", () => {
51+
const direct = resolveFormatting({
52+
directFormatting: { width: { value: 1440, type: "dxa" } },
53+
gridWidthPercent: 50,
54+
});
55+
const grid = resolveFormatting({ gridWidthPercent: 50 });
56+
57+
expect(direct.width).toEqual({
58+
type: "value",
59+
source: "direct",
60+
value: 1440,
61+
widthType: "dxa",
62+
});
63+
expect(grid.width).toEqual({
64+
type: "value",
65+
source: "grid",
66+
value: 50,
67+
widthType: "pct",
68+
});
69+
});
70+
71+
test("applies direct, style, and table border precedence before resolving theme colors", () => {
72+
const result = resolveFormatting({
73+
directFormatting: {
74+
borders: { left: { style: "dashed", color: { rgb: "123456" } } },
75+
},
76+
styleFormatting: {
77+
borders: { top: { style: "double", color: { themeColor: "accent2" } } },
78+
},
79+
tableBorders: {
80+
top: { style: "single", color: { themeColor: "accent1" } },
81+
bottom: { style: "single", color: { themeColor: "accent1" } },
82+
},
83+
position: { isFirstRow: true, isLastRow: true },
84+
theme,
85+
});
86+
87+
expect(result.borders?.top).toEqual({ style: "double", color: { rgb: "ED7D31" } });
88+
expect(result.borders?.bottom).toEqual({ style: "single", color: { rgb: "4472C4" } });
89+
expect(result.borders?.left).toEqual({ style: "dashed", color: { rgb: "123456" } });
90+
});
91+
92+
test("uses direct margins before style and table defaults", () => {
93+
const result = resolveFormatting({
94+
directFormatting: { margins: { left: { value: 120, type: "dxa" } } },
95+
styleFormatting: { margins: { left: { value: 240, type: "dxa" } } },
96+
defaultMargins: { left: 360 },
97+
});
98+
99+
expect(result.margins).toEqual({ left: 120 });
100+
});
101+
});
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import type {
2+
CellMargins,
3+
ShadingProperties,
4+
TableBorders,
5+
TableCellBorders,
6+
TableCellFormatting,
7+
TableWidthType,
8+
Theme,
9+
} from "../../types/document";
10+
import { resolveColor } from "../../utils/colorResolver";
11+
import { resolveShadingFill } from "../../utils/formatToStyle";
12+
13+
export type TableCellMarginsAttrs = {
14+
top?: number;
15+
bottom?: number;
16+
left?: number;
17+
right?: number;
18+
};
19+
20+
type EffectiveTableCellWidth =
21+
| { type: "none" }
22+
| {
23+
type: "value";
24+
source: "direct" | "grid";
25+
value: number;
26+
widthType: TableWidthType;
27+
};
28+
29+
type EffectiveTableCellBackground =
30+
| { type: "none"; source: "none" | "direct" | "style" }
31+
| { type: "color"; source: "direct" | "style"; rgb: string };
32+
33+
type EffectiveTableCellFormatting = Readonly<{
34+
width: EffectiveTableCellWidth;
35+
background: EffectiveTableCellBackground;
36+
borders: TableCellBorders | undefined;
37+
margins: TableCellMarginsAttrs | undefined;
38+
}>;
39+
40+
export type TableCellPosition = {
41+
isFirstRow?: boolean;
42+
isLastRow?: boolean;
43+
isFirstColumn?: boolean;
44+
isLastColumn?: boolean;
45+
};
46+
47+
type ResolveEffectiveTableCellFormattingOptions = {
48+
directFormatting: TableCellFormatting | undefined;
49+
styleFormatting: TableCellFormatting | undefined;
50+
tableBorders: TableBorders | undefined;
51+
position: TableCellPosition;
52+
gridWidthPercent: number | undefined;
53+
defaultMargins: TableCellMarginsAttrs | undefined;
54+
theme: Theme | null | undefined;
55+
};
56+
57+
const TABLE_BORDER_SIDES = [
58+
"top",
59+
"bottom",
60+
"left",
61+
"right",
62+
"insideH",
63+
"insideV",
64+
"topLeftToBottomRight",
65+
"topRightToBottomLeft",
66+
] as const satisfies readonly (keyof TableCellBorders)[];
67+
68+
/**
69+
* Resolve the display-facing table-cell cascade without mutating authored
70+
* OOXML formatting. Source discriminators keep inherited values distinct from
71+
* direct values that the editor may later override.
72+
*/
73+
export const resolveEffectiveTableCellFormatting = ({
74+
directFormatting,
75+
styleFormatting,
76+
tableBorders,
77+
position,
78+
gridWidthPercent,
79+
defaultMargins,
80+
theme,
81+
}: ResolveEffectiveTableCellFormattingOptions): EffectiveTableCellFormatting => {
82+
const width = resolveEffectiveWidth(directFormatting, gridWidthPercent);
83+
const background = resolveEffectiveBackground({ directFormatting, styleFormatting, theme });
84+
const borders = resolveEffectiveBorders({
85+
directFormatting,
86+
styleFormatting,
87+
tableBorders,
88+
position,
89+
theme,
90+
});
91+
const margins = resolveEffectiveMargins({
92+
directFormatting,
93+
styleFormatting,
94+
defaultMargins,
95+
});
96+
97+
return { width, background, borders, margins };
98+
};
99+
100+
const resolveEffectiveWidth = (
101+
directFormatting: TableCellFormatting | undefined,
102+
gridWidthPercent: number | undefined,
103+
): EffectiveTableCellWidth => {
104+
const directWidth = directFormatting?.width;
105+
if (directWidth) {
106+
return {
107+
type: "value",
108+
source: "direct",
109+
value: directWidth.value,
110+
widthType: directWidth.type,
111+
};
112+
}
113+
if (gridWidthPercent !== undefined) {
114+
return { type: "value", source: "grid", value: gridWidthPercent, widthType: "pct" };
115+
}
116+
return { type: "none" };
117+
};
118+
119+
type ResolveEffectiveBackgroundOptions = Pick<
120+
ResolveEffectiveTableCellFormattingOptions,
121+
"directFormatting" | "styleFormatting" | "theme"
122+
>;
123+
124+
const resolveEffectiveBackground = ({
125+
directFormatting,
126+
styleFormatting,
127+
theme,
128+
}: ResolveEffectiveBackgroundOptions): EffectiveTableCellBackground => {
129+
const directShading = directFormatting?.shading;
130+
if (directShading !== undefined) {
131+
return resolveBackgroundColor({ shading: directShading, source: "direct", theme });
132+
}
133+
134+
const styleShading = styleFormatting?.shading;
135+
if (styleShading !== undefined) {
136+
return resolveBackgroundColor({ shading: styleShading, source: "style", theme });
137+
}
138+
139+
return { type: "none", source: "none" };
140+
};
141+
142+
type ResolveBackgroundColorOptions = {
143+
shading: ShadingProperties;
144+
source: "direct" | "style";
145+
theme: Theme | null | undefined;
146+
};
147+
148+
const resolveBackgroundColor = ({
149+
shading,
150+
source,
151+
theme,
152+
}: ResolveBackgroundColorOptions): EffectiveTableCellBackground => {
153+
const rgb = resolveShadingFill(shading, theme).replace(/^#/u, "");
154+
155+
if (!rgb) {
156+
return { type: "none", source };
157+
}
158+
return { type: "color", source, rgb };
159+
};
160+
161+
type ResolveEffectiveBordersOptions = Pick<
162+
ResolveEffectiveTableCellFormattingOptions,
163+
"directFormatting" | "styleFormatting" | "tableBorders" | "position" | "theme"
164+
>;
165+
166+
const resolveEffectiveBorders = ({
167+
directFormatting,
168+
styleFormatting,
169+
tableBorders,
170+
position,
171+
theme,
172+
}: ResolveEffectiveBordersOptions): TableCellBorders | undefined => {
173+
const baseBorders = tableBorders
174+
? {
175+
top: position.isFirstRow ? tableBorders.top : tableBorders.insideH,
176+
bottom: position.isLastRow ? tableBorders.bottom : tableBorders.insideH,
177+
left: position.isFirstColumn ? tableBorders.left : tableBorders.insideV,
178+
right: position.isLastColumn ? tableBorders.right : tableBorders.insideV,
179+
}
180+
: undefined;
181+
const styleBorders = styleFormatting?.borders;
182+
const directBorders = directFormatting?.borders;
183+
const borders =
184+
baseBorders || styleBorders || directBorders
185+
? { ...baseBorders, ...styleBorders, ...directBorders }
186+
: undefined;
187+
188+
return resolveThemedBorderColors(borders, theme);
189+
};
190+
191+
type ResolveEffectiveMarginsOptions = Pick<
192+
ResolveEffectiveTableCellFormattingOptions,
193+
"directFormatting" | "styleFormatting" | "defaultMargins"
194+
>;
195+
196+
const resolveEffectiveMargins = ({
197+
directFormatting,
198+
styleFormatting,
199+
defaultMargins,
200+
}: ResolveEffectiveMarginsOptions): TableCellMarginsAttrs | undefined => {
201+
if (directFormatting?.margins) {
202+
return cellMarginsToAttrs(directFormatting.margins);
203+
}
204+
if (styleFormatting?.margins) {
205+
return cellMarginsToAttrs(styleFormatting.margins);
206+
}
207+
return defaultMargins;
208+
};
209+
210+
const cellMarginsToAttrs = (margins: CellMargins): TableCellMarginsAttrs => {
211+
const result: TableCellMarginsAttrs = {};
212+
if (margins.top?.value !== undefined) {
213+
result.top = margins.top.value;
214+
}
215+
if (margins.bottom?.value !== undefined) {
216+
result.bottom = margins.bottom.value;
217+
}
218+
if (margins.left?.value !== undefined) {
219+
result.left = margins.left.value;
220+
}
221+
if (margins.right?.value !== undefined) {
222+
result.right = margins.right.value;
223+
}
224+
return result;
225+
};
226+
227+
const resolveThemedBorderColors = (
228+
borders: TableCellBorders | undefined,
229+
theme: Theme | null | undefined,
230+
): TableCellBorders | undefined => {
231+
if (!borders || !theme?.colorScheme) {
232+
return borders;
233+
}
234+
235+
let resolved: TableCellBorders | undefined;
236+
for (const side of TABLE_BORDER_SIDES) {
237+
const border = borders[side];
238+
if (!border?.color?.themeColor || border.color.auto) {
239+
continue;
240+
}
241+
242+
resolved ??= { ...borders };
243+
resolved[side] = {
244+
...border,
245+
color: {
246+
rgb: resolveColor(border.color, theme).replace(/^#/u, ""),
247+
},
248+
};
249+
}
250+
251+
return resolved ?? borders;
252+
};

0 commit comments

Comments
 (0)