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, double textIndent = 0)
{
public double X = x + Math.Clamp(textIndent, -100000, 100000), Y = y, StartX = x, Width = width, LineHeight = lineHeight;
private readonly double indent = Math.Clamp(textIndent, -100000, 100000);
private bool firstLine = true;
public readonly double BaseX = x, BaseWidth = width;
public readonly List<(BrowserRect Bounds, string Side)> Floats = [];
public readonly bool MeasureOnly=measureOnly;
public bool HasContent, PendingSpace;
public (double Left, double Right) FloatEdges(double top)
{
double left = BaseX, right = BaseX + BaseWidth;
foreach (var item in Floats)
if (item.Bounds.Top <= top && item.Bounds.Bottom > top)
{ if (item.Side == "left") left = Math.Max(left, item.Bounds.Right); else right = Math.Min(right, item.Bounds.Left); }
return (left, right);
}
public void RefreshEdges()
{
var edges = FloatEdges(Y); StartX = edges.Left; Width = Math.Max(0, edges.Right - edges.Left);
for (int attempt = 0; Width == 0 && !HasContent && attempt < Floats.Count; attempt++)
{
double next = Floats.Where(item => item.Bounds.Top <= Y && item.Bounds.Bottom > Y).Select(item => item.Bounds.Bottom).DefaultIfEmpty(Y).Min();
if (next <= Y) break; Y = next; edges = FloatEdges(Y); StartX = edges.Left; Width = Math.Max(0, edges.Right - edges.Left);
}
X = HasContent ? Math.Max(X, StartX) : StartX + (firstLine ? indent : 0);
}
public void NewLine(double nextHeight)
{ Y += LineHeight; LineHeight = nextHeight; HasContent = false; PendingSpace = false; firstLine = false; RefreshEdges(); }
}
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;
private Func<string, BrowserCanvasSurface?>? imageLookup;
private bool attributeGeneratedContent;
private static readonly HashSet<string> intrinsicAttributes = new(StringComparer.OrdinalIgnoreCase)
{
"style", "class", "id", "hidden", "width", "height", "size", "rows", "cols", "value", "checked", "selected", "disabled", "readonly", "type",
"src", "srcset", "href", "dir", "lang", "contenteditable", "placeholder", "start", "reversed", "span", "colspan", "rowspan", "align", "valign",
"bgcolor", "color", "border", "cellpadding", "cellspacing", "nowrap", "open", "d", "viewbox", "points", "transform", "fill", "fill-rule",
"fill-opacity", "stroke", "stroke-width", "stroke-opacity", "opacity", "x", "y", "x1", "x2", "y1", "y2", "rx", "ry", "r", "cx", "cy"
};
internal bool AttributeAffectsLayout(string name) => intrinsicAttributes.Contains(name) || attributeGeneratedContent || cascade is null || cascade.DependsOnAttribute(name);
public long CssParseGeneration => cascade?.ParseGeneration ?? 0;
public long CssReuseCount => cascade?.ReuseCount ?? 0;
public int CssSelectorEvaluations => cascade?.SelectorEvaluations ?? 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;
internal void ReleaseDocument()
{
all.Clear();painted.Clear();commands.Clear();pending.Clear();diagnostics.Clear();identities.Clear();
currentIdentityNodes.Clear();detachedIdentityNodes.Clear();identityDocument=null;identity=null;canvasBackgroundOwner=null;
metrics=new BrowserFallbackTextMetrics();interaction=new();cascade?.ResetDocument();
}
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,
Func<string, BrowserCanvasSurface?>? imageLookup = 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;
this.imageLookup = imageLookup;
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, nested float exclusions, parent-child margin collapse, 3D/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); }
// Keep explicit cleanup for the optional Legacy runtime as well as
// the independent Microsoft runtime's ordinary exception unwinding.
catch (Exception) { PruneNodeIdentities(); throw; }
attributeGeneratedContent = all.Any(box => box.Style.Get("content", "").Contains("attr(", StringComparison.OrdinalIgnoreCase));
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) { var bounds = box.Transform.Bounds(box.BorderBox); width = Math.Max(width, bounds.Right); height = Math.Max(height, bounds.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);
if (box.Element?.LocalName == "svg" && declared is null)
declared = SvgDimensions(box.Element).Width;
var image = box.Element?.LocalName == "img" ? imageLookup?.Invoke(box.Element.GetAttribute("src") ?? "") : null;
if (box.Element?.LocalName == "img" && declared is null)
declared = double.TryParse(box.Element.GetAttribute("width"), out double attrWidth) ? attrWidth : image?.Width ?? 0;
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 && style.Display is not ("inline-block" or "inline-flex"))
{
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);
if (box.Element?.LocalName == "svg" && availableHeight == 0 && style.Get("height", "auto").Contains('%') && box.Parent?.Style.Length("height", 0) is null)
declaredHeight = null;
if (box.Element?.LocalName == "svg" && declaredHeight is null)
declaredHeight = SvgDimensions(box.Element).Height;
if (box.Element?.LocalName == "img" && declaredHeight is null)
declaredHeight = double.TryParse(box.Element.GetAttribute("height"), out double attrHeight) ? attrHeight : image is { Width: > 0 } ? width * image.Height / image.Width : 0;
double? height = forcedHeight ?? (declaredHeight is { } h ? Math.Max(0, h - (style.BoxSizing == "border-box" ? verticalExtras : 0)) : null);
// Descendants resolve percentage heights against the constrained used
// height, rather than an unconstrained 100% subsequently capped below.
if (height is { } definite) height = Constrain(style, definite, "height", availableHeight, verticalExtras);
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.Element?.LocalName == "svg" ? 0 : 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, textIndent: parent.Style.Length("text-indent", content.Width) ?? 0);
double? previousBottomMargin = null;
foreach (var child in parent.children)
{
if (child.Style.Display == "none") continue;
if (QueueAbsolute(child, parent)) continue;
if (TryFloat(child, flow, fixedHeight ?? 0)) { previousBottomMargin = null; continue; }
string clear = child.Node is IElement ? child.Style.Get("clear", "none") : "none";
if (clear is "left" or "right" or "both")
{
double bottom = flow.Floats.Where(item => clear == "both" || item.Side == clear).Select(item => item.Bounds.Bottom).DefaultIfEmpty(flow.Y).Max();
if (bottom > flow.Y) { if (flow.HasContent) flow.NewLine(parent.Style.LineHeight); flow.Y = Math.Max(flow.Y, bottom); flow.RefreshEdges(); }
}
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 + .000001) flow.NewLine(parent.Style.LineHeight);
LayoutAtomicInline(child, flow.X, flow.Y, content.Width, fixedHeight ?? 0, outer);
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.HasContent = false; flow.RefreshEdges(); flow.LineHeight = parent.Style.LineHeight;
previousBottomMargin=child.Margin.Bottom;
}
AlignInlineLines(parent);
double measured = Math.Max(0, flow.Y - content.Y + (flow.HasContent ? flow.LineHeight : 0));
if (parent.Parent is null || parent.Style.Display is "inline-block" or "inline-flex" ||
parent.Style.Get("float", "none") is "left" or "right" || parent.Style.Position is "absolute" or "fixed" ||
parent.Style.OverflowX != "visible" || parent.Style.OverflowY != "visible")
measured = Math.Max(measured, flow.Floats.Select(item => item.Bounds.Bottom - content.Y).DefaultIfEmpty(0).Max());
return measured;
}
private bool TryFloat(BrowserLayoutBox box, Flow flow, double availableHeight)
{
if (box.Node is not IElement || box.Style.Get("float", "none") is not ("left" or "right") || box.Style.Position is "absolute" or "fixed") return false;
string side = box.Style.Get("float", "none");
if (side == "left" && flow.HasContent) flow.NewLine(box.Style.LineHeight);
double outer = IntrinsicWidth(box, flow.BaseWidth, true);
if (box.Style.Length("width", flow.BaseWidth) is null) outer = Math.Min(flow.BaseWidth, outer);
double y = Math.Max(flow.Y, flow.Floats.Select(item => item.Bounds.Top).DefaultIfEmpty(flow.Y).Max());
string clear = box.Style.Get("clear", "none");
if (clear is "left" or "right" or "both")
y = Math.Max(y, flow.Floats.Where(item => clear == "both" || item.Side == clear).Select(item => item.Bounds.Bottom).DefaultIfEmpty(y).Max());
var edges = flow.FloatEdges(y);
if (flow.HasContent && outer > edges.Right - Math.Max(flow.X, edges.Left)) y += flow.LineHeight;
for (int attempt = 0; attempt <= flow.Floats.Count; attempt++)
{
edges = flow.FloatEdges(y);
if (outer <= edges.Right - edges.Left) break;
double next = flow.Floats.Where(item => item.Bounds.Top <= y && item.Bounds.Bottom > y).Select(item => item.Bounds.Bottom).DefaultIfEmpty(y).Min();
if (next <= y) break; y = next;
}
double x = side == "right" ? edges.Right - outer : edges.Left;
box.ShrinkToFitWidth = box.Style.Length("width", flow.BaseWidth) is null;
LayoutBlock(box, x, y, outer, availableHeight, null, null);
if (side == "right") { double correction = outer - box.MarginBox.Width; Translate(box, correction, 0); x += correction; }
// Relative offsets affect painting, not the exclusion area.
flow.Floats.Add((new(x, y, box.MarginBox.Width, box.MarginBox.Height), side)); flow.RefreshEdges();
return true;
}
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 if (child.Style.Display is "inline-block" or "inline-flex") MeasureAtomicInline(child,flow);
else
{
if (flow.HasContent) flow.NewLine(box.Style.LineHeight);
flow.Y += IntrinsicHeight(child, flow.Width); flow.X = flow.StartX; flow.HasContent = false; flow.LineHeight = box.Style.LineHeight;
}
}
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 (TryFloat(child, flow, 0)) 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
{
if (child.Style.Display is not ("inline-block" or "inline-flex"))
{
if (flow.HasContent) flow.NewLine(box.Style.LineHeight);
LayoutBlock(child, flow.StartX, flow.Y, flow.Width, 0, null, null);
flow.Y += child.MarginBox.Height; flow.X = flow.StartX; flow.LineHeight = box.Style.LineHeight; flow.HasContent = false;
continue;
}
double width = IntrinsicWidth(child, flow.Width);
if (flow.HasContent && flow.X + width > flow.StartX + flow.Width + .000001) flow.NewLine(box.Style.LineHeight);
LayoutAtomicInline(child, flow.X, flow.Y, flow.Width, 0, width);
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, textIndent: box.Style.Length("text-indent", box.ContentBox.Width) ?? 0);
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 + .000001) 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 (column && parent.Style.Get("flex-wrap", "nowrap") != "nowrap") Note("Column flex wrapping is not implemented; one flex column 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, parent.ShrinkToFitWidth);
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;
if (!column && available > 0 && items.Count > 1 && total > available + .000001 && parent.Style.Get("flex-wrap", "nowrap") != "nowrap")
{
var lines = new List<List<BrowserLayoutBox>>(); List<BrowserLayoutBox> line = []; double usedLine = 0;
for (int index = 0; index < items.Count; index++)
{
if (line.Count != 0 && usedLine + gap + bases[index] > available + .000001) { lines.Add(line); line = []; usedLine = 0; }
usedLine += (line.Count == 0 ? 0 : gap) + bases[index]; line.Add(items[index]);
}
if (line.Count != 0) lines.Add(line);
if (lines.Count > 1)
{
if (parent.Style.Get("flex-wrap", "nowrap") == "wrap-reverse") lines.Reverse();
var originalChildren = parent.children.ToArray(); var originalContent = parent.ContentBox;
double crossUsed = 0, rowGap = parent.Style.Length("row-gap", parent.ContentBox.Width) ?? parent.Style.Length("gap", parent.ContentBox.Width) ?? 0;
try
{
foreach (var row in lines)
{
parent.children.Clear(); parent.children.AddRange(row);
parent.ContentBox = originalContent with { Y = originalContent.Y + crossUsed };
crossUsed += LayoutFlex(parent, null) + rowGap;
}
}
catch { parent.children.Clear(); parent.children.AddRange(originalChildren); parent.ContentBox = originalContent; throw; }
parent.children.Clear(); parent.children.AddRange(originalChildren); parent.ContentBox = originalContent;
if (fixedHeight is not null) Note("Wrapped row align-content distribution is not implemented.");
return fixedHeight ?? Math.Max(0, crossUsed - rowGap);
}
}
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 static (double Width, double Height) SvgDimensions(IElement element)
{
var values = (element.GetAttribute("viewBox") ?? "").Split([' ', ',', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
double width = values.Length == 4 ? CssValue.Number(values[2], 300) : 300, height = values.Length == 4 ? CssValue.Number(values[3], 150) : 150;
return (Math.Clamp(CssValue.Number(element.GetAttribute("width") ?? "", width), 0, 4096), Math.Clamp(CssValue.Number(element.GetAttribute("height") ?? "", height), 0, 4096));
}
private double IntrinsicWidth(BrowserLayoutBox box, double available, bool indefinitePercentages = false)
{
if (box.Node.NodeType == NodeType.Text) return MeasureIntrinsicText(box);
double extras = box.Style.Edges("padding", available).Horizontal + box.Style.Edges("border", available, true).Horizontal;
double margin = box.Style.Edges("margin", available).Horizontal;
if ((!indefinitePercentages || !box.Style.Get("width", "auto").Contains('%')) && box.Style.Length("width", available) is { } width) return Math.Max(0, width + margin + (box.Style.BoxSizing == "border-box" ? 0 : extras));
if (box.Element?.LocalName == "svg") return SvgDimensions(box.Element).Width + extras + margin;
if (box.Element?.LocalName == "img") return (imageLookup?.Invoke(box.Element.GetAttribute("src") ?? "")?.Width ?? CssValue.Number(box.Element.GetAttribute("width") ?? "", 0)) + extras + margin;
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, line = 0;
bool flex = box.Style.Display is "flex" or "inline-flex";
bool rowFlex = flex && !box.Style.Get("flex-direction", "row").StartsWith("column", StringComparison.Ordinal);
int count = 0;
foreach (var child in box.children.Where(child => child.Style.Display != "none" && child.Style.Position is not ("absolute" or "fixed")))
{
if (rowFlex && child.Node.NodeType == NodeType.Text && string.IsNullOrWhiteSpace(child.Node.TextContent)) continue;
// An auto intrinsic size cannot supply a definite percentage basis
// to descendants (e.g. a 100% underline inside an inline tab).
// This box's own declared width was resolved above when definite.
double childWidth = IntrinsicWidth(child, available, true);
if (rowFlex) measured += childWidth;
else if (flex || child.Node is IElement && child.Style.Display is not ("inline" or "inline-block" or "inline-flex"))
{ measured = Math.Max(measured, Math.Max(line, childWidth)); line = 0; }
else line += childWidth;
count++;
}
if (rowFlex) measured += Math.Max(0, count - 1) * (box.Style.Length("column-gap", available) ?? box.Style.Length("gap", available) ?? 0);
return Math.Min(1000000, Math.Max(measured, line) + extras + margin);
}
private double MeasureIntrinsicText(BrowserLayoutBox box)
{
string text = box.Node.TextContent;
if (box.Style.WhiteSpace is "pre" or "pre-wrap" or "break-spaces")
return Math.Min(1000000, text.Replace("\r", "").Replace("\t", " ").Split('\n').Select(line => metrics.Measure(line, box.Style.Font)).DefaultIfEmpty().Max());
// FlowText paints words and collapsed spaces as separate glyph runs.
// Shaping/kerning means measuring the unsplit source is not additive.
double width = 0; int start = 0; bool space = false;
for (int index = 0; index <= text.Length; index++)
{
if (index < text.Length && !Collapsible(text[index])) continue;
if (index > start)
{
if (space) width += metrics.Measure(" ", box.Style.Font);
width += metrics.Measure(text[start..index], box.Style.Font); space = false;
}
if (index < text.Length) space = true;
start = index + 1;
}
if (space) width += metrics.Measure(" ", box.Style.Font);
return Math.Min(1000000, width);
}
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);
if (box.Element?.LocalName == "svg" && box.Style.Get("height", "auto").Contains('%') && box.Parent?.Style.Length("height", 0) is null) declared = null;
if (declared is null && box.Element?.LocalName == "svg") declared = SvgDimensions(box.Element).Height;
if (declared is null && box.Element?.LocalName == "img")
{
var image = imageLookup?.Invoke(box.Element.GetAttribute("src") ?? "");
double imageWidth = box.Style.Length("width", basis) ?? CssValue.Number(box.Element.GetAttribute("width") ?? "", image?.Width ?? 0);
declared = CssValue.Number(box.Element.GetAttribute("height") ?? "", image is { Width: > 0 } ? imageWidth * image.Height / image.Width : 0);
}
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,textIndent:box.Style.Length("text-indent",width)??0);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 LayoutAtomicInline(BrowserLayoutBox box, double x, double y, double containingWidth, double containingHeight, double outer)
{
double extras = box.Style.Edges("margin", containingWidth).Horizontal + box.Style.Edges("padding", containingWidth).Horizontal + box.Style.Edges("border", containingWidth, true).Horizontal;
box.ShrinkToFitWidth = box.Style.Length("width", containingWidth) is null;
LayoutBlock(box, x, y, containingWidth, containingHeight, Math.Max(0, outer - extras), null);
}
private void MeasureAtomicInline(BrowserLayoutBox box,Flow flow)
{
double width=IntrinsicWidth(box,flow.Width),height=IntrinsicHeight(box,box.Style.Length("width",flow.Width) is null?width:flow.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);
box.ShrinkToFitWidth = box.Style.Length("width", containing.Width) is null && !(left is not null && right is not null);
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, true)) - 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, BrowserAffineTransform? ancestor = null)
{
if (box.Style.Display == "none") return;
var inherited = ancestor ?? BrowserAffineTransform.Identity;
var local = BrowserAffineTransform.Identity;
// Anonymous text boxes inherit the element's computed style, but do not
// establish an additional transform of their own.
if (box.Node is IElement)
{
string text = box.Style.Get("transform", "none");
if (!BrowserAffineTransform.TryParse(text, box.BorderBox, box.Style, out var parsed))
Note("Unsupported or invalid CSS 2D transform: " + text);
else if (parsed.IsTranslation)
Translate(box, parsed.E, parsed.F); // Retain the allocation-free existing translation path.
else
{
var origin = BrowserAffineTransform.Origin(box.Style, box.BorderBox);
local = BrowserAffineTransform.Translation(origin.X, origin.Y) * parsed * BrowserAffineTransform.Translation(-origin.X, -origin.Y);
}
}
box.Transform = inherited * local;
if (!box.Transform.TryInverse(out _)) { box.PaintSuppressed = true; return; }
foreach (var child in box.children) ApplyTranslations(child, box.Transform);
}
private sealed record StackingLayer(BrowserLayoutBox Box, BrowserRect? Clip, double ParentOpacity,
IReadOnlyList<BrowserRoundedClip>? RoundedClips, bool Context, int Z);
private sealed class StackingGroup
{
public readonly List<StackingLayer> Layers = [];
public readonly HashSet<BrowserLayoutBox> Deferred = [];
}
private static bool StackingContext(BrowserLayoutBox box) => box.Node is IElement &&
(box.Style.Opacity < 1 || box.Style.Get("transform", "none") != "none" || box.Style.Position is "fixed" or "sticky" ||
(box.Style.Position != "static" || box.Parent?.Style.Display is "flex" or "inline-flex" or "grid" or "inline-grid") && box.Style.Get("z-index", "auto") != "auto");
private static BrowserRoundedClip? PositionedClipShape(BrowserLayoutBox box)
{
if (box.Style.Position is not ("absolute" or "fixed")) return null;
string text = box.Style.Get("clip", "auto").Trim();
if (!text.StartsWith("rect(", StringComparison.OrdinalIgnoreCase) || !text.EndsWith(')')) return null;
var parts = text[5..^1].Replace(',', ' ').Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 4) return null;
double[] defaults = [0, box.BorderBox.Width, box.BorderBox.Height, 0], edges = new double[4];
for (int index = 0; index < 4; index++)
{
if (parts[index] == "auto") { edges[index] = defaults[index]; continue; }
if (parts[index].Contains('%') || CssValue.Length(parts[index], 0, box.Style.Font.Size, box.Style.Viewport, 16) is not { } value) return null;
edges[index] = value;
}
return new(new(box.BorderBox.X + edges[3], box.BorderBox.Y + edges[0], Math.Max(0, edges[1] - edges[3]), Math.Max(0, edges[2] - edges[0])), default, box.Transform);
}
private static BrowserRect? PositionedClip(BrowserLayoutBox box) => PositionedClipShape(box) is { } shape ? (shape.Transform ?? BrowserAffineTransform.Identity).Bounds(shape.Bounds) : null;
private static (BrowserRect? Clip, IReadOnlyList<BrowserRoundedClip>? Rounded) ChildClipping(BrowserLayoutBox box,
BrowserRect? inheritedClip, IReadOnlyList<BrowserRoundedClip>? roundedClips, out BrowserRoundedClip? added)
{
added = null;
if (PositionedClip(box) is { } positioned)
{
inheritedClip = inheritedClip?.Intersect(positioned) ?? positioned;
roundedClips = (roundedClips ?? []).Append(PositionedClipShape(box)!).ToArray();
}
if (box.Style.OverflowX == "visible" && box.Style.OverflowY == "visible") return (inheritedClip, roundedClips);
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);
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)));
added = new(box.PaddingBox, inner, box.Transform);
}
if (added is null && !box.Transform.IsIdentity) added = new(area, default, box.Transform);
if (added is not null) roundedClips = (roundedClips ?? []).Append(added).ToArray();
area = box.Transform.Bounds(area);
return (inheritedClip?.Intersect(area) ?? area, roundedClips);
}
private static void CollectStacking(BrowserLayoutBox parent, BrowserRect? clip, double opacity,
IReadOnlyList<BrowserRoundedClip>? rounded, StackingGroup group)
{
if (parent.Element?.LocalName == "svg") return;
foreach (var child in parent.children)
{
if (child.Style.Display == "none" || child.PaintSuppressed) continue;
bool context = StackingContext(child);
if (context || child.Node is IElement && child.Style.Position != "static")
{
int z = child.Style.Position != "static" || child.Parent?.Style.Display is "flex" or "inline-flex" ? child.Style.ZIndex : 0;
var layerClip = PositionedClip(child) is { } positioned ? clip?.Intersect(positioned) ?? positioned : clip;
group.Layers.Add(new(child, layerClip, opacity, rounded, context, z)); group.Deferred.Add(child);
if (context) continue;
}
var next = ChildClipping(child, clip, rounded, out _);
CollectStacking(child, next.Clip, opacity * (child.Node is IElement ? child.Style.Opacity : 1), next.Rounded, group);
}
}
private void PaintLayer(StackingLayer layer, StackingGroup group)
{
// A descendant may escape an ordinary ancestor for stacking, while
// retaining that ancestor's rectangular and rounded overflow clips.
int pushed = 0;
if (layer.Clip is { } clip) { Command(new(BrowserDrawKind.PushClip, clip, layer.Box.NodeId, layer.Box.Node)); pushed++; }
foreach (var rounded in layer.RoundedClips ?? [])
{ Command(new(BrowserDrawKind.PushClip, (rounded.Transform ?? BrowserAffineTransform.Identity).Bounds(rounded.Bounds), layer.Box.NodeId, layer.Box.Node, RoundedClip: rounded)); pushed++; }
Paint(layer.Box, layer.Clip, layer.ParentOpacity, layer.RoundedClips, layer.Context ? null : group);
for (int index = 0; index < pushed; index++) Command(new(BrowserDrawKind.PopClip, default, layer.Box.NodeId, layer.Box.Node));
}
private void Paint(BrowserLayoutBox box, BrowserRect? inheritedClip, double parentOpacity, IReadOnlyList<BrowserRoundedClip>? roundedClips = null,
StackingGroup? group = null, bool contextRoot = false)
{
if (box.Style.Display == "none" || box.PaintSuppressed) return;
if (group is null)
{
var ownClip = PositionedClip(box);
if (ownClip is { } positioned) { inheritedClip = inheritedClip?.Intersect(positioned) ?? positioned; Command(new(BrowserDrawKind.PushClip, inheritedClip.Value, box.NodeId, box.Node, RoundedClip: PositionedClipShape(box))); }
group = new(); var childrenClip = ChildClipping(box, inheritedClip, roundedClips, out _);
CollectStacking(box, childrenClip.Clip, parentOpacity * (box.Node is IElement ? box.Style.Opacity : 1), childrenClip.Rounded, group);
Paint(box, inheritedClip, parentOpacity, roundedClips, group, true);
if (ownClip is not null) Command(box, new(BrowserDrawKind.PopClip, default, box.NodeId, box.Node));
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(box, new(BrowserDrawKind.Fill, decoration.PaintBounds, box.NodeId, box.Node, Opacity: opacity, Decoration: decoration));
}
else if (box.Style.Background.Alpha > 0 && !ReferenceEquals(canvasBackgroundOwner, box))
Command(box, new(BrowserDrawKind.Fill, rect, box.NodeId, box.Node, box.Style.Background.Rgb, opacity * box.Style.Background.Alpha));
}
if (!decorated) PaintBorder(box, opacity);
foreach (string source in BrowserCssImages.Sources(box.Style.Get("background-image", "none")))
{
Command(new(BrowserDrawKind.PushClip, box.Transform.Bounds(box.BorderBox), box.NodeId, box.Node, RoundedClip: box.Transform.IsIdentity ? null : new(box.BorderBox, default, box.Transform)));
Command(box, new(BrowserDrawKind.Image, BrowserCssImages.Size(box.PaddingBox, imageLookup?.Invoke(source), box.Style), box.NodeId, box.Node, Opacity: opacity, ImageSource: source));
Command(box, new(BrowserDrawKind.PopClip, default, box.NodeId, box.Node));
}
if (box.Element?.LocalName == "img" && box.Element.GetAttribute("src") is { Length: > 0 } imageSource)
Command(box, new(BrowserDrawKind.Image, box.ContentBox, box.NodeId, box.Node, Opacity: opacity, ImageSource: imageSource));
PaintListMarker(box, opacity);
if (box.Node is IHtmlCanvasElement) Command(box, new(BrowserDrawKind.Canvas, box.ContentBox, box.NodeId, box.Node, Opacity: opacity));
if (box.Element?.LocalName == "svg")
{
Command(box, new(BrowserDrawKind.Image, box.ContentBox, box.NodeId, box.Node, Opacity: opacity, ImageSource: SvgSource(box)));
return;
}
}
bool control = ControlValue(box.Node) is not null;
if (control) Command(new(BrowserDrawKind.PushClip, box.Transform.Bounds(box.ContentBox), box.NodeId, box.Node, RoundedClip: box.Transform.IsIdentity ? null : new(box.ContentBox, default, box.Transform)));
foreach (var text in box.textCommands) Command(box, text with { Opacity = text.Opacity * opacity });
if (control) Command(box, new(BrowserDrawKind.PopClip, default, box.NodeId, box.Node));
}
var childClipping = ChildClipping(box, inheritedClip, roundedClips, out var rounded);
BrowserRect? clip = childClipping.Clip; roundedClips = childClipping.Rounded;
bool clips = box.Style.OverflowX != "visible" || box.Style.OverflowY != "visible";
if (clips)
{
Command(new(BrowserDrawKind.PushClip, clip!.Value, box.NodeId, box.Node,RoundedClip:rounded));
}
if (contextRoot) foreach (var layer in group.Layers.Where(item => item.Z < 0).OrderBy(item => item.Z)) PaintLayer(layer, group);
foreach (var child in box.children) if (!group.Deferred.Contains(child)) Paint(child, clip, opacity, roundedClips, group);
if (contextRoot) foreach (var layer in group.Layers.Where(item => item.Z >= 0).OrderBy(item => item.Z)) PaintLayer(layer, group);
if (clips) Command(box, 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 string SvgSource(BrowserLayoutBox box)
{
var clone = (IElement)box.Element!.Clone(true);
void Style(BrowserLayoutBox source, IElement target)
{
foreach (string property in new[] { "fill", "stroke", "stroke-width", "fill-rule", "fill-opacity", "stroke-opacity", "stroke-linecap", "stroke-linejoin", "color", "visibility" })
if (source.Style.Properties.TryGetValue(property, out string? value)) target.SetAttribute(property, value);
// The display command already includes the root's opacity.
if (!ReferenceEquals(source, box)) target.SetAttribute("opacity", source.Style.Opacity.ToString(System.Globalization.CultureInfo.InvariantCulture));
var children = source.children.Where(child => child.Node is IElement && child.GeneratedPseudo is null).ToArray();
for (int index = 0; index < Math.Min(children.Length, target.Children.Length); index++) Style(children[index], target.Children[index]);
}
Style(box, clone);
// A use element may reference a symbol in another inline SVG. Freeze
// those local definitions into this self-contained image; no URL fetch
// or live document reference crosses the paint publication boundary.
var references = new Queue<string>(clone.QuerySelectorAll("use").Select(node => node.GetAttribute("href") ?? node.GetAttribute("xlink:href") ?? ""));
var visited = new HashSet<string>(StringComparer.Ordinal); int referenceCount = 0;
while (references.TryDequeue(out var reference))
{
if (!reference.StartsWith('#') || !visited.Add(reference)) continue;
if (++referenceCount > 64) throw new BrowserLimitException("SVG local reference budget exceeded.");
if (clone.QuerySelectorAll("[id]").Any(node => node.Id == reference[1..])) continue;
var original = box.Element!.Owner?.GetElementById(reference[1..]);
if (original is null || original.NamespaceUri != "http://www.w3.org/2000/svg") continue;
var definition = (IElement)original.Clone(true);
// The use instance inherits its own color/fill, not the original
// definition's computed inheritance from an unrelated SVG parent.
var definitions = clone.Owner!.CreateElement("http://www.w3.org/2000/svg", "defs");
definitions.AppendChild(definition); clone.AppendChild(definitions);
foreach (var nested in definition.QuerySelectorAll("use")) references.Enqueue(nested.GetAttribute("href") ?? nested.GetAttribute("xlink:href") ?? "");
}
clone.SetAttribute("width", CssValue.Number(box.ContentBox.Width.ToString(System.Globalization.CultureInfo.InvariantCulture), 1).ToString(System.Globalization.CultureInfo.InvariantCulture));
clone.SetAttribute("height", box.ContentBox.Height.ToString(System.Globalization.CultureInfo.InvariantCulture));
clone.RemoveAttribute("style"); clone.RemoveAttribute("opacity");
return "data:image/svg+xml," + Uri.EscapeDataString(clone.OuterHtml);
}
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(box, 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(box, new(BrowserDrawKind.Border, rect, box.NodeId, box.Node, color.Rgb, color.Alpha * opacity));
}
}
private void Command(BrowserLayoutBox box, BrowserDrawCommand command) => Command(command with { Transform = box.Transform.IsIdentity ? null : box.Transform });
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, double textIndent = 0)
{
public double X = x + Math.Clamp(textIndent, -100000, 100000), Y = y, StartX = x, Width = width, LineHeight = lineHeight;
private readonly double indent = Math.Clamp(textIndent, -100000, 100000);
private bool firstLine = true;
public readonly double BaseX = x, BaseWidth = width;
public readonly List<(BrowserRect Bounds, string Side)> Floats = [];
public readonly bool MeasureOnly=measureOnly;
public bool HasContent, PendingSpace;
public (double Left, double Right) FloatEdges(double top)
{
double left = BaseX, right = BaseX + BaseWidth;
foreach (var item in Floats)
if (item.Bounds.Top <= top && item.Bounds.Bottom > top)
{ if (item.Side == "left") left = Math.Max(left, item.Bounds.Right); else right = Math.Min(right, item.Bounds.Left); }
return (left, right);
}
public void RefreshEdges()
{
var edges = FloatEdges(Y); StartX = edges.Left; Width = Math.Max(0, edges.Right - edges.Left);
for (int attempt = 0; Width == 0 && !HasContent && attempt < Floats.Count; attempt++)
{
double next = Floats.Where(item => item.Bounds.Top <= Y && item.Bounds.Bottom > Y).Select(item => item.Bounds.Bottom).DefaultIfEmpty(Y).Min();
if (next <= Y) break; Y = next; edges = FloatEdges(Y); StartX = edges.Left; Width = Math.Max(0, edges.Right - edges.Left);
}
X = HasContent ? Math.Max(X, StartX) : StartX + (firstLine ? indent : 0);
}
public void NewLine(double nextHeight)
{ Y += LineHeight; LineHeight = nextHeight; HasContent = false; PendingSpace = false; firstLine = false; RefreshEdges(); }
}
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;
private Func<string, BrowserCanvasSurface?>? imageLookup;
private bool attributeGeneratedContent;
private static readonly HashSet<string> intrinsicAttributes = new(StringComparer.OrdinalIgnoreCase)
{
"style", "class", "id", "hidden", "width", "height", "size", "rows", "cols", "value", "checked", "selected", "disabled", "readonly", "type",
"src", "srcset", "href", "dir", "lang", "contenteditable", "placeholder", "start", "reversed", "span", "colspan", "rowspan", "align", "valign",
"bgcolor", "color", "border", "cellpadding", "cellspacing", "nowrap", "open", "d", "viewbox", "points", "transform", "fill", "fill-rule",
"fill-opacity", "stroke", "stroke-width", "stroke-opacity", "opacity", "x", "y", "x1", "x2", "y1", "y2", "rx", "ry", "r", "cx", "cy"
};
internal bool AttributeAffectsLayout(string name) => intrinsicAttributes.Contains(name) || attributeGeneratedContent || cascade is null || cascade.DependsOnAttribute(name);
public long CssParseGeneration => cascade?.ParseGeneration ?? 0;
public long CssReuseCount => cascade?.ReuseCount ?? 0;
public int CssSelectorEvaluations => cascade?.SelectorEvaluations ?? 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;
internal void ReleaseDocument()
{
all.Clear();painted.Clear();commands.Clear();pending.Clear();diagnostics.Clear();identities.Clear();
currentIdentityNodes.Clear();detachedIdentityNodes.Clear();identityDocument=null;identity=null;canvasBackgroundOwner=null;
metrics=new BrowserFallbackTextMetrics();interaction=new();cascade?.ResetDocument();
}
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,
Func<string, BrowserCanvasSurface?>? imageLookup = 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;
this.imageLookup = imageLookup;
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, nested float exclusions, parent-child margin collapse, 3D/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); }
// Keep explicit cleanup for the optional Legacy runtime as well as
// the independent Microsoft runtime's ordinary exception unwinding.
catch (Exception) { PruneNodeIdentities(); throw; }
attributeGeneratedContent = all.Any(box => box.Style.Get("content", "").Contains("attr(", StringComparison.OrdinalIgnoreCase));
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) { var bounds = box.Transform.Bounds(box.BorderBox); width = Math.Max(width, bounds.Right); height = Math.Max(height, bounds.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);
if (box.Element?.LocalName == "svg" && declared is null)
declared = SvgDimensions(box.Element).Width;
var image = box.Element?.LocalName == "img" ? imageLookup?.Invoke(box.Element.GetAttribute("src") ?? "") : null;
if (box.Element?.LocalName == "img" && declared is null)
declared = double.TryParse(box.Element.GetAttribute("width"), out double attrWidth) ? attrWidth : image?.Width ?? 0;
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 && style.Display is not ("inline-block" or "inline-flex"))
{
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);
if (box.Element?.LocalName == "svg" && availableHeight == 0 && style.Get("height", "auto").Contains('%') && box.Parent?.Style.Length("height", 0) is null)
declaredHeight = null;
if (box.Element?.LocalName == "svg" && declaredHeight is null)
declaredHeight = SvgDimensions(box.Element).Height;
if (box.Element?.LocalName == "img" && declaredHeight is null)
declaredHeight = double.TryParse(box.Element.GetAttribute("height"), out double attrHeight) ? attrHeight : image is { Width: > 0 } ? width * image.Height / image.Width : 0;
double? height = forcedHeight ?? (declaredHeight is { } h ? Math.Max(0, h - (style.BoxSizing == "border-box" ? verticalExtras : 0)) : null);
// Descendants resolve percentage heights against the constrained used
// height, rather than an unconstrained 100% subsequently capped below.
if (height is { } definite) height = Constrain(style, definite, "height", availableHeight, verticalExtras);
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.Element?.LocalName == "svg" ? 0 : 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, textIndent: parent.Style.Length("text-indent", content.Width) ?? 0);
double? previousBottomMargin = null;
foreach (var child in parent.children)
{
if (child.Style.Display == "none") continue;
if (QueueAbsolute(child, parent)) continue;
if (TryFloat(child, flow, fixedHeight ?? 0)) { previousBottomMargin = null; continue; }
string clear = child.Node is IElement ? child.Style.Get("clear", "none") : "none";
if (clear is "left" or "right" or "both")
{
double bottom = flow.Floats.Where(item => clear == "both" || item.Side == clear).Select(item => item.Bounds.Bottom).DefaultIfEmpty(flow.Y).Max();
if (bottom > flow.Y) { if (flow.HasContent) flow.NewLine(parent.Style.LineHeight); flow.Y = Math.Max(flow.Y, bottom); flow.RefreshEdges(); }
}
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 + .000001) flow.NewLine(parent.Style.LineHeight);
LayoutAtomicInline(child, flow.X, flow.Y, content.Width, fixedHeight ?? 0, outer);
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.HasContent = false; flow.RefreshEdges(); flow.LineHeight = parent.Style.LineHeight;
previousBottomMargin=child.Margin.Bottom;
}
AlignInlineLines(parent);
double measured = Math.Max(0, flow.Y - content.Y + (flow.HasContent ? flow.LineHeight : 0));
if (parent.Parent is null || parent.Style.Display is "inline-block" or "inline-flex" ||
parent.Style.Get("float", "none") is "left" or "right" || parent.Style.Position is "absolute" or "fixed" ||
parent.Style.OverflowX != "visible" || parent.Style.OverflowY != "visible")
measured = Math.Max(measured, flow.Floats.Select(item => item.Bounds.Bottom - content.Y).DefaultIfEmpty(0).Max());
return measured;
}
private bool TryFloat(BrowserLayoutBox box, Flow flow, double availableHeight)
{
if (box.Node is not IElement || box.Style.Get("float", "none") is not ("left" or "right") || box.Style.Position is "absolute" or "fixed") return false;
string side = box.Style.Get("float", "none");
if (side == "left" && flow.HasContent) flow.NewLine(box.Style.LineHeight);
double outer = IntrinsicWidth(box, flow.BaseWidth, true);
if (box.Style.Length("width", flow.BaseWidth) is null) outer = Math.Min(flow.BaseWidth, outer);
double y = Math.Max(flow.Y, flow.Floats.Select(item => item.Bounds.Top).DefaultIfEmpty(flow.Y).Max());
string clear = box.Style.Get("clear", "none");
if (clear is "left" or "right" or "both")
y = Math.Max(y, flow.Floats.Where(item => clear == "both" || item.Side == clear).Select(item => item.Bounds.Bottom).DefaultIfEmpty(y).Max());
var edges = flow.FloatEdges(y);
if (flow.HasContent && outer > edges.Right - Math.Max(flow.X, edges.Left)) y += flow.LineHeight;
for (int attempt = 0; attempt <= flow.Floats.Count; attempt++)
{
edges = flow.FloatEdges(y);
if (outer <= edges.Right - edges.Left) break;
double next = flow.Floats.Where(item => item.Bounds.Top <= y && item.Bounds.Bottom > y).Select(item => item.Bounds.Bottom).DefaultIfEmpty(y).Min();
if (next <= y) break; y = next;
}
double x = side == "right" ? edges.Right - outer : edges.Left;
box.ShrinkToFitWidth = box.Style.Length("width", flow.BaseWidth) is null;
LayoutBlock(box, x, y, outer, availableHeight, null, null);
if (side == "right") { double correction = outer - box.MarginBox.Width; Translate(box, correction, 0); x += correction; }
// Relative offsets affect painting, not the exclusion area.
flow.Floats.Add((new(x, y, box.MarginBox.Width, box.MarginBox.Height), side)); flow.RefreshEdges();
return true;
}
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 if (child.Style.Display is "inline-block" or "inline-flex") MeasureAtomicInline(child,flow);
else
{
if (flow.HasContent) flow.NewLine(box.Style.LineHeight);
flow.Y += IntrinsicHeight(child, flow.Width); flow.X = flow.StartX; flow.HasContent = false; flow.LineHeight = box.Style.LineHeight;
}
}
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 (TryFloat(child, flow, 0)) 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
{
if (child.Style.Display is not ("inline-block" or "inline-flex"))
{
if (flow.HasContent) flow.NewLine(box.Style.LineHeight);
LayoutBlock(child, flow.StartX, flow.Y, flow.Width, 0, null, null);
flow.Y += child.MarginBox.Height; flow.X = flow.StartX; flow.LineHeight = box.Style.LineHeight; flow.HasContent = false;
continue;
}
double width = IntrinsicWidth(child, flow.Width);
if (flow.HasContent && flow.X + width > flow.StartX + flow.Width + .000001) flow.NewLine(box.Style.LineHeight);
LayoutAtomicInline(child, flow.X, flow.Y, flow.Width, 0, width);
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, textIndent: box.Style.Length("text-indent", box.ContentBox.Width) ?? 0);
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 + .000001) 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 (column && parent.Style.Get("flex-wrap", "nowrap") != "nowrap") Note("Column flex wrapping is not implemented; one flex column 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, parent.ShrinkToFitWidth);
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;
if (!column && available > 0 && items.Count > 1 && total > available + .000001 && parent.Style.Get("flex-wrap", "nowrap") != "nowrap")
{
var lines = new List<List<BrowserLayoutBox>>(); List<BrowserLayoutBox> line = []; double usedLine = 0;
for (int index = 0; index < items.Count; index++)
{
if (line.Count != 0 && usedLine + gap + bases[index] > available + .000001) { lines.Add(line); line = []; usedLine = 0; }
usedLine += (line.Count == 0 ? 0 : gap) + bases[index]; line.Add(items[index]);
}
if (line.Count != 0) lines.Add(line);
if (lines.Count > 1)
{
if (parent.Style.Get("flex-wrap", "nowrap") == "wrap-reverse") lines.Reverse();
var originalChildren = parent.children.ToArray(); var originalContent = parent.ContentBox;
double crossUsed = 0, rowGap = parent.Style.Length("row-gap", parent.ContentBox.Width) ?? parent.Style.Length("gap", parent.ContentBox.Width) ?? 0;
try
{
foreach (var row in lines)
{
parent.children.Clear(); parent.children.AddRange(row);
parent.ContentBox = originalContent with { Y = originalContent.Y + crossUsed };
crossUsed += LayoutFlex(parent, null) + rowGap;
}
}
catch { parent.children.Clear(); parent.children.AddRange(originalChildren); parent.ContentBox = originalContent; throw; }
parent.children.Clear(); parent.children.AddRange(originalChildren); parent.ContentBox = originalContent;
if (fixedHeight is not null) Note("Wrapped row align-content distribution is not implemented.");
return fixedHeight ?? Math.Max(0, crossUsed - rowGap);
}
}
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 static (double Width, double Height) SvgDimensions(IElement element)
{
var values = (element.GetAttribute("viewBox") ?? "").Split([' ', ',', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
double width = values.Length == 4 ? CssValue.Number(values[2], 300) : 300, height = values.Length == 4 ? CssValue.Number(values[3], 150) : 150;
return (Math.Clamp(CssValue.Number(element.GetAttribute("width") ?? "", width), 0, 4096), Math.Clamp(CssValue.Number(element.GetAttribute("height") ?? "", height), 0, 4096));
}
private double IntrinsicWidth(BrowserLayoutBox box, double available, bool indefinitePercentages = false)
{
if (box.Node.NodeType == NodeType.Text) return MeasureIntrinsicText(box);
double extras = box.Style.Edges("padding", available).Horizontal + box.Style.Edges("border", available, true).Horizontal;
double margin = box.Style.Edges("margin", available).Horizontal;
if ((!indefinitePercentages || !box.Style.Get("width", "auto").Contains('%')) && box.Style.Length("width", available) is { } width) return Math.Max(0, width + margin + (box.Style.BoxSizing == "border-box" ? 0 : extras));
if (box.Element?.LocalName == "svg") return SvgDimensions(box.Element).Width + extras + margin;
if (box.Element?.LocalName == "img") return (imageLookup?.Invoke(box.Element.GetAttribute("src") ?? "")?.Width ?? CssValue.Number(box.Element.GetAttribute("width") ?? "", 0)) + extras + margin;
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, line = 0;
bool flex = box.Style.Display is "flex" or "inline-flex";
bool rowFlex = flex && !box.Style.Get("flex-direction", "row").StartsWith("column", StringComparison.Ordinal);
int count = 0;
foreach (var child in box.children.Where(child => child.Style.Display != "none" && child.Style.Position is not ("absolute" or "fixed")))
{
if (rowFlex && child.Node.NodeType == NodeType.Text && string.IsNullOrWhiteSpace(child.Node.TextContent)) continue;
// An auto intrinsic size cannot supply a definite percentage basis
// to descendants (e.g. a 100% underline inside an inline tab).
// This box's own declared width was resolved above when definite.
double childWidth = IntrinsicWidth(child, available, true);
if (rowFlex) measured += childWidth;
else if (flex || child.Node is IElement && child.Style.Display is not ("inline" or "inline-block" or "inline-flex"))
{ measured = Math.Max(measured, Math.Max(line, childWidth)); line = 0; }
else line += childWidth;
count++;
}
if (rowFlex) measured += Math.Max(0, count - 1) * (box.Style.Length("column-gap", available) ?? box.Style.Length("gap", available) ?? 0);
return Math.Min(1000000, Math.Max(measured, line) + extras + margin);
}
private double MeasureIntrinsicText(BrowserLayoutBox box)
{
string text = box.Node.TextContent;
if (box.Style.WhiteSpace is "pre" or "pre-wrap" or "break-spaces")
return Math.Min(1000000, text.Replace("\r", "").Replace("\t", " ").Split('\n').Select(line => metrics.Measure(line, box.Style.Font)).DefaultIfEmpty().Max());
// FlowText paints words and collapsed spaces as separate glyph runs.
// Shaping/kerning means measuring the unsplit source is not additive.
double width = 0; int start = 0; bool space = false;
for (int index = 0; index <= text.Length; index++)
{
if (index < text.Length && !Collapsible(text[index])) continue;
if (index > start)
{
if (space) width += metrics.Measure(" ", box.Style.Font);
width += metrics.Measure(text[start..index], box.Style.Font); space = false;
}
if (index < text.Length) space = true;
start = index + 1;
}
if (space) width += metrics.Measure(" ", box.Style.Font);
return Math.Min(1000000, width);
}
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);
if (box.Element?.LocalName == "svg" && box.Style.Get("height", "auto").Contains('%') && box.Parent?.Style.Length("height", 0) is null) declared = null;
if (declared is null && box.Element?.LocalName == "svg") declared = SvgDimensions(box.Element).Height;
if (declared is null && box.Element?.LocalName == "img")
{
var image = imageLookup?.Invoke(box.Element.GetAttribute("src") ?? "");
double imageWidth = box.Style.Length("width", basis) ?? CssValue.Number(box.Element.GetAttribute("width") ?? "", image?.Width ?? 0);
declared = CssValue.Number(box.Element.GetAttribute("height") ?? "", image is { Width: > 0 } ? imageWidth * image.Height / image.Width : 0);
}
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,textIndent:box.Style.Length("text-indent",width)??0);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 LayoutAtomicInline(BrowserLayoutBox box, double x, double y, double containingWidth, double containingHeight, double outer)
{
double extras = box.Style.Edges("margin", containingWidth).Horizontal + box.Style.Edges("padding", containingWidth).Horizontal + box.Style.Edges("border", containingWidth, true).Horizontal;
box.ShrinkToFitWidth = box.Style.Length("width", containingWidth) is null;
LayoutBlock(box, x, y, containingWidth, containingHeight, Math.Max(0, outer - extras), null);
}
private void MeasureAtomicInline(BrowserLayoutBox box,Flow flow)
{
double width=IntrinsicWidth(box,flow.Width),height=IntrinsicHeight(box,box.Style.Length("width",flow.Width) is null?width:flow.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);
box.ShrinkToFitWidth = box.Style.Length("width", containing.Width) is null && !(left is not null && right is not null);
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, true)) - 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, BrowserAffineTransform? ancestor = null)
{
if (box.Style.Display == "none") return;
var inherited = ancestor ?? BrowserAffineTransform.Identity;
var local = BrowserAffineTransform.Identity;
// Anonymous text boxes inherit the element's computed style, but do not
// establish an additional transform of their own.
if (box.Node is IElement)
{
string text = box.Style.Get("transform", "none");
if (!BrowserAffineTransform.TryParse(text, box.BorderBox, box.Style, out var parsed))
Note("Unsupported or invalid CSS 2D transform: " + text);
else if (parsed.IsTranslation)
Translate(box, parsed.E, parsed.F); // Retain the allocation-free existing translation path.
else
{
var origin = BrowserAffineTransform.Origin(box.Style, box.BorderBox);
local = BrowserAffineTransform.Translation(origin.X, origin.Y) * parsed * BrowserAffineTransform.Translation(-origin.X, -origin.Y);
}
}
box.Transform = inherited * local;
if (!box.Transform.TryInverse(out _)) { box.PaintSuppressed = true; return; }
foreach (var child in box.children) ApplyTranslations(child, box.Transform);
}
private sealed record StackingLayer(BrowserLayoutBox Box, BrowserRect? Clip, double ParentOpacity,
IReadOnlyList<BrowserRoundedClip>? RoundedClips, bool Context, int Z);
private sealed class StackingGroup
{
public readonly List<StackingLayer> Layers = [];
public readonly HashSet<BrowserLayoutBox> Deferred = [];
}
private static bool StackingContext(BrowserLayoutBox box) => box.Node is IElement &&
(box.Style.Opacity < 1 || box.Style.Get("transform", "none") != "none" || box.Style.Position is "fixed" or "sticky" ||
(box.Style.Position != "static" || box.Parent?.Style.Display is "flex" or "inline-flex" or "grid" or "inline-grid") && box.Style.Get("z-index", "auto") != "auto");
private static BrowserRoundedClip? PositionedClipShape(BrowserLayoutBox box)
{
if (box.Style.Position is not ("absolute" or "fixed")) return null;
string text = box.Style.Get("clip", "auto").Trim();
if (!text.StartsWith("rect(", StringComparison.OrdinalIgnoreCase) || !text.EndsWith(')')) return null;
var parts = text[5..^1].Replace(',', ' ').Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 4) return null;
double[] defaults = [0, box.BorderBox.Width, box.BorderBox.Height, 0], edges = new double[4];
for (int index = 0; index < 4; index++)
{
if (parts[index] == "auto") { edges[index] = defaults[index]; continue; }
if (parts[index].Contains('%') || CssValue.Length(parts[index], 0, box.Style.Font.Size, box.Style.Viewport, 16) is not { } value) return null;
edges[index] = value;
}
return new(new(box.BorderBox.X + edges[3], box.BorderBox.Y + edges[0], Math.Max(0, edges[1] - edges[3]), Math.Max(0, edges[2] - edges[0])), default, box.Transform);
}
private static BrowserRect? PositionedClip(BrowserLayoutBox box) => PositionedClipShape(box) is { } shape ? (shape.Transform ?? BrowserAffineTransform.Identity).Bounds(shape.Bounds) : null;
private static (BrowserRect? Clip, IReadOnlyList<BrowserRoundedClip>? Rounded) ChildClipping(BrowserLayoutBox box,
BrowserRect? inheritedClip, IReadOnlyList<BrowserRoundedClip>? roundedClips, out BrowserRoundedClip? added)
{
added = null;
if (PositionedClip(box) is { } positioned)
{
inheritedClip = inheritedClip?.Intersect(positioned) ?? positioned;
roundedClips = (roundedClips ?? []).Append(PositionedClipShape(box)!).ToArray();
}
if (box.Style.OverflowX == "visible" && box.Style.OverflowY == "visible") return (inheritedClip, roundedClips);
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);
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)));
added = new(box.PaddingBox, inner, box.Transform);
}
if (added is null && !box.Transform.IsIdentity) added = new(area, default, box.Transform);
if (added is not null) roundedClips = (roundedClips ?? []).Append(added).ToArray();
area = box.Transform.Bounds(area);
return (inheritedClip?.Intersect(area) ?? area, roundedClips);
}
private static void CollectStacking(BrowserLayoutBox parent, BrowserRect? clip, double opacity,
IReadOnlyList<BrowserRoundedClip>? rounded, StackingGroup group)
{
if (parent.Element?.LocalName == "svg") return;
foreach (var child in parent.children)
{
if (child.Style.Display == "none" || child.PaintSuppressed) continue;
bool context = StackingContext(child);
if (context || child.Node is IElement && child.Style.Position != "static")
{
int z = child.Style.Position != "static" || child.Parent?.Style.Display is "flex" or "inline-flex" ? child.Style.ZIndex : 0;
var layerClip = PositionedClip(child) is { } positioned ? clip?.Intersect(positioned) ?? positioned : clip;
group.Layers.Add(new(child, layerClip, opacity, rounded, context, z)); group.Deferred.Add(child);
if (context) continue;
}
var next = ChildClipping(child, clip, rounded, out _);
CollectStacking(child, next.Clip, opacity * (child.Node is IElement ? child.Style.Opacity : 1), next.Rounded, group);
}
}
private void PaintLayer(StackingLayer layer, StackingGroup group)
{
// A descendant may escape an ordinary ancestor for stacking, while
// retaining that ancestor's rectangular and rounded overflow clips.
int pushed = 0;
if (layer.Clip is { } clip) { Command(new(BrowserDrawKind.PushClip, clip, layer.Box.NodeId, layer.Box.Node)); pushed++; }
foreach (var rounded in layer.RoundedClips ?? [])
{ Command(new(BrowserDrawKind.PushClip, (rounded.Transform ?? BrowserAffineTransform.Identity).Bounds(rounded.Bounds), layer.Box.NodeId, layer.Box.Node, RoundedClip: rounded)); pushed++; }
Paint(layer.Box, layer.Clip, layer.ParentOpacity, layer.RoundedClips, layer.Context ? null : group);
for (int index = 0; index < pushed; index++) Command(new(BrowserDrawKind.PopClip, default, layer.Box.NodeId, layer.Box.Node));
}
private void Paint(BrowserLayoutBox box, BrowserRect? inheritedClip, double parentOpacity, IReadOnlyList<BrowserRoundedClip>? roundedClips = null,
StackingGroup? group = null, bool contextRoot = false)
{
if (box.Style.Display == "none" || box.PaintSuppressed) return;
if (group is null)
{
var ownClip = PositionedClip(box);
if (ownClip is { } positioned) { inheritedClip = inheritedClip?.Intersect(positioned) ?? positioned; Command(new(BrowserDrawKind.PushClip, inheritedClip.Value, box.NodeId, box.Node, RoundedClip: PositionedClipShape(box))); }
group = new(); var childrenClip = ChildClipping(box, inheritedClip, roundedClips, out _);
CollectStacking(box, childrenClip.Clip, parentOpacity * (box.Node is IElement ? box.Style.Opacity : 1), childrenClip.Rounded, group);
Paint(box, inheritedClip, parentOpacity, roundedClips, group, true);
if (ownClip is not null) Command(box, new(BrowserDrawKind.PopClip, default, box.NodeId, box.Node));
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(box, new(BrowserDrawKind.Fill, decoration.PaintBounds, box.NodeId, box.Node, Opacity: opacity, Decoration: decoration));
}
else if (box.Style.Background.Alpha > 0 && !ReferenceEquals(canvasBackgroundOwner, box))
Command(box, new(BrowserDrawKind.Fill, rect, box.NodeId, box.Node, box.Style.Background.Rgb, opacity * box.Style.Background.Alpha));
}
if (!decorated) PaintBorder(box, opacity);
foreach (string source in BrowserCssImages.Sources(box.Style.Get("background-image", "none")))
{
Command(new(BrowserDrawKind.PushClip, box.Transform.Bounds(box.BorderBox), box.NodeId, box.Node, RoundedClip: box.Transform.IsIdentity ? null : new(box.BorderBox, default, box.Transform)));
Command(box, new(BrowserDrawKind.Image, BrowserCssImages.Size(box.PaddingBox, imageLookup?.Invoke(source), box.Style), box.NodeId, box.Node, Opacity: opacity, ImageSource: source));
Command(box, new(BrowserDrawKind.PopClip, default, box.NodeId, box.Node));
}
if (box.Element?.LocalName == "img" && box.Element.GetAttribute("src") is { Length: > 0 } imageSource)
Command(box, new(BrowserDrawKind.Image, box.ContentBox, box.NodeId, box.Node, Opacity: opacity, ImageSource: imageSource));
PaintListMarker(box, opacity);
if (box.Node is IHtmlCanvasElement) Command(box, new(BrowserDrawKind.Canvas, box.ContentBox, box.NodeId, box.Node, Opacity: opacity));
if (box.Element?.LocalName == "svg")
{
Command(box, new(BrowserDrawKind.Image, box.ContentBox, box.NodeId, box.Node, Opacity: opacity, ImageSource: SvgSource(box)));
return;
}
}
bool control = ControlValue(box.Node) is not null;
if (control) Command(new(BrowserDrawKind.PushClip, box.Transform.Bounds(box.ContentBox), box.NodeId, box.Node, RoundedClip: box.Transform.IsIdentity ? null : new(box.ContentBox, default, box.Transform)));
foreach (var text in box.textCommands) Command(box, text with { Opacity = text.Opacity * opacity });
if (control) Command(box, new(BrowserDrawKind.PopClip, default, box.NodeId, box.Node));
}
var childClipping = ChildClipping(box, inheritedClip, roundedClips, out var rounded);
BrowserRect? clip = childClipping.Clip; roundedClips = childClipping.Rounded;
bool clips = box.Style.OverflowX != "visible" || box.Style.OverflowY != "visible";
if (clips)
{
Command(new(BrowserDrawKind.PushClip, clip!.Value, box.NodeId, box.Node,RoundedClip:rounded));
}
if (contextRoot) foreach (var layer in group.Layers.Where(item => item.Z < 0).OrderBy(item => item.Z)) PaintLayer(layer, group);
foreach (var child in box.children) if (!group.Deferred.Contains(child)) Paint(child, clip, opacity, roundedClips, group);
if (contextRoot) foreach (var layer in group.Layers.Where(item => item.Z >= 0).OrderBy(item => item.Z)) PaintLayer(layer, group);
if (clips) Command(box, 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 string SvgSource(BrowserLayoutBox box)
{
var clone = (IElement)box.Element!.Clone(true);
void Style(BrowserLayoutBox source, IElement target)
{
foreach (string property in new[] { "fill", "stroke", "stroke-width", "fill-rule", "fill-opacity", "stroke-opacity", "stroke-linecap", "stroke-linejoin", "color", "visibility" })
if (source.Style.Properties.TryGetValue(property, out string? value)) target.SetAttribute(property, value);
// The display command already includes the root's opacity.
if (!ReferenceEquals(source, box)) target.SetAttribute("opacity", source.Style.Opacity.ToString(System.Globalization.CultureInfo.InvariantCulture));
var children = source.children.Where(child => child.Node is IElement && child.GeneratedPseudo is null).ToArray();
for (int index = 0; index < Math.Min(children.Length, target.Children.Length); index++) Style(children[index], target.Children[index]);
}
Style(box, clone);
// A use element may reference a symbol in another inline SVG. Freeze
// those local definitions into this self-contained image; no URL fetch
// or live document reference crosses the paint publication boundary.
var references = new Queue<string>(clone.QuerySelectorAll("use").Select(node => node.GetAttribute("href") ?? node.GetAttribute("xlink:href") ?? ""));
var visited = new HashSet<string>(StringComparer.Ordinal); int referenceCount = 0;
while (references.TryDequeue(out var reference))
{
if (!reference.StartsWith('#') || !visited.Add(reference)) continue;
if (++referenceCount > 64) throw new BrowserLimitException("SVG local reference budget exceeded.");
if (clone.QuerySelectorAll("[id]").Any(node => node.Id == reference[1..])) continue;
var original = box.Element!.Owner?.GetElementById(reference[1..]);
if (original is null || original.NamespaceUri != "http://www.w3.org/2000/svg") continue;
var definition = (IElement)original.Clone(true);
// The use instance inherits its own color/fill, not the original
// definition's computed inheritance from an unrelated SVG parent.
var definitions = clone.Owner!.CreateElement("http://www.w3.org/2000/svg", "defs");
definitions.AppendChild(definition); clone.AppendChild(definitions);
foreach (var nested in definition.QuerySelectorAll("use")) references.Enqueue(nested.GetAttribute("href") ?? nested.GetAttribute("xlink:href") ?? "");
}
clone.SetAttribute("width", CssValue.Number(box.ContentBox.Width.ToString(System.Globalization.CultureInfo.InvariantCulture), 1).ToString(System.Globalization.CultureInfo.InvariantCulture));
clone.SetAttribute("height", box.ContentBox.Height.ToString(System.Globalization.CultureInfo.InvariantCulture));
clone.RemoveAttribute("style"); clone.RemoveAttribute("opacity");
return "data:image/svg+xml," + Uri.EscapeDataString(clone.OuterHtml);
}
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(box, 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(box, new(BrowserDrawKind.Border, rect, box.NodeId, box.Node, color.Rgb, color.Alpha * opacity));
}
}
private void Command(BrowserLayoutBox box, BrowserDrawCommand command) => Command(command with { Transform = box.Transform.IsIdentity ? null : box.Transform });
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); }
}