Skip to content

Commit cc00406

Browse files
fix: Cleaned up whitespace processing and added whitespace tests (Sub6Resources#1267)
1 parent fe896de commit cc00406

File tree

6 files changed

+347
-25
lines changed

6 files changed

+347
-25
lines changed

lib/src/builtins/styled_element_builtin.dart

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -429,11 +429,12 @@ class StyledElementBuiltIn extends HtmlExtension {
429429
.entries
430430
.expandIndexed((i, child) => [
431431
child.value,
432-
if (i != context.styledElement!.children.length - 1 &&
432+
if (context.parser.shrinkWrap &&
433+
i != context.styledElement!.children.length - 1 &&
433434
child.key.style.display == Display.block &&
434435
child.key.element?.localName != "html" &&
435436
child.key.element?.localName != "body")
436-
const TextSpan(text: "\n"),
437+
const TextSpan(text: "\n", style: TextStyle(fontSize: 0)),
437438
])
438439
.toList(),
439440
),
@@ -444,14 +445,16 @@ class StyledElementBuiltIn extends HtmlExtension {
444445
style: context.styledElement!.style.generateTextStyle(),
445446
children: buildChildren()
446447
.entries
447-
.expand((child) => [
448+
.expandIndexed((index, child) => [
448449
child.value,
449-
if (child.key.style.display == Display.block &&
450+
if (context.parser.shrinkWrap &&
451+
child.key.style.display == Display.block &&
452+
index != context.styledElement!.children.length - 1 &&
450453
child.key.element?.parent?.localName != "th" &&
451454
child.key.element?.parent?.localName != "td" &&
452455
child.key.element?.localName != "html" &&
453456
child.key.element?.localName != "body")
454-
const TextSpan(text: "\n"),
457+
const TextSpan(text: "\n", style: TextStyle(fontSize: 0)),
455458
])
456459
.toList(),
457460
);

lib/src/builtins/text_builtin.dart

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,8 @@ class TextBuiltIn extends HtmlExtension {
2222
StyledElement prepare(
2323
ExtensionContext context, List<StyledElement> children) {
2424
if (context.elementName == "br") {
25-
return TextContentElement(
26-
text: '\n',
25+
return LinebreakContentElement(
2726
style: Style(whiteSpace: WhiteSpace.pre),
28-
element: context.node as dom.Element?,
2927
node: context.node,
3028
);
3129
}
@@ -45,6 +43,13 @@ class TextBuiltIn extends HtmlExtension {
4543
@override
4644
InlineSpan build(ExtensionContext context,
4745
Map<StyledElement, InlineSpan> Function() buildChildren) {
46+
if (context.styledElement is LinebreakContentElement) {
47+
return TextSpan(
48+
text: '\n',
49+
style: context.styledElement!.style.generateTextStyle(),
50+
);
51+
}
52+
4853
final element = context.styledElement! as TextContentElement;
4954
return TextSpan(
5055
style: element.style.generateTextStyle(),

lib/src/processing/whitespace.dart

Lines changed: 100 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,26 +6,30 @@ import 'package:html/dom.dart' as html;
66
class WhitespaceProcessing {
77
/// [processWhitespace] handles the removal of unnecessary whitespace from
88
/// a StyledElement tree.
9+
///
10+
/// The criteria for determining which whitespace is replaceable is outlined
11+
/// at https://www.w3.org/TR/css-text-3/
12+
/// and summarized at https://medium.com/@patrickbrosset/when-does-white-space-matter-in-html-b90e8a7cdd33
913
static StyledElement processWhitespace(StyledElement tree) {
1014
tree = _processInternalWhitespace(tree);
1115
tree = _processInlineWhitespace(tree);
16+
tree = _processBlockWhitespace(tree);
1217
tree = _removeEmptyElements(tree);
1318
return tree;
1419
}
1520

1621
/// [_processInternalWhitespace] removes unnecessary whitespace from the StyledElement tree.
17-
///
18-
/// The criteria for determining which whitespace is replaceable is outlined
19-
/// at https://www.w3.org/TR/css-text-3/
20-
/// and summarized at https://medium.com/@patrickbrosset/when-does-white-space-matter-in-html-b90e8a7cdd33
2122
static StyledElement _processInternalWhitespace(StyledElement tree) {
22-
if ((tree.style.whiteSpace ?? WhiteSpace.normal) == WhiteSpace.pre) {
23-
// Preserve this whitespace
24-
} else if (tree is TextContentElement) {
23+
if (tree.style.whiteSpace == WhiteSpace.pre) {
24+
return tree;
25+
}
26+
27+
if (tree is TextContentElement) {
2528
tree.text = _removeUnnecessaryWhitespace(tree.text!);
2629
} else {
2730
tree.children.forEach(_processInternalWhitespace);
2831
}
32+
2933
return tree;
3034
}
3135

@@ -36,13 +40,95 @@ class WhitespaceProcessing {
3640
return _processInlineWhitespaceRecursive(tree, Context(false));
3741
}
3842

43+
/// [_processBlockWhitespace] removes unnecessary whitespace from block
44+
/// rendering contexts. Specifically, a space at the beginning and end of
45+
/// each inline rendering context should be removed.
46+
static StyledElement _processBlockWhitespace(StyledElement tree) {
47+
if (tree.style.whiteSpace == WhiteSpace.pre) {
48+
return tree;
49+
}
50+
51+
bool isBlockContext = false;
52+
for (final child in tree.children) {
53+
if (child.style.display == Display.block || child.name == "br") {
54+
isBlockContext = true;
55+
}
56+
57+
_processBlockWhitespace(child);
58+
}
59+
60+
if (isBlockContext) {
61+
for (int i = 0; i < tree.children.length; i++) {
62+
final lastChild = i != 0 ? tree.children[i - 1] : null;
63+
final child = tree.children[i];
64+
final nextChild =
65+
(i + 1) != tree.children.length ? tree.children[i + 1] : null;
66+
67+
if (child.style.whiteSpace == WhiteSpace.pre) {
68+
continue;
69+
}
70+
71+
if (child.style.display == Display.block) {
72+
_removeLeadingSpace(child);
73+
_removeTrailingSpace(child);
74+
}
75+
76+
if (lastChild?.style.display == Display.block ||
77+
lastChild?.name == "br") {
78+
_removeLeadingSpace(child);
79+
}
80+
81+
if (nextChild?.style.display == Display.block ||
82+
nextChild?.name == "br") {
83+
_removeTrailingSpace(child);
84+
}
85+
}
86+
}
87+
88+
return tree;
89+
}
90+
91+
/// [_removeLeadingSpace] removes any leading space
92+
/// from the text of the tree at this level, no matter how deep in the tree
93+
/// it may be.
94+
static void _removeLeadingSpace(StyledElement element) {
95+
if (element.style.whiteSpace == WhiteSpace.pre) {
96+
return;
97+
}
98+
99+
if (element is TextContentElement) {
100+
element.text = element.text?.trimLeft();
101+
} else if (element.children.isNotEmpty) {
102+
_removeLeadingSpace(element.children.first);
103+
}
104+
}
105+
106+
/// [_removeTrailingSpace] removes any leading space
107+
/// from the text of the tree at this level, no matter how deep in the tree
108+
/// it may be.
109+
static void _removeTrailingSpace(StyledElement element) {
110+
if (element.style.whiteSpace == WhiteSpace.pre) {
111+
return;
112+
}
113+
114+
if (element is TextContentElement) {
115+
element.text = element.text?.trimRight();
116+
} else if (element.children.isNotEmpty) {
117+
_removeTrailingSpace(element.children.last);
118+
}
119+
}
120+
39121
/// [_processInlineWhitespaceRecursive] analyzes the whitespace between and among different
40122
/// inline elements, and replaces any instance of two or more spaces with a single space, according
41123
/// to the w3's HTML whitespace processing specification linked to above.
42124
static StyledElement _processInlineWhitespaceRecursive(
43125
StyledElement tree,
44126
Context<bool> keepLeadingSpace,
45127
) {
128+
if (tree.style.whiteSpace == WhiteSpace.pre) {
129+
return tree;
130+
}
131+
46132
if (tree is TextContentElement) {
47133
/// initialize indices to negative numbers to make conditionals a little easier
48134
int textIndex = -1;
@@ -62,9 +148,9 @@ class WhitespaceProcessing {
62148
final parentNodes = tree.element?.parent?.nodes;
63149

64150
/// find the index of the tree itself in the parent nodes
65-
if ((parentNodes?.length ?? 0) >= 1) {
151+
if (parentNodes?.isNotEmpty ?? false) {
66152
elementIndex =
67-
parentNodes?.indexWhere((element) => element == tree.element) ?? -1;
153+
parentNodes!.indexWhere((element) => element == tree.element);
68154
}
69155

70156
/// if the tree is any node except the last node in the node list and the
@@ -117,9 +203,7 @@ class WhitespaceProcessing {
117203
/// update the [Context] to signify to that next text node whether it should
118204
/// keep its whitespace. This is based on whether the current text ends with a
119205
/// whitespace.
120-
if (textIndex ==
121-
((tree.element?.nodes.length ?? 0) -
122-
1) && //TODO is this the proper ??
206+
if (textIndex == (tree.node.nodes.length - 1) &&
123207
tree.element?.localName != "br" &&
124208
parentAfterText.startsWith(' ')) {
125209
keepLeadingSpace.data = !tree.text!.endsWith(' ');
@@ -142,11 +226,11 @@ class WhitespaceProcessing {
142226
/// (4) Replace any instances of two or more spaces with a single space.
143227
static String _removeUnnecessaryWhitespace(String text) {
144228
return text
145-
.replaceAll(RegExp("\\ *(?=\n)"), "\n")
146-
.replaceAll(RegExp("(?:\n)\\ *"), "\n")
229+
.replaceAll(RegExp(r" *(?=\n)"), "")
230+
.replaceAll(RegExp(r"(?<=\n) *"), "")
147231
.replaceAll("\n", " ")
148232
.replaceAll("\t", " ")
149-
.replaceAll(RegExp(" {2,}"), " ");
233+
.replaceAll(RegExp(r" {2,}"), " ");
150234
}
151235

152236
/// [_removeEmptyElements] recursively removes empty elements.
@@ -155,7 +239,7 @@ class WhitespaceProcessing {
155239
/// or any block-level [TextContentElement] that contains only whitespace and doesn't follow
156240
/// a block element or a line break.
157241
static StyledElement _removeEmptyElements(StyledElement tree) {
158-
List<StyledElement> toRemove = <StyledElement>[];
242+
Set<StyledElement> toRemove = <StyledElement>{};
159243
bool lastChildBlock = true;
160244
tree.children.forEachIndexed((index, child) {
161245
if (child is EmptyContentElement) {

lib/src/tree/replaced_element.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ class TextContentElement extends ReplacedElement {
4646
}
4747
}
4848

49+
class LinebreakContentElement extends ReplacedElement {
50+
LinebreakContentElement({
51+
required super.style,
52+
required super.node,
53+
}) : super(name: 'br', elementId: "[[No ID]]");
54+
}
55+
4956
class EmptyContentElement extends ReplacedElement {
5057
EmptyContentElement({required super.node, String name = "empty"})
5158
: super(name: name, style: Style(), elementId: "[[No ID]]");

test/test_utils.dart

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import 'dart:async';
2+
13
import 'package:flutter/material.dart';
2-
import 'package:flutter_html/src/css_box_widget.dart';
4+
import 'package:flutter_html/flutter_html.dart';
35
import 'package:flutter_test/flutter_test.dart';
46

57
class TestApp extends StatelessWidget {
@@ -141,3 +143,86 @@ CssBoxWidget? findCssBox(Finder finder) {
141143
return found.first.widget as CssBoxWidget;
142144
}
143145
}
146+
147+
Future<StyledElement> generateStyledElementTreeFromHtml(
148+
WidgetTester tester, {
149+
required String html,
150+
bool applyStyleSteps = true,
151+
bool applyProcessingSteps = true,
152+
bool shrinkWrap = false,
153+
List<HtmlExtension> extensions = const [],
154+
Map<String, Style> styles = const {},
155+
}) async {
156+
final completer = Completer<StyledElement>();
157+
158+
await tester.pumpWidget(TestApp(
159+
child: Html(
160+
data: html,
161+
shrinkWrap: shrinkWrap,
162+
extensions: [
163+
...extensions,
164+
TestExtension(
165+
beforeStyleCallback: (tree) {
166+
if (!applyStyleSteps) {
167+
completer.complete(tree);
168+
}
169+
},
170+
beforeProcessingCallback: (tree) {
171+
if (!completer.isCompleted && !applyProcessingSteps) {
172+
completer.complete(tree);
173+
}
174+
},
175+
finalCallback: (tree) {
176+
if (!completer.isCompleted) {
177+
completer.complete(tree);
178+
}
179+
},
180+
),
181+
],
182+
style: styles,
183+
),
184+
));
185+
186+
return completer.future;
187+
}
188+
189+
class TestExtension extends HtmlExtension {
190+
final void Function(StyledElement)? beforeStyleCallback;
191+
final void Function(StyledElement)? beforeProcessingCallback;
192+
final void Function(StyledElement)? finalCallback;
193+
194+
TestExtension({
195+
this.beforeStyleCallback,
196+
this.beforeProcessingCallback,
197+
this.finalCallback,
198+
});
199+
200+
@override
201+
Set<String> get supportedTags => {};
202+
203+
@override
204+
bool matches(ExtensionContext context) {
205+
return context.currentStep != CurrentStep.preparing &&
206+
context.elementName == "html";
207+
}
208+
209+
@override
210+
void beforeStyle(ExtensionContext context) {
211+
beforeStyleCallback?.call(context.styledElement!);
212+
}
213+
214+
@override
215+
void beforeProcessing(ExtensionContext context) {
216+
beforeProcessingCallback?.call(context.styledElement!);
217+
}
218+
219+
@override
220+
InlineSpan build(ExtensionContext context, buildChildren) {
221+
finalCallback?.call(context.styledElement!);
222+
return context.parser.buildFromExtension(
223+
context,
224+
buildChildren,
225+
extensionsToIgnore: {this},
226+
);
227+
}
228+
}

0 commit comments

Comments
 (0)