@svelterm/core 0.1.0 → 0.21.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 (164) hide show
  1. package/CHANGELOG.md +425 -0
  2. package/README.md +42 -29
  3. package/dist/src/cli/build.d.ts +13 -0
  4. package/dist/src/cli/build.js +119 -0
  5. package/dist/src/cli/bundle.d.ts +25 -0
  6. package/dist/src/cli/bundle.js +61 -0
  7. package/dist/src/cli/dev.d.ts +10 -0
  8. package/dist/src/cli/dev.js +152 -0
  9. package/dist/src/cli/devtools.d.ts +9 -0
  10. package/dist/src/cli/devtools.js +47 -0
  11. package/dist/src/cli/init.d.ts +8 -0
  12. package/dist/src/cli/init.js +153 -0
  13. package/dist/src/cli/main.d.ts +9 -0
  14. package/dist/src/cli/main.js +52 -0
  15. package/dist/src/cli/svt-bin.d.ts +2 -0
  16. package/dist/src/cli/svt-bin.js +6 -0
  17. package/dist/src/cli/svt.d.ts +14 -0
  18. package/dist/src/cli/svt.js +76 -0
  19. package/dist/src/components/text-buffer.js +8 -5
  20. package/dist/src/css/animation-runner.d.ts +15 -6
  21. package/dist/src/css/animation-runner.js +80 -29
  22. package/dist/src/css/animation.d.ts +12 -0
  23. package/dist/src/css/animation.js +21 -0
  24. package/dist/src/css/calc.js +4 -3
  25. package/dist/src/css/color.d.ts +19 -0
  26. package/dist/src/css/color.js +371 -62
  27. package/dist/src/css/compute.d.ts +30 -3
  28. package/dist/src/css/compute.js +272 -33
  29. package/dist/src/css/defaults.d.ts +1 -1
  30. package/dist/src/css/defaults.js +9 -0
  31. package/dist/src/css/easing.d.ts +9 -0
  32. package/dist/src/css/easing.js +95 -0
  33. package/dist/src/css/incremental.d.ts +1 -1
  34. package/dist/src/css/incremental.js +2 -2
  35. package/dist/src/css/interpolate.d.ts +13 -0
  36. package/dist/src/css/interpolate.js +41 -0
  37. package/dist/src/css/parser.js +59 -3
  38. package/dist/src/css/pseudo-elements.d.ts +9 -0
  39. package/dist/src/css/pseudo-elements.js +97 -0
  40. package/dist/src/css/selector.d.ts +17 -2
  41. package/dist/src/css/selector.js +128 -13
  42. package/dist/src/css/specificity.js +17 -6
  43. package/dist/src/css/values.d.ts +6 -1
  44. package/dist/src/css/values.js +13 -6
  45. package/dist/src/debug/context.d.ts +13 -0
  46. package/dist/src/debug/context.js +11 -0
  47. package/dist/src/debug/css.d.ts +12 -0
  48. package/dist/src/debug/css.js +28 -0
  49. package/dist/src/debug/dom.d.ts +17 -0
  50. package/dist/src/debug/dom.js +92 -0
  51. package/dist/src/devtools/DevTools.compiled.js +327 -0
  52. package/dist/src/devtools/DevTools.css.js +1 -0
  53. package/dist/src/devtools/client.d.ts +36 -0
  54. package/dist/src/devtools/client.js +76 -0
  55. package/dist/src/framelog.d.ts +54 -0
  56. package/dist/src/framelog.js +99 -0
  57. package/dist/src/headless.js +12 -4
  58. package/dist/src/index.d.ts +65 -3
  59. package/dist/src/index.js +609 -81
  60. package/dist/src/input/checkable.d.ts +8 -0
  61. package/dist/src/input/checkable.js +66 -0
  62. package/dist/src/input/details.d.ts +6 -0
  63. package/dist/src/input/details.js +34 -0
  64. package/dist/src/input/focus.d.ts +6 -0
  65. package/dist/src/input/focus.js +27 -9
  66. package/dist/src/input/keyboard.d.ts +2 -2
  67. package/dist/src/input/keyboard.js +32 -5
  68. package/dist/src/input/label.d.ts +8 -0
  69. package/dist/src/input/label.js +53 -0
  70. package/dist/src/input/modal.d.ts +9 -0
  71. package/dist/src/input/modal.js +28 -0
  72. package/dist/src/input/mouse.d.ts +2 -2
  73. package/dist/src/input/mouse.js +15 -2
  74. package/dist/src/input/select.d.ts +12 -0
  75. package/dist/src/input/select.js +63 -0
  76. package/dist/src/input/selection.d.ts +48 -0
  77. package/dist/src/input/selection.js +150 -0
  78. package/dist/src/layout/engine.d.ts +2 -0
  79. package/dist/src/layout/engine.js +1084 -142
  80. package/dist/src/layout/flex.js +4 -4
  81. package/dist/src/layout/size.js +3 -2
  82. package/dist/src/layout/text.d.ts +3 -2
  83. package/dist/src/layout/text.js +96 -17
  84. package/dist/src/layout/unicode.d.ts +20 -0
  85. package/dist/src/layout/unicode.js +121 -0
  86. package/dist/src/render/animation-clock.d.ts +51 -0
  87. package/dist/src/render/animation-clock.js +213 -0
  88. package/dist/src/render/ansi-text.d.ts +26 -0
  89. package/dist/src/render/ansi-text.js +131 -0
  90. package/dist/src/render/ansi.d.ts +18 -0
  91. package/dist/src/render/ansi.js +64 -19
  92. package/dist/src/render/border.js +166 -17
  93. package/dist/src/render/buffer.d.ts +1 -0
  94. package/dist/src/render/buffer.js +5 -2
  95. package/dist/src/render/color-depth.d.ts +8 -0
  96. package/dist/src/render/color-depth.js +59 -0
  97. package/dist/src/render/context.d.ts +1 -0
  98. package/dist/src/render/context.js +17 -21
  99. package/dist/src/render/cursor-emit.d.ts +18 -0
  100. package/dist/src/render/cursor-emit.js +50 -0
  101. package/dist/src/render/diff.d.ts +12 -0
  102. package/dist/src/render/diff.js +120 -0
  103. package/dist/src/render/generation.d.ts +9 -0
  104. package/dist/src/render/generation.js +14 -0
  105. package/dist/src/render/graphics-layer.d.ts +27 -0
  106. package/dist/src/render/graphics-layer.js +86 -0
  107. package/dist/src/render/image.d.ts +27 -0
  108. package/dist/src/render/image.js +113 -0
  109. package/dist/src/render/incremental-paint.d.ts +7 -3
  110. package/dist/src/render/incremental-paint.js +52 -79
  111. package/dist/src/render/inline.d.ts +59 -0
  112. package/dist/src/render/inline.js +219 -0
  113. package/dist/src/render/kitty-graphics.d.ts +24 -0
  114. package/dist/src/render/kitty-graphics.js +58 -0
  115. package/dist/src/render/paint-text.js +68 -22
  116. package/dist/src/render/paint.d.ts +8 -1
  117. package/dist/src/render/paint.js +328 -30
  118. package/dist/src/render/png.d.ts +13 -0
  119. package/dist/src/render/png.js +145 -0
  120. package/dist/src/render/scrollbar.d.ts +8 -2
  121. package/dist/src/render/scrollbar.js +71 -14
  122. package/dist/src/render/snapshot.js +3 -1
  123. package/dist/src/renderer/default.d.ts +7 -0
  124. package/dist/src/renderer/default.js +11 -0
  125. package/dist/src/renderer/index.d.ts +8 -2
  126. package/dist/src/renderer/index.js +4 -2
  127. package/dist/src/renderer/node.d.ts +109 -0
  128. package/dist/src/renderer/node.js +165 -1
  129. package/dist/src/terminal/capabilities.d.ts +33 -0
  130. package/dist/src/terminal/capabilities.js +66 -0
  131. package/dist/src/terminal/clipboard.d.ts +9 -0
  132. package/dist/src/terminal/clipboard.js +39 -0
  133. package/dist/src/terminal/io.d.ts +82 -0
  134. package/dist/src/terminal/io.js +155 -0
  135. package/dist/src/terminal/screen.d.ts +3 -10
  136. package/dist/src/terminal/screen.js +5 -28
  137. package/dist/src/terminal/stdin-router.d.ts +8 -5
  138. package/dist/src/terminal/stdin-router.js +22 -11
  139. package/dist/src/utils/node-map.d.ts +24 -0
  140. package/dist/src/utils/node-map.js +75 -0
  141. package/dist/src/vite/config.d.ts +62 -0
  142. package/dist/src/vite/config.js +191 -0
  143. package/docs/compatibility.md +67 -0
  144. package/docs/debug/devtools.md +40 -0
  145. package/docs/debug/svt.md +50 -0
  146. package/docs/distribution.md +106 -0
  147. package/docs/elements.md +120 -0
  148. package/docs/getting-started.md +177 -0
  149. package/docs/guide/css.md +187 -0
  150. package/docs/guide/input.md +143 -0
  151. package/docs/guide/layout.md +171 -0
  152. package/docs/guide/theming.md +94 -0
  153. package/docs/how-it-works.md +115 -0
  154. package/docs/inline-mode.md +77 -0
  155. package/docs/layout.md +106 -0
  156. package/docs/motion.md +91 -0
  157. package/docs/reference/README.md +65 -0
  158. package/docs/reference/css/properties/border-corner.md +82 -0
  159. package/docs/reference/css/properties/border-style.md +168 -0
  160. package/docs/reference.md +226 -0
  161. package/docs/selectors.md +80 -0
  162. package/docs/terminal-css.md +149 -0
  163. package/docs/terminals.md +83 -0
  164. package/package.json +28 -7
@@ -1,6 +1,80 @@
1
+ import { SvtRegionNode, childrenWithPseudos } from '../renderer/node.js';
2
+ import { NodeMap } from '../utils/node-map.js';
1
3
  import { computeMainStart, computeItemGap, computeCrossOffset } from './flex.js';
2
4
  import { measureText } from './text.js';
5
+ import { measureAnsiText } from '../render/ansi-text.js';
6
+ import { imageIntrinsicSize } from '../render/image.js';
3
7
  import { resolveSize, constrain } from './size.js';
8
+ import { parseCellLength } from '../css/values.js';
9
+ /**
10
+ * Check if two adjacent siblings both have borders on their shared edge.
11
+ * Returns true if the gap between them should be reduced by 1 to account
12
+ * for the visual spacing inherent in box-drawing border characters.
13
+ */
14
+ function shouldAdjustBorderGap(prevStyle, nextStyle, direction) {
15
+ if (!prevStyle || !nextStyle)
16
+ return false;
17
+ if (prevStyle.borderStyle === 'none' || nextStyle.borderStyle === 'none')
18
+ return false;
19
+ if (direction === 'vertical') {
20
+ return prevStyle.borderBottom && nextStyle.borderTop;
21
+ }
22
+ else {
23
+ return prevStyle.borderRight && nextStyle.borderLeft;
24
+ }
25
+ }
26
+ /**
27
+ * Approximate auto min-size in the main flex axis (CSS Flexbox §4.5).
28
+ * Returns the smallest size the item can be without losing essential content:
29
+ * borders that occupy main-axis space + (1 cell if the item has children, else 0).
30
+ * overflow:hidden items can collapse to 0.
31
+ */
32
+ function autoMinMainSize(node, style, baseDir) {
33
+ if (!style)
34
+ return 0;
35
+ if (style.overflow === 'hidden' || style.overflow === 'scroll')
36
+ return 0;
37
+ if (node.children.length === 0)
38
+ return 0;
39
+ const hasBorder = style.borderStyle && style.borderStyle !== 'none';
40
+ const borderMain = hasBorder
41
+ ? (baseDir === 'row'
42
+ ? (style.borderLeft ? 1 : 0) + (style.borderRight ? 1 : 0)
43
+ : (style.borderTop ? 1 : 0) + (style.borderBottom ? 1 : 0))
44
+ : 0;
45
+ return borderMain + 1;
46
+ }
47
+ /** Table-internal display values that need an anonymous table wrapper when
48
+ * they appear in normal block flow (§17.2.1). */
49
+ function isTableInternal(node, styles) {
50
+ if (node.nodeType !== 'element')
51
+ return false;
52
+ const display = styles.get(node.id)?.display;
53
+ return display === 'table-row' || display === 'table-cell'
54
+ || display === 'table-row-group' || display === 'table-header-group'
55
+ || display === 'table-footer-group';
56
+ }
57
+ /** Inter-element whitespace and comments don't break a run of consecutive
58
+ * table-internal siblings. */
59
+ function absorbsIntoTableRun(node, styles) {
60
+ if (isTableInternal(node, styles))
61
+ return true;
62
+ if (node.nodeType === 'comment')
63
+ return true;
64
+ return node.nodeType === 'text' && (node.text ?? '').trim() === '';
65
+ }
66
+ /** Collect the run of consecutive table-internal siblings starting at
67
+ * `start`, returning the run and the index of its last absorbed child. */
68
+ function gatherTableRun(children, start, styles) {
69
+ const run = [];
70
+ let i = start;
71
+ while (i < children.length && absorbsIntoTableRun(children[i], styles)) {
72
+ if (isTableInternal(children[i], styles))
73
+ run.push(children[i]);
74
+ i++;
75
+ }
76
+ return { run, end: i - 1 };
77
+ }
4
78
  /** Flatten display:contents elements, promoting their children. */
5
79
  function flattenContents(children, styles) {
6
80
  const result = [];
@@ -15,7 +89,7 @@ function flattenContents(children, styles) {
15
89
  return result;
16
90
  }
17
91
  export function computeLayout(root, styles, availWidth, availHeight) {
18
- const boxes = new Map();
92
+ const boxes = new NodeMap();
19
93
  layoutNode(root, styles, boxes, 0, 0, availWidth, availHeight);
20
94
  return boxes;
21
95
  }
@@ -33,19 +107,38 @@ function layoutText(node, boxes, x, y, availWidth = Infinity, styles) {
33
107
  const parentStyle = node.parent ? styles?.get(node.parent.id) : undefined;
34
108
  const preserveWhitespace = parentStyle?.whiteSpace === 'pre';
35
109
  // Skip empty text and inter-element whitespace.
36
- // Whitespace-only text between element siblings is formatting, not content.
37
- // Preserve whitespace-only text that is the sole child (e.g. grid rows of spaces).
38
- const isInterElementWhitespace = !preserveWhitespace
39
- && text.trim() === ''
40
- && node.parent?.children.some(c => c.nodeType === 'element');
41
- if (text === '' || isInterElementWhitespace) {
110
+ // Preserve whitespace between inline siblings (matching browser behaviour),
111
+ // but collapse between block-level siblings or inside flex/grid containers
112
+ // (where children are blockified).
113
+ if (text === '') {
42
114
  boxes.set(node.id, { x, y, width: 0, height: 0 });
43
115
  return { width: 0, height: 0 };
44
116
  }
117
+ if (!preserveWhitespace && text.trim() === '' && node.parent?.children.some(c => c.nodeType === 'element')) {
118
+ const parentDisplay = parentStyle?.display ?? 'block';
119
+ const isFlexOrGrid = parentDisplay === 'flex' || parentDisplay === 'grid';
120
+ const hasBlockSibling = node.parent.children.some(c => {
121
+ if (c.nodeType !== 'element')
122
+ return false;
123
+ const d = styles?.get(c.id)?.display ?? 'block';
124
+ return d !== 'inline';
125
+ });
126
+ if (isFlexOrGrid || hasBlockSibling) {
127
+ boxes.set(node.id, { x, y, width: 0, height: 0 });
128
+ return { width: 0, height: 0 };
129
+ }
130
+ }
131
+ // <svt-ansi> content is pre-styled and pre-formatted: measure with
132
+ // escape sequences stripped, no wrapping.
133
+ if (node.parent?.tag === 'svt-ansi') {
134
+ const measured = measureAnsiText(text);
135
+ boxes.set(node.id, { x, y, width: measured.width, height: measured.height });
136
+ return measured;
137
+ }
45
138
  // Check parent's whiteSpace
46
139
  const noWrap = parentStyle?.whiteSpace === 'nowrap';
47
140
  const wrapWidth = noWrap ? Infinity : (availWidth > 0 ? availWidth : Infinity);
48
- const measured = measureText(text, wrapWidth);
141
+ const measured = measureText(text, wrapWidth, parentStyle?.wordBreak ?? 'normal');
49
142
  boxes.set(node.id, { x, y, width: measured.width, height: measured.height });
50
143
  return measured;
51
144
  }
@@ -58,7 +151,7 @@ function layoutElement(node, styles, boxes, x, y, availWidth, availHeight) {
58
151
  return { width: 0, height: 0 };
59
152
  // display: contents — element is invisible to layout, children promoted
60
153
  if (style?.display === 'contents') {
61
- return layoutBlockFlow(node.children, styles, boxes, x, y, availWidth, availHeight);
154
+ return layoutBlockFlow(childrenWithPseudos(node), styles, boxes, x, y, availWidth, availHeight);
62
155
  }
63
156
  // Absolute positioning: use top/left offsets relative to parent, don't consume space in flow
64
157
  if (style?.position === 'absolute' || style?.position === 'fixed') {
@@ -73,12 +166,22 @@ function layoutElement(node, styles, boxes, x, y, availWidth, availHeight) {
73
166
  left: resolvePadding(style?.marginLeft, availWidth),
74
167
  };
75
168
  const borderWidth = (style?.borderStyle && style.borderStyle !== 'none') ? 1 : 0;
169
+ const collapsesPadding = isOuterFacingBorder(style?.borderStyle);
170
+ const collapsesMargin = isInnerFacingBorder(style?.borderStyle);
76
171
  const inset = {
77
- top: resolvePadding(style?.paddingTop, availWidth) + borderWidth,
78
- right: resolvePadding(style?.paddingRight, availWidth) + borderWidth,
79
- bottom: resolvePadding(style?.paddingBottom, availWidth) + borderWidth,
80
- left: resolvePadding(style?.paddingLeft, availWidth) + borderWidth,
172
+ top: insetWithCollapse(style?.paddingTop, availWidth, style?.borderTop, borderWidth, collapsesPadding),
173
+ right: insetWithCollapse(style?.paddingRight, availWidth, style?.borderRight, borderWidth, collapsesPadding),
174
+ bottom: insetWithCollapse(style?.paddingBottom, availWidth, style?.borderBottom, borderWidth, collapsesPadding),
175
+ left: insetWithCollapse(style?.paddingLeft, availWidth, style?.borderLeft, borderWidth, collapsesPadding),
81
176
  };
177
+ if (collapsesMargin) {
178
+ margin = {
179
+ top: collapseMargin(margin.top, style?.borderTop, borderWidth),
180
+ right: collapseMargin(margin.right, style?.borderRight, borderWidth),
181
+ bottom: collapseMargin(margin.bottom, style?.borderBottom, borderWidth),
182
+ left: collapseMargin(margin.left, style?.borderLeft, borderWidth),
183
+ };
184
+ }
82
185
  // Resolve auto margins for centering
83
186
  const nodeWidthForAutoMargin = resolveSize(style?.width, availWidth);
84
187
  if (margin.left === -1 && margin.right === -1 && nodeWidthForAutoMargin !== null) {
@@ -93,17 +196,39 @@ function layoutElement(node, styles, boxes, x, y, availWidth, availHeight) {
93
196
  }
94
197
  const boxX = x + margin.left;
95
198
  const boxY = y + margin.top;
96
- const explicitWidth = resolveSize(style?.width, availWidth - margin.left - margin.right);
97
- const nodeWidth = explicitWidth !== null ? Math.min(explicitWidth, availWidth - margin.left - margin.right) : null;
98
- const nodeHeight = resolveSize(style?.height, availHeight - margin.top - margin.bottom);
99
- const innerW = (nodeWidth ?? (availWidth - margin.left - margin.right)) - inset.left - inset.right;
100
- const innerH = (nodeHeight ?? (availHeight - margin.top - margin.bottom)) - inset.top - inset.bottom;
199
+ // Walk past display:contents ancestors to find the actual layout parent
200
+ let layoutParent = node.parent;
201
+ while (layoutParent && styles.get(layoutParent.id)?.display === 'contents') {
202
+ layoutParent = layoutParent.parent;
203
+ }
204
+ const parentDisplay = layoutParent ? styles.get(layoutParent.id)?.display : undefined;
205
+ const isFlexOrGridChild = parentDisplay === 'flex' || parentDisplay === 'grid';
206
+ const isContentBox = style?.boxSizing === 'content-box';
207
+ let explicitWidth = resolveSize(style?.width, availWidth - margin.left - margin.right);
208
+ if (explicitWidth !== null && isContentBox)
209
+ explicitWidth += inset.left + inset.right;
210
+ // Flex/grid children are sized by their parent algorithm, not clamped to available width
211
+ const nodeWidth = explicitWidth !== null
212
+ ? (isFlexOrGridChild ? explicitWidth : Math.min(explicitWidth, availWidth - margin.left - margin.right))
213
+ : null;
214
+ let nodeHeight = resolveSize(style?.height, availHeight - margin.top - margin.bottom);
215
+ if (nodeHeight !== null && isContentBox)
216
+ nodeHeight += inset.top + inset.bottom;
217
+ // Apply max-width/max-height to available space so children (e.g. flex-wrap) respect constraints
218
+ let effectiveW = nodeWidth ?? (availWidth - margin.left - margin.right);
219
+ if (style?.maxWidth != null && effectiveW > style.maxWidth)
220
+ effectiveW = style.maxWidth;
221
+ let effectiveH = nodeHeight ?? (availHeight - margin.top - margin.bottom);
222
+ if (style?.maxHeight != null && effectiveH > style.maxHeight)
223
+ effectiveH = style.maxHeight;
224
+ const innerW = effectiveW - inset.left - inset.right;
225
+ const innerH = effectiveH - inset.top - inset.bottom;
101
226
  const display = style?.display ?? 'block';
102
227
  let content;
103
228
  if (display === 'flex') {
104
- content = positionChildren(node.children, styles, boxes, boxX + inset.left, boxY + inset.top, innerW, innerH, style?.flexDirection ?? 'column', style?.gap ?? 0, style?.justifyContent ?? 'start', style?.alignItems ?? 'start', style?.flexWrap ?? 'nowrap');
229
+ content = positionChildren(childrenWithPseudos(node), styles, boxes, boxX + inset.left, boxY + inset.top, innerW, innerH, style?.flexDirection ?? 'column', style?.gap ?? 0, style?.justifyContent ?? 'start', style?.alignItems ?? 'start', style?.flexWrap ?? 'nowrap');
105
230
  }
106
- else if (display === 'table') {
231
+ else if (display === 'table' || display === 'inline-table') {
107
232
  content = layoutTable(node, styles, boxes, boxX + inset.left, boxY + inset.top, innerW, innerH);
108
233
  }
109
234
  else if (display === 'grid' && style) {
@@ -111,41 +236,74 @@ function layoutElement(node, styles, boxes, x, y, availWidth, availHeight) {
111
236
  }
112
237
  else {
113
238
  // block or inline — use block flow (inline children flow horizontally within)
114
- content = layoutBlockFlow(node.children, styles, boxes, boxX + inset.left, boxY + inset.top, innerW, innerH);
239
+ content = layoutBlockFlow(childrenWithPseudos(node), styles, boxes, boxX + inset.left, boxY + inset.top, innerW, innerH);
115
240
  }
116
241
  // Block elements fill parent width; inline/inline-block shrink-wrap to content.
117
242
  // Flex/grid children are sized by the flex/grid algorithm, so they shrink-wrap.
118
- const parentDisplay = node.parent ? styles.get(node.parent.id)?.display : undefined;
119
- const isFlexOrGridChild = parentDisplay === 'flex' || parentDisplay === 'grid';
120
243
  const isBlock = (display === 'block' || display === 'flex' || display === 'grid' || display === 'table')
121
244
  && !isFlexOrGridChild;
122
- const autoWidth = isBlock
245
+ // Paint primitives (svt-region) have no meaningful content size — they
246
+ // exist to fill an allocated cell area. Default to the parent's available
247
+ // box on both axes when no explicit dimension was given, like an
248
+ // intrinsically sized replaced element rather than a content-sized div.
249
+ const isFillPrimitive = node instanceof SvtRegionNode;
250
+ let autoWidth = isFillPrimitive || isBlock
123
251
  ? (availWidth - margin.left - margin.right)
124
252
  : content.width + inset.left + inset.right;
125
- // Input/textarea have intrinsic minimum height of 1 row for the value text
126
- const intrinsicHeight = (node.tag === 'input' || node.tag === 'textarea')
253
+ // <img> is a replaced element: intrinsic size from its pixels
254
+ // (1 px per column, 2 px per row for half-block rendering)
255
+ const intrinsicImage = node.tag === 'img' ? imageIntrinsicSize(node) : null;
256
+ if (intrinsicImage) {
257
+ autoWidth = Math.min(intrinsicImage.width + inset.left + inset.right, availWidth - margin.left - margin.right);
258
+ }
259
+ // A select shrink-wraps to its longest option label plus the " ▾" indicator
260
+ if (node.tag === 'select') {
261
+ autoWidth = Math.max(autoWidth, longestOptionLength(node) + 2 + inset.left + inset.right);
262
+ }
263
+ // Input/textarea/select have intrinsic minimum height of 1 row
264
+ const intrinsicHeight = (node.tag === 'input' || node.tag === 'textarea' || node.tag === 'select')
127
265
  ? Math.max(content.height, 1)
128
266
  : content.height;
129
- const autoHeight = intrinsicHeight + inset.top + inset.bottom;
267
+ const autoHeight = isFillPrimitive
268
+ ? (availHeight - margin.top - margin.bottom)
269
+ : intrinsicImage
270
+ ? intrinsicImage.height + inset.top + inset.bottom
271
+ : intrinsicHeight + inset.top + inset.bottom;
130
272
  const finalWidth = constrain(nodeWidth ?? autoWidth, style?.minWidth, style?.maxWidth);
131
273
  const finalHeight = constrain(nodeHeight ?? autoHeight, style?.minHeight, style?.maxHeight);
132
274
  boxes.set(node.id, { x: boxX, y: boxY, width: finalWidth, height: finalHeight });
133
275
  // Return outer size including margin
134
276
  return { width: finalWidth + margin.left + margin.right, height: finalHeight + margin.top + margin.bottom };
135
277
  }
278
+ function longestOptionLength(select) {
279
+ let longest = 0;
280
+ const walk = (node) => {
281
+ for (const child of node.children) {
282
+ if (child.nodeType !== 'element')
283
+ continue;
284
+ if (child.tag === 'option')
285
+ longest = Math.max(longest, child.textContent.trim().length);
286
+ else if (child.tag === 'optgroup')
287
+ walk(child);
288
+ }
289
+ };
290
+ walk(select);
291
+ return longest;
292
+ }
136
293
  function layoutAbsolute(node, styles, boxes, x, y, availWidth, availHeight, style) {
137
294
  const borderWidth = (style.borderStyle && style.borderStyle !== 'none') ? 1 : 0;
295
+ const collapsesPadding = isOuterFacingBorder(style.borderStyle);
138
296
  const inset = {
139
- top: resolvePadding(style.paddingTop, availWidth) + borderWidth,
140
- right: resolvePadding(style.paddingRight, availWidth) + borderWidth,
141
- bottom: resolvePadding(style.paddingBottom, availWidth) + borderWidth,
142
- left: resolvePadding(style.paddingLeft, availWidth) + borderWidth,
297
+ top: insetWithCollapse(style.paddingTop, availWidth, style.borderTop, borderWidth, collapsesPadding),
298
+ right: insetWithCollapse(style.paddingRight, availWidth, style.borderRight, borderWidth, collapsesPadding),
299
+ bottom: insetWithCollapse(style.paddingBottom, availWidth, style.borderBottom, borderWidth, collapsesPadding),
300
+ left: insetWithCollapse(style.paddingLeft, availWidth, style.borderLeft, borderWidth, collapsesPadding),
143
301
  };
144
302
  const nodeWidth = resolveSize(style.width, availWidth);
145
303
  const nodeHeight = resolveSize(style.height, availHeight);
146
304
  const innerW = (nodeWidth ?? availWidth) - inset.left - inset.right;
147
305
  const innerH = (nodeHeight ?? availHeight) - inset.top - inset.bottom;
148
- const content = positionChildren(node.children, styles, boxes, x + inset.left, y + inset.top, innerW, innerH, style.flexDirection ?? 'column', style.gap ?? 0, style.justifyContent ?? 'start', style.alignItems ?? 'start');
306
+ const content = positionChildren(childrenWithPseudos(node), styles, boxes, x + inset.left, y + inset.top, innerW, innerH, style.flexDirection ?? 'column', style.gap ?? 0, style.justifyContent ?? 'start', style.alignItems ?? 'start');
149
307
  const finalWidth = constrain(nodeWidth ?? (content.width + inset.left + inset.right), style.minWidth, style.maxWidth);
150
308
  const finalHeight = constrain(nodeHeight ?? (content.height + inset.top + inset.bottom), style.minHeight, style.maxHeight);
151
309
  boxes.set(node.id, { x, y, width: finalWidth, height: finalHeight });
@@ -160,6 +318,42 @@ function resolvePadding(value, availWidth) {
160
318
  return value;
161
319
  return resolveSize(value, availWidth) ?? 0;
162
320
  }
321
+ /**
322
+ * Block-character border styles whose stroke faces outward.
323
+ * The unused (inner) portion of the border cell collapses with `padding`.
324
+ */
325
+ function isOuterFacingBorder(borderStyle) {
326
+ return borderStyle === 'eighth-cell-outer' || borderStyle === 'half-cell-outer';
327
+ }
328
+ /**
329
+ * Block-character border styles whose stroke faces inward.
330
+ * The unused (outer) portion of the border cell collapses with `margin`.
331
+ */
332
+ function isInnerFacingBorder(borderStyle) {
333
+ return borderStyle === 'eighth-cell-inner' || borderStyle === 'half-cell-inner';
334
+ }
335
+ /**
336
+ * Compute total inset on one side, applying the padding/border collapse rule when applicable.
337
+ * When the border absorbs padding, total inset = max(padding, borderWidth).
338
+ * Otherwise total inset = padding + borderWidth.
339
+ */
340
+ function insetWithCollapse(padding, availWidth, sideHasBorder, borderWidth, collapses) {
341
+ const p = resolvePadding(padding, availWidth);
342
+ const sideBorder = sideHasBorder ? borderWidth : 0;
343
+ if (collapses && sideHasBorder)
344
+ return Math.max(p, sideBorder);
345
+ return p + sideBorder;
346
+ }
347
+ /**
348
+ * Apply the margin collapse rule for inner-facing borders on a single side.
349
+ * When the border absorbs margin: effective margin = max(margin - 1, 0).
350
+ * Caller must check the side actually has a border before calling.
351
+ */
352
+ function collapseMargin(marginValue, sideHasBorder, borderWidth) {
353
+ if (!sideHasBorder || marginValue <= 0)
354
+ return marginValue;
355
+ return Math.max(marginValue - borderWidth, 0);
356
+ }
163
357
  function layoutBlockFlow(children, styles, boxes, x, y, availW, availH) {
164
358
  // Layout absolute children first
165
359
  for (const child of children) {
@@ -173,8 +367,10 @@ function layoutBlockFlow(children, styles, boxes, x, y, availW, availH) {
173
367
  let lineHeight = 0;
174
368
  let maxWidth = 0;
175
369
  let prevBlockMarginBottom = 0;
370
+ let prevBlockStyle;
176
371
  const flatChildren = flattenContents(children, styles);
177
- for (const child of flatChildren) {
372
+ for (let i = 0; i < flatChildren.length; i++) {
373
+ const child = flatChildren[i];
178
374
  if (child.nodeType === 'comment')
179
375
  continue;
180
376
  const s = styles.get(child.id);
@@ -182,7 +378,26 @@ function layoutBlockFlow(children, styles, boxes, x, y, availW, availH) {
182
378
  continue;
183
379
  if (s?.position === 'absolute' || s?.position === 'fixed')
184
380
  continue;
185
- const isInline = child.nodeType === 'text' || s?.display === 'inline' || s?.display === 'inline-block';
381
+ // Stray table-internal content (a <tr>/<td>/row-group outside a
382
+ // table) wraps in an anonymous table together with its consecutive
383
+ // table-internal siblings (§17.2.1).
384
+ if (isTableInternal(child, styles)) {
385
+ const { run, end } = gatherTableRun(flatChildren, i, styles);
386
+ i = end;
387
+ if (cursorX > x) {
388
+ cursorY += lineHeight;
389
+ cursorX = x;
390
+ lineHeight = 0;
391
+ }
392
+ const size = layoutTableChildren(run, undefined, styles, boxes, x, cursorY, availW, availH - (cursorY - y));
393
+ cursorY += size.height;
394
+ maxWidth = Math.max(maxWidth, size.width);
395
+ prevBlockMarginBottom = 0;
396
+ prevBlockStyle = undefined;
397
+ continue;
398
+ }
399
+ const isInline = child.nodeType === 'text' || s?.display === 'inline'
400
+ || s?.display === 'inline-block' || s?.display === 'inline-table';
186
401
  if (isInline) {
187
402
  // Flow horizontally
188
403
  const size = layoutNode(child, styles, boxes, cursorX, cursorY, availW - (cursorX - x), availH);
@@ -205,10 +420,15 @@ function layoutBlockFlow(children, styles, boxes, x, y, availW, availH) {
205
420
  const overlap = prevBlockMarginBottom + childMarginTop - collapsed;
206
421
  cursorY -= overlap;
207
422
  }
423
+ // Border collapse: adjacent bordered blocks overlap by 1
424
+ if (shouldAdjustBorderGap(prevBlockStyle, s, 'vertical')) {
425
+ cursorY -= 1;
426
+ }
208
427
  const size = layoutNode(child, styles, boxes, x, cursorY, availW, availH - (cursorY - y));
209
428
  cursorY += size.height;
210
429
  maxWidth = Math.max(maxWidth, size.width);
211
430
  prevBlockMarginBottom = resolvePadding(s?.marginBottom, availW);
431
+ prevBlockStyle = s;
212
432
  }
213
433
  }
214
434
  // Account for trailing inline content
@@ -217,128 +437,632 @@ function layoutBlockFlow(children, styles, boxes, x, y, availW, availH) {
217
437
  }
218
438
  return { width: maxWidth, height: cursorY - y };
219
439
  }
220
- function layoutTable(node, styles, boxes, x, y, availW, availH) {
221
- // Collect rows and cells
440
+ function rawColspan(cell) {
441
+ const raw = cell.attributes.get('colspan');
442
+ if (!raw)
443
+ return 1;
444
+ const n = parseInt(raw);
445
+ // colspan=0 means "span remaining columns"; resolved against the table's
446
+ // numCols in buildTableGrid.
447
+ if (n === 0)
448
+ return 0;
449
+ return n > 0 ? n : 1;
450
+ }
451
+ function cellRowspan(cell) {
452
+ const raw = cell.attributes.get('rowspan');
453
+ if (!raw)
454
+ return 1;
455
+ const n = parseInt(raw);
456
+ return n > 0 ? n : 1;
457
+ }
458
+ function buildTableGrid(rows) {
459
+ // First pass: numCols treating colspan=0 as 1.
460
+ let numCols = 0;
461
+ const provisional = rows.map(() => []);
462
+ for (let r = 0; r < rows.length; r++) {
463
+ let col = 0;
464
+ for (const cell of rows[r].cells) {
465
+ while (provisional[r][col])
466
+ col++;
467
+ const cs = rawColspan(cell);
468
+ const span = cs === 0 ? 1 : cs;
469
+ const rspan = cellRowspan(cell);
470
+ for (let dr = 1; dr < rspan && r + dr < rows.length; dr++) {
471
+ for (let dc = 0; dc < span; dc++)
472
+ provisional[r + dr][col + dc] = true;
473
+ }
474
+ col += span;
475
+ if (col > numCols)
476
+ numCols = col;
477
+ }
478
+ }
479
+ // Second pass: rebuild grid with colspan=0 expanded to fill remaining columns.
480
+ const occupied = rows.map(() => []);
481
+ const span = new Map();
482
+ for (let r = 0; r < rows.length; r++) {
483
+ let col = 0;
484
+ for (const cell of rows[r].cells) {
485
+ while (occupied[r][col])
486
+ col++;
487
+ const cs = rawColspan(cell);
488
+ const resolved = cs === 0 ? Math.max(1, numCols - col) : cs;
489
+ span.set(cell.id, resolved);
490
+ const rspan = cellRowspan(cell);
491
+ for (let dr = 1; dr < rspan && r + dr < rows.length; dr++) {
492
+ for (let dc = 0; dc < resolved; dc++)
493
+ occupied[r + dr][col + dc] = true;
494
+ }
495
+ col += resolved;
496
+ }
497
+ }
498
+ return { occupied, numCols, span };
499
+ }
500
+ function cellColspan(grid, cell) {
501
+ return grid.span.get(cell.id) ?? 1;
502
+ }
503
+ /** True for content that acts as a table cell inside a row: real cells, plus
504
+ * stray text / elements that get an anonymous cell box per §17.2.1. */
505
+ function isCellContent(node, styles) {
506
+ if (node.nodeType === 'text')
507
+ return (node.text ?? '').trim() !== '';
508
+ if (node.nodeType !== 'element')
509
+ return false;
510
+ const display = styles.get(node.id)?.display;
511
+ return display !== 'none' && !isRowLevelDisplay(display);
512
+ }
513
+ function isRowLevelDisplay(display) {
514
+ return display === 'table-row'
515
+ || display === 'table-row-group' || display === 'table-header-group'
516
+ || display === 'table-footer-group'
517
+ || display === 'table-caption' || display === 'table-column'
518
+ || display === 'table-column-group';
519
+ }
520
+ function cellsOfRow(trNode, styles) {
521
+ return trNode.children.filter(c => isCellContent(c, styles));
522
+ }
523
+ /**
524
+ * Group the children of a table or row-group into rows. Explicit table-rows
525
+ * keep their cells; a run of consecutive stray cell-content children forms
526
+ * one anonymous row (§17.2.1). Simplification vs the spec: each stray child
527
+ * is its own anonymous cell rather than coalescing consecutive inline
528
+ * content into one.
529
+ */
530
+ function groupIntoRows(children, styles) {
222
531
  const rows = [];
223
- for (const child of node.children) {
224
- if (child.tag === 'tr') {
225
- const cells = child.children.filter(c => c.tag === 'td' || c.tag === 'th');
226
- rows.push(cells);
532
+ let anonymous = [];
533
+ const flushAnonymous = () => {
534
+ if (anonymous.length > 0) {
535
+ rows.push({ trNode: null, cells: anonymous });
536
+ anonymous = [];
537
+ }
538
+ };
539
+ for (const child of children) {
540
+ if (child.nodeType === 'element' && styles.get(child.id)?.display === 'table-row') {
541
+ flushAnonymous();
542
+ rows.push({ trNode: child, cells: cellsOfRow(child, styles) });
543
+ }
544
+ else if (isCellContent(child, styles)) {
545
+ anonymous.push(child);
227
546
  }
228
547
  }
229
- if (rows.length === 0)
230
- return { width: 0, height: 0 };
231
- const numCols = Math.max(...rows.map(r => r.length));
232
- const colWidths = new Array(numCols).fill(0);
233
- // First pass: measure all cells to find max column widths
234
- for (const row of rows) {
235
- for (let col = 0; col < row.length; col++) {
236
- const cell = row[col];
237
- const size = layoutNode(cell, styles, boxes, 0, 0, availW, availH);
238
- colWidths[col] = Math.max(colWidths[col], size.width);
239
- }
240
- }
241
- // Add 2 cells padding between columns
242
- const colGap = 2;
243
- // Second pass: position cells with aligned columns
244
- let rowY = y;
245
- for (const row of rows) {
548
+ flushAnonymous();
549
+ return rows;
550
+ }
551
+ function collectTableRows(children, styles) {
552
+ // Per CSS 2.2 §17.5.2: header bodies (in source order) footer.
553
+ // Bare <tr> (or stray cell content) children of <table> form an implicit
554
+ // body, mirroring the browser HTML parser's auto-tbody insertion.
555
+ const headerRows = [];
556
+ const bodyRows = [];
557
+ const footerRows = [];
558
+ let strayRun = [];
559
+ const flushStray = () => {
560
+ if (strayRun.length > 0) {
561
+ bodyRows.push(...groupIntoRows(strayRun, styles));
562
+ strayRun = [];
563
+ }
564
+ };
565
+ for (const child of children) {
566
+ const display = child.nodeType === 'element' ? styles.get(child.id)?.display : undefined;
567
+ if (display === 'table-header-group') {
568
+ flushStray();
569
+ headerRows.push(...groupIntoRows(child.children, styles));
570
+ }
571
+ else if (display === 'table-footer-group') {
572
+ flushStray();
573
+ footerRows.push(...groupIntoRows(child.children, styles));
574
+ }
575
+ else if (display === 'table-row-group') {
576
+ flushStray();
577
+ bodyRows.push(...groupIntoRows(child.children, styles));
578
+ }
579
+ else {
580
+ // table-rows and stray cell content accumulate; captions/columns
581
+ // are filtered out inside groupIntoRows.
582
+ strayRun.push(child);
583
+ }
584
+ }
585
+ flushStray();
586
+ return [...headerRows, ...bodyRows, ...footerRows];
587
+ }
588
+ function findCaption(children, styles) {
589
+ return children.find(c => styles.get(c.id)?.display === 'table-caption');
590
+ }
591
+ function colSpanAttr(colNode) {
592
+ const raw = colNode.attributes.get('span');
593
+ if (!raw)
594
+ return 1;
595
+ const n = parseInt(raw);
596
+ return n > 0 ? n : 1;
597
+ }
598
+ function explicitWidth(node, styles) {
599
+ const w = styles.get(node.id)?.width;
600
+ return typeof w === 'number' && w > 0 ? w : 0;
601
+ }
602
+ function applyColHint(hints, col, span, width) {
603
+ if (width <= 0)
604
+ return;
605
+ for (let i = 0; i < span; i++) {
606
+ hints[col + i] = Math.max(hints[col + i] ?? 0, width);
607
+ }
608
+ }
609
+ function collectColHints(children, styles) {
610
+ // Walks <col> and <colgroup> in document order; returns per-column width
611
+ // hints (sparse). <col span> covers multiple columns; a <colgroup> with no
612
+ // <col> children covers `span` columns itself.
613
+ const hints = [];
614
+ let col = 0;
615
+ for (const child of children) {
616
+ const display = styles.get(child.id)?.display;
617
+ if (display === 'table-column') {
618
+ const span = colSpanAttr(child);
619
+ applyColHint(hints, col, span, explicitWidth(child, styles));
620
+ col += span;
621
+ }
622
+ else if (display === 'table-column-group') {
623
+ const colChildren = child.children.filter(c => styles.get(c.id)?.display === 'table-column');
624
+ if (colChildren.length === 0) {
625
+ const span = colSpanAttr(child);
626
+ applyColHint(hints, col, span, explicitWidth(child, styles));
627
+ col += span;
628
+ }
629
+ else {
630
+ for (const colNode of colChildren) {
631
+ const span = colSpanAttr(colNode);
632
+ applyColHint(hints, col, span, explicitWidth(colNode, styles));
633
+ col += span;
634
+ }
635
+ }
636
+ }
637
+ }
638
+ return hints;
639
+ }
640
+ /**
641
+ * Horizontal/vertical gap between table tracks. The separate model uses
642
+ * border-spacing; the collapsed model overlaps tracks by one cell where
643
+ * adjacent border strokes would otherwise double up, so they coincide and
644
+ * merge into shared grid lines. A track boundary only collapses when cells
645
+ * are bordered on both of its sides (e.g. left+right for columns) — one-sided
646
+ * borders such as row separators already draw a single line and need no overlap.
647
+ */
648
+ function tableGaps(tableStyle, rows, styles) {
649
+ if (tableStyle?.borderCollapse === 'collapse') {
650
+ return {
651
+ col: allCellsBordered(rows, styles, 'borderLeft', 'borderRight') ? -1 : 0,
652
+ row: allCellsBordered(rows, styles, 'borderTop', 'borderBottom') ? -1 : 0,
653
+ };
654
+ }
655
+ return { col: tableStyle?.borderSpacingH ?? 0, row: tableStyle?.borderSpacingV ?? 0 };
656
+ }
657
+ function allCellsBordered(rows, styles, ...sides) {
658
+ return rows.every(row => row.cells.every(cell => {
659
+ const style = styles.get(cell.id);
660
+ return style !== undefined && style.borderStyle !== 'none'
661
+ && sides.every(side => style[side]);
662
+ }));
663
+ }
664
+ function measureColumnWidths(rows, grid, styles, boxes, availW, availH, mode = 'auto') {
665
+ const colWidths = new Array(grid.numCols).fill(0);
666
+ // Only single-column cells contribute directly to column widths in this
667
+ // pass; colspan>1 cells are positioned later using the resolved widths.
668
+ // table-layout: fixed only consults the first row.
669
+ const lastRow = mode === 'fixed' ? Math.min(1, rows.length) : rows.length;
670
+ for (let r = 0; r < lastRow; r++) {
671
+ let col = 0;
672
+ for (const cell of rows[r].cells) {
673
+ while (grid.occupied[r][col])
674
+ col++;
675
+ const span = cellColspan(grid, cell);
676
+ if (span === 1) {
677
+ const size = layoutNode(cell, styles, boxes, 0, 0, availW, availH);
678
+ colWidths[col] = Math.max(colWidths[col], size.width);
679
+ }
680
+ col += span;
681
+ }
682
+ }
683
+ return colWidths;
684
+ }
685
+ function placeCaption(caption, styles, boxes, x, y, tableWidth, availH) {
686
+ const size = layoutNode(caption, styles, boxes, x, y, tableWidth, availH);
687
+ const captionBox = boxes.get(caption.id);
688
+ if (captionBox)
689
+ captionBox.width = tableWidth;
690
+ return size.height;
691
+ }
692
+ function spannedWidth(colWidths, col, span, colGap) {
693
+ let w = 0;
694
+ for (let i = 0; i < span; i++)
695
+ w += colWidths[col + i] ?? 0;
696
+ return w + colGap * Math.max(0, span - 1);
697
+ }
698
+ function shiftSubtreeY(node, boxes, dy) {
699
+ for (const child of node.children) {
700
+ const box = boxes.get(child.id);
701
+ if (box)
702
+ box.y += dy;
703
+ shiftSubtreeY(child, boxes, dy);
704
+ }
705
+ }
706
+ function applyVerticalAlign(cell, totalHeight, contentHeight, styles, boxes) {
707
+ const cellBox = boxes.get(cell.id);
708
+ if (cellBox)
709
+ cellBox.height = totalHeight;
710
+ const slack = totalHeight - contentHeight;
711
+ if (slack <= 0)
712
+ return;
713
+ const va = styles.get(cell.id)?.verticalAlign ?? 'top';
714
+ if (va === 'top')
715
+ return;
716
+ const dy = va === 'middle' ? Math.floor(slack / 2) : slack;
717
+ shiftSubtreeY(cell, boxes, dy);
718
+ }
719
+ function placeRows(rows, grid, styles, boxes, x, startY, colWidths, tableWidth, availH, gaps) {
720
+ const rowHeights = [];
721
+ const placed = [];
722
+ // Pass 1: lay out each cell, accumulate per-row height from non-rowspan cells.
723
+ let rowY = startY;
724
+ for (let r = 0; r < rows.length; r++) {
725
+ const { trNode, cells } = rows[r];
726
+ let col = 0;
246
727
  let colX = x;
247
728
  let rowHeight = 0;
248
- // Layout the tr element
249
- const trNode = node.children.find(c => c.tag === 'tr' && c.children.includes(row[0]));
250
- for (let col = 0; col < row.length; col++) {
251
- const cell = row[col];
252
- const size = layoutNode(cell, styles, boxes, colX, rowY, colWidths[col], availH);
253
- // Table cells fill their column width
729
+ for (const cell of cells) {
730
+ while (grid.occupied[r][col]) {
731
+ colX += colWidths[col] + gaps.col;
732
+ col++;
733
+ }
734
+ const span = cellColspan(grid, cell);
735
+ const rspan = cellRowspan(cell);
736
+ const cellWidth = spannedWidth(colWidths, col, span, gaps.col);
737
+ const size = layoutNode(cell, styles, boxes, colX, rowY, cellWidth, availH);
254
738
  const cellBox = boxes.get(cell.id);
255
- if (cellBox && cellBox.width < colWidths[col])
256
- cellBox.width = colWidths[col];
257
- rowHeight = Math.max(rowHeight, size.height);
258
- colX += colWidths[col] + colGap;
739
+ if (cellBox && cellBox.width < cellWidth)
740
+ cellBox.width = cellWidth;
741
+ if (rspan === 1)
742
+ rowHeight = Math.max(rowHeight, size.height);
743
+ placed.push({ cell, rowIdx: r, rspan, contentHeight: size.height });
744
+ colX += cellWidth + gaps.col;
745
+ col += span;
259
746
  }
260
- if (trNode) {
261
- const trWidth = colWidths.reduce((sum, w) => sum + w, 0) + colGap * (numCols - 1);
262
- boxes.set(trNode.id, { x, y: rowY, width: trWidth, height: rowHeight });
747
+ const trMinHeight = trNode ? styles.get(trNode.id)?.height : undefined;
748
+ if (typeof trMinHeight === 'number' && trMinHeight > rowHeight)
749
+ rowHeight = trMinHeight;
750
+ rowHeights.push(rowHeight);
751
+ if (trNode)
752
+ boxes.set(trNode.id, { x, y: rowY, width: tableWidth, height: rowHeight });
753
+ rowY += rowHeight + gaps.row;
754
+ }
755
+ // Pass 2: stretch each cell to its row's total height and apply vertical-align.
756
+ for (const { cell, rowIdx, rspan, contentHeight } of placed) {
757
+ let totalH = 0;
758
+ let spanRows = 0;
759
+ for (let r = 0; r < rspan && rowIdx + r < rows.length; r++) {
760
+ totalH += rowHeights[rowIdx + r];
761
+ spanRows++;
263
762
  }
264
- rowY += rowHeight;
763
+ totalH += gaps.row * Math.max(0, spanRows - 1);
764
+ applyVerticalAlign(cell, totalH, contentHeight, styles, boxes);
765
+ }
766
+ return rowY - startY - (rows.length > 0 ? gaps.row : 0);
767
+ }
768
+ function layoutTable(node, styles, boxes, x, y, availW, availH) {
769
+ return layoutTableChildren(node.children, styles.get(node.id), styles, boxes, x, y, availW, availH);
770
+ }
771
+ /**
772
+ * Table layout over a list of children. Called with a table element's
773
+ * children and style, or — for anonymous tables wrapped around stray
774
+ * table-internal content (§17.2.1) — with the run of stray siblings and no
775
+ * style (anonymous tables get initial values, e.g. border-spacing 0).
776
+ */
777
+ function layoutTableChildren(children, tableStyle, styles, boxes, x, y, availW, availH) {
778
+ const rows = collectTableRows(children, styles);
779
+ const caption = findCaption(children, styles);
780
+ if (rows.length === 0 && !caption)
781
+ return { width: 0, height: 0 };
782
+ const grid = buildTableGrid(rows);
783
+ const gaps = tableGaps(tableStyle, rows, styles);
784
+ const colWidths = measureColumnWidths(rows, grid, styles, boxes, availW, availH, tableStyle?.tableLayout ?? 'auto');
785
+ // <col>/<colgroup> widths act as a minimum (auto) or as the source of
786
+ // truth (fixed) for the column.
787
+ const colHints = collectColHints(children, styles);
788
+ for (let i = 0; i < colWidths.length; i++) {
789
+ if (colHints[i] !== undefined)
790
+ colWidths[i] = Math.max(colWidths[i], colHints[i]);
791
+ }
792
+ const colsWidth = colWidths.reduce((sum, w) => sum + w, 0)
793
+ + gaps.col * Math.max(0, colWidths.length - 1);
794
+ // Measure caption against availW so the table can grow to fit it.
795
+ let captionWidth = 0;
796
+ if (caption) {
797
+ const size = layoutNode(caption, styles, boxes, 0, 0, availW, availH);
798
+ captionWidth = size.width;
799
+ }
800
+ const tableWidth = Math.max(colsWidth, captionWidth);
801
+ const captionSide = caption ? styles.get(caption.id)?.captionSide ?? 'top' : 'top';
802
+ let rowY = y;
803
+ if (caption && captionSide === 'top') {
804
+ rowY += placeCaption(caption, styles, boxes, x, rowY, tableWidth, availH);
805
+ }
806
+ rowY += placeRows(rows, grid, styles, boxes, x, rowY, colWidths, tableWidth, availH, gaps);
807
+ if (caption && captionSide === 'bottom') {
808
+ rowY += placeCaption(caption, styles, boxes, x, rowY, tableWidth, availH);
265
809
  }
266
- const totalWidth = colWidths.reduce((sum, w) => sum + w, 0) + colGap * Math.max(0, numCols - 1);
267
- return { width: totalWidth, height: rowY - y };
810
+ return { width: tableWidth, height: rowY - y };
268
811
  }
269
812
  function layoutGrid(node, styles, boxes, x, y, availW, availH, style) {
270
- const children = node.children.filter(c => c.nodeType === 'element' && styles.get(c.id)?.display !== 'none');
813
+ const children = childrenWithPseudos(node).filter(c => c.nodeType === 'element' && styles.get(c.id)?.display !== 'none');
271
814
  if (children.length === 0)
272
815
  return { width: 0, height: 0 };
273
- const colWidths = parseGridTemplate(style.gridTemplateColumns ?? '', availW);
816
+ const areas = style.gridTemplateAreas ? parseTemplateAreas(style.gridTemplateAreas) : null;
817
+ let colWidths = parseGridTemplate(style.gridTemplateColumns ?? '', availW);
818
+ if (colWidths.length === 0 && areas && areas.columnCount > 0) {
819
+ // Areas without a column template: split the width evenly
820
+ colWidths = Array(areas.columnCount).fill(Math.floor(availW / areas.columnCount));
821
+ }
274
822
  const rowHeights = parseGridTemplate(style.gridTemplateRows ?? '', availH);
275
823
  const numCols = colWidths.length || 1;
276
824
  const gap = style.gap ?? 0;
277
- let rowY = y;
278
- let maxWidth = 0;
279
- let col = 0;
280
- let rowIdx = 0;
281
- let currentRowHeight = 0; // auto-computed from content
825
+ // Pre-compute border-adjusted gaps for grid children
826
+ let hGap = gap;
827
+ let vGap = gap;
828
+ if (children.length >= 2) {
829
+ // Check first two adjacent children for horizontal collapse
830
+ if (numCols >= 2 && shouldAdjustBorderGap(styles.get(children[0].id), styles.get(children[1].id), 'horizontal')) {
831
+ hGap = Math.max(-1, gap - 1);
832
+ }
833
+ // Check first child and first child of second row for vertical collapse
834
+ if (children.length > numCols && shouldAdjustBorderGap(styles.get(children[0].id), styles.get(children[numCols].id), 'vertical')) {
835
+ vGap = Math.max(-1, gap - 1);
836
+ }
837
+ }
838
+ // Pass 1: assign each child to a row/col and compute content-based row heights
839
+ const placements = [];
840
+ const computedRowHeights = [];
841
+ const cursor = { col: 0, row: 0 };
282
842
  for (const child of children) {
283
- if (col >= numCols) {
284
- const explicitH = rowHeights[rowIdx];
285
- rowY += (explicitH ?? currentRowHeight) + gap;
286
- col = 0;
287
- rowIdx++;
288
- currentRowHeight = 0;
289
- }
290
- const colX = x + colWidths.slice(0, col).reduce((sum, w) => sum + w + gap, 0);
291
- const colW = colWidths[col] ?? availW;
292
- const explicitRowH = rowHeights[rowIdx];
293
- const size = layoutNode(child, styles, boxes, colX, rowY, colW, explicitRowH ?? (availH - (rowY - y)));
294
- // Grid children fill their column width
843
+ const childStyle = styles.get(child.id);
844
+ const area = childStyle?.gridArea ? areas?.byName.get(childStyle.gridArea) : undefined;
845
+ const placed = resolveGridPlacement(childStyle, area, cursor, numCols);
846
+ const colW = trackSpanSize(colWidths, placed.col, placed.span, hGap);
847
+ // Measure content height with unconstrained available height
848
+ const size = layoutNode(child, styles, boxes, 0, 0, colW, availH);
849
+ placements.push({ child, ...placed });
850
+ // Spanning content doesn't stretch individual tracks (matches columns)
851
+ if (placed.rowSpan === 1) {
852
+ computedRowHeights[placed.row] = Math.max(computedRowHeights[placed.row] ?? 0, size.height);
853
+ }
854
+ }
855
+ // Resolve final row track heights: template first, content otherwise
856
+ const totalRows = placements.reduce((max, p) => Math.max(max, p.row + p.rowSpan), 0);
857
+ const trackHeights = [];
858
+ for (let r = 0; r < totalRows; r++) {
859
+ trackHeights[r] = rowHeights[r] ?? computedRowHeights[r] ?? 0;
860
+ }
861
+ // Pass 2: layout each child at its final position
862
+ let maxWidth = 0;
863
+ for (const { child, col, span, row, rowSpan } of placements) {
864
+ const colX = x + trackOffset(colWidths, col, hGap);
865
+ const colW = trackSpanSize(colWidths, col, span, hGap);
866
+ const rowY = y + trackOffset(trackHeights, row, vGap);
867
+ const rh = trackSpanSize(trackHeights, row, rowSpan, vGap);
868
+ layoutNode(child, styles, boxes, colX, rowY, colW, rh);
295
869
  const childBox = boxes.get(child.id);
296
- if (childBox && childBox.width < colW)
870
+ if (childBox) {
297
871
  childBox.width = colW;
298
- currentRowHeight = Math.max(currentRowHeight, size.height);
872
+ childBox.height = rh;
873
+ }
299
874
  maxWidth = Math.max(maxWidth, colX - x + colW);
300
- col++;
301
875
  }
302
- const finalRowH = rowHeights[rowIdx] ?? currentRowHeight;
303
- return { width: maxWidth, height: (rowY - y) + finalRowH };
876
+ const totalHeight = totalRows === 0 ? 0 : trackOffset(trackHeights, totalRows, vGap) - vGap;
877
+ return { width: maxWidth, height: totalHeight };
878
+ }
879
+ /**
880
+ * Where one grid item lands: a named area wins outright; otherwise
881
+ * explicit lines/spans combine with the auto-flow cursor, which only
882
+ * auto-placed and column-placed items advance.
883
+ */
884
+ function resolveGridPlacement(childStyle, area, cursor, numCols) {
885
+ if (area) {
886
+ return {
887
+ col: area.colStart, span: area.colEnd - area.colStart,
888
+ row: area.rowStart, rowSpan: area.rowEnd - area.rowStart,
889
+ };
890
+ }
891
+ const span = childStyle?.gridColumnSpan ?? 1;
892
+ if (cursor.col >= numCols) {
893
+ cursor.col = 0;
894
+ cursor.row++;
895
+ }
896
+ const colStart = resolveGridColumnStart(childStyle, cursor.col, numCols);
897
+ const colEnd = resolveGridColumnEnd(childStyle, colStart, span, numCols);
898
+ const actualSpan = colEnd - colStart;
899
+ if (colStart > cursor.col)
900
+ cursor.col = colStart;
901
+ if (cursor.col + actualSpan > numCols) {
902
+ cursor.col = 0;
903
+ cursor.row++;
904
+ }
905
+ const row = childStyle?.gridRowStart != null ? childStyle.gridRowStart - 1 : cursor.row;
906
+ const rowSpan = childStyle?.gridRowEnd != null
907
+ ? Math.max(1, childStyle.gridRowEnd - 1 - row)
908
+ : (childStyle?.gridRowSpan ?? 1);
909
+ const col = cursor.col;
910
+ cursor.col += actualSpan;
911
+ return { col, span: actualSpan, row, rowSpan };
304
912
  }
305
- function parseGridTemplate(template, availW) {
913
+ /**
914
+ * Parse the quoted rows of grid-template-areas into per-name rectangles.
915
+ * `.` cells are holes; a name repeated across cells spans their extent.
916
+ */
917
+ function parseTemplateAreas(value) {
918
+ const byName = new Map();
919
+ let columnCount = 0;
920
+ const rows = [...value.matchAll(/"([^"]*)"|'([^']*)'/g)].map(m => (m[1] ?? m[2] ?? '').trim());
921
+ rows.forEach((rowText, rowIndex) => {
922
+ const names = rowText.split(/\s+/);
923
+ columnCount = Math.max(columnCount, names.length);
924
+ names.forEach((name, colIndex) => {
925
+ if (name === '.' || name === '')
926
+ return;
927
+ const area = byName.get(name);
928
+ if (!area) {
929
+ byName.set(name, { rowStart: rowIndex, rowEnd: rowIndex + 1, colStart: colIndex, colEnd: colIndex + 1 });
930
+ }
931
+ else {
932
+ area.rowStart = Math.min(area.rowStart, rowIndex);
933
+ area.rowEnd = Math.max(area.rowEnd, rowIndex + 1);
934
+ area.colStart = Math.min(area.colStart, colIndex);
935
+ area.colEnd = Math.max(area.colEnd, colIndex + 1);
936
+ }
937
+ });
938
+ });
939
+ return { byName, columnCount };
940
+ }
941
+ /** Resolve the start column for a grid item (0-indexed) */
942
+ function resolveGridColumnStart(style, autoCol, numCols) {
943
+ if (!style)
944
+ return autoCol;
945
+ if (style.gridColumnStart != null)
946
+ return style.gridColumnStart - 1; // CSS lines are 1-indexed
947
+ return autoCol;
948
+ }
949
+ /** Resolve the end column for a grid item (0-indexed, exclusive) */
950
+ function resolveGridColumnEnd(style, start, span, numCols) {
951
+ if (!style)
952
+ return start + span;
953
+ if (style.gridColumnEnd != null)
954
+ return style.gridColumnEnd - 1; // CSS lines are 1-indexed
955
+ return start + span;
956
+ }
957
+ /** Offset to the start of a grid track (works for either axis) */
958
+ function trackOffset(trackSizes, track, gap) {
959
+ let offset = 0;
960
+ for (let i = 0; i < track; i++) {
961
+ offset += (trackSizes[i] ?? 0) + gap;
962
+ }
963
+ return offset;
964
+ }
965
+ /** Total size spanning multiple grid tracks including gaps between them */
966
+ function trackSpanSize(trackSizes, startTrack, span, gap) {
967
+ let size = 0;
968
+ for (let i = startTrack; i < startTrack + span && i < trackSizes.length; i++) {
969
+ if (i > startTrack)
970
+ size += gap;
971
+ size += trackSizes[i];
972
+ }
973
+ return size;
974
+ }
975
+ function parseGridTemplate(template, availSize) {
306
976
  if (!template)
307
977
  return [];
308
- const parts = template.trim().split(/\s+/);
309
- const widths = [];
978
+ // Expand repeat() before splitting
979
+ const expanded = expandRepeat(template);
980
+ return resolveTrackSizes(splitTracks(expanded), availSize);
981
+ }
982
+ /** Split a track list on whitespace, keeping function arguments (minmax(a, b)) together */
983
+ function splitTracks(input) {
984
+ const tracks = [];
985
+ let current = '';
986
+ let depth = 0;
987
+ for (const ch of input.trim()) {
988
+ if (ch === '(')
989
+ depth++;
990
+ if (ch === ')')
991
+ depth--;
992
+ if (/\s/.test(ch) && depth === 0) {
993
+ if (current) {
994
+ tracks.push(current);
995
+ current = '';
996
+ }
997
+ }
998
+ else {
999
+ current += ch;
1000
+ }
1001
+ }
1002
+ if (current)
1003
+ tracks.push(current);
1004
+ return tracks;
1005
+ }
1006
+ /** Expand repeat(N, tracks...) into flat track list */
1007
+ function expandRepeat(template) {
1008
+ return template.replace(/repeat\(\s*(\d+)\s*,\s*([^)]+)\)/g, (_match, countStr, tracks) => {
1009
+ const count = parseInt(countStr);
1010
+ const trackList = tracks.trim();
1011
+ return Array(count).fill(trackList).join(' ');
1012
+ });
1013
+ }
1014
+ function resolveTrackSizes(parts, availSize) {
1015
+ const sizes = [];
310
1016
  const frParts = [];
311
1017
  let fixedTotal = 0;
312
1018
  for (let i = 0; i < parts.length; i++) {
313
1019
  const part = parts[i];
314
- if (part.endsWith('cell')) {
315
- const w = Math.round(parseFloat(part));
316
- widths.push(w);
317
- fixedTotal += w;
1020
+ const minmax = /^minmax\(([^,]+),(.+)\)$/.exec(part);
1021
+ if (minmax) {
1022
+ const min = resolveTrackLength(minmax[1].trim(), availSize) ?? 0;
1023
+ const maxRaw = minmax[2].trim();
1024
+ if (maxRaw.endsWith('fr')) {
1025
+ sizes.push(0); // placeholder
1026
+ frParts.push({ index: i, fr: parseFloat(maxRaw), min });
1027
+ }
1028
+ else {
1029
+ const w = Math.max(min, resolveTrackLength(maxRaw, availSize) ?? 0);
1030
+ sizes.push(w);
1031
+ fixedTotal += w;
1032
+ }
1033
+ continue;
318
1034
  }
319
- else if (part.endsWith('%')) {
320
- const w = Math.floor(availW * parseFloat(part) / 100);
321
- widths.push(w);
322
- fixedTotal += w;
1035
+ const fixed = resolveTrackLength(part, availSize);
1036
+ if (fixed !== null) {
1037
+ sizes.push(fixed);
1038
+ fixedTotal += fixed;
323
1039
  }
324
1040
  else if (part.endsWith('fr')) {
325
- const fr = parseFloat(part);
326
- widths.push(0); // placeholder
327
- frParts.push({ index: i, fr });
1041
+ sizes.push(0); // placeholder
1042
+ frParts.push({ index: i, fr: parseFloat(part), min: 0 });
328
1043
  }
329
1044
  else {
330
- widths.push(0);
1045
+ sizes.push(0);
331
1046
  }
332
1047
  }
333
- // Distribute remaining space to fr units
1048
+ // Distribute remaining space to fr units, honouring minmax() minimums
334
1049
  if (frParts.length > 0) {
335
1050
  const totalFr = frParts.reduce((sum, p) => sum + p.fr, 0);
336
- const remaining = Math.max(0, availW - fixedTotal);
337
- for (const { index, fr } of frParts) {
338
- widths[index] = Math.floor(remaining * fr / totalFr);
1051
+ const remaining = Math.max(0, availSize - fixedTotal);
1052
+ for (const { index, fr, min } of frParts) {
1053
+ sizes[index] = Math.max(min, Math.floor(remaining * fr / totalFr));
339
1054
  }
340
1055
  }
341
- return widths;
1056
+ return sizes;
1057
+ }
1058
+ /** A fixed track length in cells (cell/ch or %), or null for fr/auto/keywords */
1059
+ function resolveTrackLength(part, availSize) {
1060
+ const cellLength = parseCellLength(part);
1061
+ if (cellLength !== null)
1062
+ return Math.round(cellLength);
1063
+ if (part.endsWith('%'))
1064
+ return Math.floor(availSize * parseFloat(part) / 100);
1065
+ return null;
342
1066
  }
343
1067
  function positionChildren(children, styles, boxes, innerX, innerY, innerW, innerH, dir, gap, justify, align, wrap = 'nowrap') {
344
1068
  // Layout absolute children first (they don't affect flow)
@@ -362,6 +1086,8 @@ function positionChildren(children, styles, boxes, innerX, innerY, innerW, inner
362
1086
  });
363
1087
  if (visible.length === 0)
364
1088
  return { width: 0, height: 0 };
1089
+ const isReverse = dir === 'row-reverse' || dir === 'column-reverse';
1090
+ const baseDir = (dir === 'row' || dir === 'row-reverse') ? 'row' : 'column';
365
1091
  // Pre-measure to filter out zero-size items (e.g. whitespace text nodes)
366
1092
  const measured = visible.map(child => ({
367
1093
  child,
@@ -376,8 +1102,6 @@ function positionChildren(children, styles, boxes, innerX, innerY, innerW, inner
376
1102
  const orderB = styles.get(b.child.id)?.order ?? 0;
377
1103
  return orderA - orderB;
378
1104
  });
379
- const isReverse = dir === 'row-reverse' || dir === 'column-reverse';
380
- const baseDir = (dir === 'row' || dir === 'row-reverse') ? 'row' : 'column';
381
1105
  const orderedItems = isReverse ? sorted.reverse() : sorted;
382
1106
  const ordered = orderedItems.map(item => item.child);
383
1107
  // Use pre-measured sizes, overridden by flex-basis when set
@@ -395,61 +1119,279 @@ function positionChildren(children, styles, boxes, innerX, innerY, innerW, inner
395
1119
  const growValues = ordered.map(child => styles.get(child.id)?.flexGrow ?? 0);
396
1120
  const shrinkValues = ordered.map(child => styles.get(child.id)?.flexShrink ?? 1);
397
1121
  const totalGrow = growValues.reduce((a, b) => a + b, 0);
1122
+ // Compute per-pair gap, adjusting for border collapse
1123
+ const borderDir = baseDir === 'column' ? 'vertical' : 'horizontal';
1124
+ const pairGaps = ordered.map((child, i) => {
1125
+ if (i === 0)
1126
+ return 0;
1127
+ const adjust = shouldAdjustBorderGap(styles.get(ordered[i - 1].id), styles.get(child.id), borderDir) ? 1 : 0;
1128
+ return Math.max(-1, gap - adjust);
1129
+ });
398
1130
  const totalMain = sizes.reduce((sum, s, i) => {
399
- return sum + (baseDir === 'row' ? s.width : s.height) + (i > 0 ? gap : 0);
1131
+ return sum + (baseDir === 'row' ? s.width : s.height) + pairGaps[i];
400
1132
  }, 0);
401
1133
  const availMain = baseDir === 'row' ? innerW : innerH;
402
- const freeSpace = Math.max(0, availMain - totalMain);
403
- const overflow = Math.max(0, totalMain - availMain);
1134
+ const rawFreeSpace = availMain - totalMain;
1135
+ const freeSpace = Math.max(0, rawFreeSpace);
1136
+ // With wrapping enabled, items wrap instead of shrinking
1137
+ const overflow = wrap === 'wrap' ? 0 : Math.max(0, -rawFreeSpace);
404
1138
  const hasGrow = totalGrow > 0;
405
1139
  const totalShrink = overflow > 0 ? shrinkValues.reduce((a, b) => a + b, 0) : 0;
1140
+ // Pre-compute grow/shrink adjustments with correct rounding
1141
+ const mainAdjust = new Array(ordered.length).fill(0);
1142
+ if (hasGrow && freeSpace > 0) {
1143
+ let distributed = 0;
1144
+ for (let i = 0; i < ordered.length; i++) {
1145
+ if (growValues[i] > 0) {
1146
+ const share = Math.floor(freeSpace * growValues[i] / totalGrow);
1147
+ mainAdjust[i] = share;
1148
+ distributed += share;
1149
+ }
1150
+ }
1151
+ // Distribute remainder 1px each to items with largest fractional parts
1152
+ let remainder = freeSpace - distributed;
1153
+ if (remainder > 0) {
1154
+ const fractions = ordered.map((_, i) => growValues[i] > 0 ? (freeSpace * growValues[i] / totalGrow) % 1 : 0);
1155
+ const indices = ordered.map((_, i) => i)
1156
+ .filter(i => growValues[i] > 0)
1157
+ .sort((a, b) => fractions[b] - fractions[a]);
1158
+ for (const idx of indices) {
1159
+ if (remainder <= 0)
1160
+ break;
1161
+ mainAdjust[idx] += 1;
1162
+ remainder--;
1163
+ }
1164
+ }
1165
+ }
1166
+ if (overflow > 0 && totalShrink > 0) {
1167
+ let distributed = 0;
1168
+ for (let i = 0; i < ordered.length; i++) {
1169
+ if (shrinkValues[i] > 0) {
1170
+ const childStyle = styles.get(ordered[i].id);
1171
+ const explicitMain = baseDir === 'row' ? childStyle?.width : childStyle?.height;
1172
+ if (explicitMain != null) {
1173
+ const share = Math.floor(overflow * shrinkValues[i] / totalShrink);
1174
+ mainAdjust[i] = -share;
1175
+ distributed += share;
1176
+ }
1177
+ }
1178
+ }
1179
+ // Distribute remainder to last shrinking item with explicit size
1180
+ let remainder = overflow - distributed;
1181
+ for (let i = ordered.length - 1; i >= 0 && remainder > 0; i--) {
1182
+ if (shrinkValues[i] > 0) {
1183
+ const childStyle = styles.get(ordered[i].id);
1184
+ const explicitMain = baseDir === 'row' ? childStyle?.width : childStyle?.height;
1185
+ if (explicitMain != null) {
1186
+ mainAdjust[i] -= remainder;
1187
+ remainder = 0;
1188
+ }
1189
+ }
1190
+ }
1191
+ }
1192
+ // Apply min-width/min-height constraints to shrink adjustments.
1193
+ // CSS Flexbox §4.5: items have auto min-size = min(content-size, specified-size).
1194
+ // Approximate content-min as: borders in main axis + (1 if has children, else 0).
1195
+ // overflow:hidden allows min to be 0 (per spec).
1196
+ for (let i = 0; i < ordered.length; i++) {
1197
+ if (mainAdjust[i] < 0) {
1198
+ const baseSize = baseDir === 'row' ? sizes[i].width : sizes[i].height;
1199
+ const childStyle = styles.get(ordered[i].id);
1200
+ const minMain = baseDir === 'row' ? childStyle?.minWidth : childStyle?.minHeight;
1201
+ if (minMain != null) {
1202
+ const adjusted = baseSize + mainAdjust[i];
1203
+ if (adjusted < minMain)
1204
+ mainAdjust[i] = minMain - baseSize;
1205
+ }
1206
+ else {
1207
+ const autoMin = autoMinMainSize(ordered[i], childStyle, baseDir);
1208
+ if (baseSize + mainAdjust[i] < autoMin)
1209
+ mainAdjust[i] = autoMin - baseSize;
1210
+ }
1211
+ // Never shrink below 0
1212
+ if (baseSize + mainAdjust[i] < 0)
1213
+ mainAdjust[i] = -baseSize;
1214
+ }
1215
+ }
1216
+ // Apply max-width/max-height constraints to grow adjustments
1217
+ for (let i = 0; i < ordered.length; i++) {
1218
+ if (mainAdjust[i] > 0) {
1219
+ const baseSize = baseDir === 'row' ? sizes[i].width : sizes[i].height;
1220
+ const childStyle = styles.get(ordered[i].id);
1221
+ const maxMain = baseDir === 'row' ? childStyle?.maxWidth : childStyle?.maxHeight;
1222
+ if (maxMain != null) {
1223
+ const adjusted = baseSize + mainAdjust[i];
1224
+ if (adjusted > maxMain) {
1225
+ const excess = adjusted - maxMain;
1226
+ mainAdjust[i] -= excess;
1227
+ // Redistribute excess to other growing items
1228
+ for (let j = 0; j < ordered.length; j++) {
1229
+ if (j !== i && growValues[j] > 0) {
1230
+ mainAdjust[j] += excess;
1231
+ break;
1232
+ }
1233
+ }
1234
+ }
1235
+ }
1236
+ }
1237
+ }
1238
+ // §9.8.1 Auto margins absorb free space before justify-content
1239
+ const autoMargins = ordered.map(() => ({ before: 0, after: 0 }));
1240
+ if (freeSpace > 0 && !hasGrow) {
1241
+ const marginProp = baseDir === 'row'
1242
+ ? { before: 'marginLeft', after: 'marginRight' }
1243
+ : { before: 'marginTop', after: 'marginBottom' };
1244
+ let autoCount = 0;
1245
+ for (let i = 0; i < ordered.length; i++) {
1246
+ const s = styles.get(ordered[i].id);
1247
+ if (s && s[marginProp.before] === -1)
1248
+ autoCount++;
1249
+ if (s && s[marginProp.after] === -1)
1250
+ autoCount++;
1251
+ }
1252
+ if (autoCount > 0) {
1253
+ const perAuto = Math.floor(freeSpace / autoCount);
1254
+ let distributed = 0;
1255
+ for (let i = 0; i < ordered.length; i++) {
1256
+ const s = styles.get(ordered[i].id);
1257
+ if (s && s[marginProp.before] === -1) {
1258
+ autoMargins[i].before = perAuto;
1259
+ distributed += perAuto;
1260
+ }
1261
+ if (s && s[marginProp.after] === -1) {
1262
+ autoMargins[i].after = perAuto;
1263
+ distributed += perAuto;
1264
+ }
1265
+ }
1266
+ // Distribute remainder to last auto margin
1267
+ let remainder = freeSpace - distributed;
1268
+ for (let i = ordered.length - 1; i >= 0 && remainder > 0; i--) {
1269
+ const s = styles.get(ordered[i].id);
1270
+ if (s && s[marginProp.after] === -1) {
1271
+ autoMargins[i].after += remainder;
1272
+ remainder = 0;
1273
+ }
1274
+ else if (s && s[marginProp.before] === -1) {
1275
+ autoMargins[i].before += remainder;
1276
+ remainder = 0;
1277
+ }
1278
+ }
1279
+ }
1280
+ }
1281
+ const hasAutoMargins = autoMargins.some(m => m.before > 0 || m.after > 0);
1282
+ // For wrapping, first determine line breaks and per-line cross sizes
1283
+ const itemLine = []; // which line each item is on
1284
+ const lineHeights = []; // cross size per line
1285
+ if (wrap === 'wrap') {
1286
+ let lineMainPos = 0;
1287
+ let currentLine = 0;
1288
+ let currentLineHeight = 0;
1289
+ for (let i = 0; i < ordered.length; i++) {
1290
+ const contentMainSize = baseDir === 'row' ? sizes[i].width : sizes[i].height;
1291
+ if (lineMainPos + contentMainSize > availMain && i > 0) {
1292
+ lineHeights.push(currentLineHeight);
1293
+ currentLine++;
1294
+ lineMainPos = 0;
1295
+ currentLineHeight = 0;
1296
+ }
1297
+ itemLine.push(currentLine);
1298
+ const crossSize = baseDir === 'row' ? sizes[i].height : sizes[i].width;
1299
+ currentLineHeight = Math.max(currentLineHeight, crossSize);
1300
+ lineMainPos += contentMainSize + (i < ordered.length - 1 ? pairGaps[i + 1] : 0);
1301
+ }
1302
+ lineHeights.push(currentLineHeight);
1303
+ }
406
1304
  // Position
407
- let mainPos = computeMainStart(justify, freeSpace, ordered.length, hasGrow);
408
- const itemGap = computeItemGap(justify, gap, freeSpace, ordered.length, hasGrow);
1305
+ let mainPos = hasAutoMargins ? 0 : computeMainStart(justify, rawFreeSpace, ordered.length, hasGrow);
1306
+ const baseItemGap = hasAutoMargins ? gap : computeItemGap(justify, gap, freeSpace, ordered.length, hasGrow);
409
1307
  let contentWidth = 0;
410
1308
  let contentHeight = 0;
411
1309
  let crossPos = 0;
412
1310
  let lineHeight = 0;
1311
+ let currentLine = 0;
1312
+ let naturalMain = 0;
413
1313
  for (let i = 0; i < ordered.length; i++) {
414
- let mainSize = baseDir === 'row' ? sizes[i].width : sizes[i].height;
415
- if (hasGrow && growValues[i] > 0) {
416
- mainSize += Math.floor(freeSpace * growValues[i] / totalGrow);
417
- }
418
- if (overflow > 0 && totalShrink > 0 && shrinkValues[i] > 0) {
419
- mainSize -= Math.floor(overflow * shrinkValues[i] / totalShrink);
420
- mainSize = Math.max(0, mainSize);
421
- }
1314
+ mainPos += autoMargins[i].before;
1315
+ let mainSize = (baseDir === 'row' ? sizes[i].width : sizes[i].height) + mainAdjust[i];
1316
+ mainSize = Math.max(0, mainSize);
422
1317
  // Wrap check
423
- if (wrap === 'wrap' && mainPos + mainSize > availMain && i > 0) {
424
- crossPos += lineHeight + gap;
1318
+ const contentMainSize = baseDir === 'row' ? sizes[i].width : sizes[i].height;
1319
+ if (wrap === 'wrap' && mainPos + contentMainSize > availMain && i > 0) {
1320
+ // Border collapse between wrap lines: reduce gap when adjacent
1321
+ // items have borders on the shared edge
1322
+ const crossDir = baseDir === 'row' ? 'vertical' : 'horizontal';
1323
+ const prevLineItem = ordered[i - 1];
1324
+ const prevStyle = styles.get(prevLineItem.id);
1325
+ const curStyle = styles.get(ordered[i].id);
1326
+ const collapseGap = shouldAdjustBorderGap(prevStyle, curStyle, crossDir) ? 1 : 0;
1327
+ crossPos += lineHeight + Math.max(-1, gap - collapseGap);
425
1328
  mainPos = 0;
426
1329
  lineHeight = 0;
1330
+ currentLine++;
427
1331
  }
428
1332
  const crossSize = baseDir === 'row' ? sizes[i].height : sizes[i].width;
429
- const crossAvail = baseDir === 'row' ? innerH : innerW;
430
- // Check align-self
1333
+ // In wrap mode, cross available is the line height; otherwise full container
1334
+ const lineCrossSize = wrap === 'wrap' ? lineHeights[currentLine] : 0;
1335
+ const crossAvail = wrap === 'wrap' ? lineCrossSize : (baseDir === 'row' ? innerH : innerW);
431
1336
  const childStyle = styles.get(ordered[i].id);
432
1337
  const selfAlign = childStyle?.alignSelf !== 'auto'
433
1338
  ? childStyle?.alignSelf ?? align
434
1339
  : align;
435
- const crossOffset = computeCrossOffset(selfAlign, crossAvail, crossSize);
1340
+ // Stretch only applies to items whose cross-axis size is auto (§8.3);
1341
+ // an explicit width/height wins and the item aligns to the line start.
1342
+ const crossSizeIsAuto = baseDir === 'row'
1343
+ ? childStyle?.height == null
1344
+ : childStyle?.width == null;
1345
+ const isStretch = selfAlign === 'stretch' && crossSizeIsAuto;
1346
+ const crossOffset = isStretch ? 0 : computeCrossOffset(selfAlign, crossAvail, crossSize);
436
1347
  const finalCx = baseDir === 'row' ? innerX + mainPos : innerX + crossOffset;
437
1348
  const finalCy = baseDir === 'row' ? innerY + crossPos + crossOffset : innerY + mainPos;
438
1349
  const childAvailW = baseDir === 'row' ? mainSize : innerW;
439
- const childAvailH = baseDir === 'row' ? innerH : mainSize;
1350
+ const childAvailH = baseDir === 'row' ? (isStretch ? crossAvail : innerH) : mainSize;
440
1351
  layoutNode(ordered[i], styles, boxes, finalCx, finalCy, childAvailW, childAvailH);
441
- // Override main-axis size for flex-grown/shrunk items
442
1352
  const box = boxes.get(ordered[i].id);
443
1353
  if (box) {
444
1354
  if (baseDir === 'row' && box.width !== mainSize)
445
1355
  box.width = mainSize;
446
1356
  if (baseDir === 'column' && box.height !== mainSize)
447
1357
  box.height = mainSize;
1358
+ if (isStretch) {
1359
+ if (baseDir === 'row' && box.height < crossAvail) {
1360
+ const stretchH = constrain(crossAvail, childStyle?.minHeight, childStyle?.maxHeight);
1361
+ box.height = stretchH;
1362
+ layoutNode(ordered[i], styles, boxes, finalCx, finalCy, mainSize, stretchH);
1363
+ const rebox = boxes.get(ordered[i].id);
1364
+ if (rebox) {
1365
+ rebox.width = mainSize;
1366
+ rebox.height = stretchH;
1367
+ }
1368
+ }
1369
+ if (baseDir === 'column' && box.width < crossAvail) {
1370
+ const stretchW = constrain(crossAvail, childStyle?.minWidth, childStyle?.maxWidth);
1371
+ box.width = stretchW;
1372
+ layoutNode(ordered[i], styles, boxes, finalCx, finalCy, stretchW, mainSize);
1373
+ const rebox = boxes.get(ordered[i].id);
1374
+ if (rebox) {
1375
+ rebox.width = stretchW;
1376
+ rebox.height = mainSize;
1377
+ }
1378
+ }
1379
+ }
448
1380
  }
449
- lineHeight = Math.max(lineHeight, baseDir === 'row' ? sizes[i].height : sizes[i].width);
450
- mainPos += mainSize + (i < ordered.length - 1 ? itemGap : 0);
1381
+ lineHeight = Math.max(lineHeight, crossSize);
1382
+ const usesJustifySpacing = justify === 'space-between' || justify === 'space-around' || justify === 'space-evenly';
1383
+ const pairItemGap = i < ordered.length - 1
1384
+ ? (usesJustifySpacing && !hasGrow ? baseItemGap : pairGaps[i + 1])
1385
+ : 0;
1386
+ mainPos += mainSize + autoMargins[i].after + pairItemGap;
1387
+ naturalMain += mainSize + (i > 0 ? pairGaps[i] : 0);
451
1388
  contentWidth = baseDir === 'row' ? Math.max(contentWidth, mainPos) : Math.max(contentWidth, sizes[i].width);
452
1389
  contentHeight = baseDir === 'row' ? crossPos + lineHeight : mainPos;
453
1390
  }
454
- return { width: contentWidth, height: contentHeight };
1391
+ // Return natural size for shrink-wrap auto-sizing (no justify offset).
1392
+ // For wrapping containers, use positioned extent (wrapping already happened).
1393
+ const mainResult = wrap === 'wrap' ? (baseDir === 'row' ? contentWidth : contentHeight) : naturalMain;
1394
+ return baseDir === 'row'
1395
+ ? { width: mainResult, height: contentHeight }
1396
+ : { width: contentWidth, height: mainResult };
455
1397
  }