render-tag 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -6
- package/lib/css-resolver.d.ts +10 -0
- package/lib/css-resolver.d.ts.map +1 -0
- package/lib/css-resolver.js +1231 -0
- package/lib/css-resolver.js.map +1 -0
- package/lib/index.d.ts +2 -6
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +5 -29
- package/lib/index.js.map +1 -1
- package/lib/layout.d.ts +5 -5
- package/lib/layout.d.ts.map +1 -1
- package/lib/layout.js +364 -292
- package/lib/layout.js.map +1 -1
- package/lib/render-tag.umd.js +10 -0
- package/lib/render-tag.umd.js.map +1 -0
- package/lib/render.d.ts.map +1 -1
- package/lib/render.js +32 -67
- package/lib/render.js.map +1 -1
- package/lib/style-resolver.d.ts.map +1 -1
- package/lib/style-resolver.js +22 -3
- package/lib/style-resolver.js.map +1 -1
- package/lib/types.d.ts +9 -19
- package/lib/types.d.ts.map +1 -1
- package/package.json +10 -3
|
@@ -0,0 +1,1231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a simple CSS string into rules.
|
|
3
|
+
* Supports: tag, .class, parent > child, comma-separated selectors.
|
|
4
|
+
* Extracts @font-face rules separately for injection into the document.
|
|
5
|
+
*/
|
|
6
|
+
function parseCSS(css) {
|
|
7
|
+
const rules = [];
|
|
8
|
+
const fontFaceRules = [];
|
|
9
|
+
// Remove comments
|
|
10
|
+
css = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
11
|
+
let i = 0;
|
|
12
|
+
while (i < css.length) {
|
|
13
|
+
// Skip whitespace
|
|
14
|
+
while (i < css.length && /\s/.test(css[i]))
|
|
15
|
+
i++;
|
|
16
|
+
if (i >= css.length)
|
|
17
|
+
break;
|
|
18
|
+
// Handle at-rules (@font-face, @media, etc.)
|
|
19
|
+
if (css[i] === '@') {
|
|
20
|
+
const atStart = i;
|
|
21
|
+
let braceDepth = 0;
|
|
22
|
+
while (i < css.length) {
|
|
23
|
+
if (css[i] === '{')
|
|
24
|
+
braceDepth++;
|
|
25
|
+
if (css[i] === '}') {
|
|
26
|
+
braceDepth--;
|
|
27
|
+
if (braceDepth <= 0) {
|
|
28
|
+
i++;
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
i++;
|
|
33
|
+
}
|
|
34
|
+
// Capture @font-face rules for injection
|
|
35
|
+
const atRule = css.slice(atStart, i);
|
|
36
|
+
if (atRule.startsWith('@font-face')) {
|
|
37
|
+
fontFaceRules.push(atRule);
|
|
38
|
+
}
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
// Read selector(s) up to '{'
|
|
42
|
+
const selectorStart = i;
|
|
43
|
+
while (i < css.length && css[i] !== '{')
|
|
44
|
+
i++;
|
|
45
|
+
if (i >= css.length)
|
|
46
|
+
break;
|
|
47
|
+
const selectorStr = css.slice(selectorStart, i).trim();
|
|
48
|
+
i++; // skip '{'
|
|
49
|
+
// Read declarations up to '}'
|
|
50
|
+
const declStart = i;
|
|
51
|
+
while (i < css.length && css[i] !== '}')
|
|
52
|
+
i++;
|
|
53
|
+
const declStr = css.slice(declStart, i).trim();
|
|
54
|
+
i++; // skip '}'
|
|
55
|
+
if (!selectorStr)
|
|
56
|
+
continue;
|
|
57
|
+
// Parse selectors (comma-separated)
|
|
58
|
+
const selectors = selectorStr.split(',').map(s => s.trim()).filter(Boolean);
|
|
59
|
+
// Parse declarations
|
|
60
|
+
const declarations = [];
|
|
61
|
+
for (const decl of declStr.split(';')) {
|
|
62
|
+
const colonIdx = decl.indexOf(':');
|
|
63
|
+
if (colonIdx === -1)
|
|
64
|
+
continue;
|
|
65
|
+
const property = decl.slice(0, colonIdx).trim().toLowerCase();
|
|
66
|
+
const value = decl.slice(colonIdx + 1).trim();
|
|
67
|
+
if (property && value) {
|
|
68
|
+
declarations.push({ property, value });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (selectors.length > 0 && declarations.length > 0) {
|
|
72
|
+
rules.push({ selectors, declarations });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { rules, fontFaceRules };
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Compute specificity for a simple selector.
|
|
79
|
+
* Returns [ids, classes, tags] tuple.
|
|
80
|
+
*/
|
|
81
|
+
function selectorSpecificity(selector) {
|
|
82
|
+
// Remove pseudo-elements for specificity calculation
|
|
83
|
+
const sel = selector.replace(/::[\w-]+/g, '');
|
|
84
|
+
const parts = sel.split(/\s*>\s*|\s+/);
|
|
85
|
+
let ids = 0, classes = 0, tags = 0;
|
|
86
|
+
for (const part of parts) {
|
|
87
|
+
// Count #id
|
|
88
|
+
const idMatches = part.match(/#[\w-]+/g);
|
|
89
|
+
if (idMatches)
|
|
90
|
+
ids += idMatches.length;
|
|
91
|
+
// Count .class
|
|
92
|
+
const classMatches = part.match(/\.[\w-]+/g);
|
|
93
|
+
if (classMatches)
|
|
94
|
+
classes += classMatches.length;
|
|
95
|
+
// Count tag (strip classes/ids/pseudo)
|
|
96
|
+
const tagPart = part.replace(/[#.][\w-]+/g, '').replace(/:[\w-]+/g, '').trim();
|
|
97
|
+
if (tagPart && tagPart !== '*')
|
|
98
|
+
tags++;
|
|
99
|
+
}
|
|
100
|
+
return [ids, classes, tags];
|
|
101
|
+
}
|
|
102
|
+
function parsePart(part) {
|
|
103
|
+
const classMatches = part.match(/\.[\w-]+/g) || [];
|
|
104
|
+
const tag = part.replace(/\.[\w-]+/g, '').replace(/:[\w-]+/g, '').trim();
|
|
105
|
+
return {
|
|
106
|
+
tag: (tag && tag !== '*') ? tag : '',
|
|
107
|
+
classes: classMatches.map(c => c.slice(1)),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Check if a parsed selector part matches an element context.
|
|
112
|
+
*/
|
|
113
|
+
function matchesPart(part, ctx) {
|
|
114
|
+
const classMatches = part.match(/\.[\w-]+/g) || [];
|
|
115
|
+
const tag = part.replace(/\.[\w-]+/g, '').replace(/:[\w-]+/g, '').trim();
|
|
116
|
+
if (tag && tag !== '*' && tag !== ctx.tagName)
|
|
117
|
+
return false;
|
|
118
|
+
for (const cls of classMatches) {
|
|
119
|
+
if (!ctx.classes.has(cls.slice(1)))
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
function matchesParsedPart(part, ctx) {
|
|
125
|
+
if (part.tag && part.tag !== ctx.tagName)
|
|
126
|
+
return false;
|
|
127
|
+
for (const cls of part.classes) {
|
|
128
|
+
if (!ctx.classes.has(cls))
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Pre-parse and tokenize a selector string into a ParsedSelector.
|
|
135
|
+
* Returns null for selectors we can't handle (pseudo-elements, pseudo-classes).
|
|
136
|
+
*/
|
|
137
|
+
function parseSelector(selector) {
|
|
138
|
+
if (selector.includes('::'))
|
|
139
|
+
return null;
|
|
140
|
+
if (/:(?:nth-|hover|focus|active|visited|first-child|last-child)/.test(selector))
|
|
141
|
+
return null;
|
|
142
|
+
const tokens = [];
|
|
143
|
+
const combinators = [];
|
|
144
|
+
const raw = selector.trim().split(/\s+/);
|
|
145
|
+
for (let i = 0; i < raw.length; i++) {
|
|
146
|
+
if (raw[i] === '>') {
|
|
147
|
+
combinators.push('>');
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
if (tokens.length > combinators.length + 1) {
|
|
151
|
+
combinators.push(' ');
|
|
152
|
+
}
|
|
153
|
+
tokens.push(raw[i]);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
while (combinators.length < tokens.length - 1) {
|
|
157
|
+
combinators.push(' ');
|
|
158
|
+
}
|
|
159
|
+
if (tokens.length === 0)
|
|
160
|
+
return null;
|
|
161
|
+
const parts = tokens.map(parsePart);
|
|
162
|
+
const rightmost = parts[parts.length - 1];
|
|
163
|
+
const rightmostTag = rightmost.tag;
|
|
164
|
+
return {
|
|
165
|
+
parts,
|
|
166
|
+
combinators,
|
|
167
|
+
rightmost,
|
|
168
|
+
rightmostIsRoot: rightmostTag === 'html' || rightmostTag === 'body',
|
|
169
|
+
spec: selectorSpecificity(selector),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Match a pre-parsed selector against an element context.
|
|
174
|
+
*/
|
|
175
|
+
function matchesParsedSelector(sel, ctx) {
|
|
176
|
+
// Quick check: rightmost part must match current element
|
|
177
|
+
if (sel.rightmostIsRoot) {
|
|
178
|
+
if (ctx.parent !== null)
|
|
179
|
+
return false; // html/body only match root
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
if (!matchesParsedPart(sel.rightmost, ctx))
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
// Single-part selector — already matched
|
|
186
|
+
if (sel.parts.length === 1)
|
|
187
|
+
return true;
|
|
188
|
+
// Walk ancestors for remaining parts (right-to-left)
|
|
189
|
+
let current = ctx.parent;
|
|
190
|
+
for (let ti = sel.parts.length - 2; ti >= 0; ti--) {
|
|
191
|
+
if (!current)
|
|
192
|
+
return false;
|
|
193
|
+
const part = sel.parts[ti];
|
|
194
|
+
const combinator = sel.combinators[ti];
|
|
195
|
+
if (combinator === '>') {
|
|
196
|
+
// Direct child: current ancestor must match
|
|
197
|
+
const isRoot = current.parent === null;
|
|
198
|
+
if (isRoot && (part.tag === 'html' || part.tag === 'body')) {
|
|
199
|
+
current = current.parent;
|
|
200
|
+
}
|
|
201
|
+
else if (matchesParsedPart(part, current)) {
|
|
202
|
+
current = current.parent;
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
// Descendant: any ancestor must match
|
|
210
|
+
let found = false;
|
|
211
|
+
while (current) {
|
|
212
|
+
const isRoot = current.parent === null;
|
|
213
|
+
if (isRoot && (part.tag === 'html' || part.tag === 'body')) {
|
|
214
|
+
current = current.parent;
|
|
215
|
+
found = true;
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
if (matchesParsedPart(part, current)) {
|
|
219
|
+
current = current.parent;
|
|
220
|
+
found = true;
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
current = current.parent;
|
|
224
|
+
}
|
|
225
|
+
if (!found)
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
// ─── Style Resolution ────────────────────────────────────────────────
|
|
232
|
+
/** Properties that inherit from parent to child */
|
|
233
|
+
const INHERITED_PROPERTIES = new Set([
|
|
234
|
+
'font-family', 'font-size', 'font-weight', 'font-style',
|
|
235
|
+
'color', 'text-align', 'text-transform',
|
|
236
|
+
'text-decoration-line', 'text-decoration-style', 'text-decoration-color',
|
|
237
|
+
'letter-spacing', 'word-spacing', 'font-kerning',
|
|
238
|
+
'line-height', 'white-space', 'word-break', 'overflow-wrap',
|
|
239
|
+
'direction', 'text-shadow', 'list-style-type',
|
|
240
|
+
'vertical-align',
|
|
241
|
+
]);
|
|
242
|
+
/** Default values for all ResolvedStyle properties */
|
|
243
|
+
function defaultStyle() {
|
|
244
|
+
return {
|
|
245
|
+
fontFamily: 'sans-serif',
|
|
246
|
+
fontSize: 16,
|
|
247
|
+
fontWeight: 400,
|
|
248
|
+
fontStyle: 'normal',
|
|
249
|
+
color: 'rgb(0, 0, 0)',
|
|
250
|
+
textAlign: 'start',
|
|
251
|
+
textTransform: 'none',
|
|
252
|
+
textDecorationLine: 'none',
|
|
253
|
+
textDecorationStyle: 'solid',
|
|
254
|
+
textDecorationColor: 'rgb(0, 0, 0)',
|
|
255
|
+
textShadow: 'none',
|
|
256
|
+
webkitTextStrokeWidth: 0,
|
|
257
|
+
webkitTextStrokeColor: '',
|
|
258
|
+
webkitTextFillColor: '',
|
|
259
|
+
webkitBackgroundClip: '',
|
|
260
|
+
backgroundImage: 'none',
|
|
261
|
+
letterSpacing: 0,
|
|
262
|
+
wordSpacing: 0,
|
|
263
|
+
fontKerning: 'auto',
|
|
264
|
+
lineHeight: 0,
|
|
265
|
+
verticalAlign: 'baseline',
|
|
266
|
+
whiteSpace: 'normal',
|
|
267
|
+
wordBreak: 'normal',
|
|
268
|
+
overflowWrap: 'normal',
|
|
269
|
+
direction: 'ltr',
|
|
270
|
+
display: 'block',
|
|
271
|
+
width: 0,
|
|
272
|
+
minHeight: 0,
|
|
273
|
+
paddingTop: 0,
|
|
274
|
+
paddingRight: 0,
|
|
275
|
+
paddingBottom: 0,
|
|
276
|
+
paddingLeft: 0,
|
|
277
|
+
marginTop: 0,
|
|
278
|
+
marginRight: 0,
|
|
279
|
+
marginBottom: 0,
|
|
280
|
+
marginLeft: 0,
|
|
281
|
+
backgroundColor: 'rgba(0, 0, 0, 0)',
|
|
282
|
+
borderTopWidth: 0,
|
|
283
|
+
borderTopColor: 'rgb(0, 0, 0)',
|
|
284
|
+
borderTopStyle: 'none',
|
|
285
|
+
borderRightWidth: 0,
|
|
286
|
+
borderRightColor: 'rgb(0, 0, 0)',
|
|
287
|
+
borderRightStyle: 'none',
|
|
288
|
+
borderBottomWidth: 0,
|
|
289
|
+
borderBottomColor: 'rgb(0, 0, 0)',
|
|
290
|
+
borderBottomStyle: 'none',
|
|
291
|
+
borderLeftWidth: 0,
|
|
292
|
+
borderLeftColor: 'rgb(0, 0, 0)',
|
|
293
|
+
borderLeftStyle: 'none',
|
|
294
|
+
flexDirection: 'row',
|
|
295
|
+
gap: 0,
|
|
296
|
+
flexGrow: 0,
|
|
297
|
+
listStyleType: 'disc',
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
/** Tag-level default display values and browser default margins */
|
|
301
|
+
const TAG_DEFAULTS = {
|
|
302
|
+
span: { display: 'inline' },
|
|
303
|
+
a: { display: 'inline' },
|
|
304
|
+
strong: { display: 'inline', fontWeight: 700 },
|
|
305
|
+
b: { display: 'inline', fontWeight: 700 },
|
|
306
|
+
em: { display: 'inline', fontStyle: 'italic' },
|
|
307
|
+
i: { display: 'inline', fontStyle: 'italic' },
|
|
308
|
+
u: { display: 'inline', textDecorationLine: 'underline' },
|
|
309
|
+
s: { display: 'inline', textDecorationLine: 'line-through' },
|
|
310
|
+
strike: { display: 'inline', textDecorationLine: 'line-through' },
|
|
311
|
+
del: { display: 'inline', textDecorationLine: 'line-through' },
|
|
312
|
+
sub: { display: 'inline', verticalAlign: 'sub', fontSize: 0.83 },
|
|
313
|
+
sup: { display: 'inline', verticalAlign: 'super', fontSize: 0.83 },
|
|
314
|
+
code: { display: 'inline', fontFamily: 'monospace' },
|
|
315
|
+
cite: { display: 'inline', fontStyle: 'italic' },
|
|
316
|
+
p: { display: 'block', marginTop: -1, marginBottom: -1 }, // -1 = 1em, resolved later
|
|
317
|
+
div: { display: 'block' },
|
|
318
|
+
h1: { display: 'block', fontSize: 2, fontWeight: 700, marginTop: -0.67, marginBottom: -0.67 },
|
|
319
|
+
h2: { display: 'block', fontSize: 1.5, fontWeight: 700, marginTop: -0.83, marginBottom: -0.83 },
|
|
320
|
+
h3: { display: 'block', fontSize: 1.17, fontWeight: 700, marginTop: -1, marginBottom: -1 },
|
|
321
|
+
h4: { display: 'block', fontSize: 1, fontWeight: 700, marginTop: -1.33, marginBottom: -1.33 },
|
|
322
|
+
h5: { display: 'block', fontSize: 0.83, fontWeight: 700, marginTop: -1.67, marginBottom: -1.67 },
|
|
323
|
+
h6: { display: 'block', fontSize: 0.67, fontWeight: 700, marginTop: -2.33, marginBottom: -2.33 },
|
|
324
|
+
ul: { display: 'block', listStyleType: 'disc', marginTop: -1, marginBottom: -1 },
|
|
325
|
+
ol: { display: 'block', listStyleType: 'decimal', marginTop: -1, marginBottom: -1 },
|
|
326
|
+
li: { display: 'list-item' },
|
|
327
|
+
blockquote: { display: 'block', marginTop: -1, marginBottom: -1, marginLeft: 40, marginRight: 40 },
|
|
328
|
+
pre: { display: 'block', whiteSpace: 'pre', fontFamily: 'monospace', marginTop: -1, marginBottom: -1 },
|
|
329
|
+
table: { display: 'table' },
|
|
330
|
+
tr: { display: 'table-row' },
|
|
331
|
+
td: { display: 'table-cell' },
|
|
332
|
+
th: { display: 'table-cell', fontWeight: 700 },
|
|
333
|
+
br: { display: 'inline' },
|
|
334
|
+
};
|
|
335
|
+
/**
|
|
336
|
+
* Parse a CSS value to pixels given a parent font size for em/% resolution.
|
|
337
|
+
*/
|
|
338
|
+
function parseValue(value, parentFontSize, containerWidth) {
|
|
339
|
+
if (!value || value === 'normal' || value === 'auto' || value === 'none')
|
|
340
|
+
return 0;
|
|
341
|
+
const trimmed = value.trim();
|
|
342
|
+
if (trimmed.endsWith('em')) {
|
|
343
|
+
const num = parseFloat(trimmed);
|
|
344
|
+
return isNaN(num) ? 0 : num * parentFontSize;
|
|
345
|
+
}
|
|
346
|
+
if (trimmed.endsWith('%')) {
|
|
347
|
+
const num = parseFloat(trimmed);
|
|
348
|
+
return isNaN(num) ? 0 : (num / 100) * containerWidth;
|
|
349
|
+
}
|
|
350
|
+
if (trimmed.endsWith('px')) {
|
|
351
|
+
const num = parseFloat(trimmed);
|
|
352
|
+
return isNaN(num) ? 0 : num;
|
|
353
|
+
}
|
|
354
|
+
// Bare number (for line-height, etc.)
|
|
355
|
+
const num = parseFloat(trimmed);
|
|
356
|
+
return isNaN(num) ? 0 : num;
|
|
357
|
+
}
|
|
358
|
+
function parseFontWeight(value) {
|
|
359
|
+
if (value === 'bold')
|
|
360
|
+
return 700;
|
|
361
|
+
if (value === 'normal')
|
|
362
|
+
return 400;
|
|
363
|
+
const num = parseInt(value, 10);
|
|
364
|
+
return isNaN(num) ? 400 : num;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Expand shorthand properties into individual ones.
|
|
368
|
+
* E.g., margin: 10px 20px → marginTop/Right/Bottom/Left
|
|
369
|
+
*/
|
|
370
|
+
function expandShorthand(property, value) {
|
|
371
|
+
if (property === 'margin' || property === 'padding') {
|
|
372
|
+
const parts = value.trim().split(/\s+/);
|
|
373
|
+
let top, right, bottom, left;
|
|
374
|
+
if (parts.length === 1) {
|
|
375
|
+
top = right = bottom = left = parts[0];
|
|
376
|
+
}
|
|
377
|
+
else if (parts.length === 2) {
|
|
378
|
+
top = bottom = parts[0];
|
|
379
|
+
right = left = parts[1];
|
|
380
|
+
}
|
|
381
|
+
else if (parts.length === 3) {
|
|
382
|
+
top = parts[0];
|
|
383
|
+
right = left = parts[1];
|
|
384
|
+
bottom = parts[2];
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
top = parts[0];
|
|
388
|
+
right = parts[1];
|
|
389
|
+
bottom = parts[2];
|
|
390
|
+
left = parts[3];
|
|
391
|
+
}
|
|
392
|
+
return [
|
|
393
|
+
{ property: `${property}-top`, value: top },
|
|
394
|
+
{ property: `${property}-right`, value: right },
|
|
395
|
+
{ property: `${property}-bottom`, value: bottom },
|
|
396
|
+
{ property: `${property}-left`, value: left },
|
|
397
|
+
];
|
|
398
|
+
}
|
|
399
|
+
if (property === 'border' || property === 'border-top' || property === 'border-right' ||
|
|
400
|
+
property === 'border-bottom' || property === 'border-left') {
|
|
401
|
+
const parts = value.trim().split(/\s+/);
|
|
402
|
+
const borderStyles = ['solid', 'dashed', 'dotted', 'double', 'none', 'hidden'];
|
|
403
|
+
const width = parts.find(p => p.endsWith('px') || /^\d/.test(p)) || '0';
|
|
404
|
+
const style = parts.find(p => borderStyles.includes(p)) || 'none';
|
|
405
|
+
const color = parts.find(p => !p.endsWith('px') && !/^\d/.test(p) && !borderStyles.includes(p)) || 'currentColor';
|
|
406
|
+
const result = [];
|
|
407
|
+
const sides = property === 'border'
|
|
408
|
+
? ['top', 'right', 'bottom', 'left']
|
|
409
|
+
: [property.replace('border-', '')];
|
|
410
|
+
for (const side of sides) {
|
|
411
|
+
result.push({ property: `border-${side}-width`, value: width });
|
|
412
|
+
result.push({ property: `border-${side}-style`, value: style });
|
|
413
|
+
result.push({ property: `border-${side}-color`, value: color });
|
|
414
|
+
}
|
|
415
|
+
return result;
|
|
416
|
+
}
|
|
417
|
+
if (property === 'list-style') {
|
|
418
|
+
// list-style: none → list-style-type: none
|
|
419
|
+
if (value === 'none') {
|
|
420
|
+
return [{ property: 'list-style-type', value: 'none' }];
|
|
421
|
+
}
|
|
422
|
+
return [{ property: 'list-style-type', value }];
|
|
423
|
+
}
|
|
424
|
+
if (property === 'text-decoration') {
|
|
425
|
+
const v = value.trim();
|
|
426
|
+
if (v === 'inherit' || v === 'none') {
|
|
427
|
+
return [{ property: 'text-decoration-line', value: 'none' }];
|
|
428
|
+
}
|
|
429
|
+
// Extract color functions (rgb(...), hsl(...)) before splitting on whitespace,
|
|
430
|
+
// because they contain spaces internally (e.g. "rgb(231, 76, 60)").
|
|
431
|
+
let colorValue = '';
|
|
432
|
+
const withoutColorFn = v.replace(/\b(rgba?\([^)]*\)|hsla?\([^)]*\))/i, (match) => {
|
|
433
|
+
colorValue = match;
|
|
434
|
+
return '';
|
|
435
|
+
});
|
|
436
|
+
const parts = withoutColorFn.split(/\s+/).filter(Boolean);
|
|
437
|
+
const lineValues = ['underline', 'overline', 'line-through'];
|
|
438
|
+
const styleValues = ['solid', 'double', 'dotted', 'dashed', 'wavy'];
|
|
439
|
+
const result = [];
|
|
440
|
+
const lines = [];
|
|
441
|
+
for (const p of parts) {
|
|
442
|
+
if (lineValues.includes(p))
|
|
443
|
+
lines.push(p);
|
|
444
|
+
else if (styleValues.includes(p))
|
|
445
|
+
result.push({ property: 'text-decoration-style', value: p });
|
|
446
|
+
else if (p.startsWith('#'))
|
|
447
|
+
result.push({ property: 'text-decoration-color', value: p });
|
|
448
|
+
}
|
|
449
|
+
if (colorValue)
|
|
450
|
+
result.push({ property: 'text-decoration-color', value: colorValue });
|
|
451
|
+
if (lines.length > 0)
|
|
452
|
+
result.unshift({ property: 'text-decoration-line', value: lines.join(' ') });
|
|
453
|
+
return result;
|
|
454
|
+
}
|
|
455
|
+
if (property === '-webkit-text-stroke') {
|
|
456
|
+
// -webkit-text-stroke: 1px #1e40af → width + color
|
|
457
|
+
const parts = value.trim().split(/\s+/);
|
|
458
|
+
const width = parts.find(p => p.endsWith('px') || /^\d/.test(p)) || '0';
|
|
459
|
+
const color = parts.find(p => !p.endsWith('px') && !/^\d/.test(p)) || 'currentColor';
|
|
460
|
+
return [
|
|
461
|
+
{ property: '-webkit-text-stroke-width', value: width },
|
|
462
|
+
{ property: '-webkit-text-stroke-color', value: color },
|
|
463
|
+
];
|
|
464
|
+
}
|
|
465
|
+
if (property === 'flex') {
|
|
466
|
+
// flex: 1 → flex-grow: 1
|
|
467
|
+
const parts = value.trim().split(/\s+/);
|
|
468
|
+
const grow = parseFloat(parts[0]);
|
|
469
|
+
if (!isNaN(grow)) {
|
|
470
|
+
return [{ property: 'flex-grow', value: String(grow) }];
|
|
471
|
+
}
|
|
472
|
+
return [];
|
|
473
|
+
}
|
|
474
|
+
if (property === 'border-collapse' || property === 'border-spacing') {
|
|
475
|
+
// Ignored — table-specific properties we don't handle
|
|
476
|
+
return [];
|
|
477
|
+
}
|
|
478
|
+
return [{ property, value }];
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Apply a CSS declaration to a ResolvedStyle, resolving units.
|
|
482
|
+
*/
|
|
483
|
+
function applyDeclaration(style, property, value, parentFontSize, containerWidth, direction) {
|
|
484
|
+
// Resolve the font-size first if that's what we're setting, since
|
|
485
|
+
// em values for other properties depend on the element's own font-size
|
|
486
|
+
const fontSize = style.fontSize || parentFontSize;
|
|
487
|
+
switch (property) {
|
|
488
|
+
// Font & text
|
|
489
|
+
case 'font-family':
|
|
490
|
+
style.fontFamily = value.trim();
|
|
491
|
+
break;
|
|
492
|
+
case 'font-size': {
|
|
493
|
+
const v = value.trim();
|
|
494
|
+
if (v.endsWith('em')) {
|
|
495
|
+
style.fontSize = parseFloat(v) * parentFontSize;
|
|
496
|
+
}
|
|
497
|
+
else if (v.endsWith('%')) {
|
|
498
|
+
style.fontSize = (parseFloat(v) / 100) * parentFontSize;
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
style.fontSize = parseFloat(v) || parentFontSize;
|
|
502
|
+
}
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
case 'font-weight':
|
|
506
|
+
style.fontWeight = parseFontWeight(value);
|
|
507
|
+
break;
|
|
508
|
+
case 'font-style':
|
|
509
|
+
style.fontStyle = value.trim();
|
|
510
|
+
break;
|
|
511
|
+
case 'color':
|
|
512
|
+
style.color = value.trim();
|
|
513
|
+
break;
|
|
514
|
+
case 'text-align':
|
|
515
|
+
style.textAlign = value.trim();
|
|
516
|
+
break;
|
|
517
|
+
case 'text-transform':
|
|
518
|
+
style.textTransform = value.trim();
|
|
519
|
+
break;
|
|
520
|
+
case 'text-decoration-line':
|
|
521
|
+
style.textDecorationLine = value.trim();
|
|
522
|
+
break;
|
|
523
|
+
// text-decoration is expanded in expandShorthand, should not reach here
|
|
524
|
+
// but handle just in case
|
|
525
|
+
case 'text-decoration': break;
|
|
526
|
+
case 'text-decoration-style':
|
|
527
|
+
style.textDecorationStyle = value.trim();
|
|
528
|
+
break;
|
|
529
|
+
case 'text-decoration-color':
|
|
530
|
+
style.textDecorationColor = value.trim();
|
|
531
|
+
break;
|
|
532
|
+
case 'text-shadow':
|
|
533
|
+
style.textShadow = value.trim();
|
|
534
|
+
break;
|
|
535
|
+
case '-webkit-text-stroke-width':
|
|
536
|
+
style.webkitTextStrokeWidth = parseValue(value, fontSize, containerWidth);
|
|
537
|
+
break;
|
|
538
|
+
case '-webkit-text-stroke-color':
|
|
539
|
+
style.webkitTextStrokeColor = value.trim();
|
|
540
|
+
break;
|
|
541
|
+
case '-webkit-text-fill-color':
|
|
542
|
+
style.webkitTextFillColor = value.trim();
|
|
543
|
+
break;
|
|
544
|
+
case '-webkit-background-clip':
|
|
545
|
+
case 'background-clip':
|
|
546
|
+
style.webkitBackgroundClip = value.trim();
|
|
547
|
+
break;
|
|
548
|
+
case 'background-image':
|
|
549
|
+
style.backgroundImage = value.trim();
|
|
550
|
+
break;
|
|
551
|
+
case 'letter-spacing':
|
|
552
|
+
style.letterSpacing = value.trim() === 'normal' ? 0 : parseValue(value, fontSize, containerWidth);
|
|
553
|
+
break;
|
|
554
|
+
case 'word-spacing':
|
|
555
|
+
style.wordSpacing = value.trim() === 'normal' ? 0 : parseValue(value, fontSize, containerWidth);
|
|
556
|
+
break;
|
|
557
|
+
case 'font-kerning':
|
|
558
|
+
style.fontKerning = value.trim();
|
|
559
|
+
break;
|
|
560
|
+
case 'line-height': {
|
|
561
|
+
const v = value.trim();
|
|
562
|
+
if (v === 'normal') {
|
|
563
|
+
style.lineHeight = 0; // 0 signals "normal"
|
|
564
|
+
}
|
|
565
|
+
else if (v.endsWith('px')) {
|
|
566
|
+
style.lineHeight = parseFloat(v) || 0;
|
|
567
|
+
}
|
|
568
|
+
else if (v.endsWith('em')) {
|
|
569
|
+
style.lineHeight = parseFloat(v) * fontSize;
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
// Unitless multiplier — compute for this element's font size
|
|
573
|
+
// and mark as unitless so children re-compute
|
|
574
|
+
const num = parseFloat(v);
|
|
575
|
+
if (!isNaN(num)) {
|
|
576
|
+
style.lineHeight = num * fontSize;
|
|
577
|
+
style._lineHeightMultiplier = num;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
case 'vertical-align':
|
|
583
|
+
style.verticalAlign = value.trim();
|
|
584
|
+
break;
|
|
585
|
+
case 'white-space':
|
|
586
|
+
style.whiteSpace = value.trim();
|
|
587
|
+
break;
|
|
588
|
+
case 'word-break':
|
|
589
|
+
style.wordBreak = value.trim();
|
|
590
|
+
break;
|
|
591
|
+
case 'overflow-wrap':
|
|
592
|
+
case 'word-wrap':
|
|
593
|
+
style.overflowWrap = value.trim();
|
|
594
|
+
break;
|
|
595
|
+
case 'direction':
|
|
596
|
+
style.direction = value.trim();
|
|
597
|
+
break;
|
|
598
|
+
// Box model
|
|
599
|
+
case 'display':
|
|
600
|
+
style.display = value.trim();
|
|
601
|
+
break;
|
|
602
|
+
case 'width': {
|
|
603
|
+
const v = value.trim();
|
|
604
|
+
if (v === '100%')
|
|
605
|
+
style.width = containerWidth;
|
|
606
|
+
else if (v !== 'auto')
|
|
607
|
+
style.width = parseValue(v, fontSize, containerWidth);
|
|
608
|
+
break;
|
|
609
|
+
}
|
|
610
|
+
case 'min-height':
|
|
611
|
+
style.minHeight = parseValue(value, fontSize, containerWidth);
|
|
612
|
+
break;
|
|
613
|
+
case 'padding-top':
|
|
614
|
+
style.paddingTop = parseValue(value, fontSize, containerWidth);
|
|
615
|
+
break;
|
|
616
|
+
case 'padding-right':
|
|
617
|
+
style.paddingRight = parseValue(value, fontSize, containerWidth);
|
|
618
|
+
break;
|
|
619
|
+
case 'padding-bottom':
|
|
620
|
+
style.paddingBottom = parseValue(value, fontSize, containerWidth);
|
|
621
|
+
break;
|
|
622
|
+
case 'padding-left':
|
|
623
|
+
style.paddingLeft = parseValue(value, fontSize, containerWidth);
|
|
624
|
+
break;
|
|
625
|
+
case 'margin-top':
|
|
626
|
+
style.marginTop = parseValue(value, fontSize, containerWidth);
|
|
627
|
+
break;
|
|
628
|
+
case 'margin-right':
|
|
629
|
+
style.marginRight = parseValue(value, fontSize, containerWidth);
|
|
630
|
+
break;
|
|
631
|
+
case 'margin-bottom':
|
|
632
|
+
style.marginBottom = parseValue(value, fontSize, containerWidth);
|
|
633
|
+
break;
|
|
634
|
+
case 'margin-left':
|
|
635
|
+
style.marginLeft = parseValue(value, fontSize, containerWidth);
|
|
636
|
+
break;
|
|
637
|
+
case 'background-color':
|
|
638
|
+
style.backgroundColor = value.trim();
|
|
639
|
+
break;
|
|
640
|
+
case 'background': {
|
|
641
|
+
const v = value.trim();
|
|
642
|
+
if (v.includes('gradient(')) {
|
|
643
|
+
// background: linear-gradient(...) → backgroundImage
|
|
644
|
+
style.backgroundImage = v;
|
|
645
|
+
}
|
|
646
|
+
else if (v.startsWith('#') || v.startsWith('rgb') || v.startsWith('hsl') ||
|
|
647
|
+
['transparent', 'none', 'inherit'].includes(v) ||
|
|
648
|
+
/^[a-z]+$/.test(v)) {
|
|
649
|
+
style.backgroundColor = v;
|
|
650
|
+
}
|
|
651
|
+
break;
|
|
652
|
+
}
|
|
653
|
+
// Logical properties → physical (based on direction)
|
|
654
|
+
case 'padding-inline-start':
|
|
655
|
+
if (direction === 'rtl')
|
|
656
|
+
style.paddingRight = parseValue(value, fontSize, containerWidth);
|
|
657
|
+
else
|
|
658
|
+
style.paddingLeft = parseValue(value, fontSize, containerWidth);
|
|
659
|
+
break;
|
|
660
|
+
case 'padding-inline-end':
|
|
661
|
+
if (direction === 'rtl')
|
|
662
|
+
style.paddingLeft = parseValue(value, fontSize, containerWidth);
|
|
663
|
+
else
|
|
664
|
+
style.paddingRight = parseValue(value, fontSize, containerWidth);
|
|
665
|
+
break;
|
|
666
|
+
case 'margin-inline-start':
|
|
667
|
+
if (direction === 'rtl')
|
|
668
|
+
style.marginRight = parseValue(value, fontSize, containerWidth);
|
|
669
|
+
else
|
|
670
|
+
style.marginLeft = parseValue(value, fontSize, containerWidth);
|
|
671
|
+
break;
|
|
672
|
+
case 'margin-inline-end':
|
|
673
|
+
if (direction === 'rtl')
|
|
674
|
+
style.marginLeft = parseValue(value, fontSize, containerWidth);
|
|
675
|
+
else
|
|
676
|
+
style.marginRight = parseValue(value, fontSize, containerWidth);
|
|
677
|
+
break;
|
|
678
|
+
// Border
|
|
679
|
+
case 'border-top-width':
|
|
680
|
+
style.borderTopWidth = parseValue(value, fontSize, containerWidth);
|
|
681
|
+
break;
|
|
682
|
+
case 'border-top-color':
|
|
683
|
+
style.borderTopColor = value.trim();
|
|
684
|
+
break;
|
|
685
|
+
case 'border-top-style':
|
|
686
|
+
style.borderTopStyle = value.trim();
|
|
687
|
+
break;
|
|
688
|
+
case 'border-right-width':
|
|
689
|
+
style.borderRightWidth = parseValue(value, fontSize, containerWidth);
|
|
690
|
+
break;
|
|
691
|
+
case 'border-right-color':
|
|
692
|
+
style.borderRightColor = value.trim();
|
|
693
|
+
break;
|
|
694
|
+
case 'border-right-style':
|
|
695
|
+
style.borderRightStyle = value.trim();
|
|
696
|
+
break;
|
|
697
|
+
case 'border-bottom-width':
|
|
698
|
+
style.borderBottomWidth = parseValue(value, fontSize, containerWidth);
|
|
699
|
+
break;
|
|
700
|
+
case 'border-bottom-color':
|
|
701
|
+
style.borderBottomColor = value.trim();
|
|
702
|
+
break;
|
|
703
|
+
case 'border-bottom-style':
|
|
704
|
+
style.borderBottomStyle = value.trim();
|
|
705
|
+
break;
|
|
706
|
+
case 'border-left-width':
|
|
707
|
+
style.borderLeftWidth = parseValue(value, fontSize, containerWidth);
|
|
708
|
+
break;
|
|
709
|
+
case 'border-left-color':
|
|
710
|
+
style.borderLeftColor = value.trim();
|
|
711
|
+
break;
|
|
712
|
+
case 'border-left-style':
|
|
713
|
+
style.borderLeftStyle = value.trim();
|
|
714
|
+
break;
|
|
715
|
+
// Flex
|
|
716
|
+
case 'flex-direction':
|
|
717
|
+
style.flexDirection = value.trim();
|
|
718
|
+
break;
|
|
719
|
+
case 'gap':
|
|
720
|
+
style.gap = parseValue(value, fontSize, containerWidth);
|
|
721
|
+
break;
|
|
722
|
+
case 'flex-grow':
|
|
723
|
+
style.flexGrow = parseFloat(value) || 0;
|
|
724
|
+
break;
|
|
725
|
+
// List
|
|
726
|
+
case 'list-style-type':
|
|
727
|
+
style.listStyleType = value.trim();
|
|
728
|
+
break;
|
|
729
|
+
// Ignored properties (not relevant for our layout)
|
|
730
|
+
case 'position':
|
|
731
|
+
case 'top':
|
|
732
|
+
case 'left':
|
|
733
|
+
case 'right':
|
|
734
|
+
case 'bottom':
|
|
735
|
+
case 'inset-inline-start':
|
|
736
|
+
case 'inset-inline-end':
|
|
737
|
+
case 'content':
|
|
738
|
+
case 'counter-reset':
|
|
739
|
+
case 'counter-increment':
|
|
740
|
+
case 'border-radius':
|
|
741
|
+
case 'border-top-left-radius':
|
|
742
|
+
case 'border-top-right-radius':
|
|
743
|
+
case 'border-bottom-left-radius':
|
|
744
|
+
case 'border-bottom-right-radius':
|
|
745
|
+
case 'cursor':
|
|
746
|
+
case 'opacity':
|
|
747
|
+
case 'overflow':
|
|
748
|
+
case 'box-sizing':
|
|
749
|
+
case 'outline':
|
|
750
|
+
case 'transition':
|
|
751
|
+
case 'transform':
|
|
752
|
+
case 'text-indent':
|
|
753
|
+
case 'font-stretch':
|
|
754
|
+
case 'font-display':
|
|
755
|
+
case 'src':
|
|
756
|
+
case 'unicode-range':
|
|
757
|
+
break;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
/** Inheritable property names (CSS kebab-case) mapped to ResolvedStyle keys */
|
|
761
|
+
const INHERITABLE_KEYS = [
|
|
762
|
+
['font-family', 'fontFamily'],
|
|
763
|
+
['font-size', 'fontSize'],
|
|
764
|
+
['font-weight', 'fontWeight'],
|
|
765
|
+
['font-style', 'fontStyle'],
|
|
766
|
+
['color', 'color'],
|
|
767
|
+
['text-align', 'textAlign'],
|
|
768
|
+
['text-transform', 'textTransform'],
|
|
769
|
+
['white-space', 'whiteSpace'],
|
|
770
|
+
['word-break', 'wordBreak'],
|
|
771
|
+
['overflow-wrap', 'overflowWrap'],
|
|
772
|
+
['direction', 'direction'],
|
|
773
|
+
['letter-spacing', 'letterSpacing'],
|
|
774
|
+
['word-spacing', 'wordSpacing'],
|
|
775
|
+
['line-height', 'lineHeight'],
|
|
776
|
+
['text-shadow', 'textShadow'],
|
|
777
|
+
['font-kerning', 'fontKerning'],
|
|
778
|
+
['list-style-type', 'listStyleType'],
|
|
779
|
+
['vertical-align', 'verticalAlign'],
|
|
780
|
+
];
|
|
781
|
+
/**
|
|
782
|
+
* Inherit properties from parent style to child style for properties
|
|
783
|
+
* not explicitly set (tracked via setProps).
|
|
784
|
+
*/
|
|
785
|
+
function inheritFrom(child, parent, setProps) {
|
|
786
|
+
for (const [cssProp, key] of INHERITABLE_KEYS) {
|
|
787
|
+
if (!setProps.has(cssProp)) {
|
|
788
|
+
if (key === 'lineHeight') {
|
|
789
|
+
// Unitless line-height: re-compute relative to child's font-size
|
|
790
|
+
const multiplier = parent._lineHeightMultiplier;
|
|
791
|
+
if (multiplier !== undefined) {
|
|
792
|
+
child.lineHeight = multiplier * child.fontSize;
|
|
793
|
+
child._lineHeightMultiplier = multiplier;
|
|
794
|
+
}
|
|
795
|
+
else {
|
|
796
|
+
child.lineHeight = parent.lineHeight;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
else {
|
|
800
|
+
child[key] = parent[key];
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Build an index of processed rules keyed by rightmost tag name and class names.
|
|
807
|
+
* The '*' key holds rules that match any element (no tag or class constraint).
|
|
808
|
+
*/
|
|
809
|
+
function buildRuleIndex(rules) {
|
|
810
|
+
const byTag = new Map();
|
|
811
|
+
const byClass = new Map();
|
|
812
|
+
const universal = [];
|
|
813
|
+
let orderBase = 0;
|
|
814
|
+
for (const rule of rules) {
|
|
815
|
+
// Pre-expand declarations once
|
|
816
|
+
const expandedDecls = [];
|
|
817
|
+
for (const decl of rule.declarations) {
|
|
818
|
+
const isImportant = decl.value.includes('!important');
|
|
819
|
+
const cleanValue = isImportant
|
|
820
|
+
? decl.value.replace(/\s*!important\s*/g, '').trim()
|
|
821
|
+
: decl.value;
|
|
822
|
+
const expanded = expandShorthand(decl.property, cleanValue);
|
|
823
|
+
for (const exp of expanded) {
|
|
824
|
+
expandedDecls.push({ property: exp.property, value: exp.value, important: isImportant });
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
for (const sel of rule.selectors) {
|
|
828
|
+
const parsed = parseSelector(sel);
|
|
829
|
+
if (!parsed)
|
|
830
|
+
continue;
|
|
831
|
+
const entry = {
|
|
832
|
+
selector: parsed,
|
|
833
|
+
declarations: expandedDecls,
|
|
834
|
+
orderBase: orderBase++,
|
|
835
|
+
};
|
|
836
|
+
const rm = parsed.rightmost;
|
|
837
|
+
if (rm.tag && !parsed.rightmostIsRoot) {
|
|
838
|
+
// Index by tag
|
|
839
|
+
const list = byTag.get(rm.tag);
|
|
840
|
+
if (list)
|
|
841
|
+
list.push(entry);
|
|
842
|
+
else
|
|
843
|
+
byTag.set(rm.tag, [entry]);
|
|
844
|
+
}
|
|
845
|
+
if (rm.classes.length > 0) {
|
|
846
|
+
// Index by first class (most selective)
|
|
847
|
+
const cls = rm.classes[0];
|
|
848
|
+
const list = byClass.get(cls);
|
|
849
|
+
if (list)
|
|
850
|
+
list.push(entry);
|
|
851
|
+
else
|
|
852
|
+
byClass.set(cls, [entry]);
|
|
853
|
+
}
|
|
854
|
+
if (!rm.tag && rm.classes.length === 0) {
|
|
855
|
+
// Universal selector or html/body root
|
|
856
|
+
universal.push(entry);
|
|
857
|
+
}
|
|
858
|
+
// Also add root-matching selectors to universal
|
|
859
|
+
if (parsed.rightmostIsRoot) {
|
|
860
|
+
universal.push(entry);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
return { byTag, byClass, universal };
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Detect list marker text for a <li> element based on tree position.
|
|
868
|
+
*/
|
|
869
|
+
function getListMarker(el) {
|
|
870
|
+
const tag = el.tagName.toLowerCase();
|
|
871
|
+
if (tag !== 'li')
|
|
872
|
+
return undefined;
|
|
873
|
+
const parent = el.parentElement;
|
|
874
|
+
if (!parent)
|
|
875
|
+
return '•';
|
|
876
|
+
const parentTag = parent.tagName.toLowerCase();
|
|
877
|
+
if (parentTag === 'ol') {
|
|
878
|
+
let index = 0;
|
|
879
|
+
for (const child of parent.children) {
|
|
880
|
+
if (child.tagName.toLowerCase() === 'li') {
|
|
881
|
+
index++;
|
|
882
|
+
if (child === el)
|
|
883
|
+
break;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
return `${index}.`;
|
|
887
|
+
}
|
|
888
|
+
if (parentTag === 'ul') {
|
|
889
|
+
return '•';
|
|
890
|
+
}
|
|
891
|
+
return undefined;
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* Parse inline style attribute into declarations.
|
|
895
|
+
*/
|
|
896
|
+
function parseInlineStyle(styleAttr) {
|
|
897
|
+
const declarations = [];
|
|
898
|
+
for (const decl of styleAttr.split(';')) {
|
|
899
|
+
const colonIdx = decl.indexOf(':');
|
|
900
|
+
if (colonIdx === -1)
|
|
901
|
+
continue;
|
|
902
|
+
const property = decl.slice(0, colonIdx).trim().toLowerCase();
|
|
903
|
+
const value = decl.slice(colonIdx + 1).trim();
|
|
904
|
+
if (property && value) {
|
|
905
|
+
declarations.push({ property, value });
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
return declarations;
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Resolve styles for a DOM tree without inserting into the document.
|
|
912
|
+
* Parses CSS rules, matches selectors, resolves cascade + inheritance.
|
|
913
|
+
*/
|
|
914
|
+
export function resolveStylesFromCSS(fragment, css, containerWidth) {
|
|
915
|
+
const { rules, fontFaceRules } = parseCSS(css);
|
|
916
|
+
// Inject @font-face rules into the document so fonts can load
|
|
917
|
+
let fontStyleEl = null;
|
|
918
|
+
if (fontFaceRules.length > 0) {
|
|
919
|
+
fontStyleEl = document.createElement('style');
|
|
920
|
+
fontStyleEl.textContent = fontFaceRules.join('\n');
|
|
921
|
+
document.head.appendChild(fontStyleEl);
|
|
922
|
+
}
|
|
923
|
+
// Build indexed rule lookup
|
|
924
|
+
const ruleIndex = buildRuleIndex(rules);
|
|
925
|
+
// Wrap fragment in a container div so resolveElement has a single root Element
|
|
926
|
+
const container = document.createElement('div');
|
|
927
|
+
container.appendChild(fragment);
|
|
928
|
+
function buildContext(el, parent) {
|
|
929
|
+
const classes = new Set();
|
|
930
|
+
const className = el.getAttribute('class');
|
|
931
|
+
if (className) {
|
|
932
|
+
for (const c of className.split(/\s+/)) {
|
|
933
|
+
if (c)
|
|
934
|
+
classes.add(c);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
return {
|
|
938
|
+
tagName: el.tagName.toLowerCase(),
|
|
939
|
+
classes,
|
|
940
|
+
parent,
|
|
941
|
+
el,
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
function resolveElement(el, parentStyle, parentCtx) {
|
|
945
|
+
const tag = el.tagName.toLowerCase();
|
|
946
|
+
const ctx = buildContext(el, parentCtx);
|
|
947
|
+
// Start with defaults
|
|
948
|
+
const style = defaultStyle();
|
|
949
|
+
// Track which properties are explicitly set (tag defaults, CSS rules, inline styles)
|
|
950
|
+
const setProps = new Set();
|
|
951
|
+
// --- Step 1: Determine font-size first (needed for em/multiplier resolution) ---
|
|
952
|
+
// Collect candidate rules from index (only rules that could match this element)
|
|
953
|
+
const candidates = [];
|
|
954
|
+
const seen = new Set();
|
|
955
|
+
const tagRules = ruleIndex.byTag.get(tag);
|
|
956
|
+
if (tagRules)
|
|
957
|
+
for (const r of tagRules) {
|
|
958
|
+
seen.add(r);
|
|
959
|
+
candidates.push(r);
|
|
960
|
+
}
|
|
961
|
+
for (const cls of ctx.classes) {
|
|
962
|
+
const clsRules = ruleIndex.byClass.get(cls);
|
|
963
|
+
if (clsRules)
|
|
964
|
+
for (const r of clsRules) {
|
|
965
|
+
if (!seen.has(r)) {
|
|
966
|
+
seen.add(r);
|
|
967
|
+
candidates.push(r);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
for (const r of ruleIndex.universal) {
|
|
972
|
+
if (!seen.has(r)) {
|
|
973
|
+
seen.add(r);
|
|
974
|
+
candidates.push(r);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
// Match candidates and collect pre-expanded declarations
|
|
978
|
+
const matched = [];
|
|
979
|
+
for (const candidate of candidates) {
|
|
980
|
+
if (matchesParsedSelector(candidate.selector, ctx)) {
|
|
981
|
+
for (const decl of candidate.declarations) {
|
|
982
|
+
matched.push({
|
|
983
|
+
property: decl.property,
|
|
984
|
+
value: decl.value,
|
|
985
|
+
specificity: candidate.selector.spec,
|
|
986
|
+
order: candidate.orderBase,
|
|
987
|
+
important: decl.important,
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
// Tag default font-size
|
|
993
|
+
const tagDef = TAG_DEFAULTS[tag];
|
|
994
|
+
let fontSizeSet = false;
|
|
995
|
+
if (tagDef?.fontSize !== undefined) {
|
|
996
|
+
const val = tagDef.fontSize;
|
|
997
|
+
if (val < 10) {
|
|
998
|
+
style.fontSize = val * parentStyle.fontSize;
|
|
999
|
+
}
|
|
1000
|
+
else {
|
|
1001
|
+
style.fontSize = val;
|
|
1002
|
+
}
|
|
1003
|
+
fontSizeSet = true;
|
|
1004
|
+
setProps.add('font-size');
|
|
1005
|
+
}
|
|
1006
|
+
// Sort by: !important first, then specificity, then source order
|
|
1007
|
+
if (matched.length > 1) {
|
|
1008
|
+
matched.sort((a, b) => {
|
|
1009
|
+
if (a.important !== b.important)
|
|
1010
|
+
return a.important ? 1 : -1;
|
|
1011
|
+
const sa = a.specificity, sb = b.specificity;
|
|
1012
|
+
if (sa[0] !== sb[0])
|
|
1013
|
+
return sa[0] - sb[0];
|
|
1014
|
+
if (sa[1] !== sb[1])
|
|
1015
|
+
return sa[1] - sb[1];
|
|
1016
|
+
if (sa[2] !== sb[2])
|
|
1017
|
+
return sa[2] - sb[2];
|
|
1018
|
+
return a.order - b.order;
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
// Apply font-size from CSS rules
|
|
1022
|
+
for (const m of matched) {
|
|
1023
|
+
if (m.property === 'font-size') {
|
|
1024
|
+
applyDeclaration(style, m.property, m.value, parentStyle.fontSize, containerWidth, parentStyle.direction);
|
|
1025
|
+
fontSizeSet = true;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
// Apply font-size from inline styles
|
|
1029
|
+
if (el instanceof HTMLElement && el.style.cssText) {
|
|
1030
|
+
const inlineDecls = parseInlineStyle(el.style.cssText);
|
|
1031
|
+
for (const decl of inlineDecls) {
|
|
1032
|
+
if (decl.property === 'font-size') {
|
|
1033
|
+
applyDeclaration(style, decl.property, decl.value, parentStyle.fontSize, containerWidth, parentStyle.direction);
|
|
1034
|
+
fontSizeSet = true;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
// Inherit font-size from parent if not set
|
|
1039
|
+
if (!fontSizeSet) {
|
|
1040
|
+
style.fontSize = parentStyle.fontSize;
|
|
1041
|
+
}
|
|
1042
|
+
// Now style.fontSize is the element's computed font-size
|
|
1043
|
+
const elemFontSize = style.fontSize;
|
|
1044
|
+
// --- Step 2: Apply all other properties using resolved font-size ---
|
|
1045
|
+
// Apply non-fontSize tag defaults
|
|
1046
|
+
if (tagDef) {
|
|
1047
|
+
for (const [key, val] of Object.entries(tagDef)) {
|
|
1048
|
+
if (key === 'fontSize')
|
|
1049
|
+
continue; // already handled
|
|
1050
|
+
style[key] = val;
|
|
1051
|
+
const cssKey = key.replace(/[A-Z]/g, m => '-' + m.toLowerCase());
|
|
1052
|
+
setProps.add(cssKey);
|
|
1053
|
+
}
|
|
1054
|
+
// Resolve negative margin values (em multipliers from tag defaults)
|
|
1055
|
+
if (style.marginTop < 0)
|
|
1056
|
+
style.marginTop = Math.abs(style.marginTop) * elemFontSize;
|
|
1057
|
+
if (style.marginBottom < 0)
|
|
1058
|
+
style.marginBottom = Math.abs(style.marginBottom) * elemFontSize;
|
|
1059
|
+
// Default padding-inline-start for lists (direction-aware)
|
|
1060
|
+
if (tag === 'ul' || tag === 'ol') {
|
|
1061
|
+
const dir = parentStyle.direction;
|
|
1062
|
+
if (dir === 'rtl') {
|
|
1063
|
+
style.paddingRight = 40;
|
|
1064
|
+
setProps.add('padding-right');
|
|
1065
|
+
}
|
|
1066
|
+
else {
|
|
1067
|
+
style.paddingLeft = 40;
|
|
1068
|
+
setProps.add('padding-left');
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
// Determine direction from parent for logical property resolution
|
|
1073
|
+
const direction = parentStyle.direction;
|
|
1074
|
+
// Property aliases: CSS name → canonical name for setProps tracking
|
|
1075
|
+
const PROP_ALIASES = {
|
|
1076
|
+
'word-wrap': 'overflow-wrap',
|
|
1077
|
+
};
|
|
1078
|
+
// Apply matched CSS declarations (skip font-size, already applied)
|
|
1079
|
+
for (const m of matched) {
|
|
1080
|
+
if (m.property === 'font-size')
|
|
1081
|
+
continue;
|
|
1082
|
+
applyDeclaration(style, m.property, m.value, elemFontSize, containerWidth, direction);
|
|
1083
|
+
setProps.add(PROP_ALIASES[m.property] || m.property);
|
|
1084
|
+
}
|
|
1085
|
+
// Apply inline styles (highest specificity, skip font-size)
|
|
1086
|
+
const hasInlineWidth = el instanceof HTMLElement && !!el.style.width;
|
|
1087
|
+
if (el instanceof HTMLElement && el.style.cssText) {
|
|
1088
|
+
const inlineDecls = parseInlineStyle(el.style.cssText);
|
|
1089
|
+
for (const decl of inlineDecls) {
|
|
1090
|
+
if (decl.property === 'font-size') {
|
|
1091
|
+
setProps.add('font-size');
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
1094
|
+
const expanded = expandShorthand(decl.property, decl.value);
|
|
1095
|
+
for (const exp of expanded) {
|
|
1096
|
+
applyDeclaration(style, exp.property, exp.value, elemFontSize, containerWidth, direction);
|
|
1097
|
+
setProps.add(PROP_ALIASES[exp.property] || exp.property);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
// Only keep explicit width from inline styles (match DOM resolver behavior)
|
|
1102
|
+
if (!hasInlineWidth) {
|
|
1103
|
+
style.width = 0;
|
|
1104
|
+
}
|
|
1105
|
+
// Handle `dir` attribute
|
|
1106
|
+
const dirAttr = el.getAttribute('dir');
|
|
1107
|
+
if (dirAttr) {
|
|
1108
|
+
style.direction = dirAttr;
|
|
1109
|
+
setProps.add('direction');
|
|
1110
|
+
}
|
|
1111
|
+
// Inherit from parent for properties not explicitly set
|
|
1112
|
+
setProps.add('font-size'); // already resolved
|
|
1113
|
+
inheritFrom(style, parentStyle, setProps);
|
|
1114
|
+
// Auto-set currentColor defaults (browser default behavior)
|
|
1115
|
+
if (!setProps.has('text-decoration-color')) {
|
|
1116
|
+
style.textDecorationColor = style.color;
|
|
1117
|
+
}
|
|
1118
|
+
if (!setProps.has('-webkit-text-stroke-color')) {
|
|
1119
|
+
if (style.webkitTextStrokeColor === '' || style.webkitTextStrokeColor === 'currentColor') {
|
|
1120
|
+
style.webkitTextStrokeColor = style.color;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
else if (style.webkitTextStrokeColor === 'currentColor') {
|
|
1124
|
+
style.webkitTextStrokeColor = style.color;
|
|
1125
|
+
}
|
|
1126
|
+
for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
|
|
1127
|
+
const colorKey = `border${side}Color`;
|
|
1128
|
+
const propName = `border-${side.toLowerCase()}-color`;
|
|
1129
|
+
if (!setProps.has(propName)) {
|
|
1130
|
+
style[colorKey] = style.color;
|
|
1131
|
+
}
|
|
1132
|
+
else if (style[colorKey] === 'currentColor') {
|
|
1133
|
+
style[colorKey] = style.color;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
// Handle text-decoration inheritance (propagates visually, not via normal inheritance)
|
|
1137
|
+
const decoSet = new Set(style.textDecorationLine.split(/\s+/).filter(d => d && d !== 'none'));
|
|
1138
|
+
if (parentStyle.textDecorationLine && parentStyle.textDecorationLine !== 'none') {
|
|
1139
|
+
for (const d of parentStyle.textDecorationLine.split(/\s+/)) {
|
|
1140
|
+
if (d && d !== 'none')
|
|
1141
|
+
decoSet.add(d);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
if (decoSet.size > 0) {
|
|
1145
|
+
style.textDecorationLine = [...decoSet].join(' ');
|
|
1146
|
+
}
|
|
1147
|
+
// List marker
|
|
1148
|
+
const marker = getListMarker(el);
|
|
1149
|
+
// Walk children
|
|
1150
|
+
const children = [];
|
|
1151
|
+
for (const child of el.childNodes) {
|
|
1152
|
+
const childNode = walkNode(child, style, ctx);
|
|
1153
|
+
if (childNode)
|
|
1154
|
+
children.push(childNode);
|
|
1155
|
+
}
|
|
1156
|
+
return {
|
|
1157
|
+
element: el,
|
|
1158
|
+
tagName: tag,
|
|
1159
|
+
style,
|
|
1160
|
+
children,
|
|
1161
|
+
textContent: null,
|
|
1162
|
+
listMarker: marker,
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
function walkNode(node, parentStyle, parentCtx) {
|
|
1166
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
1167
|
+
const text = node.textContent;
|
|
1168
|
+
if (!text)
|
|
1169
|
+
return null;
|
|
1170
|
+
if (text.trim() === '' && !text.includes('\u00A0')) {
|
|
1171
|
+
const ws = parentStyle.whiteSpace;
|
|
1172
|
+
const prev = node.previousSibling;
|
|
1173
|
+
const next = node.nextSibling;
|
|
1174
|
+
const isInlineSibling = (n) => {
|
|
1175
|
+
if (!n || n.nodeType !== Node.ELEMENT_NODE)
|
|
1176
|
+
return n?.nodeType === Node.TEXT_NODE;
|
|
1177
|
+
const tag = n.tagName.toLowerCase();
|
|
1178
|
+
const def = TAG_DEFAULTS[tag];
|
|
1179
|
+
const d = def?.display || 'block';
|
|
1180
|
+
return d === 'inline' || d === 'inline-block';
|
|
1181
|
+
};
|
|
1182
|
+
if (prev && next && !isInlineSibling(prev) && !isInlineSibling(next)) {
|
|
1183
|
+
if (ws === 'pre' || ws === 'pre-wrap' || ws === 'pre-line') {
|
|
1184
|
+
// Keep
|
|
1185
|
+
}
|
|
1186
|
+
else {
|
|
1187
|
+
return null;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
if (ws !== 'pre' && ws !== 'pre-wrap' && ws !== 'pre-line') {
|
|
1191
|
+
if (text.includes('\n'))
|
|
1192
|
+
return null;
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
// Clone parent style for text node (text nodes don't match CSS rules)
|
|
1196
|
+
const style = { ...parentStyle };
|
|
1197
|
+
return {
|
|
1198
|
+
element: null,
|
|
1199
|
+
tagName: '#text',
|
|
1200
|
+
style,
|
|
1201
|
+
children: [],
|
|
1202
|
+
textContent: text,
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
if (node.nodeType !== Node.ELEMENT_NODE)
|
|
1206
|
+
return null;
|
|
1207
|
+
const el = node;
|
|
1208
|
+
const tag = el.tagName.toLowerCase();
|
|
1209
|
+
if (tag === 'style' || tag === 'script')
|
|
1210
|
+
return null;
|
|
1211
|
+
// <br> → text node with newline
|
|
1212
|
+
if (tag === 'br') {
|
|
1213
|
+
return {
|
|
1214
|
+
element: null,
|
|
1215
|
+
tagName: '#text',
|
|
1216
|
+
style: { ...parentStyle },
|
|
1217
|
+
children: [],
|
|
1218
|
+
textContent: '\n',
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
return resolveElement(el, parentStyle, parentCtx);
|
|
1222
|
+
}
|
|
1223
|
+
const rootStyle = defaultStyle();
|
|
1224
|
+
const tree = resolveElement(container, rootStyle, null);
|
|
1225
|
+
const cleanup = () => {
|
|
1226
|
+
if (fontStyleEl)
|
|
1227
|
+
fontStyleEl.remove();
|
|
1228
|
+
};
|
|
1229
|
+
return { tree, cleanup };
|
|
1230
|
+
}
|
|
1231
|
+
//# sourceMappingURL=css-resolver.js.map
|