Skip to content

Commit 7801392

Browse files
authored
Merge branch 'master' into master
2 parents 4d67c41 + 1fa33d8 commit 7801392

File tree

9 files changed

+197
-45
lines changed

9 files changed

+197
-45
lines changed

README.md

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,13 @@ A Flutter widget for rendering HTML and CSS as Flutter widgets.
3333

3434
- [API Reference](#api-reference)
3535

36+
- [Constructors](#constructors)
37+
3638
- [Parameters Table](#parameters)
3739

3840
- [Data](#data)
41+
42+
- [Document](#document)
3943

4044
- [onLinkTap](#onlinktap)
4145

@@ -137,11 +141,20 @@ For a full example, see [here](https://github.com/Sub6Resources/flutter_html/tre
137141

138142
Below, you will find brief descriptions of the parameters the`Html` widget accepts and some code snippets to help you use this package.
139143

144+
## Constructors:
145+
146+
The package currently has two different constructors - `Html()` and `Html.fromDom()`.
147+
148+
The `Html()` constructor is for those who would like to directly pass HTML from the source to the package to be rendered.
149+
150+
If you would like to modify or sanitize the HTML before rendering it, then `Html.fromDom()` is for you - you can convert the HTML string to a `Document` and use its methods to modify the HTML as you wish. Then, you can directly pass the modified `Document` to the package. This eliminates the need to parse the modified `Document` back to a string, pass to `Html()`, and convert back to a `Document`, thus cutting down on load times.
151+
140152
### Parameters:
141153

142154
| Parameters | Description |
143155
|--------------|-----------------|
144-
| `data` | The HTML data passed to the `Html` widget. This is required and cannot be null. |
156+
| `data` | The HTML data passed to the `Html` widget. This is required and cannot be null when using `Html()`. |
157+
| `document` | The DOM document passed to the `Html` widget. This is required and cannot be null when using `Html.fromDom()`. |
145158
| `onLinkTap` | A function that defines what the widget should do when a link is tapped. The function exposes the `src` of the link as a `String` to use in your implementation. |
146159
| `customRender` | A powerful API that allows you to customize everything when rendering a specific HTML tag. |
147160
| `onImageError` | A function that defines what the widget should do when an image fails to load. The function exposes the exception `Object` and `StackTrace` to use in your implementation. |
@@ -155,7 +168,7 @@ Below, you will find brief descriptions of the parameters the`Html` widget accep
155168

156169
### Data:
157170

158-
The HTML data passed to the `Html` widget as a `String`. This is required and cannot be null.
171+
The HTML data passed to the `Html` widget as a `String`. This is required and cannot be null when using `Html`.
159172
Any HTML tags in the `String` that are not supported by the package will not be rendered.
160173

161174
#### Example Usage - Data:
@@ -176,6 +189,36 @@ Widget html = Html(
176189
);
177190
```
178191

192+
### Document:
193+
194+
The DOM document passed to the `Html` widget as a `Document`. This is required and cannot be null when using `Html.fromDom()`.
195+
Any HTML tags in the document that are not supported by the package will not be rendered.
196+
Using the `Html.fromDom()` constructor can be useful when you would like to sanitize the HTML string yourself before passing it to the package.
197+
198+
#### Example Usage - Document:
199+
200+
```dart
201+
import 'package:html/parser.dart' as htmlparser;
202+
import 'package:html/dom.dart' as dom;
203+
...
204+
String htmlData = """<div>
205+
<h1>Demo Page</h1>
206+
<p>This is a fantastic product that you should buy!</p>
207+
<h3>Features</h3>
208+
<ul>
209+
<li>It actually works</li>
210+
<li>It exists</li>
211+
<li>It doesn't cost much!</li>
212+
</ul>
213+
<!--You can pretty much put any html in here!-->
214+
</div>""";
215+
dom.Document document = htmlparser.parse(htmlData);
216+
/// sanitize or query document here
217+
Widget html = Html(
218+
document: document,
219+
);
220+
```
221+
179222
### onLinkTap:
180223

181224
A function that defines what the widget should do when a link is tapped.

example/lib/main.dart

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,10 @@ const htmlData = r"""
125125
<img src='asset:assets/html5.png' width='100' />
126126
<h3>Local asset svg</h3>
127127
<img src='asset:assets/mac.svg' width='100' />
128-
<h3>Base64</h3>
129-
<img alt='Red dot' src='data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==' />
128+
<h3>Data uri (with base64 support)</h3>
129+
<img alt='Red dot (png)' src='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==' />
130+
<img alt='Green dot (base64 svg)' src='data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB2aWV3Qm94PSIwIDAgMzAgMjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxjaXJjbGUgY3g9IjE1IiBjeT0iMTAiIHI9IjEwIiBmaWxsPSJncmVlbiIvPgo8L3N2Zz4=' />
131+
<img alt='Green dot (plain svg)' src='data:image/svg+xml,%3C?xml version="1.0" encoding="UTF-8"?%3E%3Csvg viewBox="0 0 30 20" xmlns="http://www.w3.org/2000/svg"%3E%3Ccircle cx="15" cy="10" r="10" fill="yellow"/%3E%3C/svg%3E' />
130132
<h3>Custom source matcher (relative paths)</h3>
131133
<img src='/wikipedia/commons/thumb/e/ef/Octicons-logo-github.svg/200px-Octicons-logo-github.svg.png' />
132134
<h3>Custom image render (flutter.dev)</h3>
@@ -245,8 +247,7 @@ class _MyHomePageState extends State<MyHomePage> {
245247
data: htmlData,
246248
//Optional parameters:
247249
customImageRenders: {
248-
networkSourceMatcher(domains: ["flutter.dev"]):
249-
(context, attributes, element) {
250+
networkSourceMatcher(domains: ["flutter.dev"]): (context, attributes, element) {
250251
return FlutterLogo(size: 36);
251252
},
252253
networkSourceMatcher(domains: ["mydomain.com"]): networkImageRender(

lib/flutter_html.dart

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@ import 'package:flutter_html/html_parser.dart';
55
import 'package:flutter_html/image_render.dart';
66
import 'package:flutter_html/style.dart';
77
import 'package:webview_flutter/webview_flutter.dart';
8+
import 'package:html/dom.dart' as dom;
89

910
class Html extends StatelessWidget {
1011
/// The `Html` widget takes HTML as input and displays a RichText
1112
/// tree of the parsed HTML content.
1213
///
1314
/// **Attributes**
14-
/// **data** *required* takes in a String of HTML data.
15-
///
15+
/// **data** *required* takes in a String of HTML data (required only for `Html` constructor).
16+
/// **document** *required* takes in a Document of HTML data (required only for `Html.fromDom` constructor).
1617
///
1718
/// **onLinkTap** This function is called whenever a link (`<a href>`)
1819
/// is tapped.
@@ -45,25 +46,62 @@ class Html extends StatelessWidget {
4546
this.blacklistedElements = const [],
4647
this.style = const {},
4748
this.navigationDelegateForIframe,
48-
}) : super(key: key);
49+
}) : document = null,
50+
assert (data != null),
51+
super(key: key);
4952

50-
final String data;
53+
Html.fromDom({
54+
Key? key,
55+
@required this.document,
56+
this.onLinkTap,
57+
this.customRender = const {},
58+
this.customImageRenders = const {},
59+
this.onImageError,
60+
this.shrinkWrap = false,
61+
this.onImageTap,
62+
this.blacklistedElements = const [],
63+
this.style = const {},
64+
this.navigationDelegateForIframe,
65+
}) : data = null,
66+
assert(document != null),
67+
super(key: key);
68+
69+
/// The HTML data passed to the widget as a String
70+
final String? data;
71+
72+
/// The HTML data passed to the widget as a pre-processed [dom.Document]
73+
final dom.Document? document;
74+
75+
/// A function that defines what to do when a link is tapped
5176
final OnTap? onLinkTap;
77+
78+
/// An API that allows you to customize the entire process of image rendering.
79+
/// See the README for more details.
5280
final Map<ImageSourceMatcher, ImageRender> customImageRenders;
81+
82+
/// A function that defines what to do when an image errors
5383
final ImageErrorListener? onImageError;
84+
85+
/// A function that defines what to do when either <math> or <tex> fails to render
86+
/// You can return a widget here to override the default error widget.
5487
final OnMathError? onMathError;
88+
89+
90+
/// A parameter that should be set when the HTML widget is expected to be
91+
/// flexible
5592
final bool shrinkWrap;
5693

57-
/// Properties for the Image widget that gets rendered by the rich text parser
94+
/// A function that defines what to do when an image is tapped
5895
final OnTap? onImageTap;
5996

97+
/// A list of HTML tags that defines what elements are not rendered
6098
final List<String> blacklistedElements;
6199

62100
/// Either return a custom widget for specific node types or return null to
63101
/// fallback to the default rendering.
64102
final Map<String, CustomRender> customRender;
65103

66-
/// Fancy New Parser parameters
104+
/// An API that allows you to override the default style for any HTML element
67105
final Map<String, Style> style;
68106

69107
/// Decides how to handle a specific navigation request in the WebView of an
@@ -73,12 +111,13 @@ class Html extends StatelessWidget {
73111

74112
@override
75113
Widget build(BuildContext context) {
114+
final dom.Document doc = data != null ? HtmlParser.parseHTML(data!) : document!;
76115
final double? width = shrinkWrap ? null : MediaQuery.of(context).size.width;
77116

78117
return Container(
79118
width: width,
80119
child: HtmlParser(
81-
htmlData: data,
120+
htmlData: doc,
82121
onLinkTap: onLinkTap,
83122
onImageTap: onImageTap,
84123
onImageError: onImageError,

lib/html_parser.dart

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ typedef CustomRender = dynamic Function(
3535
);
3636

3737
class HtmlParser extends StatelessWidget {
38-
final String htmlData;
38+
final dom.Document htmlData;
3939
final OnTap? onLinkTap;
4040
final OnTap? onImageTap;
4141
final ImageErrorListener? onImageError;
@@ -64,9 +64,8 @@ class HtmlParser extends StatelessWidget {
6464

6565
@override
6666
Widget build(BuildContext context) {
67-
dom.Document document = parseHTML(htmlData);
6867
StyledElement lexedTree = lexDomTree(
69-
document,
68+
htmlData,
7069
customRender.keys.toList(),
7170
blacklistedElements,
7271
navigationDelegateForIframe,
@@ -180,7 +179,7 @@ class HtmlParser extends StatelessWidget {
180179
return EmptyContentElement();
181180
}
182181
} else if (node is dom.Text) {
183-
return TextContentElement(text: node.text, style: Style());
182+
return TextContentElement(text: node.text, style: Style(), element: node.parent, node: node);
184183
} else {
185184
return EmptyContentElement();
186185
}
@@ -472,14 +471,18 @@ class HtmlParser extends StatelessWidget {
472471
}
473472

474473
if (tree is TextContentElement) {
475-
if (wpc.data && tree.text!.startsWith(' ')) {
474+
int index = -1;
475+
if ((tree.element?.nodes.length ?? 0) > 1) {
476+
index = tree.element?.nodes.indexWhere((element) => element == tree.node) ?? -1;
477+
}
478+
if (index < 1 && tree.text!.startsWith(' ')
479+
&& tree.element?.localName != "br") {
476480
tree.text = tree.text!.replaceFirst(' ', '');
477481
}
478-
479-
if (tree.text!.endsWith(' ') || tree.text!.endsWith('\n')) {
480-
wpc.data = true;
481-
} else {
482-
wpc.data = false;
482+
if (index == (tree.element?.nodes.length ?? 1) - 1
483+
&& (tree.text!.endsWith(' ') || tree.text!.endsWith('\n'))
484+
&& tree.element?.localName != "br") {
485+
tree.text = tree.text!.trimRight();
483486
}
484487
}
485488

lib/image_render.dart

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,15 @@ typedef ImageSourceMatcher = bool Function(
1111
dom.Element? element,
1212
);
1313

14-
ImageSourceMatcher base64DataUriMatcher() => (attributes, element) =>
15-
_src(attributes) != null &&
16-
_src(attributes)!.startsWith("data:image") &&
17-
_src(attributes)!.contains("base64,");
14+
final _dataUriFormat = RegExp("^(?<scheme>data):(?<mime>image\/[\\w\+\-\.]+)(?<encoding>;base64)?\,(?<data>.*)");
15+
16+
ImageSourceMatcher dataUriMatcher({String? encoding = 'base64', String? mime}) => (attributes, element) {
17+
if (_src(attributes) == null) return false;
18+
final dataUri = _dataUriFormat.firstMatch(_src(attributes)!);
19+
return dataUri != null &&
20+
(mime == null || dataUri.namedGroup('mime') == mime) &&
21+
(encoding == null || dataUri.namedGroup('encoding') == ';$encoding');
22+
};
1823

1924
ImageSourceMatcher networkSourceMatcher({
2025
List<String> schemas: const ["https", "http"],
@@ -56,8 +61,7 @@ ImageRender base64ImageRender() => (context, attributes, element) {
5661
decodedImage,
5762
frameBuilder: (ctx, child, frame, _) {
5863
if (frame == null) {
59-
return Text(_alt(attributes) ?? "",
60-
style: context.style.generateTextStyle());
64+
return Text(_alt(attributes) ?? "", style: context.style.generateTextStyle());
6165
}
6266
return child;
6367
},
@@ -79,8 +83,7 @@ ImageRender assetImageRender({
7983
height: height ?? _height(attributes),
8084
frameBuilder: (ctx, child, frame, _) {
8185
if (frame == null) {
82-
return Text(_alt(attributes) ?? "",
83-
style: context.style.generateTextStyle());
86+
return Text(_alt(attributes) ?? "", style: context.style.generateTextStyle());
8487
}
8588
return child;
8689
},
@@ -109,8 +112,7 @@ ImageRender networkImageRender({
109112
},
110113
);
111114
Completer<Size> completer = Completer();
112-
Image image =
113-
Image.network(src, frameBuilder: (ctx, child, frame, _) {
115+
Image image = Image.network(src, frameBuilder: (ctx, child, frame, _) {
114116
if (frame == null) {
115117
if (!completer.isCompleted) {
116118
completer.completeError("error");
@@ -124,8 +126,7 @@ ImageRender networkImageRender({
124126
image.image.resolve(ImageConfiguration()).addListener(
125127
ImageStreamListener((ImageInfo image, bool synchronousCall) {
126128
var myImage = image.image;
127-
Size size =
128-
Size(myImage.width.toDouble(), myImage.height.toDouble());
129+
Size size = Size(myImage.width.toDouble(), myImage.height.toDouble());
129130
if (!completer.isCompleted) {
130131
completer.complete(size);
131132
}
@@ -147,28 +148,47 @@ ImageRender networkImageRender({
147148
frameBuilder: (ctx, child, frame, _) {
148149
if (frame == null) {
149150
return altWidget?.call(_alt(attributes)) ??
150-
Text(_alt(attributes) ?? "",
151-
style: context.style.generateTextStyle());
151+
Text(_alt(attributes) ?? "", style: context.style.generateTextStyle());
152152
}
153153
return child;
154154
},
155155
);
156156
} else if (snapshot.hasError) {
157-
return altWidget?.call(_alt(attributes)) ?? Text(_alt(attributes) ?? "",
158-
style: context.style.generateTextStyle());
157+
return altWidget?.call(_alt(attributes)) ??
158+
Text(_alt(attributes) ?? "", style: context.style.generateTextStyle());
159159
} else {
160160
return loadingWidget?.call() ?? const CircularProgressIndicator();
161161
}
162162
},
163163
);
164164
};
165165

166+
ImageRender svgDataImageRender() => (context, attributes, element) {
167+
final dataUri = _dataUriFormat.firstMatch(_src(attributes)!);
168+
final data = dataUri?.namedGroup('data');
169+
if (data == null) return null;
170+
if (dataUri?.namedGroup('encoding') == ';base64') {
171+
final decodedImage = base64.decode(data.trim());
172+
return SvgPicture.memory(
173+
decodedImage,
174+
width: _width(attributes),
175+
height: _height(attributes),
176+
);
177+
}
178+
return SvgPicture.string(Uri.decodeFull(data));
179+
};
180+
166181
ImageRender svgNetworkImageRender() => (context, attributes, element) {
167-
return SvgPicture.network(_src(attributes)!);
182+
return SvgPicture.network(
183+
attributes["src"]!,
184+
width: _width(attributes),
185+
height: _height(attributes),
186+
);
168187
};
169188

170189
final Map<ImageSourceMatcher, ImageRender> defaultImageRenders = {
171-
base64DataUriMatcher(): base64ImageRender(),
190+
dataUriMatcher(mime: 'image/svg+xml', encoding: null): svgDataImageRender(),
191+
dataUriMatcher(): base64ImageRender(),
172192
assetUriMatcher(): assetImageRender(),
173193
networkSourceMatcher(extension: "svg"): svgNetworkImageRender(),
174194
networkSourceMatcher(): networkImageRender(),

lib/src/layout_element.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ class TableLayoutElement extends LayoutElement {
3030
@override
3131
Widget toWidget(RenderContext context) {
3232
return Container(
33+
margin: style.margin,
34+
padding: style.padding,
3335
decoration: BoxDecoration(
3436
color: style.backgroundColor,
3537
border: style.border,
@@ -95,7 +97,7 @@ class TableLayoutElement extends LayoutElement {
9597
for (var row in rows) {
9698
int columni = 0;
9799
for (var child in row.children) {
98-
if (columnRowOffset[columni] > 0) {
100+
while (columnRowOffset[columni] > 0) {
99101
columnRowOffset[columni] = columnRowOffset[columni] - 1;
100102
columni++;
101103
}

0 commit comments

Comments
 (0)