Skip to content

Commit bb5996a

Browse files
committed
feat(layout): Add text wrapping and alignment support for StackNode
Refactor measurement and rendering logic, and extract line break calculation into a shared method. Fix floating-point error accumulation in alignment calculations, using integer stepping and error diffusion to ensure pixel-perfect precision. Update text measurement method calls to match the latest API of the dependency library.
1 parent ae45fff commit bb5996a

File tree

4 files changed

+160
-92
lines changed

4 files changed

+160
-92
lines changed

src/Render/AssetProvider.cs

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -52,19 +52,6 @@ public Image LoadImage(string ns, string key)
5252
return (mainFont, fallbacks);
5353
}
5454

55-
public Size Measure(string text, string family, float size)
56-
{
57-
(FontFamily fontFamily, List<FontFamily> fallbacks) = ResolveFont(family);
58-
float scaledSize = (float)(size * 72 / 300d);
59-
Font font = fontFamily.CreateFont(scaledSize);
60-
FontRectangle rect = TextMeasurer.MeasureAdvance(text, new(font)
61-
{
62-
FallbackFontFamilies = fallbacks,
63-
Dpi = 300
64-
});
65-
return new((int)Math.Ceiling(rect.Width), (int)Math.Ceiling(rect.Height));
66-
}
67-
6855
public string? GetPath(string ns)
6956
{
7057
if (!_pathRules.TryGetValue(ns, out (string, string?) pathRule))

src/Render/NodeRenderer.Layout.cs

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
using Limekuma.Render.Nodes;
12
using Limekuma.Render.Types;
3+
using SixLabors.ImageSharp;
24

35
namespace Limekuma.Render;
46

@@ -11,20 +13,83 @@ explicitSize is { } value
1113
? desired
1214
: fallback;
1315

16+
private static List<List<(Node Node, Size Size)>> ResolveStackLines(List<(Node Node, Size Size)> items, bool isRow,
17+
bool wrap, float spacing, float containerMain)
18+
{
19+
List<List<(Node Node, Size Size)>> lines = [];
20+
List<(Node Node, Size Size)> currentLine = [];
21+
float currentMain = 0;
22+
foreach ((Node node, Size size) in items)
23+
{
24+
float itemMain = isRow ? size.Width : size.Height;
25+
float nextMain = currentLine.Count is 0 ? itemMain : currentMain + spacing + itemMain;
26+
bool wrapNow = wrap && currentLine.Count > 0 && nextMain > containerMain;
27+
if (wrapNow)
28+
{
29+
lines.Add(currentLine);
30+
currentLine = [];
31+
currentMain = 0;
32+
}
33+
34+
currentLine.Add((node, size));
35+
currentMain = currentLine.Count is 1 ? itemMain : currentMain + spacing + itemMain;
36+
}
37+
38+
if (currentLine.Count > 0)
39+
{
40+
lines.Add(currentLine);
41+
}
42+
43+
return lines;
44+
}
45+
46+
private static List<(float Main, int Cross)> ResolveStackLineSizes(List<List<(Node Node, Size Size)>> lines, bool isRow,
47+
float spacing)
48+
{
49+
List<(float Main, int Cross)> lineSizes = [];
50+
foreach (List<(Node Node, Size Size)> line in lines)
51+
{
52+
float lineMain = line.Sum(i => isRow ? i.Size.Width : i.Size.Height);
53+
if (line.Count > 1)
54+
{
55+
lineMain += spacing * (line.Count - 1);
56+
}
57+
58+
int lineCross = line.Max(i => isRow ? i.Size.Height : i.Size.Width);
59+
lineSizes.Add((lineMain, lineCross));
60+
}
61+
62+
return lineSizes;
63+
}
64+
65+
private static (float Main, float Cross) ResolveStackContentSize(List<(float Main, int Cross)> lineSizes,
66+
float runSpacing)
67+
{
68+
if (lineSizes.Count is 0)
69+
{
70+
return (0, 0);
71+
}
72+
73+
float contentMain = lineSizes.Max(l => l.Main);
74+
float contentCross = lineSizes.Sum(l => l.Cross) + Math.Max(0, lineSizes.Count - 1) * runSpacing;
75+
return (contentMain, contentCross);
76+
}
77+
1478
private static (float Start, float Between) ResolveMainAxisLayout(StackJustifyContent justify, float containerMain,
1579
float contentMain, float baseSpacing, int itemCount)
1680
{
17-
float remaining = Math.Max(0, containerMain - contentMain);
81+
float remaining = containerMain - contentMain;
82+
float distributable = Math.Max(0, remaining);
1883
return justify switch
1984
{
2085
StackJustifyContent.Start => (0, baseSpacing),
2186
StackJustifyContent.Center => (remaining / 2, baseSpacing),
2287
StackJustifyContent.End => (remaining, baseSpacing),
23-
StackJustifyContent.SpaceBetween when itemCount > 1 => (0, baseSpacing + remaining / (itemCount - 1)),
88+
StackJustifyContent.SpaceBetween when itemCount > 1 => (0, baseSpacing + distributable / (itemCount - 1)),
2489
StackJustifyContent.SpaceAround when itemCount > 0 =>
25-
(remaining / (itemCount * 2), baseSpacing + remaining / itemCount),
90+
(distributable / (itemCount * 2), baseSpacing + distributable / itemCount),
2691
StackJustifyContent.SpaceEvenly when itemCount > 0 =>
27-
(remaining / (itemCount + 1), baseSpacing + remaining / (itemCount + 1)),
92+
(distributable / (itemCount + 1), baseSpacing + distributable / (itemCount + 1)),
2893
_ => (0, baseSpacing)
2994
};
3095
}
@@ -53,7 +118,7 @@ private static int ResolveStartCenterEndOffset(ContentAlign align, int container
53118

54119
private static float ResolveStartCenterEndOffset(ContentAlign align, float containerSize, float itemSize)
55120
{
56-
float room = Math.Max(0, containerSize - itemSize);
121+
float room = containerSize - itemSize;
57122
return align switch
58123
{
59124
ContentAlign.Center => room / 2,

src/Render/NodeRenderer.Measurement.cs

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ private static Size MeasureImageNode(ImageNode image, AssetProvider assets)
4141
private static Size MeasureTextNode(TextNode text, AssetProvider measurer)
4242
{
4343
(FontFamily mainFont, List<FontFamily> fallbacks) = measurer.ResolveFont(text.FontFamily);
44-
float scaledFontSize = (float)(text.FontSize * 72f / 300f);
44+
float scaledFontSize = text.FontSize * 72 / 300;
4545
Font font = new(mainFont, scaledFontSize);
4646
RichTextOptions options = new(font)
4747
{
@@ -60,7 +60,7 @@ private static Size MeasureTextNode(TextNode text, AssetProvider measurer)
6060
measuredText = TruncateTextByWidth(measuredText, ts, tw, options);
6161
}
6262

63-
FontRectangle rect = TextMeasurer.MeasureSize(measuredText, options);
63+
FontRectangle rect = TextMeasurer.MeasureAdvance(measuredText, options);
6464
return new((int)Math.Ceiling(rect.Width), (int)Math.Ceiling(rect.Height));
6565
}
6666

@@ -79,24 +79,23 @@ private static Size MeasureStackNode(StackNode stack, AssetProvider assets, Asse
7979
Dictionary<Node, Size> measurementCache)
8080
{
8181
List<Node> flowChildren = ExpandFlowChildren(stack.Children);
82-
float width = 0;
83-
float height = 0;
84-
for (int i = 0; i < flowChildren.Count; ++i)
85-
{
86-
Size child = Measure(flowChildren[i], assets, measurer, measurementCache);
87-
if (stack.Direction is StackDirection.Row)
88-
{
89-
width += child.Width + (i < flowChildren.Count - 1 ? stack.Spacing : 0);
90-
height = Math.Max(height, child.Height);
91-
}
92-
else
93-
{
94-
height += child.Height + (i < flowChildren.Count - 1 ? stack.Spacing : 0);
95-
width = Math.Max(width, child.Width);
96-
}
97-
}
98-
99-
return new((int)Math.Ceiling(width), (int)Math.Ceiling(height));
82+
List<(Node Node, Size Size)> items =
83+
[
84+
.. flowChildren.Select(c => (c, Measure(c, assets, measurer, measurementCache)))
85+
];
86+
bool isRow = stack.Direction is StackDirection.Row;
87+
float wrapMain = ResolveMainAxisContainerSize(
88+
isRow ? stack.Width : stack.Height,
89+
null,
90+
int.MaxValue);
91+
List<List<(Node Node, Size Size)>> lines =
92+
ResolveStackLines(items, isRow, stack.Wrap, stack.Spacing, wrapMain);
93+
List<(float Main, int Cross)> lineSizes = ResolveStackLineSizes(lines, isRow, stack.Spacing);
94+
(float contentMain, float contentCross) = ResolveStackContentSize(lineSizes, stack.RunSpacing);
95+
96+
int contentWidth = (int)Math.Ceiling(isRow ? contentMain : contentCross);
97+
int contentHeight = (int)Math.Ceiling(isRow ? contentCross : contentMain);
98+
return new(stack.Width ?? contentWidth, stack.Height ?? contentHeight);
10099
}
101100

102101
private static Size MeasureGridNode(GridNode grid, AssetProvider assets, AssetProvider measurer,

src/Render/NodeRenderer.Rendering.cs

Lines changed: 71 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -88,49 +88,14 @@ .. flowChildren.Select(c => (c, Measure(c, assets, measurer, measurementCache)))
8888
}
8989

9090
bool isRow = stack.Direction is StackDirection.Row;
91-
float containerMain = ResolveMainAxisContainerSize(
91+
float wrapMain = ResolveMainAxisContainerSize(
9292
isRow ? stack.Width : stack.Height,
93-
isRow ? desiredSize?.Width : desiredSize?.Height,
93+
null,
9494
int.MaxValue);
95-
List<List<(Node Node, Size Size)>> lines = [];
96-
List<(Node Node, Size Size)> currentLine = [];
97-
float currentMain = 0;
98-
foreach ((Node node, Size size) in items)
99-
{
100-
float itemMain = isRow ? size.Width : size.Height;
101-
float nextMain = currentLine.Count is 0 ? itemMain : currentMain + stack.Spacing + itemMain;
102-
bool wrapNow = stack.Wrap && currentLine.Count > 0 && nextMain > containerMain;
103-
if (wrapNow)
104-
{
105-
lines.Add(currentLine);
106-
currentLine = [];
107-
currentMain = 0;
108-
}
109-
110-
currentLine.Add((node, size));
111-
currentMain = currentLine.Count is 1 ? itemMain : currentMain + stack.Spacing + itemMain;
112-
}
113-
114-
if (currentLine.Count > 0)
115-
{
116-
lines.Add(currentLine);
117-
}
118-
119-
List<(float Main, int Cross)> lineSize = [];
120-
foreach (List<(Node Node, Size Size)> line in lines)
121-
{
122-
float lineMain = line.Sum(i => isRow ? i.Size.Width : i.Size.Height);
123-
if (line.Count > 1)
124-
{
125-
lineMain += stack.Spacing * (line.Count - 1);
126-
}
127-
128-
int lineCross = line.Max(i => isRow ? i.Size.Height : i.Size.Width);
129-
lineSize.Add((lineMain, lineCross));
130-
}
131-
132-
float contentMain = lineSize.Max(l => l.Main);
133-
float contentCross = lineSize.Sum(l => l.Cross) + Math.Max(0, lines.Count - 1) * stack.RunSpacing;
95+
List<List<(Node Node, Size Size)>> lines =
96+
ResolveStackLines(items, isRow, stack.Wrap, stack.Spacing, wrapMain);
97+
List<(float Main, int Cross)> lineSizes = ResolveStackLineSizes(lines, isRow, stack.Spacing);
98+
(float contentMain, float contentCross) = ResolveStackContentSize(lineSizes, stack.RunSpacing);
13499
float resolvedContainerMain = ResolveMainAxisContainerSize(
135100
isRow ? stack.Width : stack.Height,
136101
isRow ? desiredSize?.Width : desiredSize?.Height,
@@ -140,17 +105,30 @@ .. flowChildren.Select(c => (c, Measure(c, assets, measurer, measurementCache)))
140105
isRow ? desiredSize?.Height : desiredSize?.Width,
141106
contentCross);
142107

143-
float crossCursor = (isRow ? origin.Y : origin.X) +
108+
float crossBase = (isRow ? origin.Y : origin.X) +
144109
ResolveStartCenterEndOffset(stack.AlignContent, resolvedContainerCross, contentCross);
110+
int crossCursor = (int)Math.Round(crossBase, MidpointRounding.AwayFromZero);
111+
float runError = crossBase - crossCursor;
112+
int runSpacingWhole = stack.RunSpacing >= 0
113+
? (int)Math.Floor(stack.RunSpacing)
114+
: (int)Math.Ceiling(stack.RunSpacing);
115+
float runSpacingFraction = stack.RunSpacing - runSpacingWhole;
145116
for (int lineIndex = 0; lineIndex < lines.Count; lineIndex++)
146117
{
147118
List<(Node Node, Size Size)> line = lines[lineIndex];
148-
(float lineMain, int lineCross) = lineSize[lineIndex];
119+
(float lineMain, int lineCross) = lineSizes[lineIndex];
149120
(float startMain, float between) = ResolveMainAxisLayout(stack.JustifyContent, resolvedContainerMain, lineMain,
150121
stack.Spacing, line.Count);
151-
float mainCursor = startMain;
152-
foreach ((Node node, Size size) in line)
122+
float mainBase = (isRow ? origin.X : origin.Y) + startMain;
123+
int mainCursor = (int)Math.Round(mainBase, MidpointRounding.AwayFromZero);
124+
float gapError = mainBase - mainCursor;
125+
int betweenWhole = between >= 0
126+
? (int)Math.Floor(between)
127+
: (int)Math.Ceiling(between);
128+
float betweenFraction = between - betweenWhole;
129+
for (int itemIndex = 0; itemIndex < line.Count; itemIndex++)
153130
{
131+
(Node node, Size size) = line[itemIndex];
154132
int itemCross = isRow ? size.Height : size.Width;
155133
int crossOffset = ResolveStartCenterEndOffset(stack.AlignItems, lineCross, itemCross);
156134
Size? childDesiredSize = null;
@@ -159,15 +137,54 @@ .. flowChildren.Select(c => (c, Measure(c, assets, measurer, measurementCache)))
159137
childDesiredSize = isRow ? new(size.Width, lineCross) : new(lineCross, size.Height);
160138
}
161139

162-
Point childOrigin = isRow
163-
? new((int)Math.Round(origin.X + mainCursor), (int)Math.Round(crossCursor + crossOffset))
164-
: new((int)Math.Round(crossCursor + crossOffset), (int)Math.Round(origin.Y + mainCursor));
140+
float childX = isRow ? mainCursor : crossCursor + crossOffset;
141+
float childY = isRow ? crossCursor + crossOffset : mainCursor;
142+
Point childOrigin = new(
143+
(int)Math.Round(childX, MidpointRounding.AwayFromZero),
144+
(int)Math.Round(childY, MidpointRounding.AwayFromZero));
165145
RenderNode(canvas, node, assets, measurer, childOrigin, inheritedOpacity, childDesiredSize, scale,
166146
resampler, measurementCache);
167-
mainCursor += (isRow ? size.Width : size.Height) + between;
147+
if (itemIndex == line.Count - 1)
148+
{
149+
continue;
150+
}
151+
152+
int itemMain = isRow ? size.Width : size.Height;
153+
int step = itemMain + betweenWhole;
154+
gapError += betweenFraction;
155+
if (gapError >= 1f)
156+
{
157+
step++;
158+
gapError -= 1f;
159+
}
160+
else if (gapError <= -1f)
161+
{
162+
step--;
163+
gapError += 1f;
164+
}
165+
166+
mainCursor += step;
167+
}
168+
169+
if (lineIndex == lines.Count - 1)
170+
{
171+
continue;
172+
}
173+
174+
int lineStep = lineCross + runSpacingWhole;
175+
runError += runSpacingFraction;
176+
if (runError >= 1f)
177+
{
178+
lineStep++;
179+
runError -= 1f;
180+
}
181+
else if (runError <= -1f)
182+
{
183+
lineStep--;
184+
runError += 1f;
168185
}
169186

170-
crossCursor += lineCross + stack.RunSpacing;
187+
crossCursor += lineStep;
171188
}
172189
}
173190

@@ -267,7 +284,7 @@ private static void RenderTextNode(Image canvas, TextNode textNode, AssetProvide
267284
float inheritedOpacity)
268285
{
269286
(FontFamily mainFont, List<FontFamily> fallbacks) = assets.ResolveFont(textNode.FontFamily);
270-
float scaledFontSize = textNode.FontSize * 72f / 300f;
287+
float scaledFontSize = textNode.FontSize * 72 / 300;
271288
Font font = new(mainFont, scaledFontSize);
272289
RichTextOptions options = new(font)
273290
{
@@ -313,12 +330,12 @@ private static string TruncateTextByWidth(string text, string suffix, float maxW
313330
return string.Empty;
314331
}
315332

316-
if (TextMeasurer.MeasureSize(text, options).Width <= maxWidth)
333+
if (TextMeasurer.MeasureAdvance(text, options).Width <= maxWidth)
317334
{
318335
return text;
319336
}
320337

321-
if (TextMeasurer.MeasureSize(suffix, options).Width > maxWidth)
338+
if (TextMeasurer.MeasureAdvance(suffix, options).Width > maxWidth)
322339
{
323340
return string.Empty;
324341
}
@@ -330,7 +347,7 @@ private static string TruncateTextByWidth(string text, string suffix, float maxW
330347
{
331348
int mid = (low + high + 1) / 2;
332349
string candidate = GetTextElementPrefix(text, textElementStarts, mid) + suffix;
333-
if (TextMeasurer.MeasureSize(candidate, options).Width <= maxWidth)
350+
if (TextMeasurer.MeasureAdvance(candidate, options).Width <= maxWidth)
334351
{
335352
low = mid;
336353
continue;

0 commit comments

Comments
 (0)