docgen-utils 1.0.11 → 1.0.13

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.
Files changed (76) hide show
  1. package/dist/bundle.js +42918 -6708
  2. package/dist/bundle.min.js +289 -109
  3. package/dist/cli.js +26450 -1266
  4. package/dist/packages/cli/commands/export-docs.d.ts.map +1 -1
  5. package/dist/packages/cli/commands/export-docs.js +131 -2
  6. package/dist/packages/cli/commands/export-docs.js.map +1 -1
  7. package/dist/packages/cli/commands/export-slides.d.ts.map +1 -1
  8. package/dist/packages/cli/commands/export-slides.js +25 -1
  9. package/dist/packages/cli/commands/export-slides.js.map +1 -1
  10. package/dist/packages/docs/common.d.ts +10 -0
  11. package/dist/packages/docs/common.d.ts.map +1 -1
  12. package/dist/packages/docs/common.js.map +1 -1
  13. package/dist/packages/docs/convert.d.ts.map +1 -1
  14. package/dist/packages/docs/convert.js +246 -218
  15. package/dist/packages/docs/convert.js.map +1 -1
  16. package/dist/packages/docs/create-document.d.ts.map +1 -1
  17. package/dist/packages/docs/create-document.js +43 -3
  18. package/dist/packages/docs/create-document.js.map +1 -1
  19. package/dist/packages/docs/export.d.ts +9 -8
  20. package/dist/packages/docs/export.d.ts.map +1 -1
  21. package/dist/packages/docs/export.js +23 -36
  22. package/dist/packages/docs/export.js.map +1 -1
  23. package/dist/packages/docs/import-docx.d.ts.map +1 -1
  24. package/dist/packages/docs/import-docx.js +397 -7
  25. package/dist/packages/docs/import-docx.js.map +1 -1
  26. package/dist/packages/docs/parse-colors.d.ts +37 -0
  27. package/dist/packages/docs/parse-colors.d.ts.map +1 -0
  28. package/dist/packages/docs/parse-colors.js +507 -0
  29. package/dist/packages/docs/parse-colors.js.map +1 -0
  30. package/dist/packages/docs/parse-css.d.ts +98 -0
  31. package/dist/packages/docs/parse-css.d.ts.map +1 -0
  32. package/dist/packages/docs/parse-css.js +1592 -0
  33. package/dist/packages/docs/parse-css.js.map +1 -0
  34. package/dist/packages/docs/parse-helpers.d.ts +45 -0
  35. package/dist/packages/docs/parse-helpers.d.ts.map +1 -0
  36. package/dist/packages/docs/parse-helpers.js +214 -0
  37. package/dist/packages/docs/parse-helpers.js.map +1 -0
  38. package/dist/packages/docs/parse-inline.d.ts +41 -0
  39. package/dist/packages/docs/parse-inline.d.ts.map +1 -0
  40. package/dist/packages/docs/parse-inline.js +473 -0
  41. package/dist/packages/docs/parse-inline.js.map +1 -0
  42. package/dist/packages/docs/parse-layout.d.ts +57 -0
  43. package/dist/packages/docs/parse-layout.d.ts.map +1 -0
  44. package/dist/packages/docs/parse-layout.js +295 -0
  45. package/dist/packages/docs/parse-layout.js.map +1 -0
  46. package/dist/packages/docs/parse-special.d.ts +51 -0
  47. package/dist/packages/docs/parse-special.d.ts.map +1 -0
  48. package/dist/packages/docs/parse-special.js +251 -0
  49. package/dist/packages/docs/parse-special.js.map +1 -0
  50. package/dist/packages/docs/parse-units.d.ts +68 -0
  51. package/dist/packages/docs/parse-units.d.ts.map +1 -0
  52. package/dist/packages/docs/parse-units.js +275 -0
  53. package/dist/packages/docs/parse-units.js.map +1 -0
  54. package/dist/packages/docs/parse.d.ts.map +1 -1
  55. package/dist/packages/docs/parse.js +957 -2800
  56. package/dist/packages/docs/parse.js.map +1 -1
  57. package/dist/packages/slides/common.d.ts +7 -0
  58. package/dist/packages/slides/common.d.ts.map +1 -1
  59. package/dist/packages/slides/convert.d.ts.map +1 -1
  60. package/dist/packages/slides/convert.js +92 -7
  61. package/dist/packages/slides/convert.js.map +1 -1
  62. package/dist/packages/slides/fonts.d.ts +41 -0
  63. package/dist/packages/slides/fonts.d.ts.map +1 -0
  64. package/dist/packages/slides/fonts.js +209 -0
  65. package/dist/packages/slides/fonts.js.map +1 -0
  66. package/dist/packages/slides/import-pptx.d.ts.map +1 -1
  67. package/dist/packages/slides/import-pptx.js +583 -120
  68. package/dist/packages/slides/import-pptx.js.map +1 -1
  69. package/dist/packages/slides/parse.d.ts.map +1 -1
  70. package/dist/packages/slides/parse.js +724 -91
  71. package/dist/packages/slides/parse.js.map +1 -1
  72. package/dist/packages/slides/transform.d.ts +6 -6
  73. package/dist/packages/slides/transform.d.ts.map +1 -1
  74. package/dist/packages/slides/transform.js +25 -51
  75. package/dist/packages/slides/transform.js.map +1 -1
  76. package/package.json +3 -2
