using AngleSharp.Dom;
using AngleSharp.Html.Dom;
namespace XFESystem.Browser;
/// <summary>
/// Original bounded CSS layout subset. Coordinates and text metrics are real
/// calculations over the supplied DOM; no page-, editor- or Monaco-specific sizes.
/// Not a complete CSS formatting/stacking implementation (see Diagnostics).
/// </summary>
public sealed class BrowserLayoutEngine(BrowserCssLimits? limits = null)
{
private readonly BrowserCssLimits limits = limits ?? new();
private sealed class Flow(double x, double y, double width, double lineHeight, bool measureOnly = false)
{
public double X = x, Y = y, StartX = x, Width = width, LineHeight = lineHeight;
public readonly bool MeasureOnly=measureOnly;
public bool HasContent, PendingSpace;
public void NewLine(double nextHeight)
{ Y += LineHeight; X = StartX; LineHeight = nextHeight; HasContent = false; PendingSpace = false; }
}
private sealed record PendingAbsolute(BrowserLayoutBox Box, BrowserLayoutBox Parent, BrowserLayoutBox? Anchor);
private readonly List<BrowserLayoutBox> all = [], painted = [];
private readonly List<BrowserDrawCommand> commands = [];
private readonly List<PendingAbsolute> pending = [];
private readonly List<string> diagnostics = [];
private readonly Dictionary<INode, int> identities = new(ReferenceEqualityComparer.Instance);
private readonly HashSet<INode> currentIdentityNodes = new(ReferenceEqualityComparer.Instance);
private readonly List<INode> detachedIdentityNodes = [];
private IDocument? identityDocument;
private int nextId, nodeCount;
private BrowserViewport viewport;
private IBrowserTextMetrics metrics = new BrowserFallbackTextMetrics();
private Func<INode, int>? identity;
private BrowserCssCascade cascade = null!;
private BrowserInteractionState interaction = new();
private BrowserLayoutBox? canvasBackgroundOwner;
public long CssParseGeneration => cascade?.ParseGeneration ?? 0;
public long CssReuseCount => cascade?.ReuseCount ?? 0;
public long LastStylesheetParseTicks { get; private set; }
internal bool InteractionChangesStyles(BrowserInteractionState before, BrowserInteractionState after) =>
cascade is null || cascade.InteractionChangesStyles(before, after, all.Where(box => box.Node is IElement).Select(box => (IElement)box.Node).Distinct());
/// <summary>Identity references retained for the most recent bounded layout; snapshots own their separate boxes.</summary>
public int RetainedNodeIdentityCount => identities.Count;
public void SetInteractionState(INode? hovered, INode? active, INode? focus) => interaction = new(hovered, active, focus);
public BrowserDisplayList Layout(IDocument document, BrowserViewport viewport,
IReadOnlyList<BrowserStyleSheet>? linkedSheets = null, IBrowserTextMetrics? textMetrics = null,
Func<INode, int>? nodeIdentity = null, bool blockAuthorStyles = false, BrowserInteractionState? interaction = null)
{
ArgumentNullException.ThrowIfNull(document);
if (!double.IsFinite(viewport.Width) || !double.IsFinite(viewport.Height) || viewport.Width <= 0 || viewport.Height <= 0 ||
viewport.Width > 100000 || viewport.Height > 100000 || !double.IsFinite(viewport.ScrollX) || !double.IsFinite(viewport.ScrollY))
throw new ArgumentOutOfRangeException(nameof(viewport));
if (!ReferenceEquals(document, identityDocument))
{
if (identityDocument is not null) { cascade?.ResetDocument(); this.interaction = new(); }
identities.Clear(); nextId = 0; identityDocument = document;
}
all.Clear(); painted.Clear(); commands.Clear(); pending.Clear(); diagnostics.Clear(); currentIdentityNodes.Clear(); nodeCount = 0;
this.viewport = viewport; metrics = textMetrics ?? new BrowserFallbackTextMetrics(); identity = nodeIdentity;
if (textMetrics is null) diagnostics.Add("Host fallback text metrics are proportional estimates; native rendering must supply its real metrics.");
if (interaction is not null) this.interaction = interaction;
cascade ??= new(limits, viewport, diagnostics, blockAuthorStyles, this.interaction);
long stylesheetStarted = System.Diagnostics.Stopwatch.GetTimestamp();
cascade.BeginLayout(viewport, diagnostics, blockAuthorStyles, this.interaction); cascade.Load(document, linkedSheets);
LastStylesheetParseTicks = System.Diagnostics.Stopwatch.GetTimestamp() - stylesheetStarted;
diagnostics.Add("CSS subset: no grid, complex table spans, floats, parent-child margin collapse, scale/rotate/perspective transforms, arbitrary webfonts, generated counters/quotes/URLs, bidi/shaping, full flex wrapping or full stacking contexts.");
var element = document.DocumentElement ?? document.Body;
if (element is null) { identities.Clear(); return new(viewport, [], [], diagnostics.ToArray(), viewport.Width, viewport.Height); }
BrowserLayoutBox root;
try { root = Build(element, null, 0); }
// The current native runtime does not unwind ordinary finally blocks.
// Mirror cleanup on the exceptional path, as well as normal completion.
catch (Exception) { PruneNodeIdentities(); throw; }
PruneNodeIdentities();
LayoutBlock(root, 0, 0, viewport.Width, viewport.Height, null, null);
for (int index = 0; index < pending.Count; index++) LayoutAbsolute(pending[index]);
ApplyTranslations(root);
PaintCanvasBackground(root);
Paint(root, null, 1);
double width = viewport.Width, height = Math.Max(viewport.Height, root.MarginBox.Bottom);
foreach (var box in painted)
{
if (box.Clip is null) { width = Math.Max(width, box.BorderBox.Right); height = Math.Max(height, box.BorderBox.Bottom); }
}
foreach (var box in all) if (box.Node is IElement) box.Style.Resolve(box);
return new(viewport, commands.ToArray(), painted.ToArray(), diagnostics.ToArray(), width, height, all.ToArray(), metrics);
}
private BrowserLayoutBox Build(INode node, BrowserLayoutBox? parent, int depth)
{
if (++nodeCount > limits.MaximumNodes || depth > limits.MaximumDepth) throw new BrowserLimitException("CSS layout DOM bound reached.");
var style = node is IElement element ? cascade.Compute(element, parent?.Style) : parent!.Style;
if (!identities.TryGetValue(node, out int id)) { id = identity?.Invoke(node) ?? ++nextId; identities.Add(node, id); }
currentIdentityNodes.Add(node);
var box = new BrowserLayoutBox { Node = node, NodeId = id, Style = style, Parent = parent };
if (node is IElement item && style.Display == "list-item") box.MarkerStyle = cascade.ComputeMarker(item, style);
all.Add(box);
if(node is IElement origin&&node is not(IHtmlTextAreaElement or IHtmlCanvasElement))AddGenerated(box,origin,"before");
if (node is not (IHtmlTextAreaElement or IHtmlCanvasElement))
foreach (var child in node.ChildNodes)
if (child.NodeType == NodeType.Text || child is IElement) box.children.Add(Build(child, box, depth + 1));
if(node is IElement after&&node is not(IHtmlTextAreaElement or IHtmlCanvasElement))AddGenerated(box,after,"after");
return box;
}
private void AddGenerated(BrowserLayoutBox parent,IElement origin,string pseudo)
{
var style=cascade.ComputePseudo(origin,pseudo,parent.Style);
if(style is null||style.Display=="none")return;
string? text=GeneratedContent(style.Get("content","normal"),origin);
if(text is null)return;
if(++nodeCount>limits.MaximumNodes)throw new BrowserLimitException("CSS generated box limit reached.");
var box=new BrowserLayoutBox{Node=origin,NodeId=parent.NodeId,Style=style,Parent=parent,GeneratedPseudo=pseudo,GeneratedText=text};
all.Add(box);parent.children.Add(box);
}
private string? GeneratedContent(string content,IElement origin)
{
var result=new System.Text.StringBuilder();
for(int offset=0;offset<content.Length;)
{
while(offset<content.Length&&char.IsWhiteSpace(content[offset]))offset++;
if(offset>=content.Length)break;
char quote=content[offset];
if(quote is '\'' or '"')
{
offset++;
while(offset<content.Length&&content[offset]!=quote)
{
char ch=content[offset++];
if(ch=='\\'&&offset<content.Length)
{
if(content[offset] is '\r' or '\n' or '\f'){if(content[offset++]=='\r'&&offset<content.Length&&content[offset]=='\n')offset++;continue;}
int begin=offset;while(offset<content.Length&&offset-begin<6&&Uri.IsHexDigit(content[offset]))offset++;
if(offset>begin)
{int scalar=int.Parse(content[begin..offset],System.Globalization.NumberStyles.HexNumber,System.Globalization.CultureInfo.InvariantCulture);result.Append(scalar is >0 and <=0x10FFFF&&scalar is not(>=0xD800 and <=0xDFFF)?char.ConvertFromUtf32(scalar):"\uFFFD");if(offset<content.Length&&char.IsWhiteSpace(content[offset]))offset++;continue;}
ch=content[offset++];
}
result.Append(ch);if(result.Length>4096)throw new BrowserLimitException("CSS generated text limit reached.");
}
if(offset>=content.Length)return null;offset++;
}
else if(content.AsSpan(offset).StartsWith("attr(",StringComparison.Ordinal))
{int end=content.IndexOf(')',offset+5);if(end<0)return null;string name=content[(offset+5)..end].Trim();result.Append(origin.GetAttribute(name)??"");offset=end+1;}
else{Note("Unsupported CSS generated content expression.");return null;}
if(result.Length>4096)throw new BrowserLimitException("CSS generated text limit reached.");
}
return result.ToString();
}
private void PruneNodeIdentities()
{
// An engine survives mutations and navigation. Keep only the nodes
// built this time, even when a DOM or selector budget aborts Build.
// Older display lists retain their own immutable geometry snapshots.
detachedIdentityNodes.Clear();
foreach (var node in identities.Keys)
if (!currentIdentityNodes.Contains(node)) detachedIdentityNodes.Add(node);
foreach (var node in detachedIdentityNodes) identities.Remove(node);
detachedIdentityNodes.Clear(); currentIdentityNodes.Clear();
}
private void LayoutBlock(BrowserLayoutBox box, double x, double y, double availableWidth, double availableHeight,
double? forcedWidth, double? forcedHeight)
{
if (box.Style.Display == "none") return;
var style = box.Style;
box.Margin = style.Edges("margin", availableWidth);
box.Padding = style.Edges("padding", availableWidth);
box.Border = style.Edges("border", availableWidth, true);
double extras = box.Padding.Horizontal + box.Border.Horizontal;
double? declared = style.Length("width", availableWidth);
double width = forcedWidth ?? (declared is { } w ? w - (style.BoxSizing == "border-box" ? extras : 0) : availableWidth - extras - box.Margin.Horizontal);
width = Constrain(style, width, "width", availableWidth, extras);
double spare = availableWidth - width - extras - box.Margin.Horizontal;
if (spare > 0 && declared is not null)
{
bool leftAuto = style.Get("margin-left", "0") == "auto", rightAuto = style.Get("margin-right", "0") == "auto";
if (leftAuto || rightAuto) box.Margin = box.Margin with { Left = leftAuto ? spare / (rightAuto ? 2 : 1) : box.Margin.Left,
Right = rightAuto ? spare / (leftAuto ? 2 : 1) : box.Margin.Right };
}
double verticalExtras = box.Padding.Vertical + box.Border.Vertical;
double? declaredHeight = style.Length("height", availableHeight);
double? height = forcedHeight ?? (declaredHeight is { } h ? Math.Max(0, h - (style.BoxSizing == "border-box" ? verticalExtras : 0)) : null);
double contentX = x + box.Margin.Left + box.Border.Left + box.Padding.Left;
double contentY = y + box.Margin.Top + box.Border.Top + box.Padding.Top;
box.ContentBox = new(contentX, contentY, width, height ?? 0);
double measured = box.GeneratedText is not null ? LayoutGenerated(box) : ControlValue(box.Node) is { } controlValue ? LayoutControl(box, controlValue) :
box.Element?.LocalName == "table" || style.Display == "table" ? LayoutTable(box) :
style.Display is "flex" or "inline-flex" ? LayoutFlex(box, height) : LayoutFlow(box, height);
double resolvedHeight = Constrain(style, height ?? measured, "height", availableHeight, verticalExtras);
UpdateRectangles(box, new(contentX, contentY, box.ContentBox.Width, resolvedHeight));
if (style.Position == "relative") Translate(box,
style.Length("left", availableWidth) ?? -(style.Length("right", availableWidth) ?? 0),
style.Length("top", availableHeight) ?? -(style.Length("bottom", availableHeight) ?? 0));
}
private static double Constrain(CssComputedStyle style, double value, string axis, double basis, double extras)
{
double Adjustment(double length) => Math.Max(0, length - (style.BoxSizing == "border-box" ? extras : 0));
double minimum = Adjustment(style.Length("min-" + axis, basis) ?? 0);
double maximum = style.Length("max-" + axis, basis) is { } maximumValue ? Adjustment(maximumValue) : 1000000;
return Math.Clamp(Math.Max(minimum, Math.Min(value, maximum)), 0, 1000000);
}
private static void UpdateRectangles(BrowserLayoutBox box, BrowserRect content)
{
box.ContentBox = content;
box.PaddingBox = Inflate(content, box.Padding);
box.BorderBox = Inflate(box.PaddingBox, box.Border);
box.MarginBox = Inflate(box.BorderBox, box.Margin);
if (box.Node is IElement) { box.fragments.Clear(); box.fragments.Add(box.BorderBox); }
}
private static BrowserRect Inflate(BrowserRect rectangle, BrowserEdges edges) => new(rectangle.X - edges.Left,
rectangle.Y - edges.Top, Math.Max(0, rectangle.Width + edges.Horizontal), Math.Max(0, rectangle.Height + edges.Vertical));
private double LayoutFlow(BrowserLayoutBox parent, double? fixedHeight)
{
var content = parent.ContentBox;
var flow = new Flow(content.X, content.Y, content.Width, parent.Style.LineHeight);
double? previousBottomMargin = null;
foreach (var child in parent.children)
{
if (child.Style.Display == "none") continue;
if (QueueAbsolute(child, parent)) continue;
if (child.Node.NodeType == NodeType.Text) { FlowText(child, flow); if(flow.HasContent)previousBottomMargin=null; continue; }
if (child.Element?.LocalName == "br") { flow.NewLine(parent.Style.LineHeight); continue; }
if (child.Style.Display == "inline") { FlowInline(child, flow); if(flow.HasContent)previousBottomMargin=null; continue; }
if (child.Style.Display is "inline-block" or "inline-flex")
{
double outer = IntrinsicWidth(child, content.Width);
if (flow.HasContent && flow.X + outer > flow.StartX + flow.Width) flow.NewLine(parent.Style.LineHeight);
LayoutBlock(child, flow.X, flow.Y, outer, fixedHeight ?? 0, null, null);
ApplyInlineVerticalAlign(child,flow.LineHeight);
flow.X += child.MarginBox.Width; flow.LineHeight = Math.Max(flow.LineHeight, child.MarginBox.Height); flow.HasContent = true;
previousBottomMargin=null;
continue;
}
if (flow.HasContent) flow.NewLine(parent.Style.LineHeight);
if(previousBottomMargin is { } previous)
{
double next=child.Style.Edges("margin",content.Width).Top;
double collapsed=Math.Max(Math.Max(previous,0),Math.Max(next,0))+Math.Min(Math.Min(previous,0),Math.Min(next,0));
flow.Y-=previous+next-collapsed;
}
LayoutBlock(child, content.X, flow.Y, content.Width, fixedHeight ?? 0, null, null);
flow.Y += child.MarginBox.Height; flow.X = flow.StartX; flow.LineHeight = parent.Style.LineHeight;
previousBottomMargin=child.Margin.Bottom;
}
AlignInlineLines(parent);
return Math.Max(0, flow.Y - content.Y + (flow.HasContent ? flow.LineHeight : 0));
}
private void AlignInlineLines(BrowserLayoutBox parent)
{
string alignment = parent.Style.Get("text-align", "left");
if (alignment is not ("center" or "right" or "end"))
{ if (alignment == "justify") Note("Justified text is not implemented; left alignment is used."); return; }
var leaves = new List<BrowserLayoutBox>();
void Collect(BrowserLayoutBox box)
{
if (box.Style.Display == "none" || box.Style.Position is "absolute" or "fixed") return;
if (box.Node.NodeType == NodeType.Text || box.Style.Display is "inline-block" or "inline-flex") leaves.Add(box);
else if (box.Style.Display == "inline") foreach (var child in box.children) Collect(child);
}
foreach (var child in parent.children) Collect(child);
var ends = new Dictionary<double, double>();
foreach (var leaf in leaves)
foreach (var rect in leaf.Node.NodeType == NodeType.Text ? (IEnumerable<BrowserRect>)leaf.fragments : [leaf.MarginBox])
ends[rect.Y] = Math.Max(ends.GetValueOrDefault(rect.Y), rect.Right);
double Shift(double y) => ends.TryGetValue(y, out double right) ? Math.Max(0, parent.ContentBox.Right - right) / (alignment == "center" ? 2 : 1) : 0;
foreach (var leaf in leaves)
{
if (leaf.Node.NodeType != NodeType.Text) { Translate(leaf, Shift(leaf.MarginBox.Y), 0); continue; }
for (int index = 0; index < leaf.fragments.Count; index++) leaf.fragments[index] = leaf.fragments[index].Translate(Shift(leaf.fragments[index].Y), 0);
for (int index = 0; index < leaf.textCommands.Count; index++)
{ var command = leaf.textCommands[index]; leaf.textCommands[index] = command with { Bounds = command.Bounds.Translate(Shift(command.Bounds.Y), 0) }; }
BrowserRect union = default; foreach (var rect in leaf.fragments) union = union.Union(rect);
leaf.ContentBox = leaf.PaddingBox = leaf.BorderBox = leaf.MarginBox = union;
}
void Refresh(BrowserLayoutBox box)
{
if (box.Node.NodeType == NodeType.Text || box.Style.Display != "inline") return;
foreach (var child in box.children) Refresh(child);
box.fragments.Clear();
foreach (var group in box.children.SelectMany(child => child.fragments).GroupBy(rect => rect.Y))
{ BrowserRect line = default; foreach (var rect in group) line = line.Union(rect); box.fragments.Add(line); }
BrowserRect union = default; foreach (var rect in box.fragments) union = union.Union(rect);
box.ContentBox = union; box.PaddingBox = Inflate(union, box.Padding); box.BorderBox = Inflate(box.PaddingBox, box.Border); box.MarginBox = Inflate(box.BorderBox, box.Margin);
}
foreach (var child in parent.children) Refresh(child);
}
private void FlowInline(BrowserLayoutBox box, Flow flow)
{
if(box.GeneratedText is not null)
{
var margin=box.Style.Edges("margin",flow.Width);var padding=box.Style.Edges("padding",flow.Width);var border=box.Style.Edges("border",flow.Width,true);
flow.X+=margin.Left+padding.Left+border.Left;FlowText(box,flow);
if(!flow.MeasureOnly)
{box.Margin=margin;box.Padding=padding;box.Border=border;box.PaddingBox=Inflate(box.ContentBox,padding);box.BorderBox=Inflate(box.PaddingBox,border);box.MarginBox=Inflate(box.BorderBox,margin);ApplyInlineVerticalAlign(box,flow.LineHeight);}
flow.X+=padding.Right+border.Right+margin.Right;return;
}
if(flow.MeasureOnly)
{
var margin=box.Style.Edges("margin",flow.Width);var padding=box.Style.Edges("padding",flow.Width);var border=box.Style.Edges("border",flow.Width,true);
flow.X+=margin.Left+padding.Left+border.Left;
foreach(var child in box.children)
{
if(child.Style.Display=="none"||child.Style.Position is "absolute" or "fixed")continue;
if(child.Node.NodeType==NodeType.Text)FlowText(child,flow);
else if(child.Element?.LocalName=="br")flow.NewLine(box.Style.LineHeight);
else if(child.Style.Display=="inline")FlowInline(child,flow);
else MeasureAtomicInline(child,flow);
}
flow.X+=padding.Right+border.Right+margin.Right;return;
}
box.Margin = box.Style.Edges("margin", flow.Width); box.Padding = box.Style.Edges("padding", flow.Width);
box.Border = box.Style.Edges("border", flow.Width, true);
flow.X += box.Margin.Left + box.Padding.Left + box.Border.Left;
foreach (var child in box.children)
{
if (child.Style.Display == "none" || QueueAbsolute(child, box)) continue;
if (child.Node.NodeType == NodeType.Text) FlowText(child, flow);
else if (child.Element?.LocalName == "br") flow.NewLine(box.Style.LineHeight);
else if (child.Style.Display == "inline") FlowInline(child, flow);
else
{
double width = IntrinsicWidth(child, flow.Width);
if (flow.HasContent && flow.X + width > flow.StartX + flow.Width) flow.NewLine(box.Style.LineHeight);
LayoutBlock(child, flow.X, flow.Y, width, 0, null, null);
ApplyInlineVerticalAlign(child,flow.LineHeight);
flow.X += child.MarginBox.Width; flow.LineHeight = Math.Max(flow.LineHeight, child.MarginBox.Height); flow.HasContent = true;
}
}
foreach (var group in box.children.SelectMany(child => child.fragments).GroupBy(rect => rect.Y))
{
BrowserRect line = default; foreach (var rect in group) line = line.Union(rect);
box.fragments.Add(line);
}
BrowserRect content = default; foreach (var fragment in box.fragments) content = content.Union(fragment);
if (box.fragments.Count == 0) content = new(flow.X, flow.Y, 0, box.Style.LineHeight);
box.ContentBox = content; box.PaddingBox = Inflate(content, box.Padding); box.BorderBox = Inflate(box.PaddingBox, box.Border);
box.MarginBox = Inflate(box.BorderBox, box.Margin);
flow.X += box.Padding.Right + box.Border.Right + box.Margin.Right;
if (box.Style.Position == "relative") Translate(box, box.Style.Length("left", flow.Width) ?? 0, box.Style.Length("top", 0) ?? 0);
}
private void FlowText(BrowserLayoutBox box, Flow flow)
{
string text = box.GeneratedText??box.Node.TextContent;
double initialX = flow.X, initialY = flow.Y;
bool preserve = box.Style.WhiteSpace is "pre" or "pre-wrap" or "break-spaces";
bool noWrap = box.Style.WhiteSpace is "pre" or "nowrap";
if (preserve)
{
var rendered = new System.Text.StringBuilder(); var offsets = new List<int> { 0 };
void Flush()
{
if (rendered.Length > 0) AddText(box, flow, rendered.ToString(), !noWrap, sourceOffsets: offsets.ToArray());
rendered.Clear(); offsets.Clear();
}
for (int index = 0; index < text.Length; index++)
{
if (text[index] == '\r') { if (offsets.Count > 0) offsets[^1] = index + 1; continue; }
if (text[index] == '\n') { Flush(); flow.NewLine(box.Style.LineHeight); offsets.Add(index + 1); continue; }
int count = text[index] == '\t' ? 4 : 1;
for (int part = 0; part < count; part++) { rendered.Append(text[index] == '\t' ? ' ' : text[index]); offsets.Add(part == count - 1 ? index + 1 : index); }
}
Flush();
}
else
{
int start = 0, spaceStart = -1, spaceEnd = -1;
for (int index = 0; index <= text.Length; index++)
{
if (index < text.Length && !Collapsible(text[index])) continue;
if (index > start)
{
string word = text[start..index];
if (flow.PendingSpace && flow.HasContent) AddText(box, flow, " ", !noWrap,
sourceOffsets: [Math.Max(0, spaceStart), Math.Max(0, spaceEnd)]);
flow.PendingSpace = false; spaceStart = spaceEnd = -1;
AddText(box, flow, word, !noWrap, start, index - start);
}
if (index < text.Length) { flow.PendingSpace = true; if (spaceStart < 0) spaceStart = index; spaceEnd = index + 1; }
start = index + 1;
}
}
if(flow.MeasureOnly)return;
BrowserRect union = default; foreach (var rect in box.fragments) union = union.Union(rect);
if (box.fragments.Count == 0) union = new(initialX, initialY, 0, box.Style.LineHeight);
box.ContentBox = box.PaddingBox = box.BorderBox = box.MarginBox = union;
}
private static bool Collapsible(char value) => value is ' ' or '\r' or '\n' or '\t' or '\f';
private static string? ControlValue(INode node) => node switch
{
IHtmlTextAreaElement area => area.Value,
IHtmlInputElement input when input.Type is "checkbox" or "radio" => input.IsChecked ? "x" : "",
IHtmlInputElement input when input.Type == "password" => new string('*', input.Value.Length),
IHtmlInputElement input => input.Value.Length == 0 ? input.GetAttribute("placeholder") ?? "" : input.Value,
_ => null
};
private double LayoutControl(BrowserLayoutBox box, string value)
{
var flow = new Flow(box.ContentBox.X, box.ContentBox.Y, box.ContentBox.Width, box.Style.LineHeight);
var lines = value.Replace("\r", "").Replace("\t", " ").Split('\n');
for (int line = 0; line < lines.Length; line++)
{
if (line > 0) flow.NewLine(box.Style.LineHeight);
if (lines[line].Length > 0) AddText(box, flow, lines[line], box.Node is IHtmlTextAreaElement && box.Style.WhiteSpace != "pre");
}
return Math.Max(box.Style.LineHeight, flow.Y - box.ContentBox.Y + flow.LineHeight);
}
private double LayoutGenerated(BrowserLayoutBox box)
{var content=box.ContentBox;var flow=new Flow(content.X,content.Y,content.Width,box.Style.LineHeight);FlowText(box,flow);box.ContentBox=content;return Math.Max(0,flow.Y-content.Y+(flow.HasContent?flow.LineHeight:0));}
private static void ApplyInlineVerticalAlign(BrowserLayoutBox box,double lineHeight)
{if(box.Style.Length("vertical-align",lineHeight) is { } shift&&shift!=0)Translate(box,0,-shift);}
private void AddText(BrowserLayoutBox box, Flow flow, string text, bool wrap, int sourceStart = -1, int sourceLength = -1, int[]? sourceOffsets = null)
{
double width = metrics.Measure(text, box.Style.Font);
if (!double.IsFinite(width) || width < 0 || width > 1000000) throw new BrowserLimitException("Text metrics returned an invalid or excessive width.");
bool breakWord=box.Style.Get("overflow-wrap","normal") is "anywhere" or "break-word" || box.Style.Get("word-break","normal") == "break-all" || text.Any(character=>character is >= '\u2E80' and <= '\uFFFF');
if(wrap&&breakWord&&text.Length>1&&width>flow.Width&&flow.Width>0)
{
int start=0;
while(start<text.Length)
{
int end=start,fit=start;
double room=flow.StartX+flow.Width-flow.X;
while(end<text.Length)
{
int next=end+1; if(char.IsHighSurrogate(text[end])&&next<text.Length&&char.IsLowSurrogate(text[next]))next++;
double measured=metrics.Measure(text[start..next],box.Style.Font);
if(measured>room)break;
fit=next;end=next;
}
if(fit==start&&flow.HasContent){flow.NewLine(box.Style.LineHeight);continue;}
if(fit==start)fit=start+1+(char.IsHighSurrogate(text[start])&&start+1<text.Length&&char.IsLowSurrogate(text[start+1])?1:0);
AddText(box,flow,text[start..fit],false,sourceStart<0?-1:sourceStart+start,fit-start,
sourceOffsets is null?null:sourceOffsets[start..(fit+1)]);
start=fit;
if(start<text.Length)flow.NewLine(box.Style.LineHeight);
}
return;
}
if (wrap && flow.HasContent && flow.X + width > flow.StartX + flow.Width) flow.NewLine(box.Style.LineHeight);
if (text == " " && !flow.HasContent) return;
var rect = new BrowserRect(flow.X, flow.Y, width, box.Style.LineHeight);
if(!flow.MeasureOnly)
{
box.fragments.Add(rect); box.textCommands.Add(new(BrowserDrawKind.Text, rect, box.NodeId, box.Node,
box.Style.Color.Rgb, box.Style.Color.Alpha, text, box.Style.Font));
if (box.GeneratedPseudo is null&&sourceOffsets is not null) box.sourceRuns.Add(new(box.textCommands.Count - 1, sourceOffsets));
else if (box.GeneratedPseudo is null&&sourceStart >= 0) box.sourceRuns.Add(new(box.textCommands.Count - 1, Enumerable.Range(sourceStart, (sourceLength < 0 ? text.Length : sourceLength) + 1).ToArray()));
}
if (box.textCommands.Count > limits.MaximumDisplayCommands) throw new BrowserLimitException("Text fragment limit reached.");
flow.X += width; flow.LineHeight = Math.Max(flow.LineHeight, box.Style.LineHeight); flow.HasContent = true;
}
private double LayoutFlex(BrowserLayoutBox parent, double? fixedHeight)
{
bool column = parent.Style.Get("flex-direction", "row").StartsWith("column", StringComparison.Ordinal);
var items = parent.children.Where(child => child.Style.Display != "none" &&
!(child.Node.NodeType == NodeType.Text && string.IsNullOrWhiteSpace(child.Node.TextContent)) && !QueueAbsolute(child, parent)).ToList();
if (parent.Style.Get("flex-direction", "row").EndsWith("reverse", StringComparison.Ordinal)) items.Reverse();
items = items.OrderBy(child => CssValue.Number(child.Style.Get("order", "0"), 0)).ToList();
if (parent.Style.Get("flex-wrap", "nowrap") != "nowrap") Note("Flex wrapping is not implemented; one flex line is used.");
double available = column ? fixedHeight ?? 0 : parent.ContentBox.Width;
double gap = parent.Style.Length(column ? "row-gap" : "column-gap", available) ?? parent.Style.Length("gap", available) ?? 0;
var bases = new double[items.Count]; double total = gap * Math.Max(0, items.Count - 1), grow = 0, shrink = 0;
for (int i = 0; i < items.Count; i++)
{
var child = items[i];
var margin = child.Style.Edges("margin", parent.ContentBox.Width); var padding = child.Style.Edges("padding", parent.ContentBox.Width);
var border = child.Style.Edges("border", parent.ContentBox.Width, true);
double extra = column ? padding.Vertical + border.Vertical : padding.Horizontal + border.Horizontal;
bases[i] = child.Node.NodeType != NodeType.Text && child.Style.Length("flex-basis", available) is { } basis
? Math.Max(0, basis + (column ? margin.Vertical : margin.Horizontal) + (child.Style.BoxSizing == "border-box" ? 0 : extra))
: column ? IntrinsicHeight(child,parent.ContentBox.Width) : IntrinsicWidth(child, parent.ContentBox.Width);
total += bases[i]; grow += CssValue.Number(child.Style.Get("flex-grow", "0"), 0);
shrink += CssValue.Number(child.Style.Get("flex-shrink", "1"), 1) * bases[i];
}
if (column && fixedHeight is null) available = total;
double free = available - total, used = 0, cross = 0;
for (int i = 0; i < items.Count; i++)
{
var child = items[i]; double target = bases[i];
if (free > 0 && grow > 0) target += free * CssValue.Number(child.Style.Get("flex-grow", "0"), 0) / grow;
else if (free < 0 && shrink > 0) target += free * CssValue.Number(child.Style.Get("flex-shrink", "1"), 1) * bases[i] / shrink;
target = Math.Max(0, target);
var margin = child.Style.Edges("margin", parent.ContentBox.Width); var padding = child.Style.Edges("padding", parent.ContentBox.Width);
var border = child.Style.Edges("border", parent.ContentBox.Width, true);
double? width = column ? null : Math.Max(0, target - margin.Horizontal - padding.Horizontal - border.Horizontal);
double? height = column ? Math.Max(0, target - margin.Vertical - padding.Vertical - border.Vertical) : null;
string align = child.Style.Get("align-self", parent.Style.Get("align-items", "stretch"));
if (!column && align == "stretch" && fixedHeight is { } h && child.Style.Length("height", h) is null)
height = Math.Max(0, h - margin.Vertical - padding.Vertical - border.Vertical);
if (child.Node.NodeType == NodeType.Text)
{
var flow = new Flow(parent.ContentBox.X + (column ? 0 : used), parent.ContentBox.Y + (column ? used : 0), width ?? parent.ContentBox.Width, child.Style.LineHeight);
FlowText(child, flow);
}
else LayoutBlock(child, parent.ContentBox.X + (column ? 0 : used), parent.ContentBox.Y + (column ? used : 0),
parent.ContentBox.Width, fixedHeight ?? 0, width, height);
used += (column ? child.MarginBox.Height : child.MarginBox.Width) + gap;
cross = Math.Max(cross, column ? child.MarginBox.Width : child.MarginBox.Height);
}
used -= items.Count > 0 ? gap : 0;
double remaining = Math.Max(0, available - used);
string justify = parent.Style.Get("justify-content", "flex-start");
double offset = justify == "center" ? remaining / 2 : justify == "flex-end" ? remaining : justify == "space-around" && items.Count > 0 ? remaining / items.Count / 2 :
justify == "space-evenly" ? remaining / (items.Count + 1) : 0;
double extraGap = justify == "space-between" && items.Count > 1 ? remaining / (items.Count - 1) :
justify == "space-around" && items.Count > 0 ? remaining / items.Count : justify == "space-evenly" ? remaining / (items.Count + 1) : 0;
for (int i = 0; i < items.Count; i++)
{
var child = items[i]; string align = child.Style.Get("align-self", parent.Style.Get("align-items", "stretch"));
double crossSpace = Math.Max(0, (column ? parent.ContentBox.Width : fixedHeight ?? cross) - (column ? child.MarginBox.Width : child.MarginBox.Height));
double crossOffset = align == "center" ? crossSpace / 2 : align is "flex-end" or "end" ? crossSpace : 0;
if (!column && align == "stretch" && fixedHeight is null && child.Node is IElement && child.Style.Length("height", cross) is null)
UpdateRectangles(child, child.ContentBox with { Height = Math.Max(0, cross - child.Margin.Vertical - child.Border.Vertical - child.Padding.Vertical) });
Translate(child, column ? crossOffset : offset + i * extraGap, column ? offset + i * extraGap : crossOffset);
}
return column ? used + remaining : fixedHeight ?? cross;
}
private double IntrinsicWidth(BrowserLayoutBox box, double available)
{
if (box.Node.NodeType == NodeType.Text) return Math.Min(1000000, metrics.Measure(box.Node.TextContent, box.Style.Font));
double extras = box.Style.Edges("padding", available).Horizontal + box.Style.Edges("border", available, true).Horizontal;
double margin = box.Style.Edges("margin", available).Horizontal;
if (box.Style.Length("width", available) is { } width) return Math.Max(0, width + margin + (box.Style.BoxSizing == "border-box" ? 0 : extras));
if(box.GeneratedText is not null)return Math.Min(1000000,metrics.Measure(box.GeneratedText,box.Style.Font)+extras+margin);
if (ControlValue(box.Node) is not null) return (box.Node is IHtmlInputElement ? 160 : 320) + extras + margin;
double measured = 0;
foreach (var child in box.children.Where(child => child.Style.Display != "none" && child.Style.Position is not ("absolute" or "fixed")))
measured = child.Style.Display is "block" or "list-item" or "table" or "table-row" or "table-row-group" ? Math.Max(measured, IntrinsicWidth(child, available)) : measured + IntrinsicWidth(child, available);
return Math.Min(1000000, measured + extras + margin);
}
private double IntrinsicHeight(BrowserLayoutBox box,double? availableWidth=null)
{
if (box.Node.NodeType == NodeType.Text) return box.Style.LineHeight;
double basis=availableWidth??viewport.Width;
var padding=box.Style.Edges("padding",basis);var border=box.Style.Edges("border",basis,true);var margin=box.Style.Edges("margin",basis);
double extras = padding.Vertical+border.Vertical;
double width=box.Style.Length("width",basis) is { } w?w-(box.Style.BoxSizing=="border-box"?padding.Horizontal+border.Horizontal:0):Math.Max(0,basis-padding.Horizontal-border.Horizontal-margin.Horizontal);
double? declared = box.Style.Length("height", viewport.Height);
double height = declared is { } h ? Math.Max(0, h - (box.Style.BoxSizing == "border-box" ? extras : 0)) :
MeasureIntrinsicContent(box,width);
return Constrain(box.Style,height,"height",viewport.Height,extras)+margin.Vertical+extras;
}
private double MeasureIntrinsicContent(BrowserLayoutBox box,double width)
{
if(box.GeneratedText is not null){var generated=new Flow(0,0,width,box.Style.LineHeight,true);FlowText(box,generated);return generated.Y+(generated.HasContent?generated.LineHeight:0);}
var children=box.children.Where(child=>child.Style.Display!="none"&&child.Style.Position is not ("absolute" or "fixed")).ToArray();
if(box.Style.Display is "flex" or "inline-flex")
{
bool column=box.Style.Get("flex-direction","row").StartsWith("column",StringComparison.Ordinal);
var heights=children.Where(child=>child.Node.NodeType!=NodeType.Text||!string.IsNullOrWhiteSpace(child.Node.TextContent)).Select(child=>IntrinsicHeight(child,width)).ToArray();
return column?heights.Sum()+Math.Max(0,heights.Length-1)*(box.Style.Length("row-gap",width)??0):heights.DefaultIfEmpty().Max();
}
var flow=new Flow(0,0,width,box.Style.LineHeight,true);double? previousBottom=null;
if(ControlValue(box.Node) is { } value)
{foreach(string line in value.Replace("\r","").Split('\n')){if(flow.HasContent)flow.NewLine(box.Style.LineHeight);if(line.Length>0)AddText(box,flow,line,box.Style.WhiteSpace!="pre");}return Math.Max(box.Style.LineHeight,flow.Y+flow.LineHeight);}
foreach(var child in children)
{
if(child.Node.NodeType==NodeType.Text){FlowText(child,flow);if(flow.HasContent)previousBottom=null;continue;}
if(child.Element?.LocalName=="br"){flow.NewLine(box.Style.LineHeight);previousBottom=null;continue;}
if(child.Style.Display=="inline"){FlowInline(child,flow);if(flow.HasContent)previousBottom=null;continue;}
if(child.Style.Display is "inline-block" or "inline-flex"){MeasureAtomicInline(child,flow);previousBottom=null;continue;}
if(flow.HasContent)flow.NewLine(box.Style.LineHeight);
var margin=child.Style.Edges("margin",width);
if(previousBottom is { } previous){double collapsed=Math.Max(Math.Max(previous,0),Math.Max(margin.Top,0))+Math.Min(Math.Min(previous,0),Math.Min(margin.Top,0));flow.Y-=previous+margin.Top-collapsed;}
flow.Y+=IntrinsicHeight(child,width);flow.X=flow.StartX;flow.LineHeight=box.Style.LineHeight;previousBottom=margin.Bottom;
}
return Math.Max(0,flow.Y+(flow.HasContent?flow.LineHeight:0));
}
private void MeasureAtomicInline(BrowserLayoutBox box,Flow flow)
{
double width=IntrinsicWidth(box,flow.Width),height=IntrinsicHeight(box,width);
if(flow.HasContent&&flow.X+width>flow.StartX+flow.Width)flow.NewLine(box.Style.LineHeight);
flow.X+=width;flow.LineHeight=Math.Max(flow.LineHeight,height);flow.HasContent=true;
}
private double LayoutTable(BrowserLayoutBox table)
{
var rows = new List<BrowserLayoutBox>();
foreach (var child in table.children)
{
if (child.Style.Display == "none") continue;
if (child.Style.Display == "table-row") rows.Add(child);
else if (child.Style.Display == "table-row-group") rows.AddRange(child.children.Where(row => row.Style.Display == "table-row"));
}
int columns = rows.Select(row => row.children.Count(cell => cell.Style.Display == "table-cell")).DefaultIfEmpty().Max();
if (columns > 128) throw new BrowserLimitException("CSS table column bound reached.");
if (columns == 0) return 0;
var widths = new double[columns];
foreach (var row in rows)
{
int column = 0;
foreach (var cell in row.children.Where(cell => cell.Style.Display == "table-cell"))
{
if (cell.Element?.HasAttribute("colspan") == true || cell.Element?.HasAttribute("rowspan") == true) Note("Table row/column spans are not implemented.");
widths[column] = Math.Max(widths[column], IntrinsicWidth(cell, table.ContentBox.Width)); column++;
}
}
bool collapse = table.Style.Get("border-collapse", "separate") == "collapse";
var overlaps = new double[Math.Max(0,columns-1)];
if(collapse)
foreach(var row in rows)
{
var cells=row.children.Where(cell=>cell.Style.Display=="table-cell").ToArray();
for(int i=0;i+1<cells.Length;i++)overlaps[i]=Math.Max(overlaps[i],Math.Min(cells[i].Style.Edges("border",table.ContentBox.Width,true).Right,cells[i+1].Style.Edges("border",table.ContentBox.Width,true).Left));
}
string[] spacing = CssValue.Split(table.Style.Get("border-spacing", "2px"), ' ').ToArray();
double sx = collapse ? 0 : CssValue.Length(spacing.FirstOrDefault() ?? "2px", table.ContentBox.Width, table.Style.Font.Size, viewport, 16) ?? 2;
double sy = collapse ? 0 : CssValue.Length(spacing.LastOrDefault() ?? "2px", table.ContentBox.Width, table.Style.Font.Size, viewport, 16) ?? 2;
double total = widths.Sum(), available = Math.Max(0, table.ContentBox.Width - sx * (columns + 1) + overlaps.Sum());
bool intrinsic = table.Style.Get("width", "auto") is "max-content" or "fit-content" or "auto";
if (total < available && !intrinsic) for (int i = 0; i < widths.Length; i++) widths[i] += (available - total) / columns;
else if (total > available && total > 0) for (int i = 0; i < widths.Length; i++) widths[i] *= available / total;
double y = table.ContentBox.Y + sy;
double previousBottomBorder=0;
foreach (var row in rows)
{
var cells=row.children.Where(cell=>cell.Style.Display=="table-cell").ToArray();
double topBorder=cells.Select(cell=>cell.Style.Edges("border",table.ContentBox.Width,true).Top).DefaultIfEmpty().Max();
if(collapse)y-=Math.Min(previousBottomBorder,topBorder);
double x = table.ContentBox.X + sx, height = 0; int column = 0;
foreach (var cell in cells)
{
double extra = cell.Style.Edges("padding", table.ContentBox.Width).Horizontal + cell.Style.Edges("border", table.ContentBox.Width, true).Horizontal;
LayoutBlock(cell, x, y, widths[column], 0, Math.Max(0, widths[column] - extra), null);
height = Math.Max(height, cell.MarginBox.Height); x += widths[column] + sx - (column<overlaps.Length?overlaps[column]:0);column++;
}
foreach (var cell in row.children.Where(cell => cell.Style.Display == "table-cell"))
UpdateRectangles(cell, cell.ContentBox with { Height = Math.Max(0, height - cell.Padding.Vertical - cell.Border.Vertical - cell.Margin.Vertical) });
UpdateRectangles(row, new(table.ContentBox.X, y, widths.Sum() + sx * (columns + 1) - overlaps.Sum(), height)); y += height + sy;
previousBottomBorder=cells.Select(cell=>cell.Border.Bottom).DefaultIfEmpty().Max();
}
foreach (var group in table.children.Where(group => group.Style.Display == "table-row-group"))
{
BrowserRect union = default; foreach (var row in group.children.Where(row => row.Style.Display == "table-row")) union = union.Union(row.BorderBox);
UpdateRectangles(group, union);
}
if (intrinsic) table.ContentBox = table.ContentBox with { Width = widths.Sum() + sx * (columns + 1) - overlaps.Sum() };
return y - table.ContentBox.Y;
}
private bool QueueAbsolute(BrowserLayoutBox child, BrowserLayoutBox parent)
{
if (child.Node.NodeType == NodeType.Text || child.Style.Position is not ("absolute" or "fixed")) return false;
BrowserLayoutBox? anchor = parent; while (anchor is not null && anchor.Style.Position == "static" && anchor.Style.Get("transform", "none") == "none") anchor = anchor.Parent;
pending.Add(new(child, parent, child.Style.Position == "fixed" ? null : anchor)); return true;
}
private void LayoutAbsolute(PendingAbsolute item)
{
var box = item.Box;
BrowserRect containing = item.Anchor?.PaddingBox ?? new(box.Style.Position == "fixed" ? viewport.ScrollX : 0,
box.Style.Position == "fixed" ? viewport.ScrollY : 0, viewport.Width, viewport.Height);
double? left = box.Style.Length("left", containing.Width), right = box.Style.Length("right", containing.Width);
double? top = box.Style.Length("top", containing.Height), bottom = box.Style.Length("bottom", containing.Height);
var margin = box.Style.Edges("margin", containing.Width); var pad = box.Style.Edges("padding", containing.Width); var border = box.Style.Edges("border", containing.Width, true);
double? width = box.Style.Length("width", containing.Width) is null ? Math.Max(0, (left is not null && right is not null ? containing.Width - left.Value - right.Value : IntrinsicWidth(box, containing.Width)) - margin.Horizontal - pad.Horizontal - border.Horizontal) : null;
double? height = box.Style.Length("height", containing.Height) is null && top is not null && bottom is not null ?
Math.Max(0, containing.Height - top.Value - bottom.Value - margin.Vertical - pad.Vertical - border.Vertical) : null;
LayoutBlock(box, containing.X + (left ?? 0), containing.Y + (top ?? 0), containing.Width, containing.Height, width, height);
if (left is null && right is not null) Translate(box, containing.Right - right.Value - box.MarginBox.Right, 0);
if (top is null && bottom is not null) Translate(box, 0, containing.Bottom - bottom.Value - box.MarginBox.Bottom);
}
private static void Translate(BrowserLayoutBox box, double x, double y)
{
box.ContentBox = box.ContentBox.Translate(x, y); box.PaddingBox = box.PaddingBox.Translate(x, y);
box.BorderBox = box.BorderBox.Translate(x, y); box.MarginBox = box.MarginBox.Translate(x, y);
for (int index = 0; index < box.fragments.Count; index++) box.fragments[index] = box.fragments[index].Translate(x, y);
for (int index = 0; index < box.textCommands.Count; index++) box.textCommands[index] = box.textCommands[index] with { Bounds = box.textCommands[index].Bounds.Translate(x, y) };
foreach (var child in box.children) Translate(child, x, y);
}
private void ApplyTranslations(BrowserLayoutBox box)
{
if (box.Node is not IElement || box.Style.Display == "none") return;
string transform = box.Style.Get("transform", "none").Trim().ToLowerInvariant();
double x = 0, y = 0;
for (int offset = 0; offset < transform.Length && transform != "none";)
{
int open = transform.IndexOf('(', offset); if (open < 0) { Note("Unsupported CSS transform: " + transform); break; }
string name = transform[offset..open].Trim(); int depth = 1, close = open + 1;
while (close < transform.Length && depth > 0) { if (transform[close] == '(') depth++; else if (transform[close] == ')') depth--; close++; }
if (depth != 0) { Note("Malformed CSS transform ignored"); break; }
string arguments = transform[(open + 1)..(close - 1)]; var pieces = new List<string>(); int start = 0; depth = 0;
for (int index = 0; index <= arguments.Length; index++)
{
if (index == arguments.Length || depth == 0 && (arguments[index] == ',' || char.IsWhiteSpace(arguments[index])))
{ if (index > start) pieces.Add(arguments[start..index].Trim()); start = index + 1; }
else if (arguments[index] == '(') depth++; else if (arguments[index] == ')') depth--;
}
double? Length(int index, double basis) => index >= pieces.Count ? null : CssValue.Length(pieces[index], basis, box.Style.Font.Size, viewport, 16);
double? dx = null, dy = null;
if (name == "translate" && pieces.Count is 1 or 2) { dx = Length(0, box.BorderBox.Width); dy = pieces.Count == 1 ? 0 : Length(1, box.BorderBox.Height); }
else if (name == "translatex" && pieces.Count == 1) { dx = Length(0, box.BorderBox.Width); dy = 0; }
else if (name == "translatey" && pieces.Count == 1) { dx = 0; dy = Length(0, box.BorderBox.Height); }
else if (name == "translate3d" && pieces.Count == 3 && Length(2, 0) == 0) { dx = Length(0, box.BorderBox.Width); dy = Length(1, box.BorderBox.Height); }
else if (name == "matrix" && pieces.Count == 6 && CssValue.Number(pieces[0], 0) == 1 && CssValue.Number(pieces[1], 1) == 0 && CssValue.Number(pieces[2], 1) == 0 && CssValue.Number(pieces[3], 0) == 1)
{ dx = CssValue.Number(pieces[4], 0); dy = CssValue.Number(pieces[5], 0); }
if (dx is null || dy is null) { Note("Unsupported CSS transform; only 2D translations are implemented: " + name); x = y = 0; break; }
x = Math.Clamp(x + dx.Value, -1000000, 1000000); y = Math.Clamp(y + dy.Value, -1000000, 1000000); offset = close;
}
if (x != 0 || y != 0) Translate(box, x, y);
foreach (var child in box.children) ApplyTranslations(child);
}
private void Paint(BrowserLayoutBox box, BrowserRect? inheritedClip, double parentOpacity, IReadOnlyList<BrowserRoundedClip>? roundedClips = null)
{
if (box.Style.Display == "none") return;
double opacity = parentOpacity * (box.Node is IElement ? box.Style.Opacity : 1); box.EffectiveOpacity = opacity; box.Clip = inheritedClip; box.roundedClips=roundedClips; box.IsLaidOut = true; painted.Add(box);
if (box.Style.Visibility != "hidden" && opacity > 0)
{
if (box.Node is IElement)
{
IEnumerable<BrowserRect> rectangles = box.Style.Display == "inline" && box.fragments.Count > 0 ? box.fragments : [box.BorderBox];
bool decorated = false;
foreach (var rect in rectangles)
{
var decoration = BrowserBoxDecoration.Create(box.Style, rect, box.Border);
if (decoration is not null)
{
decorated = true;
if (ReferenceEquals(canvasBackgroundOwner, box)) decoration = decoration with { Background = new(0, 0) };
if (decoration.Shadows.Any(shadow => shadow.Inset)) Note("Inset box shadows are retained but not painted.");
Command(new(BrowserDrawKind.Fill, decoration.PaintBounds, box.NodeId, box.Node, Opacity: opacity, Decoration: decoration));
}
else if (box.Style.Background.Alpha > 0 && !ReferenceEquals(canvasBackgroundOwner, box))
Command(new(BrowserDrawKind.Fill, rect, box.NodeId, box.Node, box.Style.Background.Rgb, opacity * box.Style.Background.Alpha));
}
if (!decorated) PaintBorder(box, opacity);
if (box.Style.Get("background-image", "none") != "none" && BrowserCssGradient.Parse(box.Style.Get("background-image", "none"),box.Style).Count == 0) Note("Unsupported CSS background image retained but not painted.");
PaintListMarker(box, opacity);
if (box.Node is IHtmlCanvasElement) Command(new(BrowserDrawKind.Canvas, box.ContentBox, box.NodeId, box.Node, Opacity: opacity));
}
bool control = ControlValue(box.Node) is not null;
if (control) Command(new(BrowserDrawKind.PushClip, box.ContentBox, box.NodeId, box.Node));
foreach (var text in box.textCommands) Command(text with { Opacity = text.Opacity * opacity });
if (control) Command(new(BrowserDrawKind.PopClip, default, box.NodeId, box.Node));
}
BrowserRect? clip = inheritedClip;
bool clips = box.Style.OverflowX != "visible" || box.Style.OverflowY != "visible";
if (clips)
{
var area = box.PaddingBox;
if (box.Style.OverflowX == "visible") area = new(-1000000, area.Y, 2000000, area.Height);
if (box.Style.OverflowY == "visible") area = new(area.X, -1000000, area.Width, 2000000);
clip = inheritedClip?.Intersect(area) ?? area;
BrowserRoundedClip? rounded=null;
if(box.Style.OverflowX!="visible"&&box.Style.OverflowY!="visible"&&BrowserBoxDecoration.Create(box.Style,box.BorderBox,box.Border) is { } decoration && decoration.Radius is { } radius && radius.Top+radius.Right+radius.Bottom+radius.Left>0)
{
var inner=new BrowserEdges(Math.Max(0,radius.Top-Math.Max(box.Border.Top,box.Border.Left)),Math.Max(0,radius.Right-Math.Max(box.Border.Top,box.Border.Right)),
Math.Max(0,radius.Bottom-Math.Max(box.Border.Bottom,box.Border.Right)),Math.Max(0,radius.Left-Math.Max(box.Border.Bottom,box.Border.Left)));
rounded=new(box.PaddingBox,inner);
roundedClips=(roundedClips??[]).Append(rounded).ToArray();
}
Command(new(BrowserDrawKind.PushClip, clip.Value, box.NodeId, box.Node,RoundedClip:rounded));
}
foreach (var child in box.children.OrderBy(child => child.Node.NodeType == NodeType.Text ? 0 : child.Style.ZIndex)) Paint(child, clip, opacity,roundedClips);
if (clips) Command(new(BrowserDrawKind.PopClip, default, box.NodeId, box.Node));
}
private void PaintCanvasBackground(BrowserLayoutBox root)
{
// CSS 2 canvas background propagation: root background covers the canvas;
// an HTML root with a transparent background uses its body's background.
// The body can have zero in-flow height when every child is positioned.
canvasBackgroundOwner = root.Style.Background.Alpha > 0 ? root : null;
if (canvasBackgroundOwner is null && root.Element?.LocalName == "html")
canvasBackgroundOwner = root.children.FirstOrDefault(child => child.Element?.LocalName == "body" && child.Style.Display != "none" && child.Style.Background.Alpha > 0);
var rect = new BrowserRect(viewport.ScrollX, viewport.ScrollY, viewport.Width, viewport.Height);
Command(new(BrowserDrawKind.Fill, rect, root.NodeId, root.Node, 0xFFFFFF, ViewportPinned: true));
if (canvasBackgroundOwner is { } owner)
Command(new(BrowserDrawKind.Fill, rect, owner.NodeId, owner.Node, owner.Style.Background.Rgb, owner.Style.Background.Alpha, ViewportPinned: true));
}
private void PaintListMarker(BrowserLayoutBox box, double opacity)
{
if (box.Style.Display != "list-item") return;
var style = box.MarkerStyle ?? box.Style;
string type = box.Style.Get("list-style-type", "disc");
if (type == "none") return;
string marker;
if (type is "decimal" or "lower-alpha" or "upper-alpha")
{
int number = 1;
if (box.Element?.ParentElement is { } parent)
{
bool reversed = parent.HasAttribute("reversed");
int.TryParse(parent.GetAttribute("start"), out int start);
number = parent.HasAttribute("start") ? start : reversed ? parent.Children.Count(child => child.LocalName == "li") : 1;
foreach (var sibling in parent.Children.Where(child => child.LocalName == "li"))
{
if (int.TryParse(sibling.GetAttribute("value"), out int value)) number = value;
if (ReferenceEquals(sibling, box.Element)) break;
number += reversed ? -1 : 1;
}
}
marker = type == "decimal" || number < 1 || number > 26 ? number.ToString(System.Globalization.CultureInfo.InvariantCulture) : ((char)((type == "upper-alpha" ? 'A' : 'a') + number - 1)).ToString();
marker += ".";
}
else marker = type == "circle" ? "◦" : type == "square" ? "▪" : "•";
double width = metrics.Measure(marker, style.Font), gap = style.Font.Size * .45;
Command(new(BrowserDrawKind.Text, new(box.ContentBox.X - width - gap, box.ContentBox.Y, width, style.LineHeight), box.NodeId, box.Node, style.Color.Rgb, style.Color.Alpha * opacity, marker, style.Font));
}
private void PaintBorder(BrowserLayoutBox box, double opacity)
{
var b = box.BorderBox; var widths = box.Border;
var sides = new[] { ("top", new BrowserRect(b.X, b.Y, b.Width, widths.Top)),
("right", new BrowserRect(b.Right - widths.Right, b.Y + widths.Top, widths.Right, Math.Max(0, b.Height - widths.Vertical))),
("bottom", new BrowserRect(b.X, b.Bottom - widths.Bottom, b.Width, widths.Bottom)),
("left", new BrowserRect(b.X, b.Y + widths.Top, widths.Left, Math.Max(0, b.Height - widths.Vertical))) };
foreach (var (side, rect) in sides)
{
if (rect.Width <= 0 || rect.Height <= 0) continue;
var color = CssValue.Color(box.Style.Get("border-" + side + "-color", "currentcolor"), box.Style.Color);
if (box.Style.Get("border-" + side + "-style", "solid") != "solid") Note("Non-solid borders currently use a solid side rectangle.");
Command(new(BrowserDrawKind.Border, rect, box.NodeId, box.Node, color.Rgb, color.Alpha * opacity));
}
}
private void Command(BrowserDrawCommand command)
{
if (commands.Count >= limits.MaximumDisplayCommands) throw new BrowserLimitException("CSS display command limit reached.");
commands.Add(command);
}
private void Note(string message) { if (diagnostics.Count < 256 && !diagnostics.Contains(message)) diagnostics.Add(message); }
}
using AngleSharp.Dom;
using AngleSharp.Html.Dom;
namespace XFESystem.Browser;
/// <summary>
/// Original bounded CSS layout subset. Coordinates and text metrics are real
/// calculations over the supplied DOM; no page-, editor- or Monaco-specific sizes.
/// Not a complete CSS formatting/stacking implementation (see Diagnostics).
/// </summary>
public sealed class BrowserLayoutEngine(BrowserCssLimits? limits = null)
{
private readonly BrowserCssLimits limits = limits ?? new();
private sealed class Flow(double x, double y, double width, double lineHeight, bool measureOnly = false)
{
public double X = x, Y = y, StartX = x, Width = width, LineHeight = lineHeight;
public readonly bool MeasureOnly=measureOnly;
public bool HasContent, PendingSpace;
public void NewLine(double nextHeight)
{ Y += LineHeight; X = StartX; LineHeight = nextHeight; HasContent = false; PendingSpace = false; }
}
private sealed record PendingAbsolute(BrowserLayoutBox Box, BrowserLayoutBox Parent, BrowserLayoutBox? Anchor);
private readonly List<BrowserLayoutBox> all = [], painted = [];
private readonly List<BrowserDrawCommand> commands = [];
private readonly List<PendingAbsolute> pending = [];
private readonly List<string> diagnostics = [];
private readonly Dictionary<INode, int> identities = new(ReferenceEqualityComparer.Instance);
private readonly HashSet<INode> currentIdentityNodes = new(ReferenceEqualityComparer.Instance);
private readonly List<INode> detachedIdentityNodes = [];
private IDocument? identityDocument;
private int nextId, nodeCount;
private BrowserViewport viewport;
private IBrowserTextMetrics metrics = new BrowserFallbackTextMetrics();
private Func<INode, int>? identity;
private BrowserCssCascade cascade = null!;
private BrowserInteractionState interaction = new();
private BrowserLayoutBox? canvasBackgroundOwner;
public long CssParseGeneration => cascade?.ParseGeneration ?? 0;
public long CssReuseCount => cascade?.ReuseCount ?? 0;
public long LastStylesheetParseTicks { get; private set; }
internal bool InteractionChangesStyles(BrowserInteractionState before, BrowserInteractionState after) =>
cascade is null || cascade.InteractionChangesStyles(before, after, all.Where(box => box.Node is IElement).Select(box => (IElement)box.Node).Distinct());
/// <summary>Identity references retained for the most recent bounded layout; snapshots own their separate boxes.</summary>
public int RetainedNodeIdentityCount => identities.Count;
public void SetInteractionState(INode? hovered, INode? active, INode? focus) => interaction = new(hovered, active, focus);
public BrowserDisplayList Layout(IDocument document, BrowserViewport viewport,
IReadOnlyList<BrowserStyleSheet>? linkedSheets = null, IBrowserTextMetrics? textMetrics = null,
Func<INode, int>? nodeIdentity = null, bool blockAuthorStyles = false, BrowserInteractionState? interaction = null)
{
ArgumentNullException.ThrowIfNull(document);
if (!double.IsFinite(viewport.Width) || !double.IsFinite(viewport.Height) || viewport.Width <= 0 || viewport.Height <= 0 ||
viewport.Width > 100000 || viewport.Height > 100000 || !double.IsFinite(viewport.ScrollX) || !double.IsFinite(viewport.ScrollY))
throw new ArgumentOutOfRangeException(nameof(viewport));
if (!ReferenceEquals(document, identityDocument))
{
if (identityDocument is not null) { cascade?.ResetDocument(); this.interaction = new(); }
identities.Clear(); nextId = 0; identityDocument = document;
}
all.Clear(); painted.Clear(); commands.Clear(); pending.Clear(); diagnostics.Clear(); currentIdentityNodes.Clear(); nodeCount = 0;
this.viewport = viewport; metrics = textMetrics ?? new BrowserFallbackTextMetrics(); identity = nodeIdentity;
if (textMetrics is null) diagnostics.Add("Host fallback text metrics are proportional estimates; native rendering must supply its real metrics.");
if (interaction is not null) this.interaction = interaction;
cascade ??= new(limits, viewport, diagnostics, blockAuthorStyles, this.interaction);
long stylesheetStarted = System.Diagnostics.Stopwatch.GetTimestamp();
cascade.BeginLayout(viewport, diagnostics, blockAuthorStyles, this.interaction); cascade.Load(document, linkedSheets);
LastStylesheetParseTicks = System.Diagnostics.Stopwatch.GetTimestamp() - stylesheetStarted;
diagnostics.Add("CSS subset: no grid, complex table spans, floats, parent-child margin collapse, scale/rotate/perspective transforms, arbitrary webfonts, generated counters/quotes/URLs, bidi/shaping, full flex wrapping or full stacking contexts.");
var element = document.DocumentElement ?? document.Body;
if (element is null) { identities.Clear(); return new(viewport, [], [], diagnostics.ToArray(), viewport.Width, viewport.Height); }
BrowserLayoutBox root;
try { root = Build(element, null, 0); }
// The current native runtime does not unwind ordinary finally blocks.
// Mirror cleanup on the exceptional path, as well as normal completion.
catch (Exception) { PruneNodeIdentities(); throw; }
PruneNodeIdentities();
LayoutBlock(root, 0, 0, viewport.Width, viewport.Height, null, null);
for (int index = 0; index < pending.Count; index++) LayoutAbsolute(pending[index]);
ApplyTranslations(root);
PaintCanvasBackground(root);
Paint(root, null, 1);
double width = viewport.Width, height = Math.Max(viewport.Height, root.MarginBox.Bottom);
foreach (var box in painted)
{
if (box.Clip is null) { width = Math.Max(width, box.BorderBox.Right); height = Math.Max(height, box.BorderBox.Bottom); }
}
foreach (var box in all) if (box.Node is IElement) box.Style.Resolve(box);
return new(viewport, commands.ToArray(), painted.ToArray(), diagnostics.ToArray(), width, height, all.ToArray(), metrics);
}
private BrowserLayoutBox Build(INode node, BrowserLayoutBox? parent, int depth)
{
if (++nodeCount > limits.MaximumNodes || depth > limits.MaximumDepth) throw new BrowserLimitException("CSS layout DOM bound reached.");
var style = node is IElement element ? cascade.Compute(element, parent?.Style) : parent!.Style;
if (!identities.TryGetValue(node, out int id)) { id = identity?.Invoke(node) ?? ++nextId; identities.Add(node, id); }
currentIdentityNodes.Add(node);
var box = new BrowserLayoutBox { Node = node, NodeId = id, Style = style, Parent = parent };
if (node is IElement item && style.Display == "list-item") box.MarkerStyle = cascade.ComputeMarker(item, style);
all.Add(box);
if(node is IElement origin&&node is not(IHtmlTextAreaElement or IHtmlCanvasElement))AddGenerated(box,origin,"before");
if (node is not (IHtmlTextAreaElement or IHtmlCanvasElement))
foreach (var child in node.ChildNodes)
if (child.NodeType == NodeType.Text || child is IElement) box.children.Add(Build(child, box, depth + 1));
if(node is IElement after&&node is not(IHtmlTextAreaElement or IHtmlCanvasElement))AddGenerated(box,after,"after");
return box;
}
private void AddGenerated(BrowserLayoutBox parent,IElement origin,string pseudo)
{
var style=cascade.ComputePseudo(origin,pseudo,parent.Style);
if(style is null||style.Display=="none")return;
string? text=GeneratedContent(style.Get("content","normal"),origin);
if(text is null)return;
if(++nodeCount>limits.MaximumNodes)throw new BrowserLimitException("CSS generated box limit reached.");
var box=new BrowserLayoutBox{Node=origin,NodeId=parent.NodeId,Style=style,Parent=parent,GeneratedPseudo=pseudo,GeneratedText=text};
all.Add(box);parent.children.Add(box);
}
private string? GeneratedContent(string content,IElement origin)
{
var result=new System.Text.StringBuilder();
for(int offset=0;offset<content.Length;)
{
while(offset<content.Length&&char.IsWhiteSpace(content[offset]))offset++;
if(offset>=content.Length)break;
char quote=content[offset];
if(quote is '\'' or '"')
{
offset++;
while(offset<content.Length&&content[offset]!=quote)
{
char ch=content[offset++];
if(ch=='\\'&&offset<content.Length)
{
if(content[offset] is '\r' or '\n' or '\f'){if(content[offset++]=='\r'&&offset<content.Length&&content[offset]=='\n')offset++;continue;}
int begin=offset;while(offset<content.Length&&offset-begin<6&&Uri.IsHexDigit(content[offset]))offset++;
if(offset>begin)
{int scalar=int.Parse(content[begin..offset],System.Globalization.NumberStyles.HexNumber,System.Globalization.CultureInfo.InvariantCulture);result.Append(scalar is >0 and <=0x10FFFF&&scalar is not(>=0xD800 and <=0xDFFF)?char.ConvertFromUtf32(scalar):"\uFFFD");if(offset<content.Length&&char.IsWhiteSpace(content[offset]))offset++;continue;}
ch=content[offset++];
}
result.Append(ch);if(result.Length>4096)throw new BrowserLimitException("CSS generated text limit reached.");
}
if(offset>=content.Length)return null;offset++;
}
else if(content.AsSpan(offset).StartsWith("attr(",StringComparison.Ordinal))
{int end=content.IndexOf(')',offset+5);if(end<0)return null;string name=content[(offset+5)..end].Trim();result.Append(origin.GetAttribute(name)??"");offset=end+1;}
else{Note("Unsupported CSS generated content expression.");return null;}
if(result.Length>4096)throw new BrowserLimitException("CSS generated text limit reached.");
}
return result.ToString();
}
private void PruneNodeIdentities()
{
// An engine survives mutations and navigation. Keep only the nodes
// built this time, even when a DOM or selector budget aborts Build.
// Older display lists retain their own immutable geometry snapshots.
detachedIdentityNodes.Clear();
foreach (var node in identities.Keys)
if (!currentIdentityNodes.Contains(node)) detachedIdentityNodes.Add(node);
foreach (var node in detachedIdentityNodes) identities.Remove(node);
detachedIdentityNodes.Clear(); currentIdentityNodes.Clear();
}
private void LayoutBlock(BrowserLayoutBox box, double x, double y, double availableWidth, double availableHeight,
double? forcedWidth, double? forcedHeight)
{
if (box.Style.Display == "none") return;
var style = box.Style;
box.Margin = style.Edges("margin", availableWidth);
box.Padding = style.Edges("padding", availableWidth);
box.Border = style.Edges("border", availableWidth, true);
double extras = box.Padding.Horizontal + box.Border.Horizontal;
double? declared = style.Length("width", availableWidth);
double width = forcedWidth ?? (declared is { } w ? w - (style.BoxSizing == "border-box" ? extras : 0) : availableWidth - extras - box.Margin.Horizontal);
width = Constrain(style, width, "width", availableWidth, extras);
double spare = availableWidth - width - extras - box.Margin.Horizontal;
if (spare > 0 && declared is not null)
{
bool leftAuto = style.Get("margin-left", "0") == "auto", rightAuto = style.Get("margin-right", "0") == "auto";
if (leftAuto || rightAuto) box.Margin = box.Margin with { Left = leftAuto ? spare / (rightAuto ? 2 : 1) : box.Margin.Left,
Right = rightAuto ? spare / (leftAuto ? 2 : 1) : box.Margin.Right };
}
double verticalExtras = box.Padding.Vertical + box.Border.Vertical;
double? declaredHeight = style.Length("height", availableHeight);
double? height = forcedHeight ?? (declaredHeight is { } h ? Math.Max(0, h - (style.BoxSizing == "border-box" ? verticalExtras : 0)) : null);
double contentX = x + box.Margin.Left + box.Border.Left + box.Padding.Left;
double contentY = y + box.Margin.Top + box.Border.Top + box.Padding.Top;
box.ContentBox = new(contentX, contentY, width, height ?? 0);
double measured = box.GeneratedText is not null ? LayoutGenerated(box) : ControlValue(box.Node) is { } controlValue ? LayoutControl(box, controlValue) :
box.Element?.LocalName == "table" || style.Display == "table" ? LayoutTable(box) :
style.Display is "flex" or "inline-flex" ? LayoutFlex(box, height) : LayoutFlow(box, height);
double resolvedHeight = Constrain(style, height ?? measured, "height", availableHeight, verticalExtras);
UpdateRectangles(box, new(contentX, contentY, box.ContentBox.Width, resolvedHeight));
if (style.Position == "relative") Translate(box,
style.Length("left", availableWidth) ?? -(style.Length("right", availableWidth) ?? 0),
style.Length("top", availableHeight) ?? -(style.Length("bottom", availableHeight) ?? 0));
}
private static double Constrain(CssComputedStyle style, double value, string axis, double basis, double extras)
{
double Adjustment(double length) => Math.Max(0, length - (style.BoxSizing == "border-box" ? extras : 0));
double minimum = Adjustment(style.Length("min-" + axis, basis) ?? 0);
double maximum = style.Length("max-" + axis, basis) is { } maximumValue ? Adjustment(maximumValue) : 1000000;
return Math.Clamp(Math.Max(minimum, Math.Min(value, maximum)), 0, 1000000);
}
private static void UpdateRectangles(BrowserLayoutBox box, BrowserRect content)
{
box.ContentBox = content;
box.PaddingBox = Inflate(content, box.Padding);
box.BorderBox = Inflate(box.PaddingBox, box.Border);
box.MarginBox = Inflate(box.BorderBox, box.Margin);
if (box.Node is IElement) { box.fragments.Clear(); box.fragments.Add(box.BorderBox); }
}
private static BrowserRect Inflate(BrowserRect rectangle, BrowserEdges edges) => new(rectangle.X - edges.Left,
rectangle.Y - edges.Top, Math.Max(0, rectangle.Width + edges.Horizontal), Math.Max(0, rectangle.Height + edges.Vertical));
private double LayoutFlow(BrowserLayoutBox parent, double? fixedHeight)
{
var content = parent.ContentBox;
var flow = new Flow(content.X, content.Y, content.Width, parent.Style.LineHeight);
double? previousBottomMargin = null;
foreach (var child in parent.children)
{
if (child.Style.Display == "none") continue;
if (QueueAbsolute(child, parent)) continue;
if (child.Node.NodeType == NodeType.Text) { FlowText(child, flow); if(flow.HasContent)previousBottomMargin=null; continue; }
if (child.Element?.LocalName == "br") { flow.NewLine(parent.Style.LineHeight); continue; }
if (child.Style.Display == "inline") { FlowInline(child, flow); if(flow.HasContent)previousBottomMargin=null; continue; }
if (child.Style.Display is "inline-block" or "inline-flex")
{
double outer = IntrinsicWidth(child, content.Width);
if (flow.HasContent && flow.X + outer > flow.StartX + flow.Width) flow.NewLine(parent.Style.LineHeight);
LayoutBlock(child, flow.X, flow.Y, outer, fixedHeight ?? 0, null, null);
ApplyInlineVerticalAlign(child,flow.LineHeight);
flow.X += child.MarginBox.Width; flow.LineHeight = Math.Max(flow.LineHeight, child.MarginBox.Height); flow.HasContent = true;
previousBottomMargin=null;
continue;
}
if (flow.HasContent) flow.NewLine(parent.Style.LineHeight);
if(previousBottomMargin is { } previous)
{
double next=child.Style.Edges("margin",content.Width).Top;
double collapsed=Math.Max(Math.Max(previous,0),Math.Max(next,0))+Math.Min(Math.Min(previous,0),Math.Min(next,0));
flow.Y-=previous+next-collapsed;
}
LayoutBlock(child, content.X, flow.Y, content.Width, fixedHeight ?? 0, null, null);
flow.Y += child.MarginBox.Height; flow.X = flow.StartX; flow.LineHeight = parent.Style.LineHeight;
previousBottomMargin=child.Margin.Bottom;
}
AlignInlineLines(parent);
return Math.Max(0, flow.Y - content.Y + (flow.HasContent ? flow.LineHeight : 0));
}
private void AlignInlineLines(BrowserLayoutBox parent)
{
string alignment = parent.Style.Get("text-align", "left");
if (alignment is not ("center" or "right" or "end"))
{ if (alignment == "justify") Note("Justified text is not implemented; left alignment is used."); return; }
var leaves = new List<BrowserLayoutBox>();
void Collect(BrowserLayoutBox box)
{
if (box.Style.Display == "none" || box.Style.Position is "absolute" or "fixed") return;
if (box.Node.NodeType == NodeType.Text || box.Style.Display is "inline-block" or "inline-flex") leaves.Add(box);
else if (box.Style.Display == "inline") foreach (var child in box.children) Collect(child);
}
foreach (var child in parent.children) Collect(child);
var ends = new Dictionary<double, double>();
foreach (var leaf in leaves)
foreach (var rect in leaf.Node.NodeType == NodeType.Text ? (IEnumerable<BrowserRect>)leaf.fragments : [leaf.MarginBox])
ends[rect.Y] = Math.Max(ends.GetValueOrDefault(rect.Y), rect.Right);
double Shift(double y) => ends.TryGetValue(y, out double right) ? Math.Max(0, parent.ContentBox.Right - right) / (alignment == "center" ? 2 : 1) : 0;
foreach (var leaf in leaves)
{
if (leaf.Node.NodeType != NodeType.Text) { Translate(leaf, Shift(leaf.MarginBox.Y), 0); continue; }
for (int index = 0; index < leaf.fragments.Count; index++) leaf.fragments[index] = leaf.fragments[index].Translate(Shift(leaf.fragments[index].Y), 0);
for (int index = 0; index < leaf.textCommands.Count; index++)
{ var command = leaf.textCommands[index]; leaf.textCommands[index] = command with { Bounds = command.Bounds.Translate(Shift(command.Bounds.Y), 0) }; }
BrowserRect union = default; foreach (var rect in leaf.fragments) union = union.Union(rect);
leaf.ContentBox = leaf.PaddingBox = leaf.BorderBox = leaf.MarginBox = union;
}
void Refresh(BrowserLayoutBox box)
{
if (box.Node.NodeType == NodeType.Text || box.Style.Display != "inline") return;
foreach (var child in box.children) Refresh(child);
box.fragments.Clear();
foreach (var group in box.children.SelectMany(child => child.fragments).GroupBy(rect => rect.Y))
{ BrowserRect line = default; foreach (var rect in group) line = line.Union(rect); box.fragments.Add(line); }
BrowserRect union = default; foreach (var rect in box.fragments) union = union.Union(rect);
box.ContentBox = union; box.PaddingBox = Inflate(union, box.Padding); box.BorderBox = Inflate(box.PaddingBox, box.Border); box.MarginBox = Inflate(box.BorderBox, box.Margin);
}
foreach (var child in parent.children) Refresh(child);
}
private void FlowInline(BrowserLayoutBox box, Flow flow)
{
if(box.GeneratedText is not null)
{
var margin=box.Style.Edges("margin",flow.Width);var padding=box.Style.Edges("padding",flow.Width);var border=box.Style.Edges("border",flow.Width,true);
flow.X+=margin.Left+padding.Left+border.Left;FlowText(box,flow);
if(!flow.MeasureOnly)
{box.Margin=margin;box.Padding=padding;box.Border=border;box.PaddingBox=Inflate(box.ContentBox,padding);box.BorderBox=Inflate(box.PaddingBox,border);box.MarginBox=Inflate(box.BorderBox,margin);ApplyInlineVerticalAlign(box,flow.LineHeight);}
flow.X+=padding.Right+border.Right+margin.Right;return;
}
if(flow.MeasureOnly)
{
var margin=box.Style.Edges("margin",flow.Width);var padding=box.Style.Edges("padding",flow.Width);var border=box.Style.Edges("border",flow.Width,true);
flow.X+=margin.Left+padding.Left+border.Left;
foreach(var child in box.children)
{
if(child.Style.Display=="none"||child.Style.Position is "absolute" or "fixed")continue;
if(child.Node.NodeType==NodeType.Text)FlowText(child,flow);
else if(child.Element?.LocalName=="br")flow.NewLine(box.Style.LineHeight);
else if(child.Style.Display=="inline")FlowInline(child,flow);
else MeasureAtomicInline(child,flow);
}
flow.X+=padding.Right+border.Right+margin.Right;return;
}
box.Margin = box.Style.Edges("margin", flow.Width); box.Padding = box.Style.Edges("padding", flow.Width);
box.Border = box.Style.Edges("border", flow.Width, true);
flow.X += box.Margin.Left + box.Padding.Left + box.Border.Left;
foreach (var child in box.children)
{
if (child.Style.Display == "none" || QueueAbsolute(child, box)) continue;
if (child.Node.NodeType == NodeType.Text) FlowText(child, flow);
else if (child.Element?.LocalName == "br") flow.NewLine(box.Style.LineHeight);
else if (child.Style.Display == "inline") FlowInline(child, flow);
else
{
double width = IntrinsicWidth(child, flow.Width);
if (flow.HasContent && flow.X + width > flow.StartX + flow.Width) flow.NewLine(box.Style.LineHeight);
LayoutBlock(child, flow.X, flow.Y, width, 0, null, null);
ApplyInlineVerticalAlign(child,flow.LineHeight);
flow.X += child.MarginBox.Width; flow.LineHeight = Math.Max(flow.LineHeight, child.MarginBox.Height); flow.HasContent = true;
}
}
foreach (var group in box.children.SelectMany(child => child.fragments).GroupBy(rect => rect.Y))
{
BrowserRect line = default; foreach (var rect in group) line = line.Union(rect);
box.fragments.Add(line);
}
BrowserRect content = default; foreach (var fragment in box.fragments) content = content.Union(fragment);
if (box.fragments.Count == 0) content = new(flow.X, flow.Y, 0, box.Style.LineHeight);
box.ContentBox = content; box.PaddingBox = Inflate(content, box.Padding); box.BorderBox = Inflate(box.PaddingBox, box.Border);
box.MarginBox = Inflate(box.BorderBox, box.Margin);
flow.X += box.Padding.Right + box.Border.Right + box.Margin.Right;
if (box.Style.Position == "relative") Translate(box, box.Style.Length("left", flow.Width) ?? 0, box.Style.Length("top", 0) ?? 0);
}
private void FlowText(BrowserLayoutBox box, Flow flow)
{
string text = box.GeneratedText??box.Node.TextContent;
double initialX = flow.X, initialY = flow.Y;
bool preserve = box.Style.WhiteSpace is "pre" or "pre-wrap" or "break-spaces";
bool noWrap = box.Style.WhiteSpace is "pre" or "nowrap";
if (preserve)
{
var rendered = new System.Text.StringBuilder(); var offsets = new List<int> { 0 };
void Flush()
{
if (rendered.Length > 0) AddText(box, flow, rendered.ToString(), !noWrap, sourceOffsets: offsets.ToArray());
rendered.Clear(); offsets.Clear();
}
for (int index = 0; index < text.Length; index++)
{
if (text[index] == '\r') { if (offsets.Count > 0) offsets[^1] = index + 1; continue; }
if (text[index] == '\n') { Flush(); flow.NewLine(box.Style.LineHeight); offsets.Add(index + 1); continue; }
int count = text[index] == '\t' ? 4 : 1;
for (int part = 0; part < count; part++) { rendered.Append(text[index] == '\t' ? ' ' : text[index]); offsets.Add(part == count - 1 ? index + 1 : index); }
}
Flush();
}
else
{
int start = 0, spaceStart = -1, spaceEnd = -1;
for (int index = 0; index <= text.Length; index++)
{
if (index < text.Length && !Collapsible(text[index])) continue;
if (index > start)
{
string word = text[start..index];
if (flow.PendingSpace && flow.HasContent) AddText(box, flow, " ", !noWrap,
sourceOffsets: [Math.Max(0, spaceStart), Math.Max(0, spaceEnd)]);
flow.PendingSpace = false; spaceStart = spaceEnd = -1;
AddText(box, flow, word, !noWrap, start, index - start);
}
if (index < text.Length) { flow.PendingSpace = true; if (spaceStart < 0) spaceStart = index; spaceEnd = index + 1; }
start = index + 1;
}
}
if(flow.MeasureOnly)return;
BrowserRect union = default; foreach (var rect in box.fragments) union = union.Union(rect);
if (box.fragments.Count == 0) union = new(initialX, initialY, 0, box.Style.LineHeight);
box.ContentBox = box.PaddingBox = box.BorderBox = box.MarginBox = union;
}
private static bool Collapsible(char value) => value is ' ' or '\r' or '\n' or '\t' or '\f';
private static string? ControlValue(INode node) => node switch
{
IHtmlTextAreaElement area => area.Value,
IHtmlInputElement input when input.Type is "checkbox" or "radio" => input.IsChecked ? "x" : "",
IHtmlInputElement input when input.Type == "password" => new string('*', input.Value.Length),
IHtmlInputElement input => input.Value.Length == 0 ? input.GetAttribute("placeholder") ?? "" : input.Value,
_ => null
};
private double LayoutControl(BrowserLayoutBox box, string value)
{
var flow = new Flow(box.ContentBox.X, box.ContentBox.Y, box.ContentBox.Width, box.Style.LineHeight);
var lines = value.Replace("\r", "").Replace("\t", " ").Split('\n');
for (int line = 0; line < lines.Length; line++)
{
if (line > 0) flow.NewLine(box.Style.LineHeight);
if (lines[line].Length > 0) AddText(box, flow, lines[line], box.Node is IHtmlTextAreaElement && box.Style.WhiteSpace != "pre");
}
return Math.Max(box.Style.LineHeight, flow.Y - box.ContentBox.Y + flow.LineHeight);
}
private double LayoutGenerated(BrowserLayoutBox box)
{var content=box.ContentBox;var flow=new Flow(content.X,content.Y,content.Width,box.Style.LineHeight);FlowText(box,flow);box.ContentBox=content;return Math.Max(0,flow.Y-content.Y+(flow.HasContent?flow.LineHeight:0));}
private static void ApplyInlineVerticalAlign(BrowserLayoutBox box,double lineHeight)
{if(box.Style.Length("vertical-align",lineHeight) is { } shift&&shift!=0)Translate(box,0,-shift);}
private void AddText(BrowserLayoutBox box, Flow flow, string text, bool wrap, int sourceStart = -1, int sourceLength = -1, int[]? sourceOffsets = null)
{
double width = metrics.Measure(text, box.Style.Font);
if (!double.IsFinite(width) || width < 0 || width > 1000000) throw new BrowserLimitException("Text metrics returned an invalid or excessive width.");
bool breakWord=box.Style.Get("overflow-wrap","normal") is "anywhere" or "break-word" || box.Style.Get("word-break","normal") == "break-all" || text.Any(character=>character is >= '\u2E80' and <= '\uFFFF');
if(wrap&&breakWord&&text.Length>1&&width>flow.Width&&flow.Width>0)
{
int start=0;
while(start<text.Length)
{
int end=start,fit=start;
double room=flow.StartX+flow.Width-flow.X;
while(end<text.Length)
{
int next=end+1; if(char.IsHighSurrogate(text[end])&&next<text.Length&&char.IsLowSurrogate(text[next]))next++;
double measured=metrics.Measure(text[start..next],box.Style.Font);
if(measured>room)break;
fit=next;end=next;
}
if(fit==start&&flow.HasContent){flow.NewLine(box.Style.LineHeight);continue;}
if(fit==start)fit=start+1+(char.IsHighSurrogate(text[start])&&start+1<text.Length&&char.IsLowSurrogate(text[start+1])?1:0);
AddText(box,flow,text[start..fit],false,sourceStart<0?-1:sourceStart+start,fit-start,
sourceOffsets is null?null:sourceOffsets[start..(fit+1)]);
start=fit;
if(start<text.Length)flow.NewLine(box.Style.LineHeight);
}
return;
}
if (wrap && flow.HasContent && flow.X + width > flow.StartX + flow.Width) flow.NewLine(box.Style.LineHeight);
if (text == " " && !flow.HasContent) return;
var rect = new BrowserRect(flow.X, flow.Y, width, box.Style.LineHeight);
if(!flow.MeasureOnly)
{
box.fragments.Add(rect); box.textCommands.Add(new(BrowserDrawKind.Text, rect, box.NodeId, box.Node,
box.Style.Color.Rgb, box.Style.Color.Alpha, text, box.Style.Font));
if (box.GeneratedPseudo is null&&sourceOffsets is not null) box.sourceRuns.Add(new(box.textCommands.Count - 1, sourceOffsets));
else if (box.GeneratedPseudo is null&&sourceStart >= 0) box.sourceRuns.Add(new(box.textCommands.Count - 1, Enumerable.Range(sourceStart, (sourceLength < 0 ? text.Length : sourceLength) + 1).ToArray()));
}
if (box.textCommands.Count > limits.MaximumDisplayCommands) throw new BrowserLimitException("Text fragment limit reached.");
flow.X += width; flow.LineHeight = Math.Max(flow.LineHeight, box.Style.LineHeight); flow.HasContent = true;
}
private double LayoutFlex(BrowserLayoutBox parent, double? fixedHeight)
{
bool column = parent.Style.Get("flex-direction", "row").StartsWith("column", StringComparison.Ordinal);
var items = parent.children.Where(child => child.Style.Display != "none" &&
!(child.Node.NodeType == NodeType.Text && string.IsNullOrWhiteSpace(child.Node.TextContent)) && !QueueAbsolute(child, parent)).ToList();
if (parent.Style.Get("flex-direction", "row").EndsWith("reverse", StringComparison.Ordinal)) items.Reverse();
items = items.OrderBy(child => CssValue.Number(child.Style.Get("order", "0"), 0)).ToList();
if (parent.Style.Get("flex-wrap", "nowrap") != "nowrap") Note("Flex wrapping is not implemented; one flex line is used.");
double available = column ? fixedHeight ?? 0 : parent.ContentBox.Width;
double gap = parent.Style.Length(column ? "row-gap" : "column-gap", available) ?? parent.Style.Length("gap", available) ?? 0;
var bases = new double[items.Count]; double total = gap * Math.Max(0, items.Count - 1), grow = 0, shrink = 0;
for (int i = 0; i < items.Count; i++)
{
var child = items[i];
var margin = child.Style.Edges("margin", parent.ContentBox.Width); var padding = child.Style.Edges("padding", parent.ContentBox.Width);
var border = child.Style.Edges("border", parent.ContentBox.Width, true);
double extra = column ? padding.Vertical + border.Vertical : padding.Horizontal + border.Horizontal;
bases[i] = child.Node.NodeType != NodeType.Text && child.Style.Length("flex-basis", available) is { } basis
? Math.Max(0, basis + (column ? margin.Vertical : margin.Horizontal) + (child.Style.BoxSizing == "border-box" ? 0 : extra))
: column ? IntrinsicHeight(child,parent.ContentBox.Width) : IntrinsicWidth(child, parent.ContentBox.Width);
total += bases[i]; grow += CssValue.Number(child.Style.Get("flex-grow", "0"), 0);
shrink += CssValue.Number(child.Style.Get("flex-shrink", "1"), 1) * bases[i];
}
if (column && fixedHeight is null) available = total;
double free = available - total, used = 0, cross = 0;
for (int i = 0; i < items.Count; i++)
{
var child = items[i]; double target = bases[i];
if (free > 0 && grow > 0) target += free * CssValue.Number(child.Style.Get("flex-grow", "0"), 0) / grow;
else if (free < 0 && shrink > 0) target += free * CssValue.Number(child.Style.Get("flex-shrink", "1"), 1) * bases[i] / shrink;
target = Math.Max(0, target);
var margin = child.Style.Edges("margin", parent.ContentBox.Width); var padding = child.Style.Edges("padding", parent.ContentBox.Width);
var border = child.Style.Edges("border", parent.ContentBox.Width, true);
double? width = column ? null : Math.Max(0, target - margin.Horizontal - padding.Horizontal - border.Horizontal);
double? height = column ? Math.Max(0, target - margin.Vertical - padding.Vertical - border.Vertical) : null;
string align = child.Style.Get("align-self", parent.Style.Get("align-items", "stretch"));
if (!column && align == "stretch" && fixedHeight is { } h && child.Style.Length("height", h) is null)
height = Math.Max(0, h - margin.Vertical - padding.Vertical - border.Vertical);
if (child.Node.NodeType == NodeType.Text)
{
var flow = new Flow(parent.ContentBox.X + (column ? 0 : used), parent.ContentBox.Y + (column ? used : 0), width ?? parent.ContentBox.Width, child.Style.LineHeight);
FlowText(child, flow);
}
else LayoutBlock(child, parent.ContentBox.X + (column ? 0 : used), parent.ContentBox.Y + (column ? used : 0),
parent.ContentBox.Width, fixedHeight ?? 0, width, height);
used += (column ? child.MarginBox.Height : child.MarginBox.Width) + gap;
cross = Math.Max(cross, column ? child.MarginBox.Width : child.MarginBox.Height);
}
used -= items.Count > 0 ? gap : 0;
double remaining = Math.Max(0, available - used);
string justify = parent.Style.Get("justify-content", "flex-start");
double offset = justify == "center" ? remaining / 2 : justify == "flex-end" ? remaining : justify == "space-around" && items.Count > 0 ? remaining / items.Count / 2 :
justify == "space-evenly" ? remaining / (items.Count + 1) : 0;
double extraGap = justify == "space-between" && items.Count > 1 ? remaining / (items.Count - 1) :
justify == "space-around" && items.Count > 0 ? remaining / items.Count : justify == "space-evenly" ? remaining / (items.Count + 1) : 0;
for (int i = 0; i < items.Count; i++)
{
var child = items[i]; string align = child.Style.Get("align-self", parent.Style.Get("align-items", "stretch"));
double crossSpace = Math.Max(0, (column ? parent.ContentBox.Width : fixedHeight ?? cross) - (column ? child.MarginBox.Width : child.MarginBox.Height));
double crossOffset = align == "center" ? crossSpace / 2 : align is "flex-end" or "end" ? crossSpace : 0;
if (!column && align == "stretch" && fixedHeight is null && child.Node is IElement && child.Style.Length("height", cross) is null)
UpdateRectangles(child, child.ContentBox with { Height = Math.Max(0, cross - child.Margin.Vertical - child.Border.Vertical - child.Padding.Vertical) });
Translate(child, column ? crossOffset : offset + i * extraGap, column ? offset + i * extraGap : crossOffset);
}
return column ? used + remaining : fixedHeight ?? cross;
}
private double IntrinsicWidth(BrowserLayoutBox box, double available)
{
if (box.Node.NodeType == NodeType.Text) return Math.Min(1000000, metrics.Measure(box.Node.TextContent, box.Style.Font));
double extras = box.Style.Edges("padding", available).Horizontal + box.Style.Edges("border", available, true).Horizontal;
double margin = box.Style.Edges("margin", available).Horizontal;
if (box.Style.Length("width", available) is { } width) return Math.Max(0, width + margin + (box.Style.BoxSizing == "border-box" ? 0 : extras));
if(box.GeneratedText is not null)return Math.Min(1000000,metrics.Measure(box.GeneratedText,box.Style.Font)+extras+margin);
if (ControlValue(box.Node) is not null) return (box.Node is IHtmlInputElement ? 160 : 320) + extras + margin;
double measured = 0;
foreach (var child in box.children.Where(child => child.Style.Display != "none" && child.Style.Position is not ("absolute" or "fixed")))
measured = child.Style.Display is "block" or "list-item" or "table" or "table-row" or "table-row-group" ? Math.Max(measured, IntrinsicWidth(child, available)) : measured + IntrinsicWidth(child, available);
return Math.Min(1000000, measured + extras + margin);
}
private double IntrinsicHeight(BrowserLayoutBox box,double? availableWidth=null)
{
if (box.Node.NodeType == NodeType.Text) return box.Style.LineHeight;
double basis=availableWidth??viewport.Width;
var padding=box.Style.Edges("padding",basis);var border=box.Style.Edges("border",basis,true);var margin=box.Style.Edges("margin",basis);
double extras = padding.Vertical+border.Vertical;
double width=box.Style.Length("width",basis) is { } w?w-(box.Style.BoxSizing=="border-box"?padding.Horizontal+border.Horizontal:0):Math.Max(0,basis-padding.Horizontal-border.Horizontal-margin.Horizontal);
double? declared = box.Style.Length("height", viewport.Height);
double height = declared is { } h ? Math.Max(0, h - (box.Style.BoxSizing == "border-box" ? extras : 0)) :
MeasureIntrinsicContent(box,width);
return Constrain(box.Style,height,"height",viewport.Height,extras)+margin.Vertical+extras;
}
private double MeasureIntrinsicContent(BrowserLayoutBox box,double width)
{
if(box.GeneratedText is not null){var generated=new Flow(0,0,width,box.Style.LineHeight,true);FlowText(box,generated);return generated.Y+(generated.HasContent?generated.LineHeight:0);}
var children=box.children.Where(child=>child.Style.Display!="none"&&child.Style.Position is not ("absolute" or "fixed")).ToArray();
if(box.Style.Display is "flex" or "inline-flex")
{
bool column=box.Style.Get("flex-direction","row").StartsWith("column",StringComparison.Ordinal);
var heights=children.Where(child=>child.Node.NodeType!=NodeType.Text||!string.IsNullOrWhiteSpace(child.Node.TextContent)).Select(child=>IntrinsicHeight(child,width)).ToArray();
return column?heights.Sum()+Math.Max(0,heights.Length-1)*(box.Style.Length("row-gap",width)??0):heights.DefaultIfEmpty().Max();
}
var flow=new Flow(0,0,width,box.Style.LineHeight,true);double? previousBottom=null;
if(ControlValue(box.Node) is { } value)
{foreach(string line in value.Replace("\r","").Split('\n')){if(flow.HasContent)flow.NewLine(box.Style.LineHeight);if(line.Length>0)AddText(box,flow,line,box.Style.WhiteSpace!="pre");}return Math.Max(box.Style.LineHeight,flow.Y+flow.LineHeight);}
foreach(var child in children)
{
if(child.Node.NodeType==NodeType.Text){FlowText(child,flow);if(flow.HasContent)previousBottom=null;continue;}
if(child.Element?.LocalName=="br"){flow.NewLine(box.Style.LineHeight);previousBottom=null;continue;}
if(child.Style.Display=="inline"){FlowInline(child,flow);if(flow.HasContent)previousBottom=null;continue;}
if(child.Style.Display is "inline-block" or "inline-flex"){MeasureAtomicInline(child,flow);previousBottom=null;continue;}
if(flow.HasContent)flow.NewLine(box.Style.LineHeight);
var margin=child.Style.Edges("margin",width);
if(previousBottom is { } previous){double collapsed=Math.Max(Math.Max(previous,0),Math.Max(margin.Top,0))+Math.Min(Math.Min(previous,0),Math.Min(margin.Top,0));flow.Y-=previous+margin.Top-collapsed;}
flow.Y+=IntrinsicHeight(child,width);flow.X=flow.StartX;flow.LineHeight=box.Style.LineHeight;previousBottom=margin.Bottom;
}
return Math.Max(0,flow.Y+(flow.HasContent?flow.LineHeight:0));
}
private void MeasureAtomicInline(BrowserLayoutBox box,Flow flow)
{
double width=IntrinsicWidth(box,flow.Width),height=IntrinsicHeight(box,width);
if(flow.HasContent&&flow.X+width>flow.StartX+flow.Width)flow.NewLine(box.Style.LineHeight);
flow.X+=width;flow.LineHeight=Math.Max(flow.LineHeight,height);flow.HasContent=true;
}
private double LayoutTable(BrowserLayoutBox table)
{
var rows = new List<BrowserLayoutBox>();
foreach (var child in table.children)
{
if (child.Style.Display == "none") continue;
if (child.Style.Display == "table-row") rows.Add(child);
else if (child.Style.Display == "table-row-group") rows.AddRange(child.children.Where(row => row.Style.Display == "table-row"));
}
int columns = rows.Select(row => row.children.Count(cell => cell.Style.Display == "table-cell")).DefaultIfEmpty().Max();
if (columns > 128) throw new BrowserLimitException("CSS table column bound reached.");
if (columns == 0) return 0;
var widths = new double[columns];
foreach (var row in rows)
{
int column = 0;
foreach (var cell in row.children.Where(cell => cell.Style.Display == "table-cell"))
{
if (cell.Element?.HasAttribute("colspan") == true || cell.Element?.HasAttribute("rowspan") == true) Note("Table row/column spans are not implemented.");
widths[column] = Math.Max(widths[column], IntrinsicWidth(cell, table.ContentBox.Width)); column++;
}
}
bool collapse = table.Style.Get("border-collapse", "separate") == "collapse";
var overlaps = new double[Math.Max(0,columns-1)];
if(collapse)
foreach(var row in rows)
{
var cells=row.children.Where(cell=>cell.Style.Display=="table-cell").ToArray();
for(int i=0;i+1<cells.Length;i++)overlaps[i]=Math.Max(overlaps[i],Math.Min(cells[i].Style.Edges("border",table.ContentBox.Width,true).Right,cells[i+1].Style.Edges("border",table.ContentBox.Width,true).Left));
}
string[] spacing = CssValue.Split(table.Style.Get("border-spacing", "2px"), ' ').ToArray();
double sx = collapse ? 0 : CssValue.Length(spacing.FirstOrDefault() ?? "2px", table.ContentBox.Width, table.Style.Font.Size, viewport, 16) ?? 2;
double sy = collapse ? 0 : CssValue.Length(spacing.LastOrDefault() ?? "2px", table.ContentBox.Width, table.Style.Font.Size, viewport, 16) ?? 2;
double total = widths.Sum(), available = Math.Max(0, table.ContentBox.Width - sx * (columns + 1) + overlaps.Sum());
bool intrinsic = table.Style.Get("width", "auto") is "max-content" or "fit-content" or "auto";
if (total < available && !intrinsic) for (int i = 0; i < widths.Length; i++) widths[i] += (available - total) / columns;
else if (total > available && total > 0) for (int i = 0; i < widths.Length; i++) widths[i] *= available / total;
double y = table.ContentBox.Y + sy;
double previousBottomBorder=0;
foreach (var row in rows)
{
var cells=row.children.Where(cell=>cell.Style.Display=="table-cell").ToArray();
double topBorder=cells.Select(cell=>cell.Style.Edges("border",table.ContentBox.Width,true).Top).DefaultIfEmpty().Max();
if(collapse)y-=Math.Min(previousBottomBorder,topBorder);
double x = table.ContentBox.X + sx, height = 0; int column = 0;
foreach (var cell in cells)
{
double extra = cell.Style.Edges("padding", table.ContentBox.Width).Horizontal + cell.Style.Edges("border", table.ContentBox.Width, true).Horizontal;
LayoutBlock(cell, x, y, widths[column], 0, Math.Max(0, widths[column] - extra), null);
height = Math.Max(height, cell.MarginBox.Height); x += widths[column] + sx - (column<overlaps.Length?overlaps[column]:0);column++;
}
foreach (var cell in row.children.Where(cell => cell.Style.Display == "table-cell"))
UpdateRectangles(cell, cell.ContentBox with { Height = Math.Max(0, height - cell.Padding.Vertical - cell.Border.Vertical - cell.Margin.Vertical) });
UpdateRectangles(row, new(table.ContentBox.X, y, widths.Sum() + sx * (columns + 1) - overlaps.Sum(), height)); y += height + sy;
previousBottomBorder=cells.Select(cell=>cell.Border.Bottom).DefaultIfEmpty().Max();
}
foreach (var group in table.children.Where(group => group.Style.Display == "table-row-group"))
{
BrowserRect union = default; foreach (var row in group.children.Where(row => row.Style.Display == "table-row")) union = union.Union(row.BorderBox);
UpdateRectangles(group, union);
}
if (intrinsic) table.ContentBox = table.ContentBox with { Width = widths.Sum() + sx * (columns + 1) - overlaps.Sum() };
return y - table.ContentBox.Y;
}
private bool QueueAbsolute(BrowserLayoutBox child, BrowserLayoutBox parent)
{
if (child.Node.NodeType == NodeType.Text || child.Style.Position is not ("absolute" or "fixed")) return false;
BrowserLayoutBox? anchor = parent; while (anchor is not null && anchor.Style.Position == "static" && anchor.Style.Get("transform", "none") == "none") anchor = anchor.Parent;
pending.Add(new(child, parent, child.Style.Position == "fixed" ? null : anchor)); return true;
}
private void LayoutAbsolute(PendingAbsolute item)
{
var box = item.Box;
BrowserRect containing = item.Anchor?.PaddingBox ?? new(box.Style.Position == "fixed" ? viewport.ScrollX : 0,
box.Style.Position == "fixed" ? viewport.ScrollY : 0, viewport.Width, viewport.Height);
double? left = box.Style.Length("left", containing.Width), right = box.Style.Length("right", containing.Width);
double? top = box.Style.Length("top", containing.Height), bottom = box.Style.Length("bottom", containing.Height);
var margin = box.Style.Edges("margin", containing.Width); var pad = box.Style.Edges("padding", containing.Width); var border = box.Style.Edges("border", containing.Width, true);
double? width = box.Style.Length("width", containing.Width) is null ? Math.Max(0, (left is not null && right is not null ? containing.Width - left.Value - right.Value : IntrinsicWidth(box, containing.Width)) - margin.Horizontal - pad.Horizontal - border.Horizontal) : null;
double? height = box.Style.Length("height", containing.Height) is null && top is not null && bottom is not null ?
Math.Max(0, containing.Height - top.Value - bottom.Value - margin.Vertical - pad.Vertical - border.Vertical) : null;
LayoutBlock(box, containing.X + (left ?? 0), containing.Y + (top ?? 0), containing.Width, containing.Height, width, height);
if (left is null && right is not null) Translate(box, containing.Right - right.Value - box.MarginBox.Right, 0);
if (top is null && bottom is not null) Translate(box, 0, containing.Bottom - bottom.Value - box.MarginBox.Bottom);
}
private static void Translate(BrowserLayoutBox box, double x, double y)
{
box.ContentBox = box.ContentBox.Translate(x, y); box.PaddingBox = box.PaddingBox.Translate(x, y);
box.BorderBox = box.BorderBox.Translate(x, y); box.MarginBox = box.MarginBox.Translate(x, y);
for (int index = 0; index < box.fragments.Count; index++) box.fragments[index] = box.fragments[index].Translate(x, y);
for (int index = 0; index < box.textCommands.Count; index++) box.textCommands[index] = box.textCommands[index] with { Bounds = box.textCommands[index].Bounds.Translate(x, y) };
foreach (var child in box.children) Translate(child, x, y);
}
private void ApplyTranslations(BrowserLayoutBox box)
{
if (box.Node is not IElement || box.Style.Display == "none") return;
string transform = box.Style.Get("transform", "none").Trim().ToLowerInvariant();
double x = 0, y = 0;
for (int offset = 0; offset < transform.Length && transform != "none";)
{
int open = transform.IndexOf('(', offset); if (open < 0) { Note("Unsupported CSS transform: " + transform); break; }
string name = transform[offset..open].Trim(); int depth = 1, close = open + 1;
while (close < transform.Length && depth > 0) { if (transform[close] == '(') depth++; else if (transform[close] == ')') depth--; close++; }
if (depth != 0) { Note("Malformed CSS transform ignored"); break; }
string arguments = transform[(open + 1)..(close - 1)]; var pieces = new List<string>(); int start = 0; depth = 0;
for (int index = 0; index <= arguments.Length; index++)
{
if (index == arguments.Length || depth == 0 && (arguments[index] == ',' || char.IsWhiteSpace(arguments[index])))
{ if (index > start) pieces.Add(arguments[start..index].Trim()); start = index + 1; }
else if (arguments[index] == '(') depth++; else if (arguments[index] == ')') depth--;
}
double? Length(int index, double basis) => index >= pieces.Count ? null : CssValue.Length(pieces[index], basis, box.Style.Font.Size, viewport, 16);
double? dx = null, dy = null;
if (name == "translate" && pieces.Count is 1 or 2) { dx = Length(0, box.BorderBox.Width); dy = pieces.Count == 1 ? 0 : Length(1, box.BorderBox.Height); }
else if (name == "translatex" && pieces.Count == 1) { dx = Length(0, box.BorderBox.Width); dy = 0; }
else if (name == "translatey" && pieces.Count == 1) { dx = 0; dy = Length(0, box.BorderBox.Height); }
else if (name == "translate3d" && pieces.Count == 3 && Length(2, 0) == 0) { dx = Length(0, box.BorderBox.Width); dy = Length(1, box.BorderBox.Height); }
else if (name == "matrix" && pieces.Count == 6 && CssValue.Number(pieces[0], 0) == 1 && CssValue.Number(pieces[1], 1) == 0 && CssValue.Number(pieces[2], 1) == 0 && CssValue.Number(pieces[3], 0) == 1)
{ dx = CssValue.Number(pieces[4], 0); dy = CssValue.Number(pieces[5], 0); }
if (dx is null || dy is null) { Note("Unsupported CSS transform; only 2D translations are implemented: " + name); x = y = 0; break; }
x = Math.Clamp(x + dx.Value, -1000000, 1000000); y = Math.Clamp(y + dy.Value, -1000000, 1000000); offset = close;
}
if (x != 0 || y != 0) Translate(box, x, y);
foreach (var child in box.children) ApplyTranslations(child);
}
private void Paint(BrowserLayoutBox box, BrowserRect? inheritedClip, double parentOpacity, IReadOnlyList<BrowserRoundedClip>? roundedClips = null)
{
if (box.Style.Display == "none") return;
double opacity = parentOpacity * (box.Node is IElement ? box.Style.Opacity : 1); box.EffectiveOpacity = opacity; box.Clip = inheritedClip; box.roundedClips=roundedClips; box.IsLaidOut = true; painted.Add(box);
if (box.Style.Visibility != "hidden" && opacity > 0)
{
if (box.Node is IElement)
{
IEnumerable<BrowserRect> rectangles = box.Style.Display == "inline" && box.fragments.Count > 0 ? box.fragments : [box.BorderBox];
bool decorated = false;
foreach (var rect in rectangles)
{
var decoration = BrowserBoxDecoration.Create(box.Style, rect, box.Border);
if (decoration is not null)
{
decorated = true;
if (ReferenceEquals(canvasBackgroundOwner, box)) decoration = decoration with { Background = new(0, 0) };
if (decoration.Shadows.Any(shadow => shadow.Inset)) Note("Inset box shadows are retained but not painted.");
Command(new(BrowserDrawKind.Fill, decoration.PaintBounds, box.NodeId, box.Node, Opacity: opacity, Decoration: decoration));
}
else if (box.Style.Background.Alpha > 0 && !ReferenceEquals(canvasBackgroundOwner, box))
Command(new(BrowserDrawKind.Fill, rect, box.NodeId, box.Node, box.Style.Background.Rgb, opacity * box.Style.Background.Alpha));
}
if (!decorated) PaintBorder(box, opacity);
if (box.Style.Get("background-image", "none") != "none" && BrowserCssGradient.Parse(box.Style.Get("background-image", "none"),box.Style).Count == 0) Note("Unsupported CSS background image retained but not painted.");
PaintListMarker(box, opacity);
if (box.Node is IHtmlCanvasElement) Command(new(BrowserDrawKind.Canvas, box.ContentBox, box.NodeId, box.Node, Opacity: opacity));
}
bool control = ControlValue(box.Node) is not null;
if (control) Command(new(BrowserDrawKind.PushClip, box.ContentBox, box.NodeId, box.Node));
foreach (var text in box.textCommands) Command(text with { Opacity = text.Opacity * opacity });
if (control) Command(new(BrowserDrawKind.PopClip, default, box.NodeId, box.Node));
}
BrowserRect? clip = inheritedClip;
bool clips = box.Style.OverflowX != "visible" || box.Style.OverflowY != "visible";
if (clips)
{
var area = box.PaddingBox;
if (box.Style.OverflowX == "visible") area = new(-1000000, area.Y, 2000000, area.Height);
if (box.Style.OverflowY == "visible") area = new(area.X, -1000000, area.Width, 2000000);
clip = inheritedClip?.Intersect(area) ?? area;
BrowserRoundedClip? rounded=null;
if(box.Style.OverflowX!="visible"&&box.Style.OverflowY!="visible"&&BrowserBoxDecoration.Create(box.Style,box.BorderBox,box.Border) is { } decoration && decoration.Radius is { } radius && radius.Top+radius.Right+radius.Bottom+radius.Left>0)
{
var inner=new BrowserEdges(Math.Max(0,radius.Top-Math.Max(box.Border.Top,box.Border.Left)),Math.Max(0,radius.Right-Math.Max(box.Border.Top,box.Border.Right)),
Math.Max(0,radius.Bottom-Math.Max(box.Border.Bottom,box.Border.Right)),Math.Max(0,radius.Left-Math.Max(box.Border.Bottom,box.Border.Left)));
rounded=new(box.PaddingBox,inner);
roundedClips=(roundedClips??[]).Append(rounded).ToArray();
}
Command(new(BrowserDrawKind.PushClip, clip.Value, box.NodeId, box.Node,RoundedClip:rounded));
}
foreach (var child in box.children.OrderBy(child => child.Node.NodeType == NodeType.Text ? 0 : child.Style.ZIndex)) Paint(child, clip, opacity,roundedClips);
if (clips) Command(new(BrowserDrawKind.PopClip, default, box.NodeId, box.Node));
}
private void PaintCanvasBackground(BrowserLayoutBox root)
{
// CSS 2 canvas background propagation: root background covers the canvas;
// an HTML root with a transparent background uses its body's background.
// The body can have zero in-flow height when every child is positioned.
canvasBackgroundOwner = root.Style.Background.Alpha > 0 ? root : null;
if (canvasBackgroundOwner is null && root.Element?.LocalName == "html")
canvasBackgroundOwner = root.children.FirstOrDefault(child => child.Element?.LocalName == "body" && child.Style.Display != "none" && child.Style.Background.Alpha > 0);
var rect = new BrowserRect(viewport.ScrollX, viewport.ScrollY, viewport.Width, viewport.Height);
Command(new(BrowserDrawKind.Fill, rect, root.NodeId, root.Node, 0xFFFFFF, ViewportPinned: true));
if (canvasBackgroundOwner is { } owner)
Command(new(BrowserDrawKind.Fill, rect, owner.NodeId, owner.Node, owner.Style.Background.Rgb, owner.Style.Background.Alpha, ViewportPinned: true));
}
private void PaintListMarker(BrowserLayoutBox box, double opacity)
{
if (box.Style.Display != "list-item") return;
var style = box.MarkerStyle ?? box.Style;
string type = box.Style.Get("list-style-type", "disc");
if (type == "none") return;
string marker;
if (type is "decimal" or "lower-alpha" or "upper-alpha")
{
int number = 1;
if (box.Element?.ParentElement is { } parent)
{
bool reversed = parent.HasAttribute("reversed");
int.TryParse(parent.GetAttribute("start"), out int start);
number = parent.HasAttribute("start") ? start : reversed ? parent.Children.Count(child => child.LocalName == "li") : 1;
foreach (var sibling in parent.Children.Where(child => child.LocalName == "li"))
{
if (int.TryParse(sibling.GetAttribute("value"), out int value)) number = value;
if (ReferenceEquals(sibling, box.Element)) break;
number += reversed ? -1 : 1;
}
}
marker = type == "decimal" || number < 1 || number > 26 ? number.ToString(System.Globalization.CultureInfo.InvariantCulture) : ((char)((type == "upper-alpha" ? 'A' : 'a') + number - 1)).ToString();
marker += ".";
}
else marker = type == "circle" ? "◦" : type == "square" ? "▪" : "•";
double width = metrics.Measure(marker, style.Font), gap = style.Font.Size * .45;
Command(new(BrowserDrawKind.Text, new(box.ContentBox.X - width - gap, box.ContentBox.Y, width, style.LineHeight), box.NodeId, box.Node, style.Color.Rgb, style.Color.Alpha * opacity, marker, style.Font));
}
private void PaintBorder(BrowserLayoutBox box, double opacity)
{
var b = box.BorderBox; var widths = box.Border;
var sides = new[] { ("top", new BrowserRect(b.X, b.Y, b.Width, widths.Top)),
("right", new BrowserRect(b.Right - widths.Right, b.Y + widths.Top, widths.Right, Math.Max(0, b.Height - widths.Vertical))),
("bottom", new BrowserRect(b.X, b.Bottom - widths.Bottom, b.Width, widths.Bottom)),
("left", new BrowserRect(b.X, b.Y + widths.Top, widths.Left, Math.Max(0, b.Height - widths.Vertical))) };
foreach (var (side, rect) in sides)
{
if (rect.Width <= 0 || rect.Height <= 0) continue;
var color = CssValue.Color(box.Style.Get("border-" + side + "-color", "currentcolor"), box.Style.Color);
if (box.Style.Get("border-" + side + "-style", "solid") != "solid") Note("Non-solid borders currently use a solid side rectangle.");
Command(new(BrowserDrawKind.Border, rect, box.NodeId, box.Node, color.Rgb, color.Alpha * opacity));
}
}
private void Command(BrowserDrawCommand command)
{
if (commands.Count >= limits.MaximumDisplayCommands) throw new BrowserLimitException("CSS display command limit reached.");
commands.Add(command);
}
private void Note(string message) { if (diagnostics.Count < 256 && !diagnostics.Contains(message)) diagnostics.Add(message); }
}