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