Skip to content

Commit c1697dd

Browse files
committed
Add ImageExtension
1 parent 6638d16 commit c1697dd

File tree

7 files changed

+212
-96
lines changed

7 files changed

+212
-96
lines changed

lib/src/builtins/image_builtin.dart

Lines changed: 136 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,34 @@ import 'package:flutter_html/flutter_html.dart';
55
import 'package:flutter_html/src/tree/image_element.dart';
66

77
class ImageBuiltIn extends Extension {
8+
final String? dataEncoding;
9+
final Set<String>? mimeTypes;
810
final Map<String, String>? networkHeaders;
11+
final Set<String> networkSchemas;
12+
final Set<String>? networkDomains;
13+
final Set<String>? fileExtensions;
14+
15+
final String assetSchema;
16+
final AssetBundle? assetBundle;
17+
final String? assetPackage;
18+
19+
final bool handleNetworkImages;
20+
final bool handleAssetImages;
21+
final bool handleDataImages;
922

10-
//TODO how can the end user access this?
1123
const ImageBuiltIn({
1224
this.networkHeaders,
25+
this.networkDomains,
26+
this.networkSchemas = const {"http", "https"},
27+
this.fileExtensions,
28+
this.assetSchema = "asset:",
29+
this.assetBundle,
30+
this.assetPackage,
31+
this.mimeTypes,
32+
this.dataEncoding,
33+
this.handleNetworkImages = true,
34+
this.handleAssetImages = true,
35+
this.handleDataImages = true,
1336
});
1437

1538
@override
@@ -23,30 +46,9 @@ class ImageBuiltIn extends Extension {
2346
return false;
2447
}
2548

26-
if (context.attributes['src'] == null) {
27-
return false;
28-
}
29-
30-
final src = context.attributes['src']!;
31-
32-
// Data Image Schema:
33-
final dataUri = dataUriFormat.firstMatch(src);
34-
if (dataUri != null && dataUri.namedGroup('mime') != "image/svg+xml") {
35-
return true;
36-
}
37-
38-
// Asset Image Schema:
39-
if (src.startsWith("asset:") && !src.endsWith(".svg")) {
40-
return true;
41-
}
42-
43-
// Network Image Schema:
44-
try {
45-
final srcUri = Uri.parse(src);
46-
return !srcUri.path.endsWith(".svg");
47-
} on FormatException {
48-
return false;
49-
}
49+
return (_matchesNetworkImage(context) && handleNetworkImages) ||
50+
(_matchesAssetImage(context) && handleAssetImages) ||
51+
(_matchesBase64Image(context) && handleDataImages);
5052
}
5153

5254
@override
@@ -72,92 +74,125 @@ class ImageBuiltIn extends Extension {
7274
Map<StyledElement, InlineSpan> Function() parseChildren) {
7375
final element = context.styledElement as ImageElement;
7476

75-
final dataUri = dataUriFormat.firstMatch(element.src);
76-
if (dataUri != null && dataUri.namedGroup('mime') != "image/svg+xml") {
77-
return WidgetSpan(
78-
child: _base64ImageRender(context),
79-
);
80-
}
77+
final imageStyle = Style(
78+
width: element.width,
79+
height: element.height,
80+
).merge(context.styledElement!.style);
8181

82-
if (element.src.startsWith("asset:") && !element.src.endsWith(".svg")) {
83-
return WidgetSpan(
84-
child: _assetImageRender(context),
85-
);
82+
late Widget child;
83+
if (_matchesBase64Image(context)) {
84+
child = _base64ImageRender(context, imageStyle);
85+
} else if (_matchesAssetImage(context)) {
86+
child = _assetImageRender(context, imageStyle);
87+
} else if (_matchesNetworkImage(context)) {
88+
child = _networkImageRender(context, imageStyle);
89+
} else {
90+
// Our matcher went a little overboard and matched
91+
// something we can't render
92+
return TextSpan(text: element.alt);
8693
}
8794

88-
try {
89-
final srcUri = Uri.parse(element.src);
90-
return WidgetSpan(
91-
child: _networkImageRender(context, srcUri),
92-
);
93-
} on FormatException {
94-
return const TextSpan(text: "");
95-
}
95+
return WidgetSpan(
96+
child: CssBoxWidget(
97+
style: imageStyle,
98+
childIsReplaced: true,
99+
child: child,
100+
),
101+
);
96102
}
97103

98104
static RegExp get dataUriFormat => RegExp(
99-
r"^(?<scheme>data):(?<mime>image\/[\w+\-.]+);(?<encoding>base64)?,\s*(?<data>.*)");
105+
r"^(?<scheme>data):(?<mime>image/[\w+\-.]+);*(?<encoding>base64)?,\s*(?<data>.*)");
100106