@@ -0,0 +1,1592 @@
1
+ import { extractFirstGradientColor, extractHexColor, extractPrimaryFont, parseGradient } from "./parse-colors";
2
+ /**
3
+ * Remove @media blocks from CSS text by tracking brace nesting.
4
+ * This properly handles nested braces within media queries.
5
+ */
6
+ export function removeMediaQueries(cssText) {
7
+ let result = "";
8
+ let i = 0;
9
+ while (i < cssText.length) {
10
+ // Check for @media at current position
11
+ if (cssText.substring(i, i + 6).toLowerCase() === "@media") {
12
+ // Find the opening brace
13
+ let braceStart = cssText.indexOf("{", i);
14
+ if (braceStart === -1) {
15
+ // Malformed CSS, include rest as-is
16
+ result += cssText.substring(i);
17
+ break;
18
+ }
19
+ // Track brace depth to find matching closing brace
20
+ let depth = 1;
21
+ let j = braceStart + 1;
22
+ while (j < cssText.length && depth > 0) {
23
+ if (cssText[j] === "{") {
24
+ depth++;
25
+ }
26
+ else if (cssText[j] === "}") {
27
+ depth--;
28
+ }
29
+ j++;
30
+ }
31
+ // Skip the entire @media block (from @media to matching })
32
+ i = j;
33
+ }
34
+ else {
35
+ result += cssText[i];
36
+ i++;
37
+ }
38
+ }
39
+ return result;
40
+ }
41
+ export function parseCssContext(doc) {
42
+ const variables = new Map();
43
+ const classColors = new Map();
44
+ const calloutStyles = new Map();
45
+ const classStyles = new Map();
46
+ const elementStyles = new Map();
47
+ const nestedStyles = new Map();
48
+ // Helper to resolve CSS variable or return value as-is
49
+ // Handles multiple var() references in a single value (e.g., "var(--a) var(--b)")
50
+ const resolveValue = (value) => {
51
+ // Replace all var() references with their resolved values
52
+ // Supports var(--name) and var(--name, fallback) syntax
53
+ return value.replace(/var\s*\(\s*(--[a-zA-Z0-9-]+)\s*(?:,\s*([^)]+))?\s*\)/g, (match, varName, fallback) => {
54
+ const resolved = variables.get(varName);
55
+ if (resolved)
56
+ return resolved;
57
+ if (fallback)
58
+ return fallback.trim();
59
+ return match; // Keep original if not found
60
+ });
61
+ };
62
+ // Find all style elements
63
+ const styleElements = doc.querySelectorAll("style");
64
+ for (const styleEl of styleElements) {
65
+ let cssText = styleEl.textContent || "";
66
+ // Remove media queries to avoid mobile-specific styles overriding desktop defaults
67
+ // Use a function-based approach to correctly handle nested braces
68
+ cssText = removeMediaQueries(cssText);
69
+ // Extract CSS variables from :root { --name: value; }
70
+ // Use matchAll to handle multiple :root blocks (e.g., from concatenated stylesheets)
71
+ const rootMatches = cssText.matchAll(/:root\s*\{([^}]+)\}/g);
72
+ for (const rootMatch of rootMatches) {
73
+ const rootContent = rootMatch[1];
74
+ const varMatches = rootContent.matchAll(/--([a-zA-Z0-9-]+)\s*:\s*([^;]+)/g);
75
+ for (const match of varMatches) {
76
+ const varName = `--${match[1]}`;
77
+ const varValue = match[2].trim();
78
+ variables.set(varName, varValue);
79
+ }
80
+ }
81
+ // Also extract CSS variables from theme classes (e.g., .theme-professional)
82
+ // These are commonly used to scope CSS variables to body elements
83
+ const themeClassMatches = cssText.matchAll(/\.theme-[a-zA-Z0-9_-]+\s*\{([^}]+)\}/g);
84
+ for (const themeMatch of themeClassMatches) {
85
+ const themeContent = themeMatch[1];
86
+ const varMatches = themeContent.matchAll(/--([a-zA-Z0-9-]+)\s*:\s*([^;]+)/g);
87
+ for (const match of varMatches) {
88
+ const varName = `--${match[1]}`;
89
+ const varValue = match[2].trim();
90
+ // Only set if not already defined (prefer :root values)
91
+ if (!variables.has(varName)) {
92
+ variables.set(varName, varValue);
93
+ }
94
+ }
95
+ }
96
+ // Extract class color rules: .classname { color: value; }
97
+ // Matches single or comma-separated class selectors: .class1, .class2 { ... }
98
+ // Uses a broad regex to capture the full selector list and rule body.
99
+ // Handles pseudo-class selectors by filtering them in the processing loop.
100
+ const classRuleMatches = cssText.matchAll(/(?:^|[;\n}])\s*((?:\.[\w-]+(?:[:\w()\-]*)?(?:\s*,\s*)?)+)\s*(\{[^}]+\})/gm);
101
+ for (const match of classRuleMatches) {
102
+ const fullMatch = match[0];
103
+ const selectorList = match[1];
104
+ const ruleBlock = match[2];
105
+ const ruleContent = ruleBlock.slice(1, -1); // Remove { }
106
+ // Split comma-separated selectors and process each one
107
+ const selectors = selectorList.split(/\s*,\s*/).map(s => s.trim()).filter(s => s.startsWith('.'));
108
+ const classNames = [];
109
+ for (const selector of selectors) {
110
+ // Skip pseudo-class selectors like .foo:hover, .foo:focus, .foo:active, .foo:visited
111
+ if (selector.match(/:(?:hover|focus|active|visited|focus-within|focus-visible)\b/)) {
112
+ continue;
113
+ }
114
+ // Skip pseudo-element selectors like .foo::before, .foo::after
115
+ if (selector.match(/::(?:before|after|first-line|first-letter|placeholder|selection)\b/)) {
116
+ continue;
117
+ }
118
+ // Extract class name(s) - handles compound selectors like .classA.classB
119
+ // For compound selectors, we extract ALL class parts
120
+ const allClassParts = [...selector.matchAll(/\.([a-zA-Z0-9_-]+)/g)].map(m => m[1]);
121
+ if (allClassParts.length === 0)
122
+ continue;
123
+ if (allClassParts.length === 1) {
124
+ // Simple selector: .classA
125
+ classNames.push(allClassParts[0]);
126
+ }
127
+ else {
128
+ // Compound selector: .classA.classB - store under a compound key
129
+ // Sort to ensure consistent key regardless of order in CSS
130
+ const compoundKey = allClassParts.sort().join('.');
131
+ classNames.push(compoundKey);
132
+ }
133
+ }
134
+ if (classNames.length === 0)
135
+ continue;
136
+ // Skip if this looks like a nested selector (has another selector before it)
137
+ // Check if the match is preceded by another class, element, or ID selector on the same line
138
+ // This catches ".parent .child", "figure.parent .child", "#id .child", "div .child" etc.
139
+ const precedingText = cssText.substring(0, match.index).split('\n').pop() || '';
140
+ if (precedingText.match(/(?:\.[a-zA-Z0-9_-]+|#[a-zA-Z0-9_-]+|[a-zA-Z][a-zA-Z0-9]*)\s*$/)) {
141
+ // This is a nested selector like ".parent .child { ... }" - skip it here
142
+ // Nested selectors will be handled by storing parent-child relationships
143
+ continue;
144
+ }
145
+ // Extract ALL style properties for this class (generalized approach)
146
+ const style = {};
147
+ // Text color
148
+ const colorMatch = ruleContent.match(/(?:^|;)\s*color\s*:\s*([^;]+)/i);
149
+ if (colorMatch) {
150
+ const colorValue = resolveValue(colorMatch[1].trim());
151
+ for (const className of classNames) {
152
+ classColors.set(className, colorValue);
153
+ }
154
+ style.color = colorValue;
155
+ }
156
+ // Background color
157
+ const bgMatch = ruleContent.match(/background(?:-(?:color|image))?\s*:\s*([^;]+)/i);
158
+ if (bgMatch) {
159
+ const bgValue = resolveValue(bgMatch[1].trim());
160
+ // For gradient backgrounds, extract the first color as fallback for DOCX
161
+ // Also store the full gradient for containers that support gradient rendering
162
+ const gradientColor = extractFirstGradientColor(bgValue);
163
+ if (gradientColor) {
164
+ style.backgroundColor = gradientColor;
165
+ // Parse full gradient for container backgrounds (not text gradients)
166
+ const gradient = parseGradient(bgValue);
167
+ if (gradient) {
168
+ style.backgroundGradient = gradient;
169
+ }
170
+ }
171
+ else {
172
+ style.backgroundColor = bgValue;
173
+ }
174
+ }
175
+ // Gradient text detection: when background-clip: text is used with a gradient background,
176
+ // extract the first color from the gradient as fallback and also store full gradient
177
+ const hasBackgroundClipText = ruleContent.match(/(?:-webkit-)?background-clip\s*:\s*text/i);
178
+ if (hasBackgroundClipText && bgMatch) {
179
+ const bgValue = resolveValue(bgMatch[1].trim());
180
+ // Parse full gradient
181
+ const gradient = parseGradient(bgValue);
182
+ if (gradient) {
183
+ style.gradient = gradient;
184
+ // Clear backgroundColor since the gradient is for text fill, NOT paragraph background
185
+ // Without this, elements get both a solid purple background shading AND gradient text
186
+ style.backgroundColor = undefined;
187
+ style.backgroundGradient = undefined;
188
+ // Also set fallback color from first stop
189
+ if (!style.color) {
190
+ style.color = gradient.stops[0]?.color;
191
+ if (style.color) {
192
+ for (const className of classNames) {
193
+ classColors.set(className, style.color);
194
+ }
195
+ }
196
+ }
197
+ }
198
+ else {
199
+ // Fallback: extract first color if gradient parsing fails
200
+ const gradientColor = extractFirstGradientColor(bgValue);
201
+ if (gradientColor && !style.color) {
202
+ style.color = gradientColor;
203
+ for (const className of classNames) {
204
+ classColors.set(className, gradientColor);
205
+ }
206
+ }
207
+ // Clear backgroundColor for text gradient
208
+ style.backgroundColor = undefined;
209
+ style.backgroundGradient = undefined;
210
+ }
211
+ }
212
+ // Border color
213
+ const borderColorMatch = ruleContent.match(/border-color\s*:\s*([^;]+)/i);
214
+ if (borderColorMatch) {
215
+ style.borderColor = resolveValue(borderColorMatch[1].trim());
216
+ }
217
+ // Border shorthand (e.g., "1px solid #e5e7eb")
218
+ const borderMatch = ruleContent.match(/(?:^|;)\s*border\s*:\s*([^;]+)/i);
219
+ if (borderMatch) {
220
+ style.border = resolveValue(borderMatch[1].trim());
221
+ }
222
+ // Border-left (used by callouts: "4px solid #2563eb")
223
+ const borderLeftMatch = ruleContent.match(/border-left\s*:\s*([^;]+)/i);
224
+ if (borderLeftMatch) {
225
+ const borderLeftValue = resolveValue(borderLeftMatch[1].trim());
226
+ // If it contains a color, extract it as borderColor
227
+ const colorInBorder = borderLeftValue.match(/#([0-9a-fA-F]{3,6})/);
228
+ if (colorInBorder && !style.borderColor) {
229
+ style.borderColor = `#${colorInBorder[1]}`;
230
+ }
231
+ // Store the full border-left value (but NOT in style.border, which is for
232
+ // the CSS `border:` shorthand. Previously this was set as a fallback which
233
+ // could cause border-left-only elements to be misclassified as full-border.)
234
+ style.borderLeft = borderLeftValue;
235
+ }
236
+ // Border-right (used by sidebar dividers, etc.)
237
+ const borderRightMatch = ruleContent.match(/border-right\s*:\s*([^;]+)/i);
238
+ if (borderRightMatch) {
239
+ style.borderRight = resolveValue(borderRightMatch[1].trim());
240
+ }
241
+ // Border-bottom (used by title blocks with divider lines)
242
+ const borderBottomMatch = ruleContent.match(/border-bottom\s*:\s*([^;]+)/i);
243
+ if (borderBottomMatch) {
244
+ style.borderBottom = resolveValue(borderBottomMatch[1].trim());
245
+ }
246
+ // Border-top (used by footer sections with top divider lines)
247
+ const borderTopMatch = ruleContent.match(/border-top\s*:\s*([^;]+)/i);
248
+ if (borderTopMatch) {
249
+ style.borderTop = resolveValue(borderTopMatch[1].trim());
250
+ }
251
+ // Display property (grid, flex, block, etc.)
252
+ const displayMatch = ruleContent.match(/display\s*:\s*([^;]+)/i);
253
+ if (displayMatch) {
254
+ style.display = resolveValue(displayMatch[1].trim());
255
+ }
256
+ // Flex property (for flex item sizing like "flex: 1")
257
+ const flexMatch = ruleContent.match(/(?:^|;)\s*flex\s*:\s*([^;]+)/i);
258
+ if (flexMatch) {
259
+ style.flex = resolveValue(flexMatch[1].trim());
260
+ }
261
+ // Flex-direction property (for horizontal vs vertical flex containers)
262
+ const flexDirectionMatch = ruleContent.match(/flex-direction\s*:\s*([^;]+)/i);
263
+ if (flexDirectionMatch) {
264
+ style.flexDirection = resolveValue(flexDirectionMatch[1].trim());
265
+ }
266
+ // Flex-wrap property (for wrapping behavior)
267
+ const flexWrapMatch = ruleContent.match(/flex-wrap\s*:\s*([^;]+)/i);
268
+ if (flexWrapMatch) {
269
+ style.flexWrap = resolveValue(flexWrapMatch[1].trim());
270
+ }
271
+ // Justify-content property (for flex alignment: center, space-between, etc.)
272
+ const justifyContentMatch = ruleContent.match(/justify-content\s*:\s*([^;]+)/i);
273
+ if (justifyContentMatch) {
274
+ style.justifyContent = resolveValue(justifyContentMatch[1].trim());
275
+ }
276
+ // Gap property (for flex/grid spacing)
277
+ const gapMatch = ruleContent.match(/(?:^|;)\s*gap\s*:\s*([^;]+)/i);
278
+ if (gapMatch) {
279
+ style.gap = resolveValue(gapMatch[1].trim());
280
+ }
281
+ // Grid template columns (for two-column layout detection)
282
+ const gridColsMatch = ruleContent.match(/grid-template-columns\s*:\s*([^;]+)/i);
283
+ if (gridColsMatch) {
284
+ style.gridTemplateColumns = resolveValue(gridColsMatch[1].trim());
285
+ }
286
+ // Text alignment
287
+ const textAlignMatch = ruleContent.match(/text-align\s*:\s*([^;]+)/i);
288
+ if (textAlignMatch) {
289
+ style.textAlign = resolveValue(textAlignMatch[1].trim());
290
+ }
291
+ // Font size
292
+ const fontSizeMatch = ruleContent.match(/font-size\s*:\s*([^;]+)/i);
293
+ if (fontSizeMatch) {
294
+ style.fontSize = resolveValue(fontSizeMatch[1].trim());
295
+ }
296
+ // Font weight
297
+ const fontWeightMatch = ruleContent.match(/font-weight\s*:\s*([^;]+)/i);
298
+ if (fontWeightMatch) {
299
+ style.fontWeight = resolveValue(fontWeightMatch[1].trim());
300
+ }
301
+ // Padding shorthand
302
+ const paddingMatch = ruleContent.match(/(?:^|;)\s*padding\s*:\s*([^;]+)/i);
303
+ if (paddingMatch) {
304
+ style.padding = resolveValue(paddingMatch[1].trim());
305
+ }
306
+ // Individual padding properties (padding-left, padding-right, etc.)
307
+ const paddingLeftMatch = ruleContent.match(/padding-left\s*:\s*([^;]+)/i);
308
+ if (paddingLeftMatch) {
309
+ style.paddingLeft = resolveValue(paddingLeftMatch[1].trim());
310
+ }
311
+ const paddingRightMatch = ruleContent.match(/padding-right\s*:\s*([^;]+)/i);
312
+ if (paddingRightMatch) {
313
+ style.paddingRight = resolveValue(paddingRightMatch[1].trim());
314
+ }
315
+ const paddingTopMatch = ruleContent.match(/padding-top\s*:\s*([^;]+)/i);
316
+ if (paddingTopMatch) {
317
+ style.paddingTop = resolveValue(paddingTopMatch[1].trim());
318
+ }
319
+ const paddingBottomMatch = ruleContent.match(/padding-bottom\s*:\s*([^;]+)/i);
320
+ if (paddingBottomMatch) {
321
+ style.paddingBottom = resolveValue(paddingBottomMatch[1].trim());
322
+ }
323
+ // Font style (italic, normal)
324
+ const fontStyleMatch = ruleContent.match(/font-style\s*:\s*([^;]+)/i);
325
+ if (fontStyleMatch) {
326
+ style.fontStyle = resolveValue(fontStyleMatch[1].trim());
327
+ }
328
+ // Font family (extract primary font from font stack)
329
+ const fontFamilyMatch = ruleContent.match(/font-family\s*:\s*([^;]+)/i);
330
+ if (fontFamilyMatch) {
331
+ const resolved = resolveValue(fontFamilyMatch[1].trim());
332
+ const primaryFont = extractPrimaryFont(resolved);
333
+ if (primaryFont) {
334
+ style.fontFamily = primaryFont;
335
+ }
336
+ }
337
+ // Text indent (e.g., "2rem", "-2rem" for hanging indents)
338
+ const textIndentMatch = ruleContent.match(/text-indent\s*:\s*([^;]+)/i);
339
+ if (textIndentMatch) {
340
+ style.textIndent = resolveValue(textIndentMatch[1].trim());
341
+ }
342
+ // Text transform (uppercase, lowercase, capitalize)
343
+ const textTransformMatch = ruleContent.match(/text-transform\s*:\s*([^;]+)/i);
344
+ if (textTransformMatch) {
345
+ style.textTransform = resolveValue(textTransformMatch[1].trim());
346
+ }
347
+ // GENERALIZED: Margin-bottom (for paragraph spacing)
348
+ const marginBottomMatch = ruleContent.match(/margin-bottom\s*:\s*([^;]+)/i);
349
+ if (marginBottomMatch) {
350
+ style.marginBottom = resolveValue(marginBottomMatch[1].trim());
351
+ }
352
+ // GENERALIZED: Line-height (for vertical spacing within text)
353
+ const lineHeightMatch = ruleContent.match(/line-height\s*:\s*([^;]+)/i);
354
+ if (lineHeightMatch) {
355
+ style.lineHeight = resolveValue(lineHeightMatch[1].trim());
356
+ }
357
+ // Letter-spacing (for character spacing)
358
+ const letterSpacingMatch = ruleContent.match(/letter-spacing\s*:\s*([^;]+)/i);
359
+ if (letterSpacingMatch) {
360
+ style.letterSpacing = resolveValue(letterSpacingMatch[1].trim());
361
+ }
362
+ // Margin-top (for heading spacing before)
363
+ const marginTopMatch = ruleContent.match(/margin-top\s*:\s*([^;]+)/i);
364
+ if (marginTopMatch) {
365
+ style.marginTop = resolveValue(marginTopMatch[1].trim());
366
+ }
367
+ // Store if we found any properties
368
+ // MERGE with existing styles (later rules can add properties without overwriting)
369
+ if (Object.keys(style).length > 0) {
370
+ for (const className of classNames) {
371
+ const existing = classStyles.get(className) || {};
372
+ classStyles.set(className, { ...existing, ...style });
373
+ }
374
+ }
375
+ }
376
+ // Parse element.class combined selectors like "dd.dish-description { font-style: italic; }"
377
+ // These are more specific than just .class, but we store under the class name
378
+ // since getElementStyles already verifies element type separately
379
+ const elementClassMatches = cssText.matchAll(/(?:^|[;\n}])\s*([a-zA-Z][a-zA-Z0-9]*)\s*\.\s*([a-zA-Z0-9_-]+)\s*\{([^}]+)\}/gm);
380
+ for (const match of elementClassMatches) {
381
+ const elementName = match[1].toLowerCase();
382
+ const className = match[2];
383
+ const ruleContent = match[3];
384
+ // Extract style properties (same as class rules above)
385
+ const style = {};
386
+ // Font style (italic, normal)
387
+ const fontStyleMatch = ruleContent.match(/font-style\s*:\s*([^;]+)/i);
388
+ if (fontStyleMatch) {
389
+ style.fontStyle = resolveValue(fontStyleMatch[1].trim());
390
+ }
391
+ // Font weight
392
+ const fontWeightMatch = ruleContent.match(/font-weight\s*:\s*([^;]+)/i);
393
+ if (fontWeightMatch) {
394
+ style.fontWeight = resolveValue(fontWeightMatch[1].trim());
395
+ }
396
+ // Text color
397
+ const colorMatch = ruleContent.match(/(?:^|;)\s*color\s*:\s*([^;]+)/i);
398
+ if (colorMatch) {
399
+ const colorValue = resolveValue(colorMatch[1].trim());
400
+ style.color = colorValue;
401
+ }
402
+ // Background color
403
+ const bgMatch = ruleContent.match(/background(?:-(?:color|image))?\s*:\s*([^;]+)/i);
404
+ if (bgMatch) {
405
+ const bgValue = resolveValue(bgMatch[1].trim());
406
+ // For gradient backgrounds, extract the first color as fallback for DOCX
407
+ // Also store the full gradient for containers that support gradient rendering
408
+ const gradientColor = extractFirstGradientColor(bgValue);
409
+ if (gradientColor) {
410
+ style.backgroundColor = gradientColor;
411
+ // Parse full gradient for container backgrounds
412
+ const gradient = parseGradient(bgValue);
413
+ if (gradient) {
414
+ style.backgroundGradient = gradient;
415
+ }
416
+ }
417
+ else {
418
+ style.backgroundColor = bgValue;
419
+ }
420
+ }
421
+ // Text alignment
422
+ const textAlignMatch = ruleContent.match(/text-align\s*:\s*([^;]+)/i);
423
+ if (textAlignMatch) {
424
+ style.textAlign = resolveValue(textAlignMatch[1].trim());
425
+ }
426
+ // Font family (extract primary font from font stack)
427
+ const fontFamilyMatch = ruleContent.match(/font-family\s*:\s*([^;]+)/i);
428
+ if (fontFamilyMatch) {
429
+ const resolved = resolveValue(fontFamilyMatch[1].trim());
430
+ const primaryFont = extractPrimaryFont(resolved);
431
+ if (primaryFont) {
432
+ style.fontFamily = primaryFont;
433
+ }
434
+ }
435
+ // Store under the class name (merge with existing)
436
+ if (Object.keys(style).length > 0) {
437
+ const existing = classStyles.get(className) || {};
438
+ classStyles.set(className, { ...existing, ...style });
439
+ }
440
+ }
441
+ // Parse nested CSS selectors like ".parent .child { color: ... }" or ".parent element { color: ... }"
442
+ // Also handles "element.parent .child { ... }" like "figure.menu-image .image-placeholder { ... }"
443
+ // Store the parent-child relationship so we can look up styles based on context
444
+ // Supports: .works-cited p { text-indent: -2rem; } or .callout .callout-label { color: ... }
445
+ // Extended pattern to match:
446
+ // - .parentClass .child { ... }
447
+ // - element.parentClass .child { ... }
448
+ // - .parentClass tr:nth-child(even) { ... } (pseudo-selectors)
449
+ // - .parentClass .classA.classB { ... } (compound child selectors)
450
+ const nestedSelectorMatches = cssText.matchAll(/(?:[a-zA-Z0-9_-]*)?\.([a-zA-Z0-9_-]+)\s+(\.?[a-zA-Z0-9_-]+(?:\.[a-zA-Z0-9_-]+)*(?::[a-zA-Z0-9_-]+(?:\([^)]*\))?)?)\s*\{([^}]+)\}/g);
451
+ for (const match of nestedSelectorMatches) {
452
+ const parentClass = match[1];
453
+ let childSelector = match[2];
454
+ // Remove leading dot if present (for .class selectors)
455
+ if (childSelector.startsWith('.')) {
456
+ childSelector = childSelector.slice(1);
457
+ }
458
+ // Skip interactive pseudo-class selectors (:hover, :focus, :active, :visited)
459
+ // These states don't exist in static DOCX output
460
+ if (childSelector.match(/:(?:hover|focus|active|visited|focus-within|focus-visible)\b/)) {
461
+ continue;
462
+ }
463
+ // Strip pseudo-selectors for the map key (e.g., "tr:nth-child(even)" → "tr")
464
+ const baseChildSelector = childSelector.replace(/:.*$/, '');
465
+ const ruleContent = match[3];
466
+ // Extract style properties for the child when inside this parent
467
+ const style = {};
468
+ // Text color
469
+ const colorMatch = ruleContent.match(/(?:^|;)\s*color\s*:\s*([^;]+)/i);
470
+ if (colorMatch) {
471
+ style.color = resolveValue(colorMatch[1].trim());
472
+ }
473
+ // Background color
474
+ const bgMatch = ruleContent.match(/background(?:-(?:color|image))?\s*:\s*([^;]+)/i);
475
+ if (bgMatch) {
476
+ const bgValue = resolveValue(bgMatch[1].trim());
477
+ // For gradient backgrounds, extract the first color as fallback for DOCX
478
+ // Also store the full gradient for containers that support gradient rendering
479
+ const gradientColor = extractFirstGradientColor(bgValue);
480
+ if (gradientColor) {
481
+ style.backgroundColor = gradientColor;
482
+ // Parse full gradient for container backgrounds
483
+ const gradient = parseGradient(bgValue);
484
+ if (gradient) {
485
+ style.backgroundGradient = gradient;
486
+ }
487
+ }
488
+ else {
489
+ style.backgroundColor = bgValue;
490
+ }
491
+ }
492
+ // Gradient text detection: when background-clip: text is used with a gradient background,
493
+ // extract the first color from the gradient as fallback and also store full gradient
494
+ const hasBackgroundClipText = ruleContent.match(/(?:-webkit-)?background-clip\s*:\s*text/i);
495
+ if (hasBackgroundClipText && bgMatch) {
496
+ const bgValue = resolveValue(bgMatch[1].trim());
497
+ // Parse full gradient
498
+ const gradient = parseGradient(bgValue);
499
+ if (gradient) {
500
+ style.gradient = gradient;
501
+ // Clear backgroundColor since the gradient is for text fill, NOT paragraph background
502
+ style.backgroundColor = undefined;
503
+ style.backgroundGradient = undefined;
504
+ // Also set fallback color from first stop
505
+ if (!style.color) {
506
+ style.color = gradient.stops[0]?.color;
507
+ }
508
+ }
509
+ else {
510
+ // Fallback: extract first color if gradient parsing fails
511
+ const gradientColor = extractFirstGradientColor(bgValue);
512
+ if (gradientColor && !style.color) {
513
+ style.color = gradientColor;
514
+ }
515
+ // Clear backgroundColor for text gradient
516
+ style.backgroundColor = undefined;
517
+ style.backgroundGradient = undefined;
518
+ }
519
+ }
520
+ // Font weight
521
+ const fontWeightMatch = ruleContent.match(/font-weight\s*:\s*([^;]+)/i);
522
+ if (fontWeightMatch) {
523
+ style.fontWeight = resolveValue(fontWeightMatch[1].trim());
524
+ }
525
+ // Text indent (for nested hanging indents like .works-cited p)
526
+ const textIndentMatch = ruleContent.match(/text-indent\s*:\s*([^;]+)/i);
527
+ if (textIndentMatch) {
528
+ style.textIndent = resolveValue(textIndentMatch[1].trim());
529
+ }
530
+ // Padding-left (for paragraph indentation in nested contexts)
531
+ const paddingLeftMatch = ruleContent.match(/padding-left\s*:\s*([^;]+)/i);
532
+ if (paddingLeftMatch) {
533
+ style.paddingLeft = resolveValue(paddingLeftMatch[1].trim());
534
+ }
535
+ // Padding shorthand (for nested selectors like .card td { padding: 0.75rem })
536
+ const paddingMatch = ruleContent.match(/(?:^|;)\s*padding\s*:\s*([^;]+)/i);
537
+ if (paddingMatch) {
538
+ style.padding = resolveValue(paddingMatch[1].trim());
539
+ }
540
+ // Individual padding properties (right, top, bottom)
541
+ const paddingRightMatch = ruleContent.match(/padding-right\s*:\s*([^;]+)/i);
542
+ if (paddingRightMatch) {
543
+ style.paddingRight = resolveValue(paddingRightMatch[1].trim());
544
+ }
545
+ const paddingTopMatch = ruleContent.match(/padding-top\s*:\s*([^;]+)/i);
546
+ if (paddingTopMatch) {
547
+ style.paddingTop = resolveValue(paddingTopMatch[1].trim());
548
+ }
549
+ const paddingBottomMatch = ruleContent.match(/padding-bottom\s*:\s*([^;]+)/i);
550
+ if (paddingBottomMatch) {
551
+ style.paddingBottom = resolveValue(paddingBottomMatch[1].trim());
552
+ }
553
+ // Font style
554
+ const fontStyleMatch = ruleContent.match(/font-style\s*:\s*([^;]+)/i);
555
+ if (fontStyleMatch) {
556
+ style.fontStyle = resolveValue(fontStyleMatch[1].trim());
557
+ }
558
+ // Font family (extract primary font from font stack)
559
+ const fontFamilyMatch = ruleContent.match(/font-family\s*:\s*([^;]+)/i);
560
+ if (fontFamilyMatch) {
561
+ const resolved = resolveValue(fontFamilyMatch[1].trim());
562
+ const primaryFont = extractPrimaryFont(resolved);
563
+ if (primaryFont) {
564
+ style.fontFamily = primaryFont;
565
+ }
566
+ }
567
+ // Border (full shorthand, e.g., "1px solid #e5e7eb")
568
+ const borderMatch = ruleContent.match(/(?:^|;)\s*border\s*:\s*([^;]+)/i);
569
+ if (borderMatch) {
570
+ style.border = resolveValue(borderMatch[1].trim());
571
+ }
572
+ // Border-left (callout styling)
573
+ const borderLeftMatch = ruleContent.match(/border-left\s*:\s*([^;]+)/i);
574
+ if (borderLeftMatch) {
575
+ const borderLeftValue = resolveValue(borderLeftMatch[1].trim());
576
+ style.borderLeft = borderLeftValue;
577
+ // Extract color from border value
578
+ const colorInBorder = borderLeftValue.match(/#([0-9a-fA-F]{3,6})/);
579
+ if (colorInBorder && !style.borderColor) {
580
+ style.borderColor = `#${colorInBorder[1]}`;
581
+ }
582
+ }
583
+ // Border-bottom (heading underline styling, e.g., h2 { border-bottom: 3px solid #7c3aed; })
584
+ const borderBottomMatch = ruleContent.match(/border-bottom\s*:\s*([^;]+)/i);
585
+ if (borderBottomMatch) {
586
+ const borderBottomValue = resolveValue(borderBottomMatch[1].trim());
587
+ style.borderBottom = borderBottomValue;
588
+ // Extract color from border value
589
+ const colorInBorder = borderBottomValue.match(/#([0-9a-fA-F]{3,6})/);
590
+ if (colorInBorder && !style.borderColor) {
591
+ style.borderColor = `#${colorInBorder[1]}`;
592
+ }
593
+ }
594
+ // Border-color (standalone property, e.g., .callout-important { border-color: #f59e0b; })
595
+ // This handles the pattern where border structure and color are split across rules:
596
+ // .callout { border-left: 4px solid; }
597
+ // .callout-important { border-color: #f59e0b; }
598
+ const borderColorMatch = ruleContent.match(/(?:^|;)\s*border-color\s*:\s*([^;]+)/i);
599
+ if (borderColorMatch) {
600
+ style.borderColor = resolveValue(borderColorMatch[1].trim());
601
+ }
602
+ // Border-top (e.g., .divider { border-top: 2px solid #e5e7eb; })
603
+ const borderTopMatch = ruleContent.match(/border-top\s*:\s*([^;]+)/i);
604
+ if (borderTopMatch) {
605
+ const borderTopValue = resolveValue(borderTopMatch[1].trim());
606
+ style.borderTop = borderTopValue;
607
+ const colorInBorder = borderTopValue.match(/#([0-9a-fA-F]{3,6})/);
608
+ if (colorInBorder && !style.borderColor) {
609
+ style.borderColor = `#${colorInBorder[1]}`;
610
+ }
611
+ }
612
+ // Border-right (for nested selectors like .container td { border-right: 1px solid #e5e7eb; })
613
+ const borderRightMatch = ruleContent.match(/border-right\s*:\s*([^;]+)/i);
614
+ if (borderRightMatch) {
615
+ const borderRightValue = resolveValue(borderRightMatch[1].trim());
616
+ style.borderRight = borderRightValue;
617
+ const colorInBorder = borderRightValue.match(/#([0-9a-fA-F]{3,6})/);
618
+ if (colorInBorder && !style.borderColor) {
619
+ style.borderColor = `#${colorInBorder[1]}`;
620
+ }
621
+ }
622
+ // Text-transform (uppercase, lowercase, capitalize for headings, labels, etc.)
623
+ const textTransformMatch = ruleContent.match(/text-transform\s*:\s*([^;]+)/i);
624
+ if (textTransformMatch) {
625
+ style.textTransform = resolveValue(textTransformMatch[1].trim());
626
+ }
627
+ // Text alignment (left, center, right, justify - for nested selectors like .body p { text-align: justify })
628
+ const textAlignMatch = ruleContent.match(/text-align\s*:\s*([^;]+)/i);
629
+ if (textAlignMatch) {
630
+ style.textAlign = resolveValue(textAlignMatch[1].trim());
631
+ }
632
+ // Font size (for nested selectors like .body p { font-size: 1.1rem })
633
+ const fontSizeMatch = ruleContent.match(/font-size\s*:\s*([^;]+)/i);
634
+ if (fontSizeMatch) {
635
+ style.fontSize = resolveValue(fontSizeMatch[1].trim());
636
+ }
637
+ // Margin-bottom (for paragraph spacing in nested selectors like .sender-info p { margin-bottom: 0.25rem })
638
+ const marginBottomMatch = ruleContent.match(/margin-bottom\s*:\s*([^;]+)/i);
639
+ if (marginBottomMatch) {
640
+ style.marginBottom = resolveValue(marginBottomMatch[1].trim());
641
+ }
642
+ // Line-height (for vertical spacing in nested selectors like .body p { line-height: 1.8 })
643
+ const lineHeightMatch = ruleContent.match(/line-height\s*:\s*([^;]+)/i);
644
+ if (lineHeightMatch) {
645
+ style.lineHeight = resolveValue(lineHeightMatch[1].trim());
646
+ }
647
+ // Letter-spacing (for character spacing in nested selectors like .header h1 { letter-spacing: 0.05em })
648
+ const letterSpacingMatch = ruleContent.match(/letter-spacing\s*:\s*([^;]+)/i);
649
+ if (letterSpacingMatch) {
650
+ style.letterSpacing = resolveValue(letterSpacingMatch[1].trim());
651
+ }
652
+ // Margin-top (for spacing before elements in nested selectors like .body h2 { margin-top: 1.5rem })
653
+ const marginTopMatch = ruleContent.match(/margin-top\s*:\s*([^;]+)/i);
654
+ if (marginTopMatch) {
655
+ style.marginTop = resolveValue(marginTopMatch[1].trim());
656
+ }
657
+ // Store in nestedStyles: parentClass -> (childSelector -> style)
658
+ // Use baseChildSelector (without pseudo-selectors) as the map key so that
659
+ // "tr:nth-child(even)" is stored under "tr" and can be found by table processing
660
+ if (Object.keys(style).length > 0) {
661
+ if (!nestedStyles.has(parentClass)) {
662
+ nestedStyles.set(parentClass, new Map());
663
+ }
664
+ const existing = nestedStyles.get(parentClass).get(baseChildSelector) || {};
665
+ nestedStyles.get(parentClass).set(baseChildSelector, { ...existing, ...style });
666
+ }
667
+ }
668
+ // Parse element type selectors (body, p, h1, h2, h3, h4, h5, h6, etc.)
669
+ // These are rules like: body { color: var(--color-text); }
670
+ // or grouped rules like: h1, h2, h3, h4, h5, h6 { color: var(--color-heading); }
671
+ // IMPORTANT: Only match STANDALONE element selectors, not selectors like ".class p" or "#id p"
672
+ // The (?:^|[\n\r}]) lookbehind ensures we're after a rule boundary (not part of a complex selector)
673
+ // We need to be careful to not match ".timeline-content p" as just "p"
674
+ const elementSelectorPattern = /(?:^|[\n\r}])(\s*(?:body|p|h[1-6]|span|div|ul|ol|li|table|th|td|blockquote|section|article|aside|nav|header|footer|figure|figcaption|address|abbr|a)(?:\s*,\s*(?:body|p|h[1-6]|span|div|ul|ol|li|table|th|td|blockquote|section|article|aside|nav|header|footer|figure|figcaption|address|abbr|a))*)\s*\{([^}]+)\}/gi;
675
+ const elementMatches = cssText.matchAll(elementSelectorPattern);
676
+ for (const match of elementMatches) {
677
+ const selectorList = match[1];
678
+ const ruleContent = match[2];
679
+ // Split comma-separated selectors
680
+ const selectors = selectorList.split(/\s*,\s*/).map(s => s.trim().toLowerCase());
681
+ // Extract style properties
682
+ const style = {};
683
+ // Text color
684
+ const colorMatch = ruleContent.match(/(?:^|;)\s*color\s*:\s*([^;]+)/i);
685
+ if (colorMatch) {
686
+ style.color = resolveValue(colorMatch[1].trim());
687
+ }
688
+ // Background color
689
+ const bgMatch = ruleContent.match(/background(?:-(?:color|image))?\s*:\s*([^;]+)/i);
690
+ if (bgMatch) {
691
+ const bgValue = resolveValue(bgMatch[1].trim());
692
+ // For gradient backgrounds, extract the first color as fallback for DOCX
693
+ // Also store the full gradient for containers that support gradient rendering
694
+ const gradientColor = extractFirstGradientColor(bgValue);
695
+ if (gradientColor) {
696
+ style.backgroundColor = gradientColor;
697
+ // Parse full gradient for container backgrounds
698
+ const gradient = parseGradient(bgValue);
699
+ if (gradient) {
700
+ style.backgroundGradient = gradient;
701
+ }
702
+ }
703
+ else {
704
+ style.backgroundColor = bgValue;
705
+ }
706
+ }
707
+ // Font size
708
+ const fontSizeMatch = ruleContent.match(/font-size\s*:\s*([^;]+)/i);
709
+ if (fontSizeMatch) {
710
+ style.fontSize = resolveValue(fontSizeMatch[1].trim());
711
+ }
712
+ // Font weight
713
+ const fontWeightMatch = ruleContent.match(/font-weight\s*:\s*([^;]+)/i);
714
+ if (fontWeightMatch) {
715
+ style.fontWeight = resolveValue(fontWeightMatch[1].trim());
716
+ }
717
+ // Padding (for table cells th, td)
718
+ const paddingMatch = ruleContent.match(/(?:^|;)\s*padding\s*:\s*([^;]+)/i);
719
+ if (paddingMatch) {
720
+ style.padding = resolveValue(paddingMatch[1].trim());
721
+ }
722
+ // Individual padding properties
723
+ const paddingLeftMatch = ruleContent.match(/padding-left\s*:\s*([^;]+)/i);
724
+ if (paddingLeftMatch) {
725
+ style.paddingLeft = resolveValue(paddingLeftMatch[1].trim());
726
+ }
727
+ const paddingRightMatch = ruleContent.match(/padding-right\s*:\s*([^;]+)/i);
728
+ if (paddingRightMatch) {
729
+ style.paddingRight = resolveValue(paddingRightMatch[1].trim());
730
+ }
731
+ const paddingTopMatch = ruleContent.match(/padding-top\s*:\s*([^;]+)/i);
732
+ if (paddingTopMatch) {
733
+ style.paddingTop = resolveValue(paddingTopMatch[1].trim());
734
+ }
735
+ const paddingBottomMatch = ruleContent.match(/padding-bottom\s*:\s*([^;]+)/i);
736
+ if (paddingBottomMatch) {
737
+ style.paddingBottom = resolveValue(paddingBottomMatch[1].trim());
738
+ }
739
+ // Font style (italic, normal) - for blockquotes
740
+ const fontStyleMatch = ruleContent.match(/font-style\s*:\s*([^;]+)/i);
741
+ if (fontStyleMatch) {
742
+ style.fontStyle = resolveValue(fontStyleMatch[1].trim());
743
+ }
744
+ // Font family (extract primary font from font stack) - for body, headings, etc.
745
+ const fontFamilyMatch = ruleContent.match(/font-family\s*:\s*([^;]+)/i);
746
+ if (fontFamilyMatch) {
747
+ const resolved = resolveValue(fontFamilyMatch[1].trim());
748
+ const primaryFont = extractPrimaryFont(resolved);
749
+ if (primaryFont) {
750
+ style.fontFamily = primaryFont;
751
+ }
752
+ }
753
+ // Text indent (for paragraph first-line indentation)
754
+ const textIndentMatch = ruleContent.match(/text-indent\s*:\s*([^;]+)/i);
755
+ if (textIndentMatch) {
756
+ style.textIndent = resolveValue(textIndentMatch[1].trim());
757
+ }
758
+ // Text transform (uppercase, lowercase, capitalize)
759
+ const textTransformMatch = ruleContent.match(/text-transform\s*:\s*([^;]+)/i);
760
+ if (textTransformMatch) {
761
+ style.textTransform = resolveValue(textTransformMatch[1].trim());
762
+ }
763
+ // GENERALIZED: Margin-bottom (for paragraph spacing)
764
+ const marginBottomMatch = ruleContent.match(/margin-bottom\s*:\s*([^;]+)/i);
765
+ if (marginBottomMatch) {
766
+ style.marginBottom = resolveValue(marginBottomMatch[1].trim());
767
+ }
768
+ // GENERALIZED: Line-height (for vertical spacing within text)
769
+ const lineHeightMatch = ruleContent.match(/line-height\s*:\s*([^;]+)/i);
770
+ if (lineHeightMatch) {
771
+ style.lineHeight = resolveValue(lineHeightMatch[1].trim());
772
+ }
773
+ // Letter-spacing (for character spacing on element selectors like h1, h2, p)
774
+ const letterSpacingMatch = ruleContent.match(/letter-spacing\s*:\s*([^;]+)/i);
775
+ if (letterSpacingMatch) {
776
+ style.letterSpacing = resolveValue(letterSpacingMatch[1].trim());
777
+ }
778
+ // Margin-top (for heading spacing before)
779
+ const marginTopMatch = ruleContent.match(/margin-top\s*:\s*([^;]+)/i);
780
+ if (marginTopMatch) {
781
+ style.marginTop = resolveValue(marginTopMatch[1].trim());
782
+ }
783
+ // Text alignment (left, center, right, justify)
784
+ const textAlignMatch = ruleContent.match(/text-align\s*:\s*([^;]+)/i);
785
+ if (textAlignMatch) {
786
+ style.textAlign = resolveValue(textAlignMatch[1].trim());
787
+ }
788
+ // Border shorthand (e.g., "1px solid #e5e7eb")
789
+ const borderMatch = ruleContent.match(/(?:^|;)\s*border\s*:\s*([^;]+)/i);
790
+ if (borderMatch) {
791
+ style.border = resolveValue(borderMatch[1].trim());
792
+ }
793
+ // Border-left (used by blockquotes/callouts: "4px solid #8b4513")
794
+ const borderLeftMatch = ruleContent.match(/border-left\s*:\s*([^;]+)/i);
795
+ if (borderLeftMatch) {
796
+ const borderLeftValue = resolveValue(borderLeftMatch[1].trim());
797
+ // If it contains a color, extract it as borderColor
798
+ const colorInBorder = borderLeftValue.match(/#([0-9a-fA-F]{3,6})/);
799
+ if (colorInBorder && !style.borderColor) {
800
+ style.borderColor = `#${colorInBorder[1]}`;
801
+ }
802
+ // Store the full border-left value
803
+ style.borderLeft = borderLeftValue;
804
+ }
805
+ // Border-right (used by sidebar dividers, etc.)
806
+ const borderRightMatch = ruleContent.match(/border-right\s*:\s*([^;]+)/i);
807
+ if (borderRightMatch) {
808
+ style.borderRight = resolveValue(borderRightMatch[1].trim());
809
+ }
810
+ // Border color (direct property)
811
+ const borderColorMatch = ruleContent.match(/border-color\s*:\s*([^;]+)/i);
812
+ if (borderColorMatch) {
813
+ style.borderColor = resolveValue(borderColorMatch[1].trim());
814
+ }
815
+ // GENERALIZED: Border-bottom (used by h2 underlines, title blocks, etc.)
816
+ // Any element can have border-bottom - extract from element selectors
817
+ const borderBottomMatch = ruleContent.match(/border-bottom\s*:\s*([^;]+)/i);
818
+ if (borderBottomMatch) {
819
+ style.borderBottom = resolveValue(borderBottomMatch[1].trim());
820
+ }
821
+ // GENERALIZED: Border-top (used by footer sections with top divider lines)
822
+ // Any element can have border-top - extract from element selectors
823
+ const borderTopMatch = ruleContent.match(/border-top\s*:\s*([^;]+)/i);
824
+ if (borderTopMatch) {
825
+ style.borderTop = resolveValue(borderTopMatch[1].trim());
826
+ }
827
+ // Apply style to each selector
828
+ if (Object.keys(style).length > 0) {
829
+ for (const selector of selectors) {
830
+ // Merge with existing styles (later rules override)
831
+ const existing = elementStyles.get(selector) || {};
832
+ elementStyles.set(selector, { ...existing, ...style });
833
+ }
834
+ }
835
+ }
836
+ // Parse element-to-element nested selectors like "thead th { ... }" or "tbody td { ... }"
837
+ // These are compound selectors with a parent element and a child element
838
+ // We store them in nestedStyles using "__elem:parentTag" as the key
839
+ // so they can be looked up by ancestor element type in table/heading parsing
840
+ const elementElements = ["thead", "tbody", "tfoot", "table", "tr", "ul", "ol", "nav", "header", "footer", "section", "article", "aside", "figure", "blockquote", "div"];
841
+ const childElements = ["th", "td", "tr", "li", "p", "h1", "h2", "h3", "h4", "h5", "h6", "span", "a", "img", "div"];
842
+ const parentList = elementElements.join("|");
843
+ const childList = childElements.join("|");
844
+ const elemElemPattern = new RegExp(`(?:^|[\\n\\r])\\s*((?:${parentList})(?:\\s*,\\s*(?:${parentList}))*)\\s+((?:${childList})(?:\\s*,\\s*(?:${childList}))*)\\s*\\{([^}]+)\\}`, "gi");
845
+ const elemElemMatches = cssText.matchAll(elemElemPattern);
846
+ for (const match of elemElemMatches) {
847
+ const parentSelectors = match[1].split(/\s*,\s*/).map(s => s.trim().toLowerCase());
848
+ const childSelectors = match[2].split(/\s*,\s*/).map(s => s.trim().toLowerCase());
849
+ const ruleContent = match[3];
850
+ // Extract style properties
851
+ const style = {};
852
+ const colorMatch = ruleContent.match(/(?:^|;)\s*color\s*:\s*([^;]+)/i);
853
+ if (colorMatch)
854
+ style.color = resolveValue(colorMatch[1].trim());
855
+ const bgMatch = ruleContent.match(/background(?:-(?:color|image))?\s*:\s*([^;]+)/i);
856
+ if (bgMatch) {
857
+ const bgValue = resolveValue(bgMatch[1].trim());
858
+ const gradientColor = extractFirstGradientColor(bgValue);
859
+ style.backgroundColor = gradientColor || bgValue;
860
+ }
861
+ const fontWeightMatch = ruleContent.match(/font-weight\s*:\s*([^;]+)/i);
862
+ if (fontWeightMatch)
863
+ style.fontWeight = resolveValue(fontWeightMatch[1].trim());
864
+ const fontSizeMatch = ruleContent.match(/font-size\s*:\s*([^;]+)/i);
865
+ if (fontSizeMatch)
866
+ style.fontSize = resolveValue(fontSizeMatch[1].trim());
867
+ const paddingMatch = ruleContent.match(/(?:^|;)\s*padding\s*:\s*([^;]+)/i);
868
+ if (paddingMatch)
869
+ style.padding = resolveValue(paddingMatch[1].trim());
870
+ const textAlignMatch = ruleContent.match(/text-align\s*:\s*([^;]+)/i);
871
+ if (textAlignMatch)
872
+ style.textAlign = resolveValue(textAlignMatch[1].trim());
873
+ const borderBottomMatch = ruleContent.match(/border-bottom\s*:\s*([^;]+)/i);
874
+ if (borderBottomMatch)
875
+ style.borderBottom = resolveValue(borderBottomMatch[1].trim());
876
+ if (Object.keys(style).length > 0) {
877
+ for (const parent of parentSelectors) {
878
+ // Store using "__elem:" prefix to distinguish from class-based nesting
879
+ const key = `__elem:${parent}`;
880
+ for (const child of childSelectors) {
881
+ const existing = nestedStyles.get(key) || new Map();
882
+ const existingChild = existing.get(child) || {};
883
+ existing.set(child, { ...existingChild, ...style });
884
+ nestedStyles.set(key, existing);
885
+ }
886
+ }
887
+ }
888
+ }
889
+ // Parse element-with-pseudo-selector nested patterns like:
890
+ // tbody tr:nth-child(even) { background: #f9fafb; }
891
+ // table tr:nth-child(even) { background-color: #f5f5f5; }
892
+ // .my-table tr:nth-child(even) { background: ... }
893
+ // These are stored as "__even-row" in elementStyles so table parsing can retrieve them
894
+ const evenRowPattern = /(?:^|[\n\r])\s*(?:(?:tbody|table|\.[\w-]+)\s+)?tr\s*:\s*nth-child\s*\(\s*even\s*\)\s*\{([^}]+)\}/gi;
895
+ const evenRowMatches = cssText.matchAll(evenRowPattern);
896
+ for (const match of evenRowMatches) {
897
+ const ruleContent = match[1];
898
+ const bgMatch = ruleContent.match(/background(?:-color)?\s*:\s*([^;]+)/i);
899
+ if (bgMatch) {
900
+ const bgValue = resolveValue(bgMatch[1].trim());
901
+ const hex = extractHexColor(bgValue);
902
+ if (hex) {
903
+ // Store as a special element style that table parsing can look up
904
+ elementStyles.set("__even-row", { backgroundColor: `#${hex}` });
905
+ }
906
+ }
907
+ }
908
+ }
909
+ return { variables, classColors, calloutStyles, classStyles, elementStyles, nestedStyles };
910
+ }
911
+ /**
912
+ * Get merged styles for an element by combining all its class styles.
913
+ * Later classes override earlier ones.
914
+ * Also checks element type selectors as a base layer.
915
+ *
916
+ * @param element The element to get styles for
917
+ * @param cssContext The CSS context
918
+ * @param parentElement Optional parent element for nested style lookups
919
+ */
920
+ export function getElementStyles(element, cssContext, parentElement) {
921
+ const result = {};
922
+ // CSS Inheritance: inherit certain properties from body styles
923
+ // In CSS, properties like line-height, color, font-family, font-size, letter-spacing
924
+ // are inherited from ancestor elements. Since our parser doesn't walk the full DOM tree
925
+ // for inheritance, we manually inherit from body styles as a baseline.
926
+ const bodyStyle = cssContext.elementStyles.get("body");
927
+ if (bodyStyle) {
928
+ // Only inherit CSS-inheritable properties, not box-model properties
929
+ if (bodyStyle.lineHeight)
930
+ result.lineHeight = bodyStyle.lineHeight;
931
+ if (bodyStyle.color)
932
+ result.color = bodyStyle.color;
933
+ if (bodyStyle.fontFamily)
934
+ result.fontFamily = bodyStyle.fontFamily;
935
+ if (bodyStyle.fontSize)
936
+ result.fontSize = bodyStyle.fontSize;
937
+ if (bodyStyle.letterSpacing)
938
+ result.letterSpacing = bodyStyle.letterSpacing;
939
+ }
940
+ // First, apply element type selector styles (lowest priority, overrides body inheritance)
941
+ const tagName = element.tagName.toLowerCase();
942
+ const elementTypeStyle = cssContext.elementStyles.get(tagName);
943
+ if (elementTypeStyle) {
944
+ Object.assign(result, elementTypeStyle);
945
+ }
946
+ // Then apply class styles (higher priority)
947
+ const classAttr = element.getAttribute("class");
948
+ const elementClasses = classAttr ? classAttr.split(/\s+/).filter(c => c.length > 0) : [];
949
+ for (const className of elementClasses) {
950
+ const classStyle = cssContext.classStyles.get(className);
951
+ if (classStyle) {
952
+ Object.assign(result, classStyle);
953
+ }
954
+ }
955
+ // Apply compound class styles (even higher specificity than single class)
956
+ // Check all compound keys in classStyles where the element has ALL required classes
957
+ if (elementClasses.length >= 2) {
958
+ for (const [key, compoundStyle] of cssContext.classStyles) {
959
+ if (key.includes('.')) {
960
+ const requiredClasses = key.split('.');
961
+ if (requiredClasses.every(c => elementClasses.includes(c))) {
962
+ Object.assign(result, compoundStyle);
963
+ }
964
+ }
965
+ }
966
+ }
967
+ // Apply nested styles if parent is provided (highest CSS priority for nested selectors)
968
+ // This handles rules like ".parent .child { color: ... }" and ".parent element { color: ... }"
969
+ if (parentElement) {
970
+ const parentClassAttr = parentElement.getAttribute("class");
971
+ const parentClasses = parentClassAttr ? parentClassAttr.split(/\s+/).filter(c => c.length > 0) : [];
972
+ // First, inherit text color from parent's class styles (CSS color inheritance)
973
+ // This handles cases like ".cta { color: white; }" where children should inherit the color
974
+ if (!result.color) {
975
+ for (const parentClass of parentClasses) {
976
+ const parentClassStyle = cssContext.classStyles.get(parentClass);
977
+ if (parentClassStyle?.color) {
978
+ result.color = parentClassStyle.color;
979
+ break;
980
+ }
981
+ }
982
+ }
983
+ // For each parent class, check if there are nested styles for our element's classes OR tag name
984
+ for (const parentClass of parentClasses) {
985
+ const nestedMap = cssContext.nestedStyles.get(parentClass);
986
+ if (nestedMap) {
987
+ // Check for element tag name (e.g., .works-cited p { ... })
988
+ const nestedTagStyle = nestedMap.get(tagName);
989
+ if (nestedTagStyle) {
990
+ Object.assign(result, nestedTagStyle);
991
+ }
992
+ // Check for element's classes (simple selectors)
993
+ for (const childClass of elementClasses) {
994
+ const nestedStyle = nestedMap.get(childClass);
995
+ if (nestedStyle) {
996
+ Object.assign(result, nestedStyle);
997
+ }
998
+ }
999
+ // Check for compound child selectors (e.g., .parent .classA.classB)
1000
+ if (elementClasses.length >= 2) {
1001
+ for (const [childKey, nestedStyle] of nestedMap) {
1002
+ if (childKey.includes('.')) {
1003
+ const requiredClasses = childKey.split('.');
1004
+ if (requiredClasses.every(c => elementClasses.includes(c))) {
1005
+ Object.assign(result, nestedStyle);
1006
+ }
1007
+ }
1008
+ }
1009
+ }
1010
+ }
1011
+ }
1012
+ }
1013
+ // Also walk up the DOM tree to find ancestor containers with nested styles
1014
+ // This handles cases where the parent element isn't passed explicitly
1015
+ // IMPORTANT: Closer ancestors have higher specificity than distant ancestors,
1016
+ // so we use a "fill-in" approach - only set properties not already set by closer ancestors
1017
+ if (!parentElement) {
1018
+ // Helper: merge only properties that aren't already set in result
1019
+ const fillIn = (source) => {
1020
+ for (const [key, value] of Object.entries(source)) {
1021
+ if (value !== undefined && result[key] === undefined) {
1022
+ result[key] = value;
1023
+ }
1024
+ }
1025
+ };
1026
+ // Track whether we've already applied a nested class→tag override.
1027
+ // The FIRST (closest) ancestor's class→tag style should override bare element-type
1028
+ // styles (e.g., `.comparison-table th` overrides `th`), because .class element
1029
+ // has higher CSS specificity (0,1,1) than element (0,0,1).
1030
+ // Subsequent (more distant) ancestors use fillIn to avoid overriding closer ones.
1031
+ let appliedNestedTagOverride = false;
1032
+ let ancestor = element.parentElement;
1033
+ while (ancestor) {
1034
+ const ancestorClassAttr = ancestor.getAttribute("class");
1035
+ const ancestorClasses = ancestorClassAttr ? ancestorClassAttr.split(/\s+/).filter(c => c.length > 0) : [];
1036
+ for (const ancestorClass of ancestorClasses) {
1037
+ const nestedMap = cssContext.nestedStyles.get(ancestorClass);
1038
+ if (nestedMap) {
1039
+ // Check for element tag name (e.g., .comparison-table th { ... })
1040
+ // CSS specificity: .class element (0,1,1) > element (0,0,1)
1041
+ // So nested class→tag styles OVERRIDE bare element-type styles.
1042
+ // Only the closest ancestor's class→tag style should override; further
1043
+ // ancestors fill in only properties not yet set.
1044
+ const nestedTagStyle = nestedMap.get(tagName);
1045
+ if (nestedTagStyle) {
1046
+ if (!appliedNestedTagOverride) {
1047
+ Object.assign(result, nestedTagStyle);
1048
+ appliedNestedTagOverride = true;
1049
+ }
1050
+ else {
1051
+ fillIn(nestedTagStyle);
1052
+ }
1053
+ }
1054
+ // Check for element's classes (simple selectors)
1055
+ for (const childClass of elementClasses) {
1056
+ const nestedStyle = nestedMap.get(childClass);
1057
+ if (nestedStyle) {
1058
+ fillIn(nestedStyle);
1059
+ }
1060
+ }
1061
+ // Check for compound child selectors (e.g., .ancestor .classA.classB)
1062
+ if (elementClasses.length >= 2) {
1063
+ for (const [childKey, nestedStyle] of nestedMap) {
1064
+ if (childKey.includes('.')) {
1065
+ const requiredClasses = childKey.split('.');
1066
+ if (requiredClasses.every(c => elementClasses.includes(c))) {
1067
+ fillIn(nestedStyle);
1068
+ }
1069
+ }
1070
+ }
1071
+ }
1072
+ }
1073
+ }
1074
+ // Check element-to-element nested styles (e.g., "thead th { ... }", "tbody td { ... }")
1075
+ // These are stored with "__elem:" prefix in nestedStyles
1076
+ const ancestorTagName2 = ancestor.tagName?.toLowerCase();
1077
+ if (ancestorTagName2) {
1078
+ const elemElemMap = cssContext.nestedStyles.get(`__elem:${ancestorTagName2}`);
1079
+ if (elemElemMap) {
1080
+ const nestedTagStyle = elemElemMap.get(tagName);
1081
+ if (nestedTagStyle) {
1082
+ fillIn(nestedTagStyle);
1083
+ }
1084
+ }
1085
+ }
1086
+ // CSS color inheritance: if result doesn't have a color yet, check if ancestor has one
1087
+ // CSS `color` is an inherited property - children inherit it from parents
1088
+ if (!result.color) {
1089
+ for (const ancestorClass of ancestorClasses) {
1090
+ const ancestorClassStyle = cssContext.classStyles.get(ancestorClass);
1091
+ if (ancestorClassStyle?.color) {
1092
+ result.color = ancestorClassStyle.color;
1093
+ break;
1094
+ }
1095
+ }
1096
+ // Also check ancestor's element type selector for color (e.g., body { color: #333 })
1097
+ if (!result.color) {
1098
+ const ancestorTagName = ancestor.tagName?.toLowerCase();
1099
+ if (ancestorTagName) {
1100
+ const ancestorTypeStyle = cssContext.elementStyles.get(ancestorTagName);
1101
+ if (ancestorTypeStyle?.color) {
1102
+ result.color = ancestorTypeStyle.color;
1103
+ }
1104
+ }
1105
+ }
1106
+ // Also check ancestor's inline style for color
1107
+ if (!result.color) {
1108
+ const ancestorInline = ancestor.getAttribute("style") || "";
1109
+ const ancestorColorMatch = ancestorInline.match(/(?:^|;)\s*color\s*:\s*([^;]+)/i);
1110
+ if (ancestorColorMatch) {
1111
+ result.color = ancestorColorMatch[1].trim();
1112
+ }
1113
+ }
1114
+ }
1115
+ ancestor = ancestor.parentElement;
1116
+ }
1117
+ }
1118
+ // Inherit font-family from ancestor elements if not set on this element
1119
+ // CSS font-family is an inherited property, so we need to walk up the DOM tree
1120
+ if (!result.fontFamily) {
1121
+ let ancestor = element.parentElement;
1122
+ while (ancestor && !result.fontFamily) {
1123
+ const ancestorTagName = ancestor.tagName?.toLowerCase();
1124
+ if (ancestorTagName) {
1125
+ // Check element type selector for ancestor (e.g., body { font-family: ... })
1126
+ const ancestorTypeStyle = cssContext.elementStyles.get(ancestorTagName);
1127
+ if (ancestorTypeStyle?.fontFamily) {
1128
+ result.fontFamily = ancestorTypeStyle.fontFamily;
1129
+ break;
1130
+ }
1131
+ // Check ancestor's class styles
1132
+ const ancestorClassAttr = ancestor.getAttribute("class");
1133
+ const ancestorClasses = ancestorClassAttr ? ancestorClassAttr.split(/\s+/).filter(c => c.length > 0) : [];
1134
+ for (const ancestorClass of ancestorClasses) {
1135
+ const classStyle = cssContext.classStyles.get(ancestorClass);
1136
+ if (classStyle?.fontFamily) {
1137
+ result.fontFamily = classStyle.fontFamily;
1138
+ break;
1139
+ }
1140
+ }
1141
+ }
1142
+ ancestor = ancestor.parentElement;
1143
+ }
1144
+ }
1145
+ // GENERALIZED: Inherit line-height from ancestor elements if not set on this element
1146
+ // CSS line-height is an inherited property (like font-family and color)
1147
+ if (!result.lineHeight) {
1148
+ let ancestor = element.parentElement;
1149
+ while (ancestor && !result.lineHeight) {
1150
+ const ancestorTagName = ancestor.tagName?.toLowerCase();
1151
+ if (ancestorTagName) {
1152
+ const ancestorTypeStyle = cssContext.elementStyles.get(ancestorTagName);
1153
+ if (ancestorTypeStyle?.lineHeight) {
1154
+ result.lineHeight = ancestorTypeStyle.lineHeight;
1155
+ break;
1156
+ }
1157
+ const ancestorClassAttr = ancestor.getAttribute("class");
1158
+ const ancestorClasses = ancestorClassAttr ? ancestorClassAttr.split(/\s+/).filter(c => c.length > 0) : [];
1159
+ for (const ancestorClass of ancestorClasses) {
1160
+ const classStyle = cssContext.classStyles.get(ancestorClass);
1161
+ if (classStyle?.lineHeight) {
1162
+ result.lineHeight = classStyle.lineHeight;
1163
+ break;
1164
+ }
1165
+ }
1166
+ }
1167
+ ancestor = ancestor.parentElement;
1168
+ }
1169
+ }
1170
+ // Note: text-align inheritance is handled by the caller (processNode's inheritedAlignment,
1171
+ // or parseBlockquoteContent's explicit blockquoteStyles.textAlign fallback).
1172
+ // We don't inherit text-align in getElementStyles to avoid double-inheritance in the main path.
1173
+ // Also check inline styles, which override CSS classes
1174
+ const inlineStyle = element.getAttribute("style") || "";
1175
+ if (inlineStyle) {
1176
+ const bgMatch = inlineStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
1177
+ if (bgMatch)
1178
+ result.backgroundColor = bgMatch[1].trim();
1179
+ const colorMatch = inlineStyle.match(/(?:^|;)\s*color\s*:\s*([^;]+)/i);
1180
+ if (colorMatch)
1181
+ result.color = colorMatch[1].trim();
1182
+ const borderMatch = inlineStyle.match(/(?:^|;)\s*border\s*:\s*([^;]+)/i);
1183
+ if (borderMatch)
1184
+ result.border = borderMatch[1].trim();
1185
+ const borderColorMatch = inlineStyle.match(/border-color\s*:\s*([^;]+)/i);
1186
+ if (borderColorMatch)
1187
+ result.borderColor = borderColorMatch[1].trim();
1188
+ const borderLeftMatch = inlineStyle.match(/border-left\s*:\s*([^;]+)/i);
1189
+ if (borderLeftMatch)
1190
+ result.borderLeft = borderLeftMatch[1].trim();
1191
+ const borderBottomMatch = inlineStyle.match(/border-bottom\s*:\s*([^;]+)/i);
1192
+ if (borderBottomMatch)
1193
+ result.borderBottom = borderBottomMatch[1].trim();
1194
+ const displayMatch = inlineStyle.match(/display\s*:\s*([^;]+)/i);
1195
+ if (displayMatch)
1196
+ result.display = displayMatch[1].trim();
1197
+ const flexMatch = inlineStyle.match(/(?:^|;)\s*flex\s*:\s*([^;]+)/i);
1198
+ if (flexMatch)
1199
+ result.flex = flexMatch[1].trim();
1200
+ const gridColsMatch = inlineStyle.match(/grid-template-columns\s*:\s*([^;]+)/i);
1201
+ if (gridColsMatch)
1202
+ result.gridTemplateColumns = gridColsMatch[1].trim();
1203
+ const textAlignMatch = inlineStyle.match(/text-align\s*:\s*([^;]+)/i);
1204
+ if (textAlignMatch)
1205
+ result.textAlign = textAlignMatch[1].trim();
1206
+ // Inline font-family (highest priority)
1207
+ const fontFamilyMatch = inlineStyle.match(/font-family\s*:\s*([^;]+)/i);
1208
+ if (fontFamilyMatch) {
1209
+ const primaryFont = extractPrimaryFont(fontFamilyMatch[1].trim());
1210
+ if (primaryFont)
1211
+ result.fontFamily = primaryFont;
1212
+ }
1213
+ // Inline line-height
1214
+ const lineHeightMatch = inlineStyle.match(/line-height\s*:\s*([^;]+)/i);
1215
+ if (lineHeightMatch) {
1216
+ result.lineHeight = lineHeightMatch[1].trim();
1217
+ }
1218
+ // Inline margin-bottom
1219
+ const marginBottomInlineMatch = inlineStyle.match(/margin-bottom\s*:\s*([^;]+)/i);
1220
+ if (marginBottomInlineMatch) {
1221
+ result.marginBottom = marginBottomInlineMatch[1].trim();
1222
+ }
1223
+ // Inline gradient text detection: background-clip: text + gradient background
1224
+ // This handles inline styles like:
1225
+ // style="background: linear-gradient(90deg, #ff0, #f00); -webkit-background-clip: text; color: transparent;"
1226
+ const hasInlineBackgroundClipText = inlineStyle.match(/(?:-webkit-)?background-clip\s*:\s*text/i);
1227
+ if (hasInlineBackgroundClipText) {
1228
+ const bgInlineMatch = inlineStyle.match(/background(?:-image)?\s*:\s*(linear-gradient\s*\([^)]+(?:\([^)]*\))*[^)]*\))/i);
1229
+ if (bgInlineMatch) {
1230
+ const gradient = parseGradient(bgInlineMatch[1]);
1231
+ if (gradient) {
1232
+ result.gradient = gradient;
1233
+ // Clear backgroundColor since the gradient is for text fill, NOT paragraph background
1234
+ result.backgroundColor = undefined;
1235
+ // Set fallback color from first stop
1236
+ if (!result.color || result.color === "transparent") {
1237
+ result.color = gradient.stops[0]?.color;
1238
+ }
1239
+ }
1240
+ }
1241
+ }
1242
+ // Inline font-style (italic)
1243
+ const fontStyleMatch = inlineStyle.match(/font-style\s*:\s*([^;]+)/i);
1244
+ if (fontStyleMatch)
1245
+ result.fontStyle = fontStyleMatch[1].trim();
1246
+ // Inline font-weight (bold)
1247
+ const fontWeightMatch = inlineStyle.match(/font-weight\s*:\s*([^;]+)/i);
1248
+ if (fontWeightMatch)
1249
+ result.fontWeight = fontWeightMatch[1].trim();
1250
+ // Inline text-transform
1251
+ const textTransformMatch = inlineStyle.match(/text-transform\s*:\s*([^;]+)/i);
1252
+ if (textTransformMatch)
1253
+ result.textTransform = textTransformMatch[1].trim();
1254
+ }
1255
+ // GENERALIZED: Use getComputedStyle as fallback for font-family and line-height
1256
+ // This ensures we get the ACTUAL computed values from Playwright's browser context,
1257
+ // including all CSS variable resolution and inheritance
1258
+ if (typeof window !== "undefined" && window.getComputedStyle) {
1259
+ try {
1260
+ const computed = window.getComputedStyle(element);
1261
+ // Font-family: Use computed style if not already set from CSS parsing
1262
+ if (!result.fontFamily && computed.fontFamily) {
1263
+ const primaryFont = extractPrimaryFont(computed.fontFamily);
1264
+ if (primaryFont) {
1265
+ result.fontFamily = primaryFont;
1266
+ }
1267
+ }
1268
+ // Line-height: Use computed style if not already set from CSS parsing
1269
+ if (!result.lineHeight && computed.lineHeight) {
1270
+ // Convert computed lineHeight (e.g., "27.2px") to a ratio or keep as-is
1271
+ result.lineHeight = computed.lineHeight;
1272
+ }
1273
+ }
1274
+ catch {
1275
+ // getComputedStyle may fail in some environments
1276
+ }
1277
+ }
1278
+ // Resolve CSS variables in all collected style values
1279
+ // This handles inline styles like "background-color: var(--color-bg)"
1280
+ // and CSS class styles that reference variables
1281
+ const resolveVar = (val) => {
1282
+ if (!val || !val.includes("var("))
1283
+ return val;
1284
+ const varMatch = val.match(/var\s*\(\s*(--[a-zA-Z0-9-]+)\s*(?:,\s*([^)]+))?\s*\)/);
1285
+ if (varMatch) {
1286
+ const resolved = cssContext.variables.get(varMatch[1]);
1287
+ if (resolved)
1288
+ return resolved;
1289
+ // Use fallback value if provided
1290
+ if (varMatch[2])
1291
+ return varMatch[2].trim();
1292
+ }
1293
+ return val;
1294
+ };
1295
+ result.backgroundColor = resolveVar(result.backgroundColor);
1296
+ result.color = resolveVar(result.color);
1297
+ result.borderColor = resolveVar(result.borderColor);
1298
+ result.border = resolveVar(result.border);
1299
+ result.borderLeft = resolveVar(result.borderLeft);
1300
+ result.borderBottom = resolveVar(result.borderBottom);
1301
+ result.borderTop = resolveVar(result.borderTop);
1302
+ // When a gradient text fill is present (background-clip: text), the gradient's first stop
1303
+ // color should be used as the text color fallback. This overrides any inherited or
1304
+ // element-level color (e.g. h1 { color: #1a1a1a }) because the gradient IS the intended
1305
+ // text color — the regular CSS color would only be visible without -webkit-background-clip: text.
1306
+ if (result.gradient && result.gradient.stops.length > 0) {
1307
+ result.color = result.gradient.stops[0].color;
1308
+ }
1309
+ return result;
1310
+ }
1311
+ /**
1312
+ * Get color for an element by checking its classes against parsed CSS rules.
1313
+ */
1314
+ export function getColorFromClasses(element, cssContext) {
1315
+ const classAttr = element.getAttribute("class");
1316
+ if (!classAttr)
1317
+ return undefined;
1318
+ const classes = classAttr.split(/\s+/).filter(c => c.length > 0);
1319
+ // First, check compound selectors (higher specificity)
1320
+ // Look for compound keys in classColors where the element has ALL required classes
1321
+ if (classes.length >= 2) {
1322
+ // Check all compound keys in classColors
1323
+ for (const [key, color] of cssContext.classColors) {
1324
+ if (key.includes('.')) {
1325
+ // This is a compound key (e.g., "metric-change.positive")
1326
+ const requiredClasses = key.split('.');
1327
+ if (requiredClasses.every(c => classes.includes(c))) {
1328
+ return extractHexColor(color);
1329
+ }
1330
+ }
1331
+ }
1332
+ }
1333
+ // Then check simple class selectors
1334
+ for (const className of classes) {
1335
+ const color = cssContext.classColors.get(className);
1336
+ if (color) {
1337
+ return extractHexColor(color);
1338
+ }
1339
+ }
1340
+ return undefined;
1341
+ }
1342
+ /**
1343
+ * Extract the text color from an element's inline style, CSS class rules, or computed style.
1344
+ * Checks inline style first, then CSS class rules, then element type rules, then computed style if available.
1345
+ * Returns the actual color from the HTML - does NOT filter out any colors.
1346
+ * Per the skill rules: ALL styling values MUST be extracted from HTML, never filtered.
1347
+ */
1348
+ export function extractTextColor(element, cssContext) {
1349
+ // Check inline style first (highest priority)
1350
+ const inlineStyle = element.getAttribute("style") || "";
1351
+ const colorMatch = inlineStyle.match(/(?:^|;)\s*color:\s*([^;]+)/i);
1352
+ if (colorMatch) {
1353
+ let colorValue = colorMatch[1].trim();
1354
+ // Resolve CSS variables in inline styles (e.g., var(--color-muted))
1355
+ if (cssContext && colorValue.includes("var(")) {
1356
+ const varMatch = colorValue.match(/var\s*\(\s*(--[a-zA-Z0-9-]+)\s*\)/);
1357
+ if (varMatch) {
1358
+ const resolvedValue = cssContext.variables.get(varMatch[1]);
1359
+ if (resolvedValue) {
1360
+ colorValue = resolvedValue;
1361
+ }
1362
+ }
1363
+ }
1364
+ const color = extractHexColor(colorValue);
1365
+ if (color) {
1366
+ return color;
1367
+ }
1368
+ }
1369
+ // Check CSS rules using getElementStyles which handles the full cascade:
1370
+ // element type < class < compound class < nested selectors < inline styles
1371
+ if (cssContext) {
1372
+ const elementStyles = getElementStyles(element, cssContext);
1373
+ if (elementStyles.color) {
1374
+ const color = extractHexColor(elementStyles.color);
1375
+ if (color) {
1376
+ return color;
1377
+ }
1378
+ }
1379
+ // Fallback to classColors map for colors not captured by getElementStyles
1380
+ const classColor = getColorFromClasses(element, cssContext);
1381
+ if (classColor) {
1382
+ return classColor;
1383
+ }
1384
+ }
1385
+ // Try computed style if available (browser environment)
1386
+ if (typeof window !== "undefined" && window.getComputedStyle) {
1387
+ try {
1388
+ const computed = window.getComputedStyle(element);
1389
+ const computedColor = computed.color;
1390
+ if (computedColor) {
1391
+ const color = extractHexColor(computedColor);
1392
+ if (color) {
1393
+ return color;
1394
+ }
1395
+ }
1396
+ }
1397
+ catch {
1398
+ // getComputedStyle may fail in some environments
1399
+ }
1400
+ }
1401
+ return undefined;
1402
+ }
1403
+ /**
1404
+ * Extract border color from a border shorthand or border-color property.
1405
+ * Also handles borderLeft, borderRight, borderTop, borderBottom.
1406
+ */
1407
+ export function extractBorderColorFromStyle(style) {
1408
+ if (style.borderColor) {
1409
+ return extractHexColor(style.borderColor);
1410
+ }
1411
+ // Check border shorthand
1412
+ if (style.border) {
1413
+ // Parse "1px solid #e5e7eb" or "1px solid rgb(184, 218, 255)" or "1px solid blue"
1414
+ // Try hex color first (most common)
1415
+ const hexMatch = style.border.match(/#([0-9a-fA-F]{3,6})/);
1416
+ if (hexMatch) {
1417
+ let hex = hexMatch[1];
1418
+ if (hex.length === 3) {
1419
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
1420
+ }
1421
+ return hex.toUpperCase();
1422
+ }
1423
+ // Try rgb/rgba color
1424
+ const rgbMatch = style.border.match(/(rgba?\s*\([^)]+\))/i);
1425
+ if (rgbMatch) {
1426
+ return extractHexColor(rgbMatch[1]);
1427
+ }
1428
+ // Try named color (last word in the shorthand, after "solid" or "dashed" etc.)
1429
+ const parts = style.border.trim().split(/\s+/);
1430
+ const lastPart = parts[parts.length - 1];
1431
+ if (lastPart && !lastPart.match(/^\d/) && !["solid", "dashed", "dotted", "double", "none", "hidden", "groove", "ridge", "inset", "outset"].includes(lastPart)) {
1432
+ return extractHexColor(lastPart);
1433
+ }
1434
+ }
1435
+ // Check individual border properties (borderLeft, borderRight, borderTop, borderBottom)
1436
+ const borderProps = [style.borderLeft, style.borderRight, style.borderTop, style.borderBottom];
1437
+ for (const borderValue of borderProps) {
1438
+ if (borderValue) {
1439
+ // Parse "4px solid #7c3aed" or similar — try hex first, then rgb, then named
1440
+ const hexMatch = borderValue.match(/#([0-9a-fA-F]{3,6})/);
1441
+ if (hexMatch) {
1442
+ let hex = hexMatch[1];
1443
+ if (hex.length === 3) {
1444
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
1445
+ }
1446
+ return hex.toUpperCase();
1447
+ }
1448
+ const rgbMatch = borderValue.match(/(rgba?\s*\([^)]+\))/i);
1449
+ if (rgbMatch) {
1450
+ return extractHexColor(rgbMatch[1]);
1451
+ }
1452
+ }
1453
+ }
1454
+ return undefined;
1455
+ }
1456
+ /**
1457
+ * Check if an element has a border-bottom style that should become a horizontal rule.
1458
+ * Returns the border color if found, undefined otherwise.
1459
+ */
1460
+ export function extractBorderBottomColor(element, cssContext) {
1461
+ // Check inline style for border-bottom
1462
+ const inlineStyle = element.getAttribute("style") || "";
1463
+ const borderMatch = inlineStyle.match(/border-bottom:\s*([^;]+)/i);
1464
+ if (borderMatch) {
1465
+ const borderValue = borderMatch[1];
1466
+ // Parse "1px solid #e5e7eb" or similar
1467
+ const colorMatch = borderValue.match(/#([0-9a-fA-F]{3,6})/);
1468
+ if (colorMatch) {
1469
+ let hex = colorMatch[1];
1470
+ if (hex.length === 3) {
1471
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
1472
+ }
1473
+ return hex.toUpperCase();
1474
+ }
1475
+ // Check for CSS variable in border
1476
+ const varMatch = borderValue.match(/var\s*\(\s*(--[a-zA-Z0-9-]+)\s*\)/);
1477
+ if (varMatch) {
1478
+ const varValue = cssContext.variables.get(varMatch[1]);
1479
+ if (varValue) {
1480
+ const hex = extractHexColor(varValue);
1481
+ if (hex)
1482
+ return hex;
1483
+ }
1484
+ }
1485
+ }
1486
+ // Check CSS styles for border-bottom (generalized approach)
1487
+ const styles = getElementStyles(element, cssContext);
1488
+ // Check border-bottom first (most specific for horizontal rules)
1489
+ if (styles.borderBottom) {
1490
+ const borderValue = styles.borderBottom;
1491
+ const colorMatch = borderValue.match(/#([0-9a-fA-F]{3,6})/);
1492
+ if (colorMatch) {
1493
+ let hex = colorMatch[1];
1494
+ if (hex.length === 3) {
1495
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
1496
+ }
1497
+ return hex.toUpperCase();
1498
+ }
1499
+ // Check for CSS variable reference in border-bottom
1500
+ const varMatch = borderValue.match(/var\s*\(\s*(--[a-zA-Z0-9-]+)\s*\)/);
1501
+ if (varMatch) {
1502
+ const varValue = cssContext.variables.get(varMatch[1]);
1503
+ if (varValue) {
1504
+ const hex = extractHexColor(varValue);
1505
+ if (hex)
1506
+ return hex;
1507
+ }
1508
+ }
1509
+ }
1510
+ // Check if this element has a border-bottom defined in CSS
1511
+ // The border property might be a shorthand like "1px solid #e5e7eb"
1512
+ if (styles.border) {
1513
+ const borderValue = styles.border;
1514
+ const colorMatch = borderValue.match(/#([0-9a-fA-F]{3,6})/);
1515
+ if (colorMatch) {
1516
+ let hex = colorMatch[1];
1517
+ if (hex.length === 3) {
1518
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
1519
+ }
1520
+ return hex.toUpperCase();
1521
+ }
1522
+ // Check for CSS variable reference
1523
+ const varMatch = borderValue.match(/var\s*\(\s*(--[a-zA-Z0-9-]+)\s*\)/);
1524
+ if (varMatch) {
1525
+ const varValue = cssContext.variables.get(varMatch[1]);
1526
+ if (varValue) {
1527
+ const hex = extractHexColor(varValue);
1528
+ if (hex)
1529
+ return hex;
1530
+ }
1531
+ }
1532
+ }
1533
+ // Also check borderColor directly
1534
+ if (styles.borderColor) {
1535
+ const hex = extractHexColor(styles.borderColor);
1536
+ if (hex)
1537
+ return hex;
1538
+ }
1539
+ return undefined;
1540
+ }
1541
+ /**
1542
+ * Extract color from a border-top CSS property on an element.
1543
+ * Checks inline styles and CSS classes, and resolves CSS variables.
1544
+ */
1545
+ export function extractBorderTopColor(element, cssContext) {
1546
+ // First, check inline style for border-top (highest specificity)
1547
+ const inlineStyle = element.getAttribute("style") || "";
1548
+ const borderMatch = inlineStyle.match(/border-top:\s*([^;]+)/i);
1549
+ if (borderMatch) {
1550
+ const borderValue = borderMatch[1];
1551
+ // Parse "1px solid #e5e7eb" or similar
1552
+ const colorMatch = borderValue.match(/#([0-9a-fA-F]{3,6})/);
1553
+ if (colorMatch) {
1554
+ let hex = colorMatch[1];
1555
+ if (hex.length === 3) {
1556
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
1557
+ }
1558
+ return hex.toUpperCase();
1559
+ }
1560
+ // Check for CSS variable in border
1561
+ const varMatch = borderValue.match(/var\s*\(\s*(--[a-zA-Z0-9-]+)\s*\)/);
1562
+ if (varMatch) {
1563
+ const varValue = cssContext.variables.get(varMatch[1]);
1564
+ if (varValue) {
1565
+ const hex = extractHexColor(varValue);
1566
+ if (hex)
1567
+ return hex;
1568
+ }
1569
+ }
1570
+ }
1571
+ // Then, check CSS class styles (e.g., .menu-footer { border-top: 2px solid #b8860b; })
1572
+ const styles = getElementStyles(element, cssContext);
1573
+ if (styles.borderTop) {
1574
+ const borderValue = styles.borderTop;
1575
+ // Parse "2px solid #b8860b" or similar
1576
+ const colorMatch = borderValue.match(/#([0-9a-fA-F]{3,6})/);
1577
+ if (colorMatch) {
1578
+ let hex = colorMatch[1];
1579
+ if (hex.length === 3) {
1580
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
1581
+ }
1582
+ return hex.toUpperCase();
1583
+ }
1584
+ // Check for CSS variable in border that was already resolved
1585
+ // (resolveValue in parseCssContext should have resolved it)
1586
+ const resolved = extractHexColor(borderValue);
1587
+ if (resolved)
1588
+ return resolved;
1589
+ }
1590
+ return undefined;
1591
+ }
1592
+ //# sourceMappingURL=parse-css.js.map