buninu-browser 0.2.2 → 0.2.4

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 (118) hide show
  1. package/lib/audio/playback-buffer.js +27 -0
  2. package/lib/audio/pulse-client.js +18 -8
  3. package/lib/cdp-server.js +53 -14
  4. package/lib/happy-dom/document-base.js +33 -0
  5. package/lib/happy-dom/parser.js +20 -6
  6. package/lib/happy-dom/selector-matcher.js +2 -2
  7. package/lib/happy-dom/selector-pseudo-adapter.js +33 -0
  8. package/lib/happy-dom/svg-attribute-adapter.js +50 -0
  9. package/lib/headless-shell.js +12 -6
  10. package/lib/input/hit-test.js +67 -19
  11. package/lib/layout/text-layout.js +289 -131
  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 +42 -0
  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 +22 -27
  19. package/lib/paint/frame-cache.js +25 -0
  20. package/lib/paint/frame.js +42 -13
  21. package/lib/paint/paragraph-text.js +24 -0
  22. package/lib/paint/raster-readback.js +41 -0
  23. package/lib/paint/rounded-rect.js +82 -0
  24. package/lib/paint/shared-frame-pool.js +105 -0
  25. package/lib/render-tree/index.js +40 -6
  26. package/lib/render-tree/svg.js +15 -7
  27. package/lib/renderer/bindings.js +244 -82
  28. package/lib/renderer/broadcast-channel.js +76 -0
  29. package/lib/renderer/canvas-2d.js +257 -14
  30. package/lib/renderer/canvas-text.js +120 -0
  31. package/lib/renderer/compositor.js +27 -10
  32. package/lib/renderer/custom-element-reactions.js +42 -0
  33. package/lib/renderer/font-loading.js +227 -0
  34. package/lib/renderer/history.js +42 -0
  35. package/lib/renderer/host.js +36 -5
  36. package/lib/renderer/image-loading.js +27 -0
  37. package/lib/renderer/image-resources.js +65 -0
  38. package/lib/renderer/interfaces.js +28 -0
  39. package/lib/renderer/keyboard-input.js +12 -0
  40. package/lib/renderer/layout-observers.js +236 -0
  41. package/lib/renderer/main.js +33 -5
  42. package/lib/renderer/media-playback-events.js +13 -0
  43. package/lib/renderer/media-source.js +126 -55
  44. package/lib/renderer/page-realm.js +278 -147
  45. package/lib/renderer/page-renderer.js +840 -301
  46. package/lib/renderer/piano-input.js +22 -0
  47. package/lib/renderer/pointer-move.js +29 -0
  48. package/lib/renderer/prepared-script-cache.js +50 -0
  49. package/lib/renderer/rendered-text.js +108 -0
  50. package/lib/renderer/rewrite-worker.js +4 -3
  51. package/lib/renderer/script-rewrite.js +13 -2
  52. package/lib/renderer/scroll-into-view.js +28 -0
  53. package/lib/renderer/slot-assignment.js +60 -0
  54. package/lib/renderer/video-presentation.js +145 -0
  55. package/lib/renderer/workers.js +2 -2
  56. package/lib/style/computed-style.js +632 -108
  57. package/lib/style/selector-traits.js +49 -5
  58. package/package.json +1 -1
  59. package/test/broadcast-channel.test.js +26 -0
  60. package/test/canvas-2d.test.js +83 -2
  61. package/test/canvas-paint.test.js +9 -0
  62. package/test/canvas-text.test.js +41 -0
  63. package/test/cdp-stream.test.js +56 -0
  64. package/test/compositor.test.js +38 -0
  65. package/test/computed-style-binding.test.js +151 -0
  66. package/test/computed-style.test.js +279 -1
  67. package/test/display-list.test.js +26 -0
  68. package/test/document-base.test.js +87 -0
  69. package/test/dom-platform.test.js +461 -0
  70. package/test/download.test.js +8 -0
  71. package/test/fetch-lifecycle.test.js +161 -0
  72. package/test/fixtures/canvas-paint.js +117 -0
  73. package/test/fixtures/canvas-text.js +42 -0
  74. package/test/fixtures/image-lifetime.js +90 -0
  75. package/test/fixtures/media-seek-snapshot.js +77 -0
  76. package/test/fixtures/mse-audio-chirp.webm +0 -0
  77. package/test/fixtures/mse-switch-large.mp4 +0 -0
  78. package/test/fixtures/mse-switch-small.mp4 +0 -0
  79. package/test/fixtures/renderer-memory.js +110 -0
  80. package/test/fixtures/summary-style.js +28 -0
  81. package/test/font-loading.test.js +97 -0
  82. package/test/grid.test.js +58 -0
  83. package/test/headless-shell.test.js +22 -0
  84. package/test/hit-test.test.js +21 -1
  85. package/test/image-lifetime.test.js +12 -0
  86. package/test/image-resources.test.js +94 -0
  87. package/test/local-media.test.js +84 -0
  88. package/test/media-playback-events.test.js +22 -0
  89. package/test/media-seek-snapshot.test.js +11 -0
  90. package/test/media-source-config.test.js +98 -0
  91. package/test/media-source-webm-accounting.test.js +98 -0
  92. package/test/media-source-webm.test.js +108 -0
  93. package/test/navigation.test.js +1 -0
  94. package/test/network-service.test.js +388 -0
  95. package/test/page-scripts.test.js +160 -2
  96. package/test/paint-frame.test.js +103 -0
  97. package/test/paint-paragraph-cache.test.js +87 -0
  98. package/test/piano-input.test.js +44 -0
  99. package/test/playback-buffer.test.js +31 -0
  100. package/test/position-audio.test.js +24 -0
  101. package/test/positioning.test.js +22 -0
  102. package/test/prepared-script-cache.test.js +45 -0
  103. package/test/pulse-client.test.js +27 -5
  104. package/test/raster-readback.test.js +40 -0
  105. package/test/render-tree.test.js +28 -0
  106. package/test/renderer-memory.test.js +11 -0
  107. package/test/selector-traits.test.js +24 -0
  108. package/test/shared-frame-pool.test.js +63 -0
  109. package/test/style-sheets.test.js +51 -0
  110. package/test/svg.test.js +29 -0
  111. package/test/text-layout.test.js +168 -0
  112. package/test/video-keyframe.test.js +61 -0
  113. package/test/video-presentation.test.js +136 -0
  114. package/tools/browser-lab.js +78 -0
  115. package/tools/browser-soak.js +75 -0
  116. package/tools/network-reference.js +49 -0
  117. package/tools/process-memory.js +38 -0
  118. package/tools/script-benchmark.js +25 -0
@@ -15,24 +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
- "none", "block", "flex", "grid", "inline", "inline-block", "inline-flex", "inline-grid", "list-item",
42
+ "-webkit-box", "-webkit-inline-box",
43
+ "none", "contents", "block", "flex", "grid", "inline", "inline-block", "inline-flex", "inline-grid", "list-item",
36
44
  "table", "inline-table", "table-row-group", "table-header-group", "table-footer-group", "table-row",
37
45
  "table-cell", "table-caption", "table-column", "table-column-group",
38
46
  ]);
@@ -40,6 +48,8 @@ const DISPLAYS = new Set([
40
48
  const FONT_SIZE_KEYWORDS = Object.freeze({
41
49
  "xx-small": 9, "x-small": 10, small: 13, medium: 16, large: 18, "x-large": 24, "xx-large": 32, "xxx-large": 48,
42
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 });
43
53
  /** HTML §15.3.8 table elements and their display types. */
44
54
  const TABLE_DISPLAYS = new Map(Object.entries({
45
55
  TABLE: "table", CAPTION: "table-caption", COLGROUP: "table-column-group", COL: "table-column",
@@ -56,29 +66,40 @@ const BACKGROUND_REPEATS = new Set(["repeat", "repeat-x", "repeat-y", "no-repeat
56
66
  const BACKGROUND_BOXES = new Set(["border-box", "padding-box", "content-box"]);
57
67
  const BACKGROUND_ATTACHMENTS = new Set(["scroll", "fixed", "local"]);
58
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]);
59
71
  const WHITE_SPACES = new Set(["normal", "nowrap", "pre", "pre-wrap"]);
60
72
  // CSS Cascade 4 §7.3 CSS-wide keywords; revert is treated as unset (no user/UA origin split).
61
73
  const CSS_WIDE_KEYWORDS = new Set(["inherit", "initial", "unset", "revert", "revert-layer"]);
62
74
  // Computed-style fields a shorthand (or a property not named after its field) covers.
63
75
  const PROPERTY_FIELDS = Object.freeze({
76
+ "-webkit-line-clamp": ["webkitLineClamp"],
77
+ "-webkit-box-orient": ["webkitBoxOrient"],
64
78
  overflow: ["overflowX", "overflowY", "overflowScrollX", "overflowScrollY"],
65
79
  "overflow-x": ["overflowX", "overflowScrollX"],
66
80
  "overflow-y": ["overflowY", "overflowScrollY"],
67
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"],
68
85
  flex: ["flexGrow", "flexShrink", "flexBasis"],
69
86
  "flex-flow": ["flexDirection", "flexWrap"],
70
- gap: ["rowGap", "columnGap", "gap"],
71
- "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"],
72
93
  "grid-area": ["gridRowStart", "gridColumnStart", "gridRowEnd", "gridColumnEnd"],
73
94
  "grid-row": ["gridRowStart", "gridRowEnd"],
74
95
  "grid-column": ["gridColumnStart", "gridColumnEnd"],
75
- "grid-template": ["gridTemplateColumns", "gridTemplateRows"],
96
+ "grid-template": ["gridTemplateColumns", "gridTemplateRows", "gridTemplateAreas"],
76
97
  "place-content": ["alignContent", "justifyContent"],
77
98
  "place-items": ["alignItems", "justifyItems"],
78
99
  "place-self": ["alignSelf", "justifySelf"],
79
100
  "line-height": ["lineHeight", "lineHeightFactor"],
80
101
  "list-style": ["listStyleType"],
81
- "border-radius": ["borderRadius"],
102
+ "border-radius": ["borderRadius", ...CORNER_FIELDS],
82
103
  "text-decoration": ["textDecorationLine", "textDecorationColor"],
83
104
  "text-decoration-line": ["textDecorationLine"],
84
105
  "text-decoration-color": ["textDecorationColor"],
@@ -88,12 +109,19 @@ const BORDER_FIELDS = Object.freeze(["borderSpecifiedWidths", "borderStyles", "b
88
109
 
89
110
  const INITIAL = Object.freeze({
90
111
  display: "inline",
112
+ direction: "ltr",
91
113
  // text-decoration: this element's own lines ("none" or an array) and color
92
114
  // (null = currentColor); textDecorations adds what ancestors propagate.
93
115
  textDecorationLine: "none",
94
116
  textDecorationColor: null,
95
117
  textDecorations: null,
96
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",
97
125
  colorScheme: "light",
98
126
  backgroundColor: "rgba(0, 0, 0, 0)",
99
127
  backgroundImage: null,
@@ -106,11 +134,15 @@ const INITIAL = Object.freeze({
106
134
  fontFamily: Object.freeze(["sans-serif"]),
107
135
  fontSize: 16,
108
136
  fontWeight: 400,
137
+ fontStyle: "normal",
138
+ fontStretch: 100,
109
139
  // null means `line-height: normal`, which layout resolves from font metrics.
110
140
  lineHeight: null,
111
141
  lineHeightFactor: null,
112
142
  textAlign: "start",
113
143
  whiteSpace: "normal",
144
+ webkitLineClamp: null,
145
+ webkitBoxOrient: "horizontal",
114
146
  listStyleType: "disc",
115
147
  margin: Object.freeze([0, 0, 0, 0]),
116
148
  padding: Object.freeze([0, 0, 0, 0]),
@@ -127,6 +159,10 @@ const INITIAL = Object.freeze({
127
159
  borderStyles: Object.freeze(["none", "none", "none", "none"]),
128
160
  borderSpecifiedWidths: Object.freeze([3, 3, 3, 3]),
129
161
  borderRadius: 0,
162
+ borderTopLeftRadius: ZERO_RADIUS,
163
+ borderTopRightRadius: ZERO_RADIUS,
164
+ borderBottomRightRadius: ZERO_RADIUS,
165
+ borderBottomLeftRadius: ZERO_RADIUS,
130
166
  flexDirection: "row",
131
167
  flexWrap: "nowrap",
132
168
  justifyContent: "normal",
@@ -136,6 +172,10 @@ const INITIAL = Object.freeze({
136
172
  gap: 0,
137
173
  rowGap: 0,
138
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,
139
179
  flexGrow: 0,
140
180
  flexShrink: 1,
141
181
  flexBasis: "auto",
@@ -145,12 +185,14 @@ const INITIAL = Object.freeze({
145
185
  inset: Object.freeze(["auto", "auto", "auto", "auto"]),
146
186
  zIndex: "auto",
147
187
  transform: null,
188
+ translate: null,
148
189
  // Raw text (default "50% 50%"); paint resolves it against the border box.
149
190
  transformOrigin: null,
150
191
  justifyItems: "normal",
151
192
  justifySelf: "auto",
152
193
  gridTemplateColumns: null,
153
194
  gridTemplateRows: null,
195
+ gridTemplateAreas: null,
154
196
  gridAutoColumns: Object.freeze([AUTO_TRACK]),
155
197
  gridAutoRows: Object.freeze([AUTO_TRACK]),
156
198
  gridAutoFlow: Object.freeze({ column: false, dense: false }),
@@ -184,6 +226,18 @@ const INITIAL = Object.freeze({
184
226
  clipPath: null,
185
227
  });
186
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
+
187
241
  /**
188
242
  * User-agent defaults from the HTML Standard's suggested rendering rules
189
243
  * (WHATWG HTML §15.3, CC BY 4.0) for the elements the renderer lays out.
@@ -238,6 +292,7 @@ const UA_CONTROL_DECLARATIONS = Object.freeze({
238
292
  select: parseDeclarations(`${FIELD}; border-radius: 2px; padding: 0 4px; white-space: pre`),
239
293
  });
240
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"];
241
296
 
242
297
  /** Which UA control defaults an element gets, or null. */
243
298
  export function controlKind(element) {
@@ -266,6 +321,7 @@ const DEFAULT_ENVIRONMENT = Object.freeze({ viewportWidth: 800, viewportHeight:
266
321
  let environment = DEFAULT_ENVIRONMENT;
267
322
 
268
323
  export class StyleEngine {
324
+ #measureText;
269
325
  #styles = new WeakMap();
270
326
  #customProperties = new WeakMap();
271
327
  #pseudoStyles = new WeakMap();
@@ -281,6 +337,10 @@ export class StyleEngine {
281
337
  #parsedSheets = new Map();
282
338
  #parsedSheetsKey = "";
283
339
 
340
+ constructor({ measureText = null } = {}) {
341
+ this.#measureText = measureText;
342
+ }
343
+
284
344
  /**
285
345
  * Computes the document's styles. With `dirty` (elements whose subtree's
286
346
  * styles may have changed), only those subtrees are restyled and every
@@ -295,6 +355,7 @@ export class StyleEngine {
295
355
  viewportHeight: viewport.height ?? DEFAULT_ENVIRONMENT.viewportHeight,
296
356
  rootFontSize: DEFAULT_ENVIRONMENT.rootFontSize,
297
357
  mobile: viewport.mobile === true,
358
+ measureText: this.#measureText,
298
359
  };
299
360
  this.restyled = 0;
300
361
  try {
@@ -638,9 +699,37 @@ export function computeElementStyle(element, parentStyle = null, authorDeclarati
638
699
  else if (BLOCK_TAGS.has(element.tagName)) values.display = element.tagName === "LI" ? "list-item" : "block";
639
700
  else if (["BUTTON", "INPUT", "SELECT", "TEXTAREA"].includes(element.tagName)) values.display = "inline-block";
640
701
  if (["B", "STRONG"].includes(element.tagName)) values.fontWeight = 700;
702
+ if (["I", "EM", "CITE", "DFN", "VAR", "ADDRESS"].includes(element.tagName)) values.fontStyle = "italic";
641
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;
642
713
 
643
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
+ }
644
733
  const isLink = (element.tagName === "A" || element.tagName === "AREA") && element.hasAttribute("href");
645
734
  const control = controlKind(element);
646
735
  if (control === "hidden") values.display = "none";
@@ -658,23 +747,58 @@ export function computeElementStyle(element, parentStyle = null, authorDeclarati
658
747
  declarations.set(declaration.name, { ...declaration, specificity: 1_000_000, order: Infinity });
659
748
  }
660
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
+ }
661
759
  // The font size comes first (em lengths use it), from whichever of
662
760
  // font-size and the font shorthand wins the cascade.
663
761
  const fontSize = declarations.get("font-size");
664
762
  const font = declarations.get("font");
665
763
  if (font && (!fontSize || compareDeclarations(font, fontSize) > 0)) {
666
- const parsed = parseFontShorthand(resolveVariables(font.value, customProperties));
667
- 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);
668
768
  } else if (fontSize) {
669
769
  applyDeclaration(values, fontSize, customProperties, parentStyle);
670
770
  }
671
771
  // light-dark() depends on the used scheme regardless of declaration order.
672
772
  const colorScheme = declarations.get("color-scheme");
673
773
  if (colorScheme) applyDeclaration(values, colorScheme, customProperties, parentStyle);
774
+ const directionDeclaration = declarations.get("direction");
775
+ if (directionDeclaration) applyDeclaration(values, directionDeclaration, customProperties, parentStyle);
674
776
  // Apply in cascade order so a later shorthand overrides earlier longhands and vice versa.
675
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
+ }
676
800
  for (const declaration of ordered) {
677
- 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;
678
802
  // The shorthand's size was settled above; its other parts apply in order.
679
803
  applyDeclaration(values, declaration.name === "font" ? { ...declaration, name: "font-parts" } : declaration, customProperties, parentStyle);
680
804
  }
@@ -684,7 +808,7 @@ export function computeElementStyle(element, parentStyle = null, authorDeclarati
684
808
  const parentDisplay = parentStyle?.display ?? "block";
685
809
  const flexOrGridItem = ["flex", "inline-flex", "grid", "inline-grid"].includes(parentDisplay);
686
810
  if (values.float !== "none" && (["absolute", "fixed"].includes(values.position) || flexOrGridItem)) values.float = "none";
687
- if (values.float !== "none" && values.display !== "none") {
811
+ if (values.float !== "none" && !["none", "contents"].includes(values.display)) {
688
812
  values.display = { flex: "inline-flex", grid: "inline-grid", "inline-flex": "inline-flex", "inline-grid": "inline-grid" }[values.display]
689
813
  ?? "inline-block";
690
814
  }
@@ -861,6 +985,110 @@ export function parseStyleSheet(source, viewport = environment, counter = { orde
861
985
  return rules;
862
986
  }
863
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
+
864
1092
  function newLayer(parent) {
865
1093
  return { parent, children: new Map(), rank: Infinity };
866
1094
  }
@@ -916,13 +1144,17 @@ function rankLayerTree(root) {
916
1144
  }
917
1145
 
918
1146
  /** Splits a `font` shorthand value into size, line height, weight and family. */
919
- function parseFontShorthand(value) {
1147
+ export function parseFontShorthand(value) {
920
1148
  const tokens = splitTopLevel(value, " ").filter(Boolean);
921
1149
  let weight = null;
1150
+ let style = "normal";
1151
+ let stretch = "normal";
922
1152
  for (let index = 0; index < tokens.length; index++) {
923
1153
  const token = tokens[index];
924
1154
  const lower = token.toLowerCase();
925
- 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;
926
1158
  if (["bold", "bolder", "lighter"].includes(lower) || /^[1-9]00$/.test(lower)) {
927
1159
  weight = lower === "bolder" ? "700" : lower === "lighter" ? "300" : lower;
928
1160
  continue;
@@ -938,7 +1170,7 @@ function parseFontShorthand(value) {
938
1170
  } else if (lineHeight === undefined && tokens[rest]?.startsWith("/")) lineHeight = tokens[rest++].slice(1); // "14px /1.5"
939
1171
  const family = tokens.slice(rest).join(" ").trim();
940
1172
  if (!family || !(FONT_SIZE_KEYWORDS[size] || /^[\d.]/.test(size) || /^(?:calc|clamp|min|max)\(/.test(size))) return null;
941
- return { size, lineHeight: lineHeight || null, weight, family };
1173
+ return { size, lineHeight: lineHeight || null, weight, style, stretch, family };
942
1174
  }
943
1175
  return null;
944
1176
  }
@@ -955,7 +1187,8 @@ function findTopLevel(source, character, start, end) {
955
1187
  if (quote) {
956
1188
  if (current === BACKSLASH) index++;
957
1189
  else if (current === quote) quote = 0;
958
- } else if (current === QUOTE || current === APOSTROPHE) quote = current;
1190
+ } else if (current === BACKSLASH) index++;
1191
+ else if (current === QUOTE || current === APOSTROPHE) quote = current;
959
1192
  else if (current === OPEN_PAREN) depth++;
960
1193
  else if (current === CLOSE_PAREN) depth = Math.max(0, depth - 1);
961
1194
  else if (current === target && depth === 0) return index;
@@ -972,7 +1205,8 @@ function matchingBrace(source, open, end) {
972
1205
  if (quote) {
973
1206
  if (current === BACKSLASH) index++;
974
1207
  else if (current === quote) quote = 0;
975
- } else if (current === QUOTE || current === APOSTROPHE) quote = current;
1208
+ } else if (current === BACKSLASH) index++;
1209
+ else if (current === QUOTE || current === APOSTROPHE) quote = current;
976
1210
  else if (current === OPEN_BRACE) depth++;
977
1211
  else if (current === CLOSE_BRACE && --depth === 0) return index;
978
1212
  }
@@ -1041,6 +1275,7 @@ function mediaLength(value) {
1041
1275
 
1042
1276
  export function parseDeclarations(source) {
1043
1277
  const declarations = new Map();
1278
+ let declarationIndex = 0;
1044
1279
  for (const part of splitDeclarations(String(source))) {
1045
1280
  const colon = part.indexOf(":");
1046
1281
  if (colon < 1) continue;
@@ -1051,7 +1286,7 @@ export function parseDeclarations(source) {
1051
1286
  const important = /\s*!important\s*$/i.test(value);
1052
1287
  value = value.replace(/\s*!important\s*$/i, "").trim();
1053
1288
  if (!name || !value || (declarations.get(name)?.important && !important)) continue;
1054
- declarations.set(name, { name, value, important });
1289
+ declarations.set(name, { name, value, important, declarationIndex: declarationIndex++ });
1055
1290
  }
1056
1291
  return declarations;
1057
1292
  }
@@ -1074,7 +1309,46 @@ function applyDeclaration(style, declaration, customProperties, parentStyle) {
1074
1309
  }
1075
1310
  }
1076
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
+
1077
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
+ }
1078
1352
  if (CSS_WIDE_KEYWORDS.has(value)) {
1079
1353
  const inherits = value === "inherit" || (value !== "initial" && INHERITED.has(name));
1080
1354
  copyProperty(style, name, inherits && parentStyle ? parentStyle : INITIAL);
@@ -1082,6 +1356,9 @@ function setProperty(style, name, value, parentStyle = null) {
1082
1356
  }
1083
1357
  if (typeof value !== "string") return;
1084
1358
  switch (name) {
1359
+ case "direction":
1360
+ if (value === "ltr" || value === "rtl") style.direction = value;
1361
+ break;
1085
1362
  case "color-scheme": {
1086
1363
  const schemes = value.toLowerCase().split(/\s+/).filter((part) => part !== "only");
1087
1364
  if (schemes.every((part) => ["normal", "light", "dark"].includes(part))) {
@@ -1090,6 +1367,13 @@ function setProperty(style, name, value, parentStyle = null) {
1090
1367
  break;
1091
1368
  }
1092
1369
  case "display": if (DISPLAYS.has(value)) style.display = value; break;
1370
+ case "-webkit-line-clamp":
1371
+ if (value === "none") style.webkitLineClamp = null;
1372
+ else if (/^[1-9]\d*$/.test(value)) style.webkitLineClamp = Number(value);
1373
+ break;
1374
+ case "-webkit-box-orient":
1375
+ if (["vertical", "horizontal"].includes(value)) style.webkitBoxOrient = value;
1376
+ break;
1093
1377
  case "text-decoration-line": {
1094
1378
  const lines = parseDecorationLines(value);
1095
1379
  if (lines) style.textDecorationLine = lines;
@@ -1111,7 +1395,7 @@ function setProperty(style, name, value, parentStyle = null) {
1111
1395
  if (!parsed) return;
1112
1396
  lines = parsed;
1113
1397
  } else if (DECORATION_STYLES.has(lower) || lower === "auto" || lower === "from-font"
1114
- || Number.isFinite(computedLength(token, style.fontSize, true)) || /%$/.test(token)) {
1398
+ || Number.isFinite(computedLength(token, style, true)) || /%$/.test(token)) {
1115
1399
  continue;
1116
1400
  } else if (lower === "currentcolor") {
1117
1401
  color = null;
@@ -1166,7 +1450,7 @@ function setProperty(style, name, value, parentStyle = null) {
1166
1450
  if (value === "auto") style.clip = null;
1167
1451
  const rect = /^rect\(\s*(.*)\s*\)$/i.exec(value);
1168
1452
  if (rect) {
1169
- 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)));
1170
1454
  if (edges.length === 4 && edges.every((edge) => edge === "auto" || Number.isFinite(edge))) style.clip = Object.freeze(edges);
1171
1455
  }
1172
1456
  break;
@@ -1176,7 +1460,7 @@ function setProperty(style, name, value, parentStyle = null) {
1176
1460
  if (value === "none") style.clipPath = null;
1177
1461
  const inset = /^inset\(\s*([^)]*?)\s*(?:round\s[^)]*)?\)$/i.exec(value);
1178
1462
  if (inset) {
1179
- const box = boxLengths(inset[1], false, style.fontSize, false, true);
1463
+ const box = boxLengths(inset[1], false, style, false, true);
1180
1464
  if (box) style.clipPath = Object.freeze({ inset: box });
1181
1465
  } else if (/^rect\(\s*0(?:px)?\s+0(?:px)?\s+0(?:px)?\s+0(?:px)?\s*\)$/i.test(value)) {
1182
1466
  style.clipPath = Object.freeze({ empty: true });
@@ -1188,6 +1472,25 @@ function setProperty(style, name, value, parentStyle = null) {
1188
1472
  if (color) style.color = color;
1189
1473
  break;
1190
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;
1191
1494
  case "background-color": {
1192
1495
  const color = resolvedColor(value, style.colorScheme);
1193
1496
  if (color) style.backgroundColor = color;
@@ -1226,7 +1529,7 @@ function setProperty(style, name, value, parentStyle = null) {
1226
1529
  break;
1227
1530
  }
1228
1531
  case "box-shadow": {
1229
- const shadows = value === "none" ? null : parseBoxShadows(value, style.fontSize);
1532
+ const shadows = value === "none" ? null : parseBoxShadows(value, style);
1230
1533
  if (value === "none" || shadows) style.boxShadow = shadows;
1231
1534
  break;
1232
1535
  }
@@ -1236,8 +1539,11 @@ function setProperty(style, name, value, parentStyle = null) {
1236
1539
  break;
1237
1540
  case "font-size": {
1238
1541
  const parentSize = parentStyle?.fontSize ?? INITIAL.fontSize;
1542
+ const specified = computedLength(value, parentStyle ?? INITIAL, true);
1543
+ const relativeSize = specified?.unit === "%" ? parentSize * specified.value / 100
1544
+ : specified?.unit === "math" && specified.kind === "calc" ? specified.px + parentSize * specified.percent / 100 : specified;
1239
1545
  const length = FONT_SIZE_KEYWORDS[value] ?? (value === "larger" ? parentSize * 1.2 : value === "smaller" ? parentSize / 1.2
1240
- : computedLength(value, parentSize));
1546
+ : relativeSize);
1241
1547
  if (length > 0) style.fontSize = length;
1242
1548
  break;
1243
1549
  }
@@ -1250,8 +1556,12 @@ function setProperty(style, name, value, parentStyle = null) {
1250
1556
  if (!parsed) break;
1251
1557
  if (name === "font") setProperty(style, "font-size", parsed.size, parentStyle);
1252
1558
  setProperty(style, "line-height", parsed.lineHeight ?? "normal", parentStyle);
1253
- setProperty(style, "font-weight", parsed.weight ?? "normal", parentStyle);
1254
- 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
+ }
1255
1565
  break;
1256
1566
  }
1257
1567
  case "position":
@@ -1261,13 +1571,13 @@ function setProperty(style, name, value, parentStyle = null) {
1261
1571
  case "right":
1262
1572
  case "bottom":
1263
1573
  case "left": {
1264
- const length = value === "auto" ? "auto" : computedLength(value, style.fontSize, true);
1574
+ const length = value === "auto" ? "auto" : computedLength(value, style, true);
1265
1575
  if (length === "auto" || Number.isFinite(length) || length?.unit) style.inset = style.inset.with(BOX_SIDES.indexOf(name), length);
1266
1576
  break;
1267
1577
  }
1268
1578
  case "inset": {
1269
1579
  const parts = splitTopLevel(value, " ").filter(Boolean)
1270
- .map((part) => part === "auto" ? "auto" : computedLength(part, style.fontSize, true));
1580
+ .map((part) => part === "auto" ? "auto" : computedLength(part, style, true));
1271
1581
  if (!parts.length || parts.length > 4 || parts.some((part) => part !== "auto" && !Number.isFinite(part) && !part?.unit)) break;
1272
1582
  const [top, right = top, bottom = top, left = right] = parts;
1273
1583
  style.inset = [top, right, bottom, left];
@@ -1279,10 +1589,19 @@ function setProperty(style, name, value, parentStyle = null) {
1279
1589
  break;
1280
1590
  }
1281
1591
  case "transform": {
1282
- const transform = value === "none" ? null : parseTransform(value, style.fontSize);
1592
+ const transform = value === "none" ? null : parseTransform(value, style);
1283
1593
  if (transform !== undefined) style.transform = transform;
1284
1594
  break;
1285
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
+ }
1286
1605
  case "transform-origin":
1287
1606
  style.transformOrigin = value.toLowerCase().replace(/\s+/g, " ");
1288
1607
  break;
@@ -1310,9 +1629,16 @@ function setProperty(style, name, value, parentStyle = null) {
1310
1629
  if (Number.isFinite(weight) && weight >= 1 && weight <= 1000) style.fontWeight = weight;
1311
1630
  break;
1312
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
+ }
1313
1639
  case "white-space": if (WHITE_SPACES.has(value)) style.whiteSpace = value; break;
1314
1640
  case "border-spacing": {
1315
- const parts = value.split(/\s+/).map((part) => computedLength(part, style.fontSize));
1641
+ const parts = value.split(/\s+/).map((part) => computedLength(part, style));
1316
1642
  if (parts.length <= 2 && parts.every((part) => Number.isFinite(part) && part >= 0)) {
1317
1643
  style.borderSpacing = Object.freeze([parts[0], parts[1] ?? parts[0]]);
1318
1644
  }
@@ -1356,7 +1682,7 @@ function setProperty(style, name, value, parentStyle = null) {
1356
1682
  // Non-negative, so 0 is valid (icon buttons set line-height: 0); a
1357
1683
  // percentage computes to a length against this element's font size.
1358
1684
  const percentage = /^(\d+\.?\d*|\.\d+)%$/.exec(value);
1359
- 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);
1360
1686
  if (length >= 0) {
1361
1687
  style.lineHeight = length;
1362
1688
  style.lineHeightFactor = null;
@@ -1365,12 +1691,12 @@ function setProperty(style, name, value, parentStyle = null) {
1365
1691
  break;
1366
1692
  }
1367
1693
  case "margin": {
1368
- const box = boxLengths(value, true, style.fontSize, true);
1694
+ const box = boxLengths(value, true, style, true);
1369
1695
  if (box) style.margin = box;
1370
1696
  break;
1371
1697
  }
1372
1698
  case "padding": {
1373
- const box = boxLengths(value, false, style.fontSize, false);
1699
+ const box = boxLengths(value, false, style, false, true);
1374
1700
  if (box) style.padding = box;
1375
1701
  break;
1376
1702
  }
@@ -1384,15 +1710,16 @@ function setProperty(style, name, value, parentStyle = null) {
1384
1710
  case "padding-left": {
1385
1711
  const [box, side] = name.split("-");
1386
1712
  const isMargin = box === "margin";
1387
- const length = isMargin && value === "auto" ? "auto" : computedLength(value, style.fontSize);
1388
- if (length === "auto" || (Number.isFinite(length) && (isMargin || length >= 0))) {
1713
+ const length = isMargin && value === "auto" ? "auto" : computedLength(value, style, !isMargin);
1714
+ if (length === "auto" || (!isMargin && isLength(length) && typeof length === "object"
1715
+ && (!isPercentage(length) || length.value >= 0)) || (Number.isFinite(length) && (isMargin || length >= 0))) {
1389
1716
  style[box] = style[box].with(BOX_SIDES.indexOf(side), length);
1390
1717
  }
1391
1718
  break;
1392
1719
  }
1393
1720
  case "width":
1394
1721
  case "height": {
1395
- const length = value === "auto" ? "auto" : computedLength(value, style.fontSize, true);
1722
+ const length = value === "auto" ? "auto" : computedLength(value, style, true);
1396
1723
  if (length === "auto" || isLength(length)) style[name] = length;
1397
1724
  break;
1398
1725
  }
@@ -1401,7 +1728,7 @@ function setProperty(style, name, value, parentStyle = null) {
1401
1728
  case "min-height":
1402
1729
  case "max-height": {
1403
1730
  const none = name.startsWith("max-") && value === "none";
1404
- const length = none ? "none" : computedLength(value, style.fontSize, true);
1731
+ const length = none ? "none" : computedLength(value, style, true);
1405
1732
  if (none || isLength(length)) {
1406
1733
  style[{ "min-width": "minWidth", "max-width": "maxWidth", "min-height": "minHeight", "max-height": "maxHeight" }[name]] = length;
1407
1734
  }
@@ -1413,7 +1740,7 @@ function setProperty(style, name, value, parentStyle = null) {
1413
1740
  case "border-bottom":
1414
1741
  case "border-left": {
1415
1742
  // Shorthands reset width (medium), style (none) and color (currentColor).
1416
- const parsed = parseBorderShorthand(value, style.fontSize);
1743
+ const parsed = parseBorderShorthand(value, style);
1417
1744
  if (!parsed) break;
1418
1745
  const sides = name === "border" ? [0, 1, 2, 3] : [BOX_SIDES.indexOf(name.slice(7))];
1419
1746
  for (const side of sides) {
@@ -1427,7 +1754,7 @@ function setProperty(style, name, value, parentStyle = null) {
1427
1754
  case "border-style":
1428
1755
  case "border-color": {
1429
1756
  const parts = splitTopLevel(value, " ").filter(Boolean).map((part) => name === "border-width"
1430
- ? borderWidth(part, style.fontSize)
1757
+ ? borderWidth(part, style)
1431
1758
  : name === "border-style" ? (BORDER_STYLES.has(part) ? part : null) : borderColor(part));
1432
1759
  if (!parts.length || parts.length > 4 || parts.some((part) => part === undefined || (name !== "border-color" && part == null))) break;
1433
1760
  const [top, right = top, bottom = top, left = right] = parts;
@@ -1443,7 +1770,7 @@ function setProperty(style, name, value, parentStyle = null) {
1443
1770
  const [, side, part] = name.split("-");
1444
1771
  const index = BOX_SIDES.indexOf(side);
1445
1772
  if (part === "width") {
1446
- const width = borderWidth(value, style.fontSize);
1773
+ const width = borderWidth(value, style);
1447
1774
  if (width != null) style.borderSpecifiedWidths = style.borderSpecifiedWidths.with(index, width);
1448
1775
  } else if (part === "style") {
1449
1776
  if (BORDER_STYLES.has(value)) style.borderStyles = style.borderStyles.with(index, value);
@@ -1454,12 +1781,40 @@ function setProperty(style, name, value, parentStyle = null) {
1454
1781
  break;
1455
1782
  }
1456
1783
  case "border-radius": {
1457
- // One radius for every corner; percentages resolve against the box at paint time.
1458
- const length = computedLength(splitTopLevel(value.split("/")[0], " ").filter(Boolean)[0] ?? "", style.fontSize, true);
1459
- 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]]);
1460
1800
  break;
1461
1801
  }
1462
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
+ }
1463
1818
  case "flex-direction": if (["row", "column"].includes(value)) style.flexDirection = value; break;
1464
1819
  case "flex-wrap": if (["nowrap", "wrap"].includes(value)) style.flexWrap = value; break;
1465
1820
  case "justify-content": {
@@ -1491,12 +1846,16 @@ function setProperty(style, name, value, parentStyle = null) {
1491
1846
  break;
1492
1847
  }
1493
1848
  case "gap": {
1494
- const [row, column = row] = splitTopLevel(value, " ").filter(Boolean)
1495
- .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));
1496
1853
  if (row >= 0 && column >= 0) {
1497
1854
  style.gap = row;
1498
1855
  style.rowGap = row;
1499
1856
  style.columnGap = column;
1857
+ style.rowGapNormal = parts[0] === "normal";
1858
+ style.columnGapNormal = (parts[1] ?? parts[0]) === "normal";
1500
1859
  }
1501
1860
  break;
1502
1861
  }
@@ -1505,13 +1864,50 @@ function setProperty(style, name, value, parentStyle = null) {
1505
1864
  case "grid-column-gap": return setProperty(style, "column-gap", value, parentStyle);
1506
1865
  case "grid-template-columns":
1507
1866
  case "grid-template-rows": {
1508
- const tracks = value === "none" ? null : parseTrackList(value, style.fontSize);
1867
+ const tracks = value === "none" ? null : parseTrackList(value, style);
1509
1868
  if (value === "none" || tracks) style[name === "grid-template-columns" ? "gridTemplateColumns" : "gridTemplateRows"] = tracks;
1510
1869
  break;
1511
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
+ }
1512
1908
  case "grid-auto-columns":
1513
1909
  case "grid-auto-rows": {
1514
- const tracks = parseTrackList(value, style.fontSize);
1910
+ const tracks = parseTrackList(value, style);
1515
1911
  if (tracks?.length && tracks.every((entry) => !entry.repeat)) {
1516
1912
  style[name === "grid-auto-columns" ? "gridAutoColumns" : "gridAutoRows"] = tracks;
1517
1913
  }
@@ -1537,12 +1933,13 @@ function setProperty(style, name, value, parentStyle = null) {
1537
1933
  const [start, end] = value.split("/").map((part) => parseGridLine(part.trim()));
1538
1934
  const axis = name === "grid-column" ? "Column" : "Row";
1539
1935
  style[`grid${axis}Start`] = start;
1540
- // A single integer leaves the end auto (spanning one track).
1541
- 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");
1542
1938
  break;
1543
1939
  }
1544
1940
  case "grid-area": {
1545
- 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("/")
1546
1943
  .map((part) => parseGridLine(part.trim()));
1547
1944
  Object.assign(style, { gridRowStart: rowStart, gridColumnStart: columnStart, gridRowEnd: rowEnd, gridColumnEnd: columnEnd });
1548
1945
  break;
@@ -1556,8 +1953,12 @@ function setProperty(style, name, value, parentStyle = null) {
1556
1953
  }
1557
1954
  case "row-gap":
1558
1955
  case "column-gap": {
1559
- const length = value === "normal" ? 0 : computedLength(value, style.fontSize);
1560
- 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
+ }
1561
1962
  break;
1562
1963
  }
1563
1964
  case "align-self": {
@@ -1572,12 +1973,12 @@ function setProperty(style, name, value, parentStyle = null) {
1572
1973
  break;
1573
1974
  }
1574
1975
  case "flex-basis": {
1575
- const basis = flexBasis(value, style.fontSize);
1976
+ const basis = flexBasis(value, style);
1576
1977
  if (basis != null) style.flexBasis = basis;
1577
1978
  break;
1578
1979
  }
1579
1980
  case "flex": {
1580
- const flex = flexShorthand(value, style.fontSize);
1981
+ const flex = flexShorthand(value, style);
1581
1982
  if (flex) Object.assign(style, flex);
1582
1983
  break;
1583
1984
  }
@@ -1791,7 +2192,7 @@ function cascadeDeclarations(element, rules, pseudo = null, keys = elementKeys(e
1791
2192
  for (const declaration of rule.declarationList) {
1792
2193
  const current = result.get(declaration.name);
1793
2194
  if (current && compareCascadeParts(declaration.important, layer, specificity, order, current) < 0) continue;
1794
- 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 });
1795
2196
  }
1796
2197
  }
1797
2198
  return result;
@@ -1826,7 +2227,10 @@ function compareDeclarations(left, right) {
1826
2227
  const leftSpecificity = "specificity" in left ? left.specificity : 0;
1827
2228
  const rightSpecificity = "specificity" in right ? right.specificity : 0;
1828
2229
  if (leftSpecificity !== rightSpecificity) return leftSpecificity - rightSpecificity;
1829
- 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);
1830
2234
  }
1831
2235
 
1832
2236
  const PSEUDO_ELEMENT = /(?:::?(before|after)|::([\w-]+))$/i;
@@ -1929,53 +2333,75 @@ function pseudoElement(pseudo) {
1929
2333
  return { tagName: `::${pseudo}`, getAttribute: () => null, hasAttribute: () => false };
1930
2334
  }
1931
2335
 
1932
- /**
1933
- * Keeps the translation part of a transform list as { x, y } lengths (px or
1934
- * percentages of the element's own border box). Other functions are ignored
1935
- * until transforms are painted with matrices.
1936
- */
1937
2336
  /**
1938
2337
  * The translation (layout moves the box) and scale (paint scales it about
1939
- * 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.
1940
2341
  */
1941
2342
  function parseTransform(value, fontSize) {
1942
- const translation = parseTranslate(value, fontSize);
1943
- if (translation === undefined) return undefined;
2343
+ let x = 0;
2344
+ let y = 0;
1944
2345
  let scaleX = 1;
1945
2346
  let scaleY = 1;
1946
2347
  let found = false;
1947
- for (const match of value.matchAll(/\b(scale|scaleX|scaleY|scale3d)\(([^)]*)\)/gi)) {
1948
- const args = splitTopLevel(match[2], ",").map((part) => part.endsWith("%") ? Number(part.slice(0, -1)) / 100 : Number(part));
1949
- 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;
1950
2359
  const kind = match[1].toLowerCase();
1951
- if (kind === "scalex") scaleX *= args[0];
1952
- else if (kind === "scaley") scaleY *= args[0];
1953
- else {
1954
- scaleX *= args[0];
1955
- 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;
1956
2380
  }
1957
2381
  found = true;
1958
2382
  }
1959
- if (!found || (scaleX === 1 && scaleY === 1)) return translation;
1960
- 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 });
1961
2385
  }
1962
2386
 
1963
- function parseTranslate(value, fontSize) {
1964
- let x = 0;
1965
- let y = 0;
1966
- let found = false;
1967
- for (const match of value.matchAll(/(translate|translateX|translateY|translate3d)\(([^)]*)\)/gi)) {
1968
- const args = splitTopLevel(match[2], ",").map((part) => computedLength(part, fontSize, true));
1969
- if (args.some((part) => !Number.isFinite(part) && !part?.unit)) return undefined;
1970
- const kind = match[1].toLowerCase();
1971
- if (kind === "translatey") y = args[0];
1972
- else {
1973
- x = args[0];
1974
- if (kind !== "translatex") y = args[1] ?? 0;
1975
- }
1976
- found = true;
1977
- }
1978
- 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 });
1979
2405
  }
1980
2406
 
1981
2407
  /** Parses `content` into its string, null for none/normal, undefined if unsupported. */
@@ -2057,12 +2483,22 @@ function copyProperty(style, name, source) {
2057
2483
  style[side[1]] = style[side[1]].with(index, source[side[1]][index]);
2058
2484
  return;
2059
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
+ }
2060
2491
  // border, border-<side>, border-<width|style|color>, border-<side>-<...>;
2061
2492
  // not border-radius, border-spacing or border-collapse.
2062
- const isBorder = /^border(?:-(?:top|right|bottom|left))?(?:-(?:width|style|color))?$/.test(name);
2063
- const fields = isBorder
2064
- ? BORDER_FIELDS
2065
- : 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())];
2066
2502
  for (const field of fields) {
2067
2503
  if (Object.hasOwn(INITIAL, field)) style[field] = source[field];
2068
2504
  }
@@ -2074,6 +2510,9 @@ function cssValue(style, name) {
2074
2510
  "font-family": style.fontFamily.map((family) => JSON.stringify(family)).join(", "),
2075
2511
  "font-size": `${style.fontSize}px`,
2076
2512
  "font-weight": String(style.fontWeight),
2513
+ "font-style": style.fontStyle,
2514
+ "font-stretch": `${style.fontStretch}%`,
2515
+ "font-width": `${style.fontStretch}%`,
2077
2516
  "line-height": style.lineHeightFactor != null ? String(style.lineHeightFactor)
2078
2517
  : style.lineHeight == null ? "normal" : `${style.lineHeight}px`,
2079
2518
  "list-style-type": style.listStyleType,
@@ -2089,7 +2528,22 @@ function pixelLength(value) {
2089
2528
  return match ? Number(match[1]) : NaN;
2090
2529
  }
2091
2530
 
2092
- 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) {
2093
2547
  const text = String(value).trim();
2094
2548
  if (allowPercentage) {
2095
2549
  const percentage = /^([+-]?(?:\d+\.?\d*|\.\d+))%$/.exec(text);
@@ -2098,14 +2552,14 @@ function computedLength(value, emSize, allowPercentage = false) {
2098
2552
  const absolute = pixelLength(text);
2099
2553
  if (Number.isFinite(absolute)) return absolute;
2100
2554
  if (/^calc\(/i.test(text)) {
2101
- const sum = calcLength(text, emSize);
2555
+ const sum = calcLength(text, font);
2102
2556
  if (!sum || !(sum.percent === 0 || allowPercentage)) return NaN;
2103
2557
  return sum.percent === 0 ? sum.px : Object.freeze({ unit: "math", kind: "calc", px: sum.px, percent: sum.percent });
2104
2558
  }
2105
2559
  const math = /^(clamp|min|max)\((.*)\)$/i.exec(text);
2106
2560
  if (math) {
2107
2561
  const kind = math[1].toLowerCase();
2108
- const values = splitTopLevel(math[2], ",").map((part) => computedLength(part, emSize, allowPercentage));
2562
+ const values = splitTopLevel(math[2], ",").map((part) => computedLength(part, font, allowPercentage));
2109
2563
  if (!values.length || values.some((part) => !Number.isFinite(part) && !(part?.unit))) return NaN;
2110
2564
  // Percentages inside math functions resolve at layout time.
2111
2565
  if (values.some((part) => part?.unit)) {
@@ -2117,14 +2571,38 @@ function computedLength(value, emSize, allowPercentage = false) {
2117
2571
  if (kind === "max") return Math.max(...values);
2118
2572
  return values.length === 3 ? Math.max(values[0], Math.min(values[1], values[2])) : NaN;
2119
2573
  }
2120
- 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);
2121
2575
  if (!relative) return NaN;
2122
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
+ }
2123
2603
  const scale = unit === "rem" ? environment.rootFontSize
2124
2604
  : unit === "em" ? emSize
2125
- : unit === "vw" ? environment.viewportWidth / 100
2126
- : unit === "vh" ? environment.viewportHeight / 100
2127
- : 4 / 3;
2605
+ : unit === "ch" ? characterWidth : 4 / 3;
2128
2606
  return Number(relative[1]) * scale;
2129
2607
  }
2130
2608
 
@@ -2135,7 +2613,7 @@ function computedLength(value, emSize, allowPercentage = false) {
2135
2613
  * for anything else (such as min() of a percentage inside calc()).
2136
2614
  */
2137
2615
  function calcLength(text, emSize) {
2138
- 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);
2139
2617
  if (!tokens || tokens.join("").replace(/\s+/g, "") !== String(text).replace(/\s+/g, "")) return null;
2140
2618
  let index = 0;
2141
2619
  // Values are { px, percent } (a length) or { number }.
@@ -2329,10 +2807,47 @@ function parseGridLine(token) {
2329
2807
  if (span) return Object.freeze({ span: Math.max(1, Number(span[1] ?? span[2])) });
2330
2808
  const line = Number(token);
2331
2809
  if (Number.isInteger(line) && line !== 0) return Object.freeze({ line });
2332
- // 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
+ }
2333
2813
  return "auto";
2334
2814
  }
2335
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
+
2336
2851
  const BORDER_STYLES = new Set(["none", "hidden", "solid", "dashed", "dotted", "double", "groove", "ridge", "inset", "outset"]);
2337
2852
  const BORDER_WIDTH_KEYWORDS = Object.freeze({ thin: 1, medium: 3, thick: 5 });
2338
2853
 
@@ -2370,12 +2885,12 @@ function flexBasis(value, fontSize) {
2370
2885
  function flexShorthand(value, fontSize) {
2371
2886
  if (value === "none") return { flexGrow: 0, flexShrink: 0, flexBasis: "auto" };
2372
2887
  if (value === "auto") return { flexGrow: 1, flexShrink: 1, flexBasis: "auto" };
2373
- const parts = value.trim().split(/\s+/);
2888
+ const parts = splitTopLevel(value.trim(), " ").filter(Boolean);
2374
2889
  const numbers = [];
2375
2890
  let basis = null;
2376
2891
  for (const part of parts) {
2377
2892
  const number = Number(part);
2378
- 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);
2379
2894
  else if (basis == null && (basis = flexBasis(part, fontSize)) != null) continue;
2380
2895
  else return null;
2381
2896
  }
@@ -2384,7 +2899,9 @@ function flexShorthand(value, fontSize) {
2384
2899
  return {
2385
2900
  flexGrow: numbers[0] ?? 1,
2386
2901
  flexShrink: numbers[1] ?? 1,
2387
- 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"),
2388
2905
  };
2389
2906
  }
2390
2907
 
@@ -2460,12 +2977,18 @@ export function absolutizeStyleSheetUrls(text, base) {
2460
2977
  function splitTopLevel(source, separator) {
2461
2978
  const parts = [];
2462
2979
  let depth = 0;
2980
+ let quote = "";
2463
2981
  let start = 0;
2464
2982
  for (let index = 0; index < source.length; index++) {
2465
2983
  const character = source[index];
2466
- 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++;
2467
2990
  else if (character === ")") depth--;
2468
- else if (character === separator && depth === 0) {
2991
+ else if ((character === separator || separator === " " && /\s/.test(character)) && depth === 0) {
2469
2992
  parts.push(source.slice(start, index).trim());
2470
2993
  start = index + 1;
2471
2994
  }
@@ -2475,10 +2998,11 @@ function splitTopLevel(source, separator) {
2475
2998
  }
2476
2999
 
2477
3000
  function boxLengths(value, allowNegative, emSize, allowAuto, allowPercentage = false) {
2478
- const parts = value.trim().split(/\s+/).map((part) =>
3001
+ const parts = splitTopLevel(value.trim(), " ").filter(Boolean).map((part) =>
2479
3002
  allowAuto && part === "auto" ? "auto" : computedLength(part, emSize, allowPercentage));
2480
3003
  if (parts.length < 1 || parts.length > 4 || parts.some((part) =>
2481
- part !== "auto" && !isPercentage(part) && (!Number.isFinite(part) || (!allowNegative && part < 0)))) return null;
3004
+ part !== "auto" && !(allowPercentage && isLength(part) && typeof part === "object"
3005
+ && (allowNegative || !isPercentage(part) || part.value >= 0)) && (!Number.isFinite(part) || (!allowNegative && part < 0)))) return null;
2482
3006
  const [top, right = top, bottom = top, left = right] = parts.length === 3
2483
3007
  ? [parts[0], parts[1], parts[2], parts[1]]
2484
3008
  : parts.length === 2