buninu-browser 0.2.3 → 0.3.0

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 (116) hide show
  1. package/README.md +86 -1
  2. package/lib/cdp-server.js +47 -14
  3. package/lib/clipboard.js +22 -0
  4. package/lib/happy-dom/document-base.js +33 -0
  5. package/lib/happy-dom/parser.js +15 -6
  6. package/lib/happy-dom/selector-matcher.js +2 -2
  7. package/lib/happy-dom/selector-pseudo-adapter.js +19 -0
  8. package/lib/happy-dom/svg-attribute-adapter.js +50 -0
  9. package/lib/headless-shell.js +40 -11
  10. package/lib/input/hit-test.js +67 -19
  11. package/lib/layout/text-layout.js +213 -91
  12. package/lib/media/byte-pipe.js +2 -2
  13. package/lib/media/position-audio.js +10 -1
  14. package/lib/media/source-snapshots.js +126 -0
  15. package/lib/media/video-keyframe.js +17 -4
  16. package/lib/media/webm-stream.js +106 -0
  17. package/lib/network/network-service.js +268 -120
  18. package/lib/paint/display-list.js +8 -28
  19. package/lib/paint/frame-cache.js +25 -0
  20. package/lib/paint/frame.js +77 -16
  21. package/lib/paint/paragraph-text.js +24 -0
  22. package/lib/paint/raster-readback.js +41 -0
  23. package/lib/paint/raw-frames.js +47 -0
  24. package/lib/paint/rounded-rect.js +82 -0
  25. package/lib/paint/shared-frame-pool.js +105 -0
  26. package/lib/render-tree/index.js +38 -6
  27. package/lib/render-tree/svg.js +15 -7
  28. package/lib/renderer/bindings.js +233 -80
  29. package/lib/renderer/broadcast-channel.js +76 -0
  30. package/lib/renderer/canvas-2d.js +257 -14
  31. package/lib/renderer/canvas-text.js +120 -0
  32. package/lib/renderer/compositor-channel.js +103 -0
  33. package/lib/renderer/compositor.js +34 -40
  34. package/lib/renderer/custom-element-reactions.js +25 -1
  35. package/lib/renderer/flags.js +1 -1
  36. package/lib/renderer/font-loading.js +227 -0
  37. package/lib/renderer/host.js +133 -98
  38. package/lib/renderer/image-resources.js +65 -0
  39. package/lib/renderer/interfaces.js +28 -0
  40. package/lib/renderer/layout-observers.js +236 -0
  41. package/lib/renderer/main.js +52 -5
  42. package/lib/renderer/media-source.js +126 -55
  43. package/lib/renderer/page-realm.js +287 -99
  44. package/lib/renderer/page-renderer.js +663 -239
  45. package/lib/renderer/prepared-script-cache.js +50 -0
  46. package/lib/renderer/rendered-text.js +108 -0
  47. package/lib/renderer/rewrite-worker.js +4 -3
  48. package/lib/renderer/script-rewrite.js +13 -2
  49. package/lib/renderer/scroll-into-view.js +28 -0
  50. package/lib/renderer/slot-assignment.js +60 -0
  51. package/lib/renderer/video-presentation.js +23 -4
  52. package/lib/renderer/workers.js +2 -2
  53. package/lib/style/computed-style.js +611 -104
  54. package/lib/style/selector-traits.js +49 -5
  55. package/package.json +1 -1
  56. package/test/broadcast-channel.test.js +26 -0
  57. package/test/canvas-2d.test.js +83 -2
  58. package/test/canvas-paint.test.js +9 -0
  59. package/test/canvas-text.test.js +41 -0
  60. package/test/cdp-stream.test.js +56 -0
  61. package/test/clipboard.test.js +35 -0
  62. package/test/computed-style-binding.test.js +151 -0
  63. package/test/computed-style.test.js +258 -1
  64. package/test/display-list.test.js +12 -0
  65. package/test/document-base.test.js +87 -0
  66. package/test/dom-platform.test.js +461 -0
  67. package/test/fetch-lifecycle.test.js +161 -0
  68. package/test/fixtures/browser-raw-demo.html +36 -0
  69. package/test/fixtures/canvas-paint.js +117 -0
  70. package/test/fixtures/canvas-text.js +42 -0
  71. package/test/fixtures/image-lifetime.js +90 -0
  72. package/test/fixtures/media-seek-snapshot.js +77 -0
  73. package/test/fixtures/mse-audio-chirp.webm +0 -0
  74. package/test/fixtures/mse-switch-large.mp4 +0 -0
  75. package/test/fixtures/mse-switch-small.mp4 +0 -0
  76. package/test/fixtures/raw-capture-benchmark.json +76 -0
  77. package/test/fixtures/renderer-memory.js +110 -0
  78. package/test/fixtures/summary-style.js +28 -0
  79. package/test/font-loading.test.js +97 -0
  80. package/test/grid.test.js +58 -0
  81. package/test/hit-test.test.js +21 -1
  82. package/test/image-lifetime.test.js +12 -0
  83. package/test/image-resources.test.js +94 -0
  84. package/test/local-media.test.js +84 -0
  85. package/test/media-seek-snapshot.test.js +11 -0
  86. package/test/media-source-config.test.js +98 -0
  87. package/test/media-source-webm-accounting.test.js +98 -0
  88. package/test/media-source-webm.test.js +108 -0
  89. package/test/navigation.test.js +1 -0
  90. package/test/network-service.test.js +388 -0
  91. package/test/page-scripts.test.js +1 -1
  92. package/test/paint-frame.test.js +103 -0
  93. package/test/paint-paragraph-cache.test.js +87 -0
  94. package/test/position-audio.test.js +24 -0
  95. package/test/positioning.test.js +22 -0
  96. package/test/prepared-script-cache.test.js +45 -0
  97. package/test/pulse-client.test.js +10 -3
  98. package/test/raster-readback.test.js +40 -0
  99. package/test/raw-capture.test.js +152 -0
  100. package/test/render-tree.test.js +28 -0
  101. package/test/renderer-memory.test.js +11 -0
  102. package/test/selector-traits.test.js +24 -0
  103. package/test/shared-frame-pool.test.js +63 -0
  104. package/test/style-sheets.test.js +51 -0
  105. package/test/svg.test.js +29 -0
  106. package/test/text-layout.test.js +106 -0
  107. package/test/video-keyframe.test.js +61 -0
  108. package/test/video-presentation.test.js +69 -0
  109. package/test/window-resize.test.js +36 -0
  110. package/tools/bench-raw-capture.js +32 -0
  111. package/tools/browser-lab.js +78 -0
  112. package/tools/browser-soak.js +75 -0
  113. package/tools/network-reference.js +49 -0
  114. package/tools/process-memory.js +38 -0
  115. package/tools/script-benchmark.js +25 -0
  116. package/ai-debug.log +0 -5537
@@ -15,25 +15,32 @@ const BLOCK_TAGS = new Set([
15
15
  ]);
16
16
  const NONE_TAGS = new Set(["HEAD", "LINK", "META", "NOFRAMES", "SCRIPT", "STYLE", "TEMPLATE", "TITLE"]);
17
17
  const INHERITED = new Set([
18
- "color", "color-scheme", "font-family", "font-size", "font-weight", "line-height", "list-style-type", "text-align", "white-space",
19
- "visibility", "overflow-wrap", "word-break", "border-spacing", "border-collapse", "pointer-events",
18
+ "color", "color-scheme", "font-family", "font-size", "font-weight", "font-style", "font-stretch", "line-height", "list-style-type", "text-align", "white-space",
19
+ "visibility", "overflow-wrap", "word-break", "border-spacing", "border-collapse", "pointer-events", "direction",
20
+ "fill", "stroke", "stroke-width", "fill-opacity", "stroke-opacity", "fill-rule",
20
21
  ]);
21
22
  // Inherited properties whose computed value is copied from the parent as is.
22
23
  // line-height is not: a unitless factor resolves against each element's own
23
24
  // font size, so it goes through its specified form (copyProperty).
24
25
  const INHERITED_FIELDS = new Map([
25
26
  ["color", "color"], ["font-family", "fontFamily"], ["font-size", "fontSize"], ["font-weight", "fontWeight"],
27
+ ["font-style", "fontStyle"],
28
+ ["font-stretch", "fontStretch"],
26
29
  ["list-style-type", "listStyleType"], ["text-align", "textAlign"], ["white-space", "whiteSpace"],
27
30
  ["visibility", "visibility"], ["overflow-wrap", "overflowWrap"], ["word-break", "wordBreak"],
28
31
  ["border-spacing", "borderSpacing"], ["border-collapse", "borderCollapse"], ["pointer-events", "pointerEvents"],
32
+ ["direction", "direction"],
33
+ ["fill", "fill"], ["stroke", "stroke"], ["stroke-width", "strokeWidth"],
34
+ ["fill-opacity", "fillOpacity"], ["stroke-opacity", "strokeOpacity"], ["fill-rule", "fillRule"],
29
35
  ]);
30
36
  const LIST_STYLE_TYPES = new Set([
31
37
  "none", "disc", "circle", "square", "decimal", "decimal-leading-zero",
32
38
  "lower-alpha", "upper-alpha", "lower-latin", "upper-latin", "lower-roman", "upper-roman",
39
+ "disclosure-open", "disclosure-closed",
33
40
  ]);
34
41
  const DISPLAYS = new Set([
35
42
  "-webkit-box", "-webkit-inline-box",
36
- "none", "block", "flex", "grid", "inline", "inline-block", "inline-flex", "inline-grid", "list-item",
43
+ "none", "contents", "block", "flex", "grid", "inline", "inline-block", "inline-flex", "inline-grid", "list-item",
37
44
  "table", "inline-table", "table-row-group", "table-header-group", "table-footer-group", "table-row",
38
45
  "table-cell", "table-caption", "table-column", "table-column-group",
39
46
  ]);