101-
//TODO remove repeated code between these methods:
107+
bool _matchesBase64Image(ExtensionContext context) {
108+
final attributes = context.attributes;
102109

103-
Widget _base64ImageRender(ExtensionContext context) {
110+
if (attributes['src'] == null) {
111+
return false;
112+
}
113+
114+
final dataUri = dataUriFormat.firstMatch(attributes['src']!);
115+
116+
return context.elementName == "img" &&
117+
dataUri != null &&
118+
(mimeTypes == null ||
119+
mimeTypes!.contains(dataUri.namedGroup('mime'))) &&
120+
dataUri.namedGroup('mime') != 'image/svg+xml' &&
121+
(dataEncoding == null ||
122+
dataUri.namedGroup('encoding') == dataEncoding);
123+
}
124+
125+
bool _matchesAssetImage(ExtensionContext context) {
126+
final attributes = context.attributes;
127+
128+
return context.elementName == "img" &&
129+
attributes['src'] != null &&
130+
!attributes['src']!.endsWith(".svg") &&
131+
attributes['src']!.startsWith(assetSchema) &&
132+
(fileExtensions == null ||
133+
attributes['src']!.endsWithAnyFileExtension(fileExtensions!));
134+
}
135+
136+
bool _matchesNetworkImage(ExtensionContext context) {
137+
final attributes = context.attributes;
138+
139+
if (attributes['src'] == null) {
140+
return false;
141+
}
142+
143+
final src = Uri.tryParse(attributes['src']!);
144+
if (src == null) {
145+
return false;
146+
}
147+
148+
return context.elementName == "img" &&
149+
networkSchemas.contains(src.scheme) &&
150+
!src.path.endsWith(".svg") &&
151+
(networkDomains == null || networkDomains!.contains(src.host)) &&
152+
(fileExtensions == null ||
153+
src.path.endsWithAnyFileExtension(fileExtensions!));
154+
}
155+
156+
Widget _base64ImageRender(ExtensionContext context, Style imageStyle) {
104157
final element = context.styledElement as ImageElement;
105158
final decodedImage = base64.decode(element.src.split("base64,")[1].trim());
106-
final imageStyle = Style(
107-
width: element.width,
108-
height: element.height,
109-
).merge(context.styledElement!.style);
110159

111-
return CssBoxWidget(
112-
style: imageStyle,
113-
childIsReplaced: true,
114-
child: Image.memory(
115-
decodedImage,
116-
width: imageStyle.width?.value,
117-
height: imageStyle.height?.value,
118-
fit: BoxFit.fill,
119-
errorBuilder: (ctx, error, stackTrace) {
120-
return Text(
121-
element.alt ?? "",
122-
style: context.styledElement!.style.generateTextStyle(),
123-
);
124-
},
125-
),
160+
return Image.memory(
161+
decodedImage,
162+
width: imageStyle.width?.value,
163+
height: imageStyle.height?.value,
164+
fit: BoxFit.fill,
165+
errorBuilder: (ctx, error, stackTrace) {
166+
return Text(
167+
element.alt ?? "",
168+
style: context.styledElement!.style.generateTextStyle(),
169+
);
170+
},
126171
);
127172
}
128173

129-
Widget _assetImageRender(ExtensionContext context) {
174+
Widget _assetImageRender(ExtensionContext context, Style imageStyle) {
130175
final element = context.styledElement as ImageElement;
131176
final assetPath = element.src.replaceFirst('asset:', '');
132-
final imageStyle = Style(
133-
width: element.width,
134-
height: element.height,
135-
).merge(context.styledElement!.style);
136177

137-
return CssBoxWidget(
138-
style: imageStyle,
139-
childIsReplaced: true,
140-
child: Image.asset(
141-
assetPath,
142-
width: imageStyle.width?.value,
143-
height: imageStyle.height?.value,
144-
fit: BoxFit.fill,
145-
errorBuilder: (ctx, error, stackTrace) {
146-
return Text(
147-
element.alt ?? "",
148-
style: context.styledElement!.style.generateTextStyle(),
149-
);
150-
},
151-
),
178+
return Image.asset(
179+
assetPath,
180+
width: imageStyle.width?.value,
181+
height: imageStyle.height?.value,
182+
fit: BoxFit.fill,
183+
bundle: assetBundle,
184+
package: assetPackage,
185+
errorBuilder: (ctx, error, stackTrace) {
186+
return Text(
187+
element.alt ?? "",
188+
style: context.styledElement!.style.generateTextStyle(),
189+
);
190+
},
152191
);
153192
}
154193

155-
Widget _networkImageRender(ExtensionContext context, Uri srcUri) {
194+
Widget _networkImageRender(ExtensionContext context, Style imageStyle) {
156195
final element = context.styledElement as ImageElement;
157-
final imageStyle = Style(
158-
width: element.width,
159-
height: element.height,
160-
).merge(context.styledElement!.style);
161196

162197
return CssBoxWidget(
163198
style: imageStyle,
@@ -178,3 +213,14 @@ class ImageBuiltIn extends Extension {
178213
);
179214
}
180215
}
216+
217+
extension _SetFolding on String {
218+
bool endsWithAnyFileExtension(Iterable<String> endings) {
219+
for (final element in endings) {
220+
if (endsWith(".$element")) {
221+
return true;
222+
}
223+
}
224+
return false;
225+
}
226+
}

lib/src/css_box_widget.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ class CssBoxWidget extends StatelessWidget {
166166
}
167167

168168
class _CSSBoxRenderer extends MultiChildRenderObjectWidget {
169-
_CSSBoxRenderer({
169+
const _CSSBoxRenderer({
170170
Key? key,
171171
required super.children,
172172
required this.display,

lib/src/extension/extension.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import 'package:flutter_html/src/tree/styled_element.dart';
66
export 'package:flutter_html/src/extension/extension_context.dart';
77
export 'package:flutter_html/src/extension/helpers/tag_extension.dart';
88
export 'package:flutter_html/src/extension/helpers/matcher_extension.dart';
9+
export 'package:flutter_html/src/extension/helpers/image_extension.dart';
910

1011
/// The [Extension] class allows you to customize the behavior of flutter_html
1112
/// or add additional functionality.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import 'package:flutter/widgets.dart';
2+
import 'package:flutter_html/src/builtins/image_builtin.dart';
3+
import 'package:flutter_html/src/extension/extension.dart';
4+
5+
class ImageExtension extends ImageBuiltIn {
6+
late final InlineSpan Function(ExtensionContext) builder;
7+
8+
/// [ImageExtension] allows you to extend the built-in <img> support by
9+
/// providing a custom way to render a specific selection of attributes
10+
/// or providing headers or asset package/bundle specifications.
11+
ImageExtension({
12+
super.assetBundle,
13+
super.assetPackage,
14+
super.assetSchema = "asset:",
15+
super.dataEncoding,
16+
super.fileExtensions,
17+
super.mimeTypes,
18+
super.networkDomains,
19+
super.networkHeaders,
20+
super.networkSchemas = const {"http", "https"},
21+
super.handleAssetImages = true,
22+
super.handleDataImages = true,
23+
super.handleNetworkImages = true,
24+
Widget? child,
25+
Widget Function(ExtensionContext)? builder,
26+
}) : assert((child != null) || (builder != null),
27+
"Either child or builder needs to be provided to ImageExtension") {
28+
if (child != null) {
29+
this.builder = (_) => WidgetSpan(child: child);
30+
} else {
31+
this.builder = (context) => WidgetSpan(child: builder!.call(context));
32+
}
33+
}
34+
35+
/// See [ImageExtension]. The only difference is that this method allows you
36+
/// to directly pass an InlineSpan through `child` or `builder`, allowing you
37+
/// to construct more seamless extensions.
38+
ImageExtension.inline({
39+
super.assetBundle,
40+
super.assetPackage,
41+
super.assetSchema = "asset:",
42+
super.dataEncoding,
43+
super.fileExtensions,
44+
super.mimeTypes,
45+
super.networkDomains,
46+
super.networkHeaders,
47+
super.networkSchemas = const {"http", "https"},
48+
super.handleAssetImages = true,
49+
super.handleDataImages = true,
50+
super.handleNetworkImages = true,
51+
InlineSpan? child,
52+
InlineSpan Function(ExtensionContext)? builder,
53+
}) : assert((child != null) || (builder != null),
54+
"Either child or builder needs to be provided to ImageExtension.inline") {
55+
if (child != null) {
56+
this.builder = (_) => child;
57+
} else {
58+
this.builder = builder!;
59+
}
60+
}
61+
62+
@override
63+
InlineSpan parse(ExtensionContext context, parseChildren) {
64+
return builder.call(context);
65+
}
66+
}

lib/src/extension/helpers/matcher_extension.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ class MatcherExtension extends Extension {
99
required this.matcher,
1010
Widget? child,
1111
Widget Function(ExtensionContext)? builder,
12-
}) : assert((child != null) ^ (builder != null)) {
12+
}) : assert((child != null) || (builder != null)) {
1313
if (child != null) {
1414
this.builder = (_) => WidgetSpan(child: child);
1515
} else {
@@ -21,7 +21,7 @@ class MatcherExtension extends Extension {
2121
required this.matcher,
2222
InlineSpan? child,
2323
InlineSpan Function(ExtensionContext)? builder,
24-
}) : assert((child != null) ^ (builder != null)) {
24+
}) : assert((child != null) || (builder != null)) {
2525
if (child != null) {
2626
this.builder = (_) => child;
2727
} else {

lib/src/extension/helpers/tag_extension.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ class TagExtension extends Extension {
1313
required this.tagsToExtend,
1414
Widget? child,
1515
Widget Function(ExtensionContext)? builder,
16-
}) : assert((child == null) ^ (builder == null),
16+
}) : assert((child != null) || (builder != null),
1717
"Either child or builder needs to be provided to TagExtension") {
1818
if (child != null) {
1919
this.builder = (_) => WidgetSpan(child: child);
@@ -29,7 +29,7 @@ class TagExtension extends Extension {
2929
required this.tagsToExtend,
3030
InlineSpan? child,
3131
InlineSpan Function(ExtensionContext)? builder,
32-
}) : assert((child == null) ^ (builder == null),
32+
}) : assert((child != null) || (builder != null),
3333
"Either child or builder needs to be provided to TagExtension.inline") {
3434
if (child != null) {
3535
this.builder = (_) => child;

0 commit comments

Comments
 (0)