@@ -41,6 +48,8 @@ const DISPLAYS = new Set([
41
48
  const FONT_SIZE_KEYWORDS = Object.freeze({
42
49
  "xx-small": 9, "x-small": 10, small: 13, medium: 16, large: 18, "x-large": 24, "xx-large": 32, "xxx-large": 48,
43
50
  });
51
+ const FONT_STRETCH_KEYWORDS = Object.freeze({ "ultra-condensed": 50, "extra-condensed": 62.5, condensed: 75,
52
+ "semi-condensed": 87.5, normal: 100, "semi-expanded": 112.5, expanded: 125, "extra-expanded": 150, "ultra-expanded": 200 });
44
53
  /** HTML §15.3.8 table elements and their display types. */
45
54
  const TABLE_DISPLAYS = new Map(Object.entries({
46
55
  TABLE: "table", CAPTION: "table-caption", COLGROUP: "table-column-group", COL: "table-column",
@@ -57,6 +66,8 @@ const BACKGROUND_REPEATS = new Set(["repeat", "repeat-x", "repeat-y", "no-repeat
57
66
  const BACKGROUND_BOXES = new Set(["border-box", "padding-box", "content-box"]);
58
67
  const BACKGROUND_ATTACHMENTS = new Set(["scroll", "fixed", "local"]);
59
68
  const BOX_SIDES = Object.freeze(["top", "right", "bottom", "left"]);
69
+ const CORNER_FIELDS = Object.freeze(["borderTopLeftRadius", "borderTopRightRadius", "borderBottomRightRadius", "borderBottomLeftRadius"]);
70
+ const ZERO_RADIUS = Object.freeze([0, 0]);
60
71
  const WHITE_SPACES = new Set(["normal", "nowrap", "pre", "pre-wrap"]);
61
72
  // CSS Cascade 4 §7.3 CSS-wide keywords; revert is treated as unset (no user/UA origin split).
62
73
  const CSS_WIDE_KEYWORDS = new Set(["inherit", "initial", "unset", "revert", "revert-layer"]);
@@ -68,20 +79,27 @@ const PROPERTY_FIELDS = Object.freeze({
68
79
  "overflow-x": ["overflowX", "overflowScrollX"],
69
80
  "overflow-y": ["overflowY", "overflowScrollY"],
70
81
  background: ["backgroundColor", "backgroundImage", "backgroundPosition", "backgroundSize", "backgroundRepeat"],
82
+ font: ["fontSize", "fontFamily", "fontWeight", "fontStyle", "fontStretch", "lineHeight", "lineHeightFactor"],
83
+ "font-width": ["fontStretch"],
84
+ "font-parts": ["lineHeight", "lineHeightFactor"],
71
85
  flex: ["flexGrow", "flexShrink", "flexBasis"],
72
86
  "flex-flow": ["flexDirection", "flexWrap"],
73
- gap: ["rowGap", "columnGap", "gap"],
74
- "grid-gap": ["rowGap", "columnGap", "gap"],
87
+ gap: ["rowGap", "columnGap", "gap", "rowGapNormal", "columnGapNormal"],
88
+ "grid-gap": ["rowGap", "columnGap", "gap", "rowGapNormal", "columnGapNormal"],
89
+ "row-gap": ["rowGap", "rowGapNormal"],
90
+ "column-gap": ["columnGap", "columnGapNormal"],
91
+ "grid-row-gap": ["rowGap", "rowGapNormal"],
92
+ "grid-column-gap": ["columnGap", "columnGapNormal"],
75
93
  "grid-area": ["gridRowStart", "gridColumnStart", "gridRowEnd", "gridColumnEnd"],
76
94
  "grid-row": ["gridRowStart", "gridRowEnd"],
77
95
  "grid-column": ["gridColumnStart", "gridColumnEnd"],
78
- "grid-template": ["gridTemplateColumns", "gridTemplateRows"],
96
+ "grid-template": ["gridTemplateColumns", "gridTemplateRows", "gridTemplateAreas"],
79
97
  "place-content": ["alignContent", "justifyContent"],
80
98
  "place-items": ["alignItems", "justifyItems"],
81
99
  "place-self": ["alignSelf", "justifySelf"],
82
100
  "line-height": ["lineHeight", "lineHeightFactor"],
83
101
  "list-style": ["listStyleType"],
84
- "border-radius": ["borderRadius"],
102
+ "border-radius": ["borderRadius", ...CORNER_FIELDS],
85
103
  "text-decoration": ["textDecorationLine", "textDecorationColor"],
86
104
  "text-decoration-line": ["textDecorationLine"],
87
105
  "text-decoration-color": ["textDecorationColor"],
@@ -91,12 +109,19 @@ const BORDER_FIELDS = Object.freeze(["borderSpecifiedWidths", "borderStyles", "b
91
109
 
92
110
  const INITIAL = Object.freeze({
93
111
  display: "inline",
112
+ direction: "ltr",
94
113
  // text-decoration: this element's own lines ("none" or an array) and color
95
114
  // (null = currentColor); textDecorations adds what ancestors propagate.
96
115
  textDecorationLine: "none",
97
116
  textDecorationColor: null,
98
117
  textDecorations: null,
99
118
  color: "rgba(0, 0, 0, 1)",
119
+ fill: "rgba(0, 0, 0, 1)",
120
+ stroke: "none",
121
+ strokeWidth: 1,
122
+ fillOpacity: 1,
123
+ strokeOpacity: 1,
124
+ fillRule: "nonzero",
100
125
  colorScheme: "light",
101
126
  backgroundColor: "rgba(0, 0, 0, 0)",
102
127
  backgroundImage: null,
@@ -109,6 +134,8 @@ const INITIAL = Object.freeze({
109
134
  fontFamily: Object.freeze(["sans-serif"]),
110
135
  fontSize: 16,
111
136
  fontWeight: 400,
137
+ fontStyle: "normal",
138
+ fontStretch: 100,
112
139
  // null means `line-height: normal`, which layout resolves from font metrics.
113
140
  lineHeight: null,
114
141
  lineHeightFactor: null,
@@ -132,6 +159,10 @@ const INITIAL = Object.freeze({
132
159
  borderStyles: Object.freeze(["none", "none", "none", "none"]),
133
160
  borderSpecifiedWidths: Object.freeze([3, 3, 3, 3]),
134
161
  borderRadius: 0,
162
+ borderTopLeftRadius: ZERO_RADIUS,
163
+ borderTopRightRadius: ZERO_RADIUS,
164
+ borderBottomRightRadius: ZERO_RADIUS,
165
+ borderBottomLeftRadius: ZERO_RADIUS,
135
166
  flexDirection: "row",
136
167
  flexWrap: "nowrap",
137
168
  justifyContent: "normal",
@@ -141,6 +172,10 @@ const INITIAL = Object.freeze({
141
172
  gap: 0,
142
173
  rowGap: 0,
143
174
  columnGap: 0,
175
+ // Layout uses zero for normal in flex/grid, but CSSOM must distinguish it
176
+ // from an explicitly specified zero length.
177
+ rowGapNormal: true,
178
+ columnGapNormal: true,
144
179
  flexGrow: 0,
145
180
  flexShrink: 1,
146
181
  flexBasis: "auto",
@@ -150,12 +185,14 @@ const INITIAL = Object.freeze({
150
185
  inset: Object.freeze(["auto", "auto", "auto", "auto"]),
151
186
  zIndex: "auto",
152
187
  transform: null,
188
+ translate: null,
153
189
  // Raw text (default "50% 50%"); paint resolves it against the border box.
154
190
  transformOrigin: null,
155
191
  justifyItems: "normal",
156
192
  justifySelf: "auto",
157
193
  gridTemplateColumns: null,
158
194
  gridTemplateRows: null,
195
+ gridTemplateAreas: null,
159
196
  gridAutoColumns: Object.freeze([AUTO_TRACK]),
160
197
  gridAutoRows: Object.freeze([AUTO_TRACK]),
161
198
  gridAutoFlow: Object.freeze({ column: false, dense: false }),
@@ -189,6 +226,18 @@ const INITIAL = Object.freeze({
189
226
  clipPath: null,
190
227
  });
191
228
 
229
+ // `all` resets every supported CSS property except direction (and custom
230
+ // properties). Derived rendering fields are recomputed from their owners.
231
+ const RESET_EXCLUDED_FIELDS = new Set([
232
+ "direction", "textDecorations", "lineHeightFactor", "borderWidths", "borderSpecifiedWidths",
233
+ "borderStyles", "borderColors", "inset", "overflowScrollX", "overflowScrollY",
234
+ "rowGapNormal", "columnGapNormal",
235
+ ]);
236
+ const ALL_PROPERTIES = Object.freeze(Object.keys(INITIAL)
237
+ .filter((field) => !RESET_EXCLUDED_FIELDS.has(field))
238
+ .map((field) => field.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`))
239
+ .concat(["border-width", "border-style", "border-color", "top", "right", "bottom", "left"]));
240
+
192
241
  /**
193
242
  * User-agent defaults from the HTML Standard's suggested rendering rules
194
243
  * (WHATWG HTML §15.3, CC BY 4.0) for the elements the renderer lays out.
@@ -243,6 +292,7 @@ const UA_CONTROL_DECLARATIONS = Object.freeze({
243
292
  select: parseDeclarations(`${FIELD}; border-radius: 2px; padding: 0 4px; white-space: pre`),
244
293
  });
245
294
  const BUTTON_INPUTS = new Set(["button", "submit", "reset", "image", "file", "color"]);
295
+ const SVG_PRESENTATION_PROPERTIES = ["fill", "stroke", "stroke-width", "fill-opacity", "stroke-opacity", "fill-rule", "color", "opacity", "display", "visibility"];
246
296
 
247
297
  /** Which UA control defaults an element gets, or null. */
248
298
  export function controlKind(element) {
@@ -271,6 +321,7 @@ const DEFAULT_ENVIRONMENT = Object.freeze({ viewportWidth: 800, viewportHeight:
271
321
  let environment = DEFAULT_ENVIRONMENT;
272
322
 
273
323
  export class StyleEngine {
324
+ #measureText;
274
325
  #styles = new WeakMap();
275
326
  #customProperties = new WeakMap();
276
327
  #pseudoStyles = new WeakMap();
@@ -286,6 +337,10 @@ export class StyleEngine {
286
337
  #parsedSheets = new Map();
287
338
  #parsedSheetsKey = "";
288
339
 
340
+ constructor({ measureText = null } = {}) {
341
+ this.#measureText = measureText;
342
+ }
343
+
289
344
  /**
290
345
  * Computes the document's styles. With `dirty` (elements whose subtree's
291
346
  * styles may have changed), only those subtrees are restyled and every
@@ -300,6 +355,7 @@ export class StyleEngine {
300
355
  viewportHeight: viewport.height ?? DEFAULT_ENVIRONMENT.viewportHeight,
301
356
  rootFontSize: DEFAULT_ENVIRONMENT.rootFontSize,
302
357
  mobile: viewport.mobile === true,
358
+ measureText: this.#measureText,
303
359
  };
304
360
  this.restyled = 0;
305
361
  try {
@@ -643,9 +699,37 @@ export function computeElementStyle(element, parentStyle = null, authorDeclarati
643
699
  else if (BLOCK_TAGS.has(element.tagName)) values.display = element.tagName === "LI" ? "list-item" : "block";
644
700
  else if (["BUTTON", "INPUT", "SELECT", "TEXTAREA"].includes(element.tagName)) values.display = "inline-block";
645
701
  if (["B", "STRONG"].includes(element.tagName)) values.fontWeight = 700;
702
+ if (["I", "EM", "CITE", "DFN", "VAR", "ADDRESS"].includes(element.tagName)) values.fontStyle = "italic";
646
703
  if (element.tagName === "PRE") values.whiteSpace = "pre";
704
+ if (element.tagName === "SUMMARY") {
705
+ const details = element.parentElement;
706
+ const principal = details?.tagName === "DETAILS"
707
+ && [...details.children].find((child) => child.tagName === "SUMMARY") === element;
708
+ values.display = principal ? "list-item" : "block";
709
+ if (principal) values.listStyleType = details.hasAttribute("open") ? "disclosure-open" : "disclosure-closed";
710
+ }
711
+ const direction = element.getAttribute("dir")?.toLowerCase();
712
+ if (direction === "ltr" || direction === "rtl") values.direction = direction;
647
713
 
648
714
  const declarations = new Map(authorDeclarations ?? []);
715
+ // HTML dimension attributes are presentational hints, below author CSS.
716
+ // Percentages are supported by the legacy dimension parsing rules, even
717
+ // though the modern authoring syntax normally uses non-negative integers.
718
+ if (["IMG", "VIDEO", "IFRAME"].includes(element.tagName)) {
719
+ for (const name of ["width", "height"]) {
720
+ const match = /^[\t\n\f\r ]*(\d+(?:\.\d+)?)(%?)/.exec(element.getAttribute(name) ?? "");
721
+ if (match && !declarations.has(name)) declarations.set(name, {
722
+ name, value: `${match[1]}${match[2] || "px"}`, specificity: 0, order: -1, layer: -Infinity,
723
+ });
724
+ }
725
+ }
726
+ // SVG presentation attributes precede stylesheet rules at author origin.
727
+ if (element.namespaceURI === "http://www.w3.org/2000/svg") {
728
+ for (const name of SVG_PRESENTATION_PROPERTIES) {
729
+ const value = element.getAttribute(name);
730
+ if (value != null && !declarations.has(name)) declarations.set(name, { name, value, specificity: 0, order: -1, layer: -Infinity });
731
+ }
732
+ }
649
733
  const isLink = (element.tagName === "A" || element.tagName === "AREA") && element.hasAttribute("href");
650
734
  const control = controlKind(element);
651
735
  if (control === "hidden") values.display = "none";
@@ -663,23 +747,58 @@ export function computeElementStyle(element, parentStyle = null, authorDeclarati
663
747
  declarations.set(declaration.name, { ...declaration, specificity: 1_000_000, order: Infinity });
664
748
  }
665
749
  }
750
+ const reset = declarations.get("all");
751
+ if (reset && CSS_WIDE_KEYWORDS.has(resolveVariables(reset.value, customProperties))) {
752
+ // Expand before resolving font prerequisites. Competing declarations
753
+ // keep their normal importance, specificity and source-order priority.
754
+ for (const name of ALL_PROPERTIES) {
755
+ const current = declarations.get(name);
756
+ if (!current || compareDeclarations(reset, current) >= 0) declarations.set(name, { ...reset, name });
757
+ }
758
+ }
666
759
  // The font size comes first (em lengths use it), from whichever of
667
760
  // font-size and the font shorthand wins the cascade.
668
761
  const fontSize = declarations.get("font-size");
669
762
  const font = declarations.get("font");
670
763
  if (font && (!fontSize || compareDeclarations(font, fontSize) > 0)) {
671
- const parsed = parseFontShorthand(resolveVariables(font.value, customProperties));
672
- if (parsed) setProperty(values, "font-size", parsed.size, parentStyle);
764
+ const fontValue = resolveVariables(font.value, customProperties);
765
+ const parsed = parseFontShorthand(fontValue);
766
+ if (CSS_WIDE_KEYWORDS.has(fontValue)) setProperty(values, "font-size", fontValue, parentStyle);
767
+ else if (parsed) setProperty(values, "font-size", parsed.size, parentStyle);
673
768
  } else if (fontSize) {
674
769
  applyDeclaration(values, fontSize, customProperties, parentStyle);
675
770
  }
676
771
  // light-dark() depends on the used scheme regardless of declaration order.
677
772
  const colorScheme = declarations.get("color-scheme");
678
773
  if (colorScheme) applyDeclaration(values, colorScheme, customProperties, parentStyle);
774
+ const directionDeclaration = declarations.get("direction");
775
+ if (directionDeclaration) applyDeclaration(values, directionDeclaration, customProperties, parentStyle);
679
776
  // Apply in cascade order so a later shorthand overrides earlier longhands and vice versa.
680
777
  const ordered = [...declarations.values()].sort(compareDeclarations);
778
+ // Font-relative lengths such as ch use this element's final font, even
779
+ // when width/padding appear before font-family or the font shorthand.
780
+ for (const declaration of ordered) {
781
+ if (declaration.name === "font") {
782
+ const fontValue = resolveVariables(declaration.value, customProperties);
783
+ const parsed = parseFontShorthand(fontValue);
784
+ if (CSS_WIDE_KEYWORDS.has(fontValue)) {
785
+ setProperty(values, "font-family", fontValue, parentStyle);
786
+ setProperty(values, "font-weight", fontValue, parentStyle);
787
+ setProperty(values, "font-style", fontValue, parentStyle);
788
+ setProperty(values, "font-stretch", fontValue, parentStyle);
789
+ }
790
+ if (parsed) {
791
+ setProperty(values, "font-family", parsed.family, parentStyle);
792
+ setProperty(values, "font-weight", parsed.weight ?? "normal", parentStyle);
793
+ setProperty(values, "font-style", parsed.style ?? "normal", parentStyle);
794
+ setProperty(values, "font-stretch", parsed.stretch ?? "normal", parentStyle);
795
+ }
796
+ } else if (["font-family", "font-weight", "font-style", "font-stretch", "font-width"].includes(declaration.name)) {
797
+ applyDeclaration(values, declaration, customProperties, parentStyle);
798
+ }
799
+ }
681
800
  for (const declaration of ordered) {
682
- if (declaration.name.startsWith("--") || declaration.name === "font-size" || declaration.name === "color-scheme") continue;
801
+ if (declaration.name.startsWith("--") || ["font-size", "font-family", "font-weight", "font-style", "font-stretch", "font-width", "color-scheme", "direction"].includes(declaration.name)) continue;
683
802
  // The shorthand's size was settled above; its other parts apply in order.
684
803
  applyDeclaration(values, declaration.name === "font" ? { ...declaration, name: "font-parts" } : declaration, customProperties, parentStyle);
685
804
  }
@@ -689,7 +808,7 @@ export function computeElementStyle(element, parentStyle = null, authorDeclarati
689
808
  const parentDisplay = parentStyle?.display ?? "block";
690
809
  const flexOrGridItem = ["flex", "inline-flex", "grid", "inline-grid"].includes(parentDisplay);
691
810
  if (values.float !== "none" && (["absolute", "fixed"].includes(values.position) || flexOrGridItem)) values.float = "none";
692
- if (values.float !== "none" && values.display !== "none") {
811
+ if (values.float !== "none" && !["none", "contents"].includes(values.display)) {
693
812
  values.display = { flex: "inline-flex", grid: "inline-grid", "inline-flex": "inline-flex", "inline-grid": "inline-grid" }[values.display]
694
813
  ?? "inline-block";
695
814
  }
@@ -866,6 +985,110 @@ export function parseStyleSheet(source, viewport = environment, counter = { orde
866
985
  return rules;
867
986
  }
868
987
 
988
+ /** Font resource descriptors, separate from selector rules (CSS Fonts §4). */
989
+ export function extractFontFaces(source, baseURL, viewport = environment) {
990
+ const css = String(source).replace(/\/\*[\s\S]*?\*\//g, "");
991
+ const faces = [];
992
+ function visit(start, end) {
993
+ let index = start;
994
+ while (index < end) {
995
+ const open = findTopLevel(css, "{", index, end);
996
+ const semicolon = findTopLevel(css, ";", index, end);
997
+ if (semicolon !== -1 && (open === -1 || semicolon < open)) {
998
+ index = semicolon + 1;
999
+ continue;
1000
+ }
1001
+ if (open === -1) break;
1002
+ const close = matchingBrace(css, open, end);
1003
+ const prelude = css.slice(index, open).trim();
1004
+ const name = /^@([\w-]+)/.exec(prelude)?.[1]?.toLowerCase();
1005
+ if (name === "font-face") {
1006
+ const descriptors = parseDeclarations(css.slice(open + 1, close));
1007
+ const value = (name) => descriptors.get(name)?.important ? undefined : descriptors.get(name)?.value;
1008
+ const family = fontFaceFamily(value("font-family"));
1009
+ const sources = fontFaceSources(value("src"), baseURL);
1010
+ if (family && sources.length) {
1011
+ const weights = (value("font-weight") ?? "normal").split(/\s+/).map((part) =>
1012
+ part === "normal" ? 400 : part === "bold" ? 700 : Number(part));
1013
+ const weight = weights.length <= 2 && weights.every((part) => part >= 1 && part <= 1000)
1014
+ ? [Math.min(...weights), Math.max(...weights)] : [400, 400];
1015
+ const style = /^(normal|italic|oblique)(?:\s|$)/.exec(value("font-style") ?? "normal")?.[1] ?? "normal";
1016
+ const stretches = (value("font-width") ?? value("font-stretch") ?? "normal").split(/\s+/).map(fontStretch);
1017
+ const stretch = stretches.length <= 2 && stretches.every(Number.isFinite)
1018
+ ? [Math.min(...stretches), Math.max(...stretches)] : [100, 100];
1019
+ const display = value("font-display");
1020
+ faces.push({ family, sources, weight, style, stretch,
1021
+ unicodeRanges: fontFaceUnicodeRanges(value("unicode-range")),
1022
+ display: ["auto", "block", "swap", "fallback", "optional"].includes(display) ? display : "auto" });
1023
+ }
1024
+ } else if (name === "layer" || name === "supports"
1025
+ || name === "media" && mediaQueryMatches(prelude.slice(6).trim(), viewport)) {
1026
+ visit(open + 1, close);
1027
+ }
1028
+ index = close + 1;
1029
+ }
1030
+ }
1031
+ visit(0, css.length);
1032
+ return faces;
1033
+ }
1034
+
1035
+ function decodeCssString(value) {
1036
+ const text = String(value ?? "").trim();
1037
+ const quoted = /^(?:"((?:[^"\\]|\\[\s\S])*)"|'((?:[^'\\]|\\[\s\S])*)')$/.exec(text);
1038
+ return (quoted ? quoted[1] ?? quoted[2] : text).replace(/\\(?:([\da-f]{1,6})\s?|([\s\S]))/gi,
1039
+ (_, hex, literal) => hex ? String.fromCodePoint(Math.min(0x10ffff, parseInt(hex, 16)) || 0xfffd)
1040
+ : /[\r\n\f]/.test(literal) ? "" : literal);
1041
+ }
1042
+
1043
+ function fontFaceFamily(value) {
1044
+ if (!value) return null;
1045
+ const text = value.trim();
1046
+ if (/^["']/.test(text)) {
1047
+ return /^(?:"(?:[^"\\]|\\[\s\S])*"|'(?:[^'\\]|\\[\s\S])*')$/.test(text) ? decodeCssString(text) : null;
1048
+ }
1049
+ if (!/^(?:-?(?:[a-z_\u0080-\uFFFF]|--)[\w\u0080-\uFFFF-]*)(?:\s+-?(?:[a-z_\u0080-\uFFFF]|--)[\w\u0080-\uFFFF-]*)*$/i.test(text)) return null;
1050
+ if (CSS_WIDE_KEYWORDS.has(text.toLowerCase()) || ["serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui"].includes(text.toLowerCase())) return null;
1051
+ return text.replace(/\s+/g, " ");
1052
+ }
1053
+
1054
+ function fontFaceSources(value, baseURL) {
1055
+ if (!value) return [];
1056
+ const result = [];
1057
+ for (const source of splitTopLevel(value, ",")) {
1058
+ const match = /^(url|local)\(\s*((?:"(?:[^"\\]|\\[\s\S])*"|'(?:[^'\\]|\\[\s\S])*'|[^)]*?))\s*\)\s*(.*)$/i.exec(source);
1059
+ if (!match) continue;
1060
+ const target = decodeCssString(match[2]);
1061
+ if (!target) continue;
1062
+ if (match[1].toLowerCase() === "local") {
1063
+ if (!match[3]) result.push({ local: target });
1064
+ continue;
1065
+ }
1066
+ let url;
1067
+ try { url = new URL(target, baseURL).href; } catch { continue; }
1068
+ const format = /^format\(\s*([^)]*)\s*\)/i.exec(match[3]);
1069
+ const rest = format ? match[3].slice(format[0].length).trim() : match[3];
1070
+ if (rest && !/^tech\([^)]*\)$/i.test(rest)) continue;
1071
+ result.push({ url, ...(format ? { format: decodeCssString(format[1]).toLowerCase() } : {}),
1072
+ ...(rest ? { tech: rest.slice(5, -1).split(",").map((part) => part.trim().toLowerCase()) } : {}) });
1073
+ }
1074
+ return result;
1075
+ }
1076
+
1077
+ function fontFaceUnicodeRanges(value) {
1078
+ const fallback = [[0, 0x10ffff]];
1079
+ if (!value) return fallback;
1080
+ const ranges = [];
1081
+ for (const token of value.split(",")) {
1082
+ const match = /^\s*U\+([\da-f]{1,6})(?:-([\da-f]{1,6}))?\s*$|^\s*U\+([\da-f]{0,5}\?{1,6})\s*$/i.exec(token);
1083
+ if (!match || match[3]?.length > 6) return fallback;
1084
+ const from = parseInt(match[3]?.replaceAll("?", "0") ?? match[1], 16);
1085
+ const to = Math.min(0x10ffff, parseInt(match[3]?.replaceAll("?", "F") ?? match[2] ?? match[1], 16));
1086
+ if (from > to || from > 0x10ffff) return fallback;
1087
+ ranges.push([from, to]);
1088
+ }
1089
+ return ranges.length ? ranges : fallback;
1090
+ }
1091
+
869
1092
  function newLayer(parent) {
870
1093
  return { parent, children: new Map(), rank: Infinity };
871
1094
  }
@@ -921,13 +1144,17 @@ function rankLayerTree(root) {
921
1144
  }
922
1145
 
923
1146
  /** Splits a `font` shorthand value into size, line height, weight and family. */
924
- function parseFontShorthand(value) {
1147
+ export function parseFontShorthand(value) {
925
1148
  const tokens = splitTopLevel(value, " ").filter(Boolean);
926
1149
  let weight = null;
1150
+ let style = "normal";
1151
+ let stretch = "normal";
927
1152
  for (let index = 0; index < tokens.length; index++) {
928
1153
  const token = tokens[index];
929
1154
  const lower = token.toLowerCase();
930
- if (["normal", "italic", "oblique", "small-caps"].includes(lower) || /^(?:ultra-|extra-|semi-)?(?:condensed|expanded)$/.test(lower)) continue;
1155
+ if (lower === "italic" || lower === "oblique") { style = lower; continue; }
1156
+ if (lower !== "normal" && Object.hasOwn(FONT_STRETCH_KEYWORDS, lower)) { stretch = lower; continue; }
1157
+ if (["normal", "small-caps"].includes(lower)) continue;
931
1158
  if (["bold", "bolder", "lighter"].includes(lower) || /^[1-9]00$/.test(lower)) {
932
1159
  weight = lower === "bolder" ? "700" : lower === "lighter" ? "300" : lower;
933
1160
  continue;
@@ -943,7 +1170,7 @@ function parseFontShorthand(value) {
943
1170
  } else if (lineHeight === undefined && tokens[rest]?.startsWith("/")) lineHeight = tokens[rest++].slice(1); // "14px /1.5"
944
1171
  const family = tokens.slice(rest).join(" ").trim();
945
1172
  if (!family || !(FONT_SIZE_KEYWORDS[size] || /^[\d.]/.test(size) || /^(?:calc|clamp|min|max)\(/.test(size))) return null;
946
- return { size, lineHeight: lineHeight || null, weight, family };
1173
+ return { size, lineHeight: lineHeight || null, weight, style, stretch, family };
947
1174
  }
948
1175
  return null;
949
1176
  }
@@ -960,7 +1187,8 @@ function findTopLevel(source, character, start, end) {
960
1187
  if (quote) {
961
1188
  if (current === BACKSLASH) index++;
962
1189
  else if (current === quote) quote = 0;
963
- } else if (current === QUOTE || current === APOSTROPHE) quote = current;
1190
+ } else if (current === BACKSLASH) index++;
1191
+ else if (current === QUOTE || current === APOSTROPHE) quote = current;
964
1192
  else if (current === OPEN_PAREN) depth++;
965
1193
  else if (current === CLOSE_PAREN) depth = Math.max(0, depth - 1);
966
1194
  else if (current === target && depth === 0) return index;
@@ -977,7 +1205,8 @@ function matchingBrace(source, open, end) {
977
1205
  if (quote) {
978
1206
  if (current === BACKSLASH) index++;
979
1207
  else if (current === quote) quote = 0;
980
- } else if (current === QUOTE || current === APOSTROPHE) quote = current;
1208
+ } else if (current === BACKSLASH) index++;
1209
+ else if (current === QUOTE || current === APOSTROPHE) quote = current;
981
1210
  else if (current === OPEN_BRACE) depth++;
982
1211
  else if (current === CLOSE_BRACE && --depth === 0) return index;
983
1212
  }
@@ -1046,6 +1275,7 @@ function mediaLength(value) {
1046
1275
 
1047
1276
  export function parseDeclarations(source) {
1048
1277
  const declarations = new Map();
1278
+ let declarationIndex = 0;
1049
1279
  for (const part of splitDeclarations(String(source))) {
1050
1280
  const colon = part.indexOf(":");
1051
1281
  if (colon < 1) continue;
@@ -1056,7 +1286,7 @@ export function parseDeclarations(source) {
1056
1286
  const important = /\s*!important\s*$/i.test(value);
1057
1287
  value = value.replace(/\s*!important\s*$/i, "").trim();
1058
1288
  if (!name || !value || (declarations.get(name)?.important && !important)) continue;
1059
- declarations.set(name, { name, value, important });
1289
+ declarations.set(name, { name, value, important, declarationIndex: declarationIndex++ });
1060
1290
  }
1061
1291
  return declarations;
1062
1292
  }
@@ -1079,7 +1309,46 @@ function applyDeclaration(style, declaration, customProperties, parentStyle) {
1079
1309
  }
1080
1310
  }
1081
1311
 
1312
+ const LOGICAL_SIZES = Object.freeze({
1313
+ "inline-size": "width", "block-size": "height",
1314
+ "min-inline-size": "min-width", "max-inline-size": "max-width",
1315
+ "min-block-size": "min-height", "max-block-size": "max-height",
1316
+ });
1317
+
1082
1318
  function setProperty(style, name, value, parentStyle = null) {
1319
+ if (name === "font-width") return setProperty(style, "font-stretch", value, parentStyle);
1320
+ const logicalCorner = /^border-(start|end)-(start|end)-radius$/.exec(name);
1321
+ if (logicalCorner) {
1322
+ const block = logicalCorner[1] === "start" ? "top" : "bottom";
1323
+ const inline = (logicalCorner[2] === "start") !== (style.direction === "rtl") ? "left" : "right";
1324
+ return setProperty(style, `border-${block}-${inline}-radius`, value, parentStyle);
1325
+ }
1326
+ // The layout engine uses horizontal writing. Resolve logical declarations
1327
+ // into their physical slots in cascade order, after direction is computed.
1328
+ if (Object.hasOwn(LOGICAL_SIZES, name)) return setProperty(style, LOGICAL_SIZES[name], value, parentStyle);
1329
+ if (name.includes("-inline") || name.includes("-block")) {
1330
+ const logical = /^(margin|padding|inset)-(inline|block)(?:-(start|end))?$/.exec(name);
1331
+ if (logical) {
1332
+ const [, property, axis, edge] = logical;
1333
+ const sides = axis === "block" ? ["top", "bottom"]
1334
+ : style.direction === "rtl" ? ["right", "left"] : ["left", "right"];
1335
+ const physical = (side) => property === "inset" ? side : `${property}-${side}`;
1336
+ if (edge) return setProperty(style, physical(sides[edge === "start" ? 0 : 1]), value, parentStyle);
1337
+ if (CSS_WIDE_KEYWORDS.has(value)) {
1338
+ for (const side of sides) setProperty(style, physical(side), value, parentStyle);
1339
+ return;
1340
+ }
1341
+ const parts = splitTopLevel(value, " ").filter(Boolean);
1342
+ if (!parts.length || parts.length > 2) return;
1343
+ const lengths = parts.map((part) => property !== "padding" && part === "auto" ? "auto"
1344
+ : computedLength(part, style, property !== "margin"));
1345
+ if (lengths.some((length) => length !== "auto" && !Number.isFinite(length) && !length?.unit
1346
+ || property === "padding" && (typeof length === "number" ? length < 0 : length?.unit === "%" && length.value < 0))) return;
1347
+ setProperty(style, physical(sides[0]), parts[0], parentStyle);
1348
+ setProperty(style, physical(sides[1]), parts[1] ?? parts[0], parentStyle);
1349
+ return;
1350
+ }
1351
+ }
1083
1352
  if (CSS_WIDE_KEYWORDS.has(value)) {
1084
1353
  const inherits = value === "inherit" || (value !== "initial" && INHERITED.has(name));
1085
1354
  copyProperty(style, name, inherits && parentStyle ? parentStyle : INITIAL);
@@ -1087,6 +1356,9 @@ function setProperty(style, name, value, parentStyle = null) {
1087
1356
  }
1088
1357
  if (typeof value !== "string") return;
1089
1358
  switch (name) {
1359
+ case "direction":
1360
+ if (value === "ltr" || value === "rtl") style.direction = value;
1361
+ break;
1090
1362
  case "color-scheme": {
1091
1363
  const schemes = value.toLowerCase().split(/\s+/).filter((part) => part !== "only");
1092
1364
  if (schemes.every((part) => ["normal", "light", "dark"].includes(part))) {
@@ -1123,7 +1395,7 @@ function setProperty(style, name, value, parentStyle = null) {
1123
1395
  if (!parsed) return;
1124
1396
  lines = parsed;
1125
1397
  } else if (DECORATION_STYLES.has(lower) || lower === "auto" || lower === "from-font"
1126
- || Number.isFinite(computedLength(token, style.fontSize, true)) || /%$/.test(token)) {
1398
+ || Number.isFinite(computedLength(token, style, true)) || /%$/.test(token)) {
1127
1399
  continue;
1128
1400
  } else if (lower === "currentcolor") {
1129
1401
  color = null;
@@ -1178,7 +1450,7 @@ function setProperty(style, name, value, parentStyle = null) {
1178
1450
  if (value === "auto") style.clip = null;
1179
1451
  const rect = /^rect\(\s*(.*)\s*\)$/i.exec(value);
1180
1452
  if (rect) {
1181
- const edges = rect[1].split(/\s*,\s*|\s+/).map((part) => (part === "auto" ? "auto" : computedLength(part, style.fontSize)));
1453
+ const edges = rect[1].split(/\s*,\s*|\s+/).map((part) => (part === "auto" ? "auto" : computedLength(part, style)));
1182
1454
  if (edges.length === 4 && edges.every((edge) => edge === "auto" || Number.isFinite(edge))) style.clip = Object.freeze(edges);
1183
1455
  }
1184
1456
  break;
@@ -1188,7 +1460,7 @@ function setProperty(style, name, value, parentStyle = null) {
1188
1460
  if (value === "none") style.clipPath = null;
1189
1461
  const inset = /^inset\(\s*([^)]*?)\s*(?:round\s[^)]*)?\)$/i.exec(value);
1190
1462
  if (inset) {
1191
- const box = boxLengths(inset[1], false, style.fontSize, false, true);
1463
+ const box = boxLengths(inset[1], false, style, false, true);
1192
1464
  if (box) style.clipPath = Object.freeze({ inset: box });
1193
1465
  } else if (/^rect\(\s*0(?:px)?\s+0(?:px)?\s+0(?:px)?\s+0(?:px)?\s*\)$/i.test(value)) {
1194
1466
  style.clipPath = Object.freeze({ empty: true });
@@ -1200,6 +1472,25 @@ function setProperty(style, name, value, parentStyle = null) {
1200
1472
  if (color) style.color = color;
1201
1473
  break;
1202
1474
  }
1475
+ case "fill":
1476
+ case "stroke": {
1477
+ const paint = ["none", "currentcolor"].includes(value.toLowerCase()) ? value.toLowerCase()
1478
+ : /^url\(/i.test(value) ? value : resolvedColor(value, style.colorScheme);
1479
+ if (paint) style[name] = paint;
1480
+ break;
1481
+ }
1482
+ case "stroke-width": {
1483
+ const width = /^[\d.]+$/.test(value) ? Number(value) : computedLength(value, style);
1484
+ if (Number.isFinite(width) && width >= 0) style.strokeWidth = width;
1485
+ break;
1486
+ }
1487
+ case "fill-opacity":
1488
+ case "stroke-opacity": {
1489
+ const opacity = value.endsWith("%") ? Number(value.slice(0, -1)) / 100 : Number(value);
1490
+ if (Number.isFinite(opacity)) style[name === "fill-opacity" ? "fillOpacity" : "strokeOpacity"] = Math.max(0, Math.min(1, opacity));
1491
+ break;
1492
+ }
1493
+ case "fill-rule": if (["nonzero", "evenodd"].includes(value)) style.fillRule = value; break;
1203
1494
  case "background-color": {
1204
1495
  const color = resolvedColor(value, style.colorScheme);
1205
1496
  if (color) style.backgroundColor = color;
@@ -1238,7 +1529,7 @@ function setProperty(style, name, value, parentStyle = null) {
1238
1529
  break;
1239
1530
  }
1240
1531
  case "box-shadow": {
1241
- const shadows = value === "none" ? null : parseBoxShadows(value, style.fontSize);
1532
+ const shadows = value === "none" ? null : parseBoxShadows(value, style);
1242
1533
  if (value === "none" || shadows) style.boxShadow = shadows;
1243
1534
  break;
1244
1535
  }
@@ -1248,7 +1539,7 @@ function setProperty(style, name, value, parentStyle = null) {
1248
1539
  break;
1249
1540
  case "font-size": {
1250
1541
  const parentSize = parentStyle?.fontSize ?? INITIAL.fontSize;
1251
- const specified = computedLength(value, parentSize, true);
1542
+ const specified = computedLength(value, parentStyle ?? INITIAL, true);
1252
1543
  const relativeSize = specified?.unit === "%" ? parentSize * specified.value / 100
1253
1544
  : specified?.unit === "math" && specified.kind === "calc" ? specified.px + parentSize * specified.percent / 100 : specified;
1254
1545
  const length = FONT_SIZE_KEYWORDS[value] ?? (value === "larger" ? parentSize * 1.2 : value === "smaller" ? parentSize / 1.2
@@ -1265,8 +1556,12 @@ function setProperty(style, name, value, parentStyle = null) {
1265
1556
  if (!parsed) break;
1266
1557
  if (name === "font") setProperty(style, "font-size", parsed.size, parentStyle);
1267
1558
  setProperty(style, "line-height", parsed.lineHeight ?? "normal", parentStyle);
1268
- setProperty(style, "font-weight", parsed.weight ?? "normal", parentStyle);
1269
- setProperty(style, "font-family", parsed.family, parentStyle);
1559
+ if (name === "font") {
1560
+ setProperty(style, "font-weight", parsed.weight ?? "normal", parentStyle);
1561
+ setProperty(style, "font-style", parsed.style ?? "normal", parentStyle);
1562
+ setProperty(style, "font-stretch", parsed.stretch ?? "normal", parentStyle);
1563
+ setProperty(style, "font-family", parsed.family, parentStyle);
1564
+ }
1270
1565
  break;
1271
1566
  }
1272
1567
  case "position":
@@ -1276,13 +1571,13 @@ function setProperty(style, name, value, parentStyle = null) {
1276
1571
  case "right":
1277
1572
  case "bottom":
1278
1573
  case "left": {
1279
- const length = value === "auto" ? "auto" : computedLength(value, style.fontSize, true);
1574
+ const length = value === "auto" ? "auto" : computedLength(value, style, true);
1280
1575
  if (length === "auto" || Number.isFinite(length) || length?.unit) style.inset = style.inset.with(BOX_SIDES.indexOf(name), length);
1281
1576
  break;
1282
1577
  }
1283
1578
  case "inset": {
1284
1579
  const parts = splitTopLevel(value, " ").filter(Boolean)
1285
- .map((part) => part === "auto" ? "auto" : computedLength(part, style.fontSize, true));
1580
+ .map((part) => part === "auto" ? "auto" : computedLength(part, style, true));
1286
1581
  if (!parts.length || parts.length > 4 || parts.some((part) => part !== "auto" && !Number.isFinite(part) && !part?.unit)) break;
1287
1582
  const [top, right = top, bottom = top, left = right] = parts;
1288
1583
  style.inset = [top, right, bottom, left];
@@ -1294,10 +1589,19 @@ function setProperty(style, name, value, parentStyle = null) {
1294
1589
  break;
1295
1590
  }
1296
1591
  case "transform": {
1297
- const transform = value === "none" ? null : parseTransform(value, style.fontSize);
1592
+ const transform = value === "none" ? null : parseTransform(value, style);
1298
1593
  if (transform !== undefined) style.transform = transform;
1299
1594
  break;
1300
1595
  }
1596
+ case "translate": {
1597
+ if (value === "none") { style.translate = null; break; }
1598
+ const parts = splitTopLevel(value, " ").filter(Boolean);
1599
+ if (parts.length < 1 || parts.length > 2) break;
1600
+ const lengths = parts.map(part => computedLength(part, style, true));
1601
+ if (lengths.some(part => !Number.isFinite(part) && !part?.unit)) break;
1602
+ style.translate = Object.freeze({x:lengths[0],y:lengths[1] ?? 0});
1603
+ break;
1604
+ }
1301
1605
  case "transform-origin":
1302
1606
  style.transformOrigin = value.toLowerCase().replace(/\s+/g, " ");
1303
1607
  break;
@@ -1325,9 +1629,16 @@ function setProperty(style, name, value, parentStyle = null) {
1325
1629
  if (Number.isFinite(weight) && weight >= 1 && weight <= 1000) style.fontWeight = weight;
1326
1630
  break;
1327
1631
  }
1632
+ case "font-style": if (["normal", "italic", "oblique"].includes(value)) style.fontStyle = value; break;
1633
+ case "font-stretch":
1634
+ case "font-width": {
1635
+ const stretch = fontStretch(value);
1636
+ if (Number.isFinite(stretch)) style.fontStretch = stretch;
1637
+ break;
1638
+ }
1328
1639
  case "white-space": if (WHITE_SPACES.has(value)) style.whiteSpace = value; break;
1329
1640
  case "border-spacing": {
1330
- const parts = value.split(/\s+/).map((part) => computedLength(part, style.fontSize));
1641
+ const parts = value.split(/\s+/).map((part) => computedLength(part, style));
1331
1642
  if (parts.length <= 2 && parts.every((part) => Number.isFinite(part) && part >= 0)) {
1332
1643
  style.borderSpacing = Object.freeze([parts[0], parts[1] ?? parts[0]]);
1333
1644
  }
@@ -1371,7 +1682,7 @@ function setProperty(style, name, value, parentStyle = null) {
1371
1682
  // Non-negative, so 0 is valid (icon buttons set line-height: 0); a
1372
1683
  // percentage computes to a length against this element's font size.
1373
1684
  const percentage = /^(\d+\.?\d*|\.\d+)%$/.exec(value);
1374
- const length = percentage ? Number(percentage[1]) / 100 * style.fontSize : computedLength(value, style.fontSize);
1685
+ const length = percentage ? Number(percentage[1]) / 100 * style.fontSize : computedLength(value, style);
1375
1686
  if (length >= 0) {
1376
1687
  style.lineHeight = length;
1377
1688
  style.lineHeightFactor = null;
@@ -1380,12 +1691,12 @@ function setProperty(style, name, value, parentStyle = null) {
1380
1691
  break;
1381
1692
  }
1382
1693
  case "margin": {
1383
- const box = boxLengths(value, true, style.fontSize, true);
1694
+ const box = boxLengths(value, true, style, true);
1384
1695
  if (box) style.margin = box;
1385
1696
  break;
1386
1697
  }
1387
1698
  case "padding": {
1388
- const box = boxLengths(value, false, style.fontSize, false, true);
1699
+ const box = boxLengths(value, false, style, false, true);
1389
1700
  if (box) style.padding = box;
1390
1701
  break;
1391
1702
  }
@@ -1399,7 +1710,7 @@ function setProperty(style, name, value, parentStyle = null) {
1399
1710
  case "padding-left": {
1400
1711
  const [box, side] = name.split("-");
1401
1712
  const isMargin = box === "margin";
1402
- const length = isMargin && value === "auto" ? "auto" : computedLength(value, style.fontSize, !isMargin);
1713
+ const length = isMargin && value === "auto" ? "auto" : computedLength(value, style, !isMargin);
1403
1714
  if (length === "auto" || (!isMargin && isLength(length) && typeof length === "object"
1404
1715
  && (!isPercentage(length) || length.value >= 0)) || (Number.isFinite(length) && (isMargin || length >= 0))) {
1405
1716
  style[box] = style[box].with(BOX_SIDES.indexOf(side), length);
@@ -1408,7 +1719,7 @@ function setProperty(style, name, value, parentStyle = null) {
1408
1719
  }
1409
1720
  case "width":
1410
1721
  case "height": {
1411
- const length = value === "auto" ? "auto" : computedLength(value, style.fontSize, true);
1722
+ const length = value === "auto" ? "auto" : computedLength(value, style, true);
1412
1723
  if (length === "auto" || isLength(length)) style[name] = length;
1413
1724
  break;
1414
1725
  }
@@ -1417,7 +1728,7 @@ function setProperty(style, name, value, parentStyle = null) {
1417
1728
  case "min-height":
1418
1729
  case "max-height": {
1419
1730
  const none = name.startsWith("max-") && value === "none";
1420
- const length = none ? "none" : computedLength(value, style.fontSize, true);
1731
+ const length = none ? "none" : computedLength(value, style, true);
1421
1732
  if (none || isLength(length)) {
1422
1733
  style[{ "min-width": "minWidth", "max-width": "maxWidth", "min-height": "minHeight", "max-height": "maxHeight" }[name]] = length;
1423
1734
  }
@@ -1429,7 +1740,7 @@ function setProperty(style, name, value, parentStyle = null) {
1429
1740
  case "border-bottom":
1430
1741
  case "border-left": {
1431
1742
  // Shorthands reset width (medium), style (none) and color (currentColor).
1432
- const parsed = parseBorderShorthand(value, style.fontSize);
1743
+ const parsed = parseBorderShorthand(value, style);
1433
1744
  if (!parsed) break;
1434
1745
  const sides = name === "border" ? [0, 1, 2, 3] : [BOX_SIDES.indexOf(name.slice(7))];
1435
1746
  for (const side of sides) {
@@ -1443,7 +1754,7 @@ function setProperty(style, name, value, parentStyle = null) {
1443
1754
  case "border-style":
1444
1755
  case "border-color": {
1445
1756
  const parts = splitTopLevel(value, " ").filter(Boolean).map((part) => name === "border-width"
1446
- ? borderWidth(part, style.fontSize)
1757
+ ? borderWidth(part, style)
1447
1758
  : name === "border-style" ? (BORDER_STYLES.has(part) ? part : null) : borderColor(part));
1448
1759
  if (!parts.length || parts.length > 4 || parts.some((part) => part === undefined || (name !== "border-color" && part == null))) break;
1449
1760
  const [top, right = top, bottom = top, left = right] = parts;
@@ -1459,7 +1770,7 @@ function setProperty(style, name, value, parentStyle = null) {
1459
1770
  const [, side, part] = name.split("-");
1460
1771
  const index = BOX_SIDES.indexOf(side);
1461
1772
  if (part === "width") {
1462
- const width = borderWidth(value, style.fontSize);
1773
+ const width = borderWidth(value, style);
1463
1774
  if (width != null) style.borderSpecifiedWidths = style.borderSpecifiedWidths.with(index, width);
1464
1775
  } else if (part === "style") {
1465
1776
  if (BORDER_STYLES.has(value)) style.borderStyles = style.borderStyles.with(index, value);
@@ -1470,12 +1781,40 @@ function setProperty(style, name, value, parentStyle = null) {
1470
1781
  break;
1471
1782
  }
1472
1783
  case "border-radius": {
1473
- // One radius for every corner; percentages resolve against the box at paint time.
1474
- const length = computedLength(splitTopLevel(value.split("/")[0], " ").filter(Boolean)[0] ?? "", style.fontSize, true);
1475
- if (length >= 0 || length?.unit === "%") style.borderRadius = length;
1784
+ const axes = splitTopLevel(value, "/");
1785
+ if (axes.length > 2) break;
1786
+ const horizontal = radiusLengths(axes[0], style, 4);
1787
+ const vertical = axes.length === 2 ? radiusLengths(axes[1], style, 4) : horizontal;
1788
+ if (!horizontal || !vertical) break;
1789
+ const expand = values => [values[0], values[1] ?? values[0], values[2] ?? values[0], values[3] ?? values[1] ?? values[0]];
1790
+ const x = expand(horizontal), y = expand(vertical);
1791
+ for (let i = 0; i < 4; i++) style[CORNER_FIELDS[i]] = Object.freeze([x[i], y[i]]);
1792
+ style.borderRadius = x[0];
1793
+ break;
1794
+ }
1795
+ case "border-top-left-radius": case "border-top-right-radius":
1796
+ case "border-bottom-right-radius": case "border-bottom-left-radius": {
1797
+ const lengths = radiusLengths(value, style, 2);
1798
+ if (lengths) style[name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())]
1799
+ = Object.freeze([lengths[0], lengths[1] ?? lengths[0]]);
1476
1800
  break;
1477
1801
  }
1478
1802
  case "box-sizing": if (["content-box", "border-box"].includes(value)) style.boxSizing = value; break;
1803
+ case "flex-flow": {
1804
+ const parts = value.split(/\s+/);
1805
+ let direction = null;
1806
+ let wrap = null;
1807
+ for (const part of parts) {
1808
+ if (["row", "column"].includes(part) && direction == null) direction = part;
1809
+ else if (["nowrap", "wrap"].includes(part) && wrap == null) wrap = part;
1810
+ else return;
1811
+ }
1812
+ if (parts.length) {
1813
+ style.flexDirection = direction ?? "row";
1814
+ style.flexWrap = wrap ?? "nowrap";
1815
+ }
1816
+ break;
1817
+ }
1479
1818
  case "flex-direction": if (["row", "column"].includes(value)) style.flexDirection = value; break;
1480
1819
  case "flex-wrap": if (["nowrap", "wrap"].includes(value)) style.flexWrap = value; break;
1481
1820
  case "justify-content": {
@@ -1507,12 +1846,16 @@ function setProperty(style, name, value, parentStyle = null) {
1507
1846
  break;
1508
1847
  }
1509
1848
  case "gap": {
1510
- const [row, column = row] = splitTopLevel(value, " ").filter(Boolean)
1511
- .map((part) => part === "normal" ? 0 : computedLength(part, style.fontSize));
1849
+ const parts = splitTopLevel(value, " ").filter(Boolean);
1850
+ if (parts.length < 1 || parts.length > 2) break;
1851
+ const [row, column = row] = parts
1852
+ .map((part) => part === "normal" ? 0 : computedLength(part, style));
1512
1853
  if (row >= 0 && column >= 0) {
1513
1854
  style.gap = row;
1514
1855
  style.rowGap = row;
1515
1856
  style.columnGap = column;
1857
+ style.rowGapNormal = parts[0] === "normal";
1858
+ style.columnGapNormal = (parts[1] ?? parts[0]) === "normal";
1516
1859
  }
1517
1860
  break;
1518
1861
  }
@@ -1521,13 +1864,50 @@ function setProperty(style, name, value, parentStyle = null) {
1521
1864
  case "grid-column-gap": return setProperty(style, "column-gap", value, parentStyle);
1522
1865
  case "grid-template-columns":
1523
1866
  case "grid-template-rows": {
1524
- const tracks = value === "none" ? null : parseTrackList(value, style.fontSize);
1867
+ const tracks = value === "none" ? null : parseTrackList(value, style);
1525
1868
  if (value === "none" || tracks) style[name === "grid-template-columns" ? "gridTemplateColumns" : "gridTemplateRows"] = tracks;
1526
1869
  break;
1527
1870
  }
1871
+ case "grid-template-areas": {
1872
+ const areas = value === "none" ? null : parseGridAreas(value);
1873
+ if (value === "none" || areas) style.gridTemplateAreas = areas;
1874
+ break;
1875
+ }
1876
+ case "grid-template": {
1877
+ if (value === "none") {
1878
+ style.gridTemplateRows = style.gridTemplateColumns = style.gridTemplateAreas = null;
1879
+ break;
1880
+ }
1881
+ const parts = splitTopLevel(value, "/");
1882
+ if (parts.length > 2) break;
1883
+ const columns = parts[1] == null || parts[1] === "none" ? null : parseTrackList(parts[1], style);
1884
+ if (parts[1] != null && parts[1] !== "none" && !columns) break;
1885
+ // A string token needs no surrounding whitespace in minified CSS.
1886
+ const rowText = parts[0].replace(/\[[^\]]*\]/g, "").replace(/"[^"\\]*"|'[^'\\]*'/g, " $& ");
1887
+ const tokens = splitTopLevel(rowText, " ").filter(Boolean);
1888
+ if (tokens.some((token) => /^["']/.test(token))) {
1889
+ const rows = [], strings = [];
1890
+ let valid = true;
1891
+ for (let index = 0; index < tokens.length; index++) {
1892
+ const token = tokens[index];
1893
+ if (!/^(?:"[^"\\]*"|'[^'\\]*')$/.test(token)) { valid = false; break; }
1894
+ strings.push(token);
1895
+ const size = tokens[index + 1] && !/^["']/.test(tokens[index + 1]) ? tokens[++index] : "auto";
1896
+ const track = parseTrackSize(size, style);
1897
+ if (!track) { valid = false; break; }
1898
+ rows.push(track);
1899
+ }
1900
+ const areas = valid && parseGridAreas(strings.join(" "));
1901
+ if (areas) Object.assign(style, { gridTemplateRows: Object.freeze(rows), gridTemplateColumns: columns, gridTemplateAreas: areas });
1902
+ } else if (parts.length === 2) {
1903
+ const rows = parts[0] === "none" ? null : parseTrackList(parts[0], style);
1904
+ if (parts[0] === "none" || rows) Object.assign(style, { gridTemplateRows: rows, gridTemplateColumns: columns, gridTemplateAreas: null });
1905
+ }
1906
+ break;
1907
+ }
1528
1908
  case "grid-auto-columns":
1529
1909
  case "grid-auto-rows": {
1530
- const tracks = parseTrackList(value, style.fontSize);
1910
+ const tracks = parseTrackList(value, style);
1531
1911
  if (tracks?.length && tracks.every((entry) => !entry.repeat)) {
1532
1912
  style[name === "grid-auto-columns" ? "gridAutoColumns" : "gridAutoRows"] = tracks;
1533
1913
  }
@@ -1553,12 +1933,13 @@ function setProperty(style, name, value, parentStyle = null) {
1553
1933
  const [start, end] = value.split("/").map((part) => parseGridLine(part.trim()));
1554
1934
  const axis = name === "grid-column" ? "Column" : "Row";
1555
1935
  style[`grid${axis}Start`] = start;
1556
- // A single integer leaves the end auto (spanning one track).
1557
- style[`grid${axis}End`] = end ?? "auto";
1936
+ // A lone area name denotes both edges; a lone integer spans one track.
1937
+ style[`grid${axis}End`] = end ?? (start.name ? start : "auto");
1558
1938
  break;
1559
1939
  }
1560
1940
  case "grid-area": {
1561
- const [rowStart, columnStart = "auto", rowEnd = "auto", columnEnd = "auto"] = value.split("/")
1941
+ const [rowStart, columnStart = rowStart.name ? rowStart : "auto",
1942
+ rowEnd = rowStart.name ? rowStart : "auto", columnEnd = columnStart.name ? columnStart : "auto"] = value.split("/")
1562
1943
  .map((part) => parseGridLine(part.trim()));
1563
1944
  Object.assign(style, { gridRowStart: rowStart, gridColumnStart: columnStart, gridRowEnd: rowEnd, gridColumnEnd: columnEnd });
1564
1945
  break;
@@ -1572,8 +1953,12 @@ function setProperty(style, name, value, parentStyle = null) {
1572
1953
  }
1573
1954
  case "row-gap":
1574
1955
  case "column-gap": {
1575
- const length = value === "normal" ? 0 : computedLength(value, style.fontSize);
1576
- if (length >= 0) style[name === "row-gap" ? "rowGap" : "columnGap"] = length;
1956
+ const length = value === "normal" ? 0 : computedLength(value, style);
1957
+ if (length >= 0) {
1958
+ const field = name === "row-gap" ? "rowGap" : "columnGap";
1959
+ style[field] = length;
1960
+ style[`${field}Normal`] = value === "normal";
1961
+ }
1577
1962
  break;
1578
1963
  }
1579
1964
  case "align-self": {
@@ -1588,12 +1973,12 @@ function setProperty(style, name, value, parentStyle = null) {
1588
1973
  break;
1589
1974
  }
1590
1975
  case "flex-basis": {
1591
- const basis = flexBasis(value, style.fontSize);
1976
+ const basis = flexBasis(value, style);
1592
1977
  if (basis != null) style.flexBasis = basis;
1593
1978
  break;
1594
1979
  }
1595
1980
  case "flex": {
1596
- const flex = flexShorthand(value, style.fontSize);
1981
+ const flex = flexShorthand(value, style);
1597
1982
  if (flex) Object.assign(style, flex);
1598
1983
  break;
1599
1984
  }
@@ -1807,7 +2192,7 @@ function cascadeDeclarations(element, rules, pseudo = null, keys = elementKeys(e
1807
2192
  for (const declaration of rule.declarationList) {
1808
2193
  const current = result.get(declaration.name);
1809
2194
  if (current && compareCascadeParts(declaration.important, layer, specificity, order, current) < 0) continue;
1810
- result.set(declaration.name, { name: declaration.name, value: declaration.value, important: declaration.important, specificity, order, layer });
2195
+ result.set(declaration.name, { ...declaration, specificity, order, layer });
1811
2196
  }
1812
2197
  }
1813
2198
  return result;
@@ -1842,7 +2227,10 @@ function compareDeclarations(left, right) {
1842
2227
  const leftSpecificity = "specificity" in left ? left.specificity : 0;
1843
2228
  const rightSpecificity = "specificity" in right ? right.specificity : 0;
1844
2229
  if (leftSpecificity !== rightSpecificity) return leftSpecificity - rightSpecificity;
1845
- return ("order" in left ? left.order : 0) - ("order" in right ? right.order : 0);
2230
+ const leftOrder = left.order ?? 0;
2231
+ const rightOrder = right.order ?? 0;
2232
+ if (leftOrder !== rightOrder) return leftOrder - rightOrder;
2233
+ return (left.declarationIndex ?? 0) - (right.declarationIndex ?? 0);
1846
2234
  }
1847
2235
 
1848
2236
  const PSEUDO_ELEMENT = /(?:::?(before|after)|::([\w-]+))$/i;
@@ -1945,53 +2333,75 @@ function pseudoElement(pseudo) {
1945
2333
  return { tagName: `::${pseudo}`, getAttribute: () => null, hasAttribute: () => false };
1946
2334
  }
1947
2335
 
1948
- /**
1949
- * Keeps the translation part of a transform list as { x, y } lengths (px or
1950
- * percentages of the element's own border box). Other functions are ignored
1951
- * until transforms are painted with matrices.
1952
- */
1953
2336
  /**
1954
2337
  * The translation (layout moves the box) and scale (paint scales it about
1955
- * transform-origin) of a transform list; other functions are ignored.
2338
+ * transform-origin) of a transform list. Compose in list order: a translation
2339
+ * after a scale uses that scaled coordinate system. Percentages remain lengths
2340
+ * until layout knows the element's own border box. Other functions are ignored.
1956
2341
  */
1957
2342
  function parseTransform(value, fontSize) {
1958
- const translation = parseTranslate(value, fontSize);
1959
- if (translation === undefined) return undefined;
2343
+ let x = 0;
2344
+ let y = 0;
1960
2345
  let scaleX = 1;
1961
2346
  let scaleY = 1;
1962
2347
  let found = false;
1963
- for (const match of value.matchAll(/\b(scale|scaleX|scaleY|scale3d)\(([^)]*)\)/gi)) {
1964
- const args = splitTopLevel(match[2], ",").map((part) => part.endsWith("%") ? Number(part.slice(0, -1)) / 100 : Number(part));
1965
- if (!args.length || args.some((part) => !Number.isFinite(part))) return undefined;
2348
+ const functions = /\b([a-z][a-z0-9]*)\(/gi;
2349
+ let match;
2350
+ while ((match = functions.exec(value))) {
2351
+ let end = functions.lastIndex;
2352
+ let depth = 1;
2353
+ while (end < value.length && depth) {
2354
+ if (value[end] === "(") depth++;
2355
+ else if (value[end] === ")") depth--;
2356
+ end++;
2357
+ }
2358
+ if (depth) return undefined;
1966
2359
  const kind = match[1].toLowerCase();
1967
- if (kind === "scalex") scaleX *= args[0];
1968
- else if (kind === "scaley") scaleY *= args[0];
1969
- else {
1970
- scaleX *= args[0];
1971
- scaleY *= args[1] ?? args[0];
2360
+ const parts = splitTopLevel(value.slice(functions.lastIndex, end - 1), ",");
2361
+ functions.lastIndex = end;
2362
+ if (["translate", "translatex", "translatey", "translate3d"].includes(kind)) {
2363
+ const args = parts.map(part => computedLength(part, fontSize, true));
2364
+ const max = kind === "translate3d" ? 3 : kind === "translate" ? 2 : 1;
2365
+ if (!args.length || args.length > max || args.some(part => !Number.isFinite(part) && !part?.unit)) return undefined;
2366
+ if (kind === "translatey") y = addTransformLength(y, scaleTransformLength(args[0], scaleY));
2367
+ else {
2368
+ x = addTransformLength(x, scaleTransformLength(args[0], scaleX));
2369
+ if (kind !== "translatex") y = addTransformLength(y, scaleTransformLength(args[1] ?? 0, scaleY));
2370
+ }
2371
+ } else if (["scale", "scalex", "scaley", "scale3d"].includes(kind)) {
2372
+ const args = parts.map(part => part.endsWith("%") ? Number(part.slice(0, -1)) / 100 : Number(part));
2373
+ const max = kind === "scale3d" ? 3 : kind === "scale" ? 2 : 1;
2374
+ if (!args.length || args.length > max || args.some(part => !Number.isFinite(part))) return undefined;
2375
+ if (kind === "scalex") scaleX *= args[0];
2376
+ else if (kind === "scaley") scaleY *= args[0];
2377
+ else { scaleX *= args[0]; scaleY *= args[1] ?? args[0]; }
2378
+ } else {
2379
+ continue;
1972
2380
  }
1973
2381
  found = true;
1974
2382
  }
1975
- if (!found || (scaleX === 1 && scaleY === 1)) return translation;
1976
- return Object.freeze({ x: translation?.x ?? 0, y: translation?.y ?? 0, scaleX, scaleY });
2383
+ if (!found) return null;
2384
+ return Object.freeze(scaleX === 1 && scaleY === 1 ? { x, y } : { x, y, scaleX, scaleY });
1977
2385
  }
1978
2386
 
1979
- function parseTranslate(value, fontSize) {
1980
- let x = 0;
1981
- let y = 0;
1982
- let found = false;
1983
- for (const match of value.matchAll(/(translate|translateX|translateY|translate3d)\(([^)]*)\)/gi)) {
1984
- const args = splitTopLevel(match[2], ",").map((part) => computedLength(part, fontSize, true));
1985
- if (args.some((part) => !Number.isFinite(part) && !part?.unit)) return undefined;
1986
- const kind = match[1].toLowerCase();
1987
- if (kind === "translatey") y = args[0];
1988
- else {
1989
- x = args[0];
1990
- if (kind !== "translatex") y = args[1] ?? 0;
1991
- }
1992
- found = true;
1993
- }
1994
- return found ? Object.freeze({ x, y }) : null;
2387
+ function scaleTransformLength(value, factor) {
2388
+ if (factor === 1) return value;
2389
+ if (typeof value === "number") return value * factor;
2390
+ if (value.unit === "%") return Object.freeze({ unit: "%", value: value.value * factor });
2391
+ if (value.kind === "calc") return Object.freeze({ ...value, px: value.px * factor, percent: value.percent * factor });
2392
+ return Object.freeze({ unit: "math", kind: "scale", value, factor });
2393
+ }
2394
+
2395
+ function addTransformLength(a, b) {
2396
+ if (a === 0) return b;
2397
+ if (b === 0) return a;
2398
+ const linear = value => typeof value === "number" ? { px: value, percent: 0 }
2399
+ : value.unit === "%" ? { px: 0, percent: value.value } : value.kind === "calc" ? value : null;
2400
+ const left = linear(a), right = linear(b);
2401
+ if (!left || !right) return Object.freeze({ unit: "math", kind: "sum", values: Object.freeze([a, b]) });
2402
+ const px = left.px + right.px, percent = left.percent + right.percent;
2403
+ return percent === 0 ? px : px === 0 ? Object.freeze({ unit: "%", value: percent })
2404
+ : Object.freeze({ unit: "math", kind: "calc", px, percent });
1995
2405
  }
1996
2406
 
1997
2407
  /** Parses `content` into its string, null for none/normal, undefined if unsupported. */
@@ -2073,12 +2483,22 @@ function copyProperty(style, name, source) {
2073
2483
  style[side[1]] = style[side[1]].with(index, source[side[1]][index]);
2074
2484
  return;
2075
2485
  }
2486
+ const insetSide = BOX_SIDES.indexOf(name);
2487
+ if (insetSide >= 0) {
2488
+ style.inset = style.inset.with(insetSide, source.inset[insetSide]);
2489
+ return;
2490
+ }
2076
2491
  // border, border-<side>, border-<width|style|color>, border-<side>-<...>;
2077
2492
  // not border-radius, border-spacing or border-collapse.
2078
- const isBorder = /^border(?:-(?:top|right|bottom|left))?(?:-(?:width|style|color))?$/.test(name);
2079
- const fields = isBorder
2080
- ? BORDER_FIELDS
2081
- : PROPERTY_FIELDS[name] ?? [name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())];
2493
+ const border = /^border(?:-(top|right|bottom|left))?(?:-(width|style|color))?$/.exec(name);
2494
+ if (border) {
2495
+ const fields = border[2] === "width" ? ["borderSpecifiedWidths"]
2496
+ : border[2] === "style" ? ["borderStyles"] : border[2] === "color" ? ["borderColors"] : BORDER_FIELDS;
2497
+ const side = border[1] == null ? -1 : BOX_SIDES.indexOf(border[1]);
2498
+ for (const field of fields) style[field] = side < 0 ? source[field] : style[field].with(side, source[field][side]);
2499
+ return;
2500
+ }
2501
+ const fields = PROPERTY_FIELDS[name] ?? [name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())];
2082
2502
  for (const field of fields) {
2083
2503
  if (Object.hasOwn(INITIAL, field)) style[field] = source[field];
2084
2504
  }
@@ -2090,6 +2510,9 @@ function cssValue(style, name) {
2090
2510
  "font-family": style.fontFamily.map((family) => JSON.stringify(family)).join(", "),
2091
2511
  "font-size": `${style.fontSize}px`,
2092
2512
  "font-weight": String(style.fontWeight),
2513
+ "font-style": style.fontStyle,
2514
+ "font-stretch": `${style.fontStretch}%`,
2515
+ "font-width": `${style.fontStretch}%`,
2093
2516
  "line-height": style.lineHeightFactor != null ? String(style.lineHeightFactor)
2094
2517
  : style.lineHeight == null ? "normal" : `${style.lineHeight}px`,
2095
2518
  "list-style-type": style.listStyleType,
@@ -2105,7 +2528,22 @@ function pixelLength(value) {
2105
2528
  return match ? Number(match[1]) : NaN;
2106
2529
  }
2107
2530
 
2108
- function computedLength(value, emSize, allowPercentage = false) {
2531
+ function radiusLengths(value, style, maximum) {
2532
+ const parts = splitTopLevel(value, " ").filter(Boolean);
2533
+ if (!parts.length || parts.length > maximum) return null;
2534
+ const lengths = parts.map(part => computedLength(part, style, true));
2535
+ return lengths.every((length, index) => Number.isFinite(length)
2536
+ ? length >= 0 || /^(?:calc|min|max|clamp)\(/i.test(parts[index])
2537
+ : length?.unit === "%" ? length.value >= 0 : length?.unit === "math") ? lengths : null;
2538
+ }
2539
+
2540
+ function fontStretch(value) {
2541
+ if (Object.hasOwn(FONT_STRETCH_KEYWORDS, value)) return FONT_STRETCH_KEYWORDS[value];
2542
+ const percent = /^(?:\d+\.?\d*|\.\d+)%$/.test(value) ? Number.parseFloat(value) : NaN;
2543
+ return percent >= 0 ? percent : NaN;
2544
+ }
2545
+
2546
+ function computedLength(value, font, allowPercentage = false) {
2109
2547
  const text = String(value).trim();
2110
2548
  if (allowPercentage) {
2111
2549
  const percentage = /^([+-]?(?:\d+\.?\d*|\.\d+))%$/.exec(text);
@@ -2114,14 +2552,14 @@ function computedLength(value, emSize, allowPercentage = false) {
2114
2552
  const absolute = pixelLength(text);
2115
2553
  if (Number.isFinite(absolute)) return absolute;
2116
2554
  if (/^calc\(/i.test(text)) {
2117
- const sum = calcLength(text, emSize);
2555
+ const sum = calcLength(text, font);
2118
2556
  if (!sum || !(sum.percent === 0 || allowPercentage)) return NaN;
2119
2557
  return sum.percent === 0 ? sum.px : Object.freeze({ unit: "math", kind: "calc", px: sum.px, percent: sum.percent });
2120
2558
  }
2121
2559
  const math = /^(clamp|min|max)\((.*)\)$/i.exec(text);
2122
2560
  if (math) {
2123
2561
  const kind = math[1].toLowerCase();
2124
- const values = splitTopLevel(math[2], ",").map((part) => computedLength(part, emSize, allowPercentage));
2562
+ const values = splitTopLevel(math[2], ",").map((part) => computedLength(part, font, allowPercentage));
2125
2563
  if (!values.length || values.some((part) => !Number.isFinite(part) && !(part?.unit))) return NaN;
2126
2564
  // Percentages inside math functions resolve at layout time.
2127
2565
  if (values.some((part) => part?.unit)) {
@@ -2133,14 +2571,38 @@ function computedLength(value, emSize, allowPercentage = false) {
2133
2571
  if (kind === "max") return Math.max(...values);
2134
2572
  return values.length === 3 ? Math.max(values[0], Math.min(values[1], values[2])) : NaN;
2135
2573
  }
2136
- const relative = /^([+-]?(?:\d+\.?\d*|\.\d+))(rem|em|vw|vh|pt)$/i.exec(text);
2574
+ const relative = /^([+-]?(?:\d+\.?\d*|\.\d+))(rem|em|ch|[sld]?v(?:w|h|i|b|min|max)|cq(?:w|h|i|b|min|max)|pt)$/i.exec(text);
2137
2575
  if (!relative) return NaN;
2138
2576
  const unit = relative[2].toLowerCase();
2577
+ if (/^(?:[sld]?v|cq)/.test(unit)) {
2578
+ // A headless viewport has no retractable browser UI: small, large and
2579
+ // dynamic viewport sizes coincide. Inline/block follow horizontal writing.
2580
+ // Without an eligible query container, cq* uses the small viewport
2581
+ // (CSS Containment 3 §6). Container-type sizing is not implemented yet.
2582
+ const axis = unit.replace(/^(?:[sld]?v|cq)/, "");
2583
+ const {viewportWidth:width,viewportHeight:height} = environment;
2584
+ const size = axis === "w" || axis === "i" ? width : axis === "h" || axis === "b" ? height
2585
+ : axis === "min" ? Math.min(width,height) : Math.max(width,height);
2586
+ return Number(relative[1]) * size / 100;
2587
+ }
2588
+ const emSize = typeof font === "object" ? font.fontSize : font;
2589
+ // CSS Values 4 §6.1.1: ch is the advance of U+0030 in the used font.
2590
+ // The spec's 0.5em fallback applies when no font measurement is available.
2591
+ let characterWidth = emSize / 2;
2592
+ if (unit === "ch" && environment.measureText) {
2593
+ // The style is still being built; do not give a mutable style identity
2594
+ // to the renderer's immutable-style font-key cache.
2595
+ const measured = environment.measureText("0", {
2596
+ fontSize: emSize, fontFamily: font?.fontFamily ?? INITIAL.fontFamily,
2597
+ fontWeight: font?.fontWeight ?? INITIAL.fontWeight,
2598
+ fontStyle: font?.fontStyle ?? INITIAL.fontStyle,
2599
+ fontStretch: font?.fontStretch ?? INITIAL.fontStretch,
2600
+ });
2601
+ if (Number.isFinite(measured) && measured >= 0) characterWidth = measured;
2602
+ }
2139
2603
  const scale = unit === "rem" ? environment.rootFontSize
2140
2604
  : unit === "em" ? emSize
2141
- : unit === "vw" ? environment.viewportWidth / 100
2142
- : unit === "vh" ? environment.viewportHeight / 100
2143
- : 4 / 3;
2605
+ : unit === "ch" ? characterWidth : 4 / 3;
2144
2606
  return Number(relative[1]) * scale;
2145
2607
  }
2146
2608
 
@@ -2151,7 +2613,7 @@ function computedLength(value, emSize, allowPercentage = false) {
2151
2613
  * for anything else (such as min() of a percentage inside calc()).
2152
2614
  */
2153
2615
  function calcLength(text, emSize) {
2154
- const tokens = String(text).match(/[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?(?:%|[a-z]+)?|[a-z-]+\(|[()*/+-]/gi);
2616
+ const tokens = String(text).match(/[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?(?:%|[a-z]+)?|[a-z-]+\(|[(),*/+-]/gi);
2155
2617
  if (!tokens || tokens.join("").replace(/\s+/g, "") !== String(text).replace(/\s+/g, "")) return null;
2156
2618
  let index = 0;
2157
2619
  // Values are { px, percent } (a length) or { number }.
@@ -2345,10 +2807,47 @@ function parseGridLine(token) {
2345
2807
  if (span) return Object.freeze({ span: Math.max(1, Number(span[1] ?? span[2])) });
2346
2808
  const line = Number(token);
2347
2809
  if (Number.isInteger(line) && line !== 0) return Object.freeze({ line });
2348
- // Named lines and areas are not supported yet; treat them as auto.
2810
+ if (/^-?(?:[a-zA-Z_\u0080-\uFFFF]|--)[\w\u0080-\uFFFF-]*$/.test(token) && token !== "span") {
2811
+ return Object.freeze({ name: token });
2812
+ }
2349
2813
  return "auto";
2350
2814
  }
2351
2815
 
2816
+ /** CSS Grid §7.3: equal-width strings whose named cells form rectangles. */
2817
+ function parseGridAreas(value) {
2818
+ const rows = [];
2819
+ const strings = /\s*(["'])(.*?)\1\s*/gy;
2820
+ while (strings.lastIndex < value.length) {
2821
+ const match = strings.exec(value);
2822
+ if (!match) return null;
2823
+ const cells = match[2].match(/[\w\u0080-\uFFFF-]+|\.+|[^\s]/g) ?? [];
2824
+ if (!cells.length || cells.some((cell) => !/^(?:[\w\u0080-\uFFFF-]+|\.+)$/.test(cell))) return null;
2825
+ if (rows.length && cells.length !== rows[0].length) return null;
2826
+ rows.push(cells.map((cell) => cell[0] === "." ? null : cell));
2827
+ }
2828
+ if (!rows.length) return null;
2829
+ const areas = Object.create(null);
2830
+ for (let row = 0; row < rows.length; row++) {
2831
+ for (let column = 0; column < rows[row].length; column++) {
2832
+ const name = rows[row][column];
2833
+ if (name == null) continue;
2834
+ const area = areas[name] ??= { row, column, rowEnd: row + 1, columnEnd: column + 1 };
2835
+ area.rowEnd = row + 1;
2836
+ area.column = Math.min(area.column, column);
2837
+ area.columnEnd = Math.max(area.columnEnd, column + 1);
2838
+ }
2839
+ }
2840
+ for (const [name, area] of Object.entries(areas)) {
2841
+ for (let row = area.row; row < area.rowEnd; row++) {
2842
+ for (let column = area.column; column < area.columnEnd; column++) {
2843
+ if (rows[row][column] !== name) return null;
2844
+ }
2845
+ }
2846
+ Object.freeze(area);
2847
+ }
2848
+ return Object.freeze({ rows: rows.length, columns: rows[0].length, areas: Object.freeze(areas) });
2849
+ }
2850
+
2352
2851
  const BORDER_STYLES = new Set(["none", "hidden", "solid", "dashed", "dotted", "double", "groove", "ridge", "inset", "outset"]);
2353
2852
  const BORDER_WIDTH_KEYWORDS = Object.freeze({ thin: 1, medium: 3, thick: 5 });
2354
2853
 
@@ -2386,12 +2885,12 @@ function flexBasis(value, fontSize) {
2386
2885
  function flexShorthand(value, fontSize) {
2387
2886
  if (value === "none") return { flexGrow: 0, flexShrink: 0, flexBasis: "auto" };
2388
2887
  if (value === "auto") return { flexGrow: 1, flexShrink: 1, flexBasis: "auto" };
2389
- const parts = value.trim().split(/\s+/);
2888
+ const parts = splitTopLevel(value.trim(), " ").filter(Boolean);
2390
2889
  const numbers = [];
2391
2890
  let basis = null;
2392
2891
  for (const part of parts) {
2393
2892
  const number = Number(part);
2394
- if (part !== "" && Number.isFinite(number) && numbers.length < 2 && basis == null) numbers.push(number);
2893
+ if (part !== "" && Number.isFinite(number) && number >= 0 && numbers.length < 2 && basis == null) numbers.push(number);
2395
2894
  else if (basis == null && (basis = flexBasis(part, fontSize)) != null) continue;
2396
2895
  else return null;
2397
2896
  }
@@ -2400,7 +2899,9 @@ function flexShorthand(value, fontSize) {
2400
2899
  return {
2401
2900
  flexGrow: numbers[0] ?? 1,
2402
2901
  flexShrink: numbers[1] ?? 1,
2403
- flexBasis: basis ?? (numbers.length ? 0 : "auto"),
2902
+ // The omitted basis is a percentage, not a definite zero length. In an
2903
+ // auto-height column, 0% is indefinite and uses the item's content size.
2904
+ flexBasis: basis ?? (numbers.length ? Object.freeze({unit:"%",value:0}) : "auto"),
2404
2905
  };
2405
2906
  }
2406
2907
 
@@ -2476,10 +2977,16 @@ export function absolutizeStyleSheetUrls(text, base) {
2476
2977
  function splitTopLevel(source, separator) {
2477
2978
  const parts = [];
2478
2979
  let depth = 0;
2980
+ let quote = "";
2479
2981
  let start = 0;
2480
2982
  for (let index = 0; index < source.length; index++) {
2481
2983
  const character = source[index];
2482
- if (character === "(") depth++;
2984
+ if (quote) {
2985
+ if (character === "\\") index++;
2986
+ else if (character === quote) quote = "";
2987
+ } else if (character === "\\") index++;
2988
+ else if (character === '"' || character === "'") quote = character;
2989
+ else if (character === "(") depth++;
2483
2990
  else if (character === ")") depth--;
2484
2991
  else if ((character === separator || separator === " " && /\s/.test(character)) && depth === 0) {
2485
2992
  parts.push(source.slice(start, index).trim());