parent
eb80d3c916
commit
35c1130191
@ -0,0 +1,635 @@
|
|||||||
|
import 'package:collection/collection.dart';
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import './flutter_html.dart';
|
||||||
|
import './src/utils.dart';
|
||||||
|
|
||||||
|
typedef CustomRenderMatcher = bool Function(RenderContext context);
|
||||||
|
|
||||||
|
CustomRenderMatcher tagMatcher(String tag) => (context) {
|
||||||
|
return context.tree.element?.localName == tag;
|
||||||
|
};
|
||||||
|
|
||||||
|
CustomRenderMatcher blockElementMatcher() => (context) {
|
||||||
|
return context.tree.style.display == Display.BLOCK &&
|
||||||
|
(context.tree.children.isNotEmpty ||
|
||||||
|
context.tree.element?.localName == "hr");
|
||||||
|
};
|
||||||
|
|
||||||
|
CustomRenderMatcher listElementMatcher() => (context) {
|
||||||
|
return context.tree.style.display == Display.LIST_ITEM;
|
||||||
|
};
|
||||||
|
|
||||||
|
CustomRenderMatcher replacedElementMatcher() => (context) {
|
||||||
|
return context.tree is ReplacedElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
CustomRenderMatcher dataUriMatcher(
|
||||||
|
{String? encoding = 'base64', String? mime}) =>
|
||||||
|
(context) {
|
||||||
|
if (context.tree.element?.attributes == null ||
|
||||||
|
_src(context.tree.element!.attributes.cast()) == null) return false;
|
||||||
|
final dataUri = _dataUriFormat
|
||||||
|
.firstMatch(_src(context.tree.element!.attributes.cast())!);
|
||||||
|
return dataUri != null &&
|
||||||
|
dataUri.namedGroup('mime') != "image/svg+xml" &&
|
||||||
|
(mime == null || dataUri.namedGroup('mime') == mime) &&
|
||||||
|
(encoding == null || dataUri.namedGroup('encoding') == ';$encoding');
|
||||||
|
};
|
||||||
|
|
||||||
|
CustomRenderMatcher networkSourceMatcher({
|
||||||
|
List<String> schemas: const ["https", "http"],
|
||||||
|
List<String>? domains,
|
||||||
|
String? extension,
|
||||||
|
}) =>
|
||||||
|
(context) {
|
||||||
|
if (context.tree.element?.attributes.cast() == null ||
|
||||||
|
_src(context.tree.element!.attributes.cast()) == null) return false;
|
||||||
|
try {
|
||||||
|
final src = Uri.parse(_src(context.tree.element!.attributes.cast())!);
|
||||||
|
return schemas.contains(src.scheme) &&
|
||||||
|
(domains == null || domains.contains(src.host)) &&
|
||||||
|
(extension == null || src.path.endsWith(".$extension"));
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
CustomRenderMatcher assetUriMatcher() => (context) =>
|
||||||
|
context.tree.element?.attributes.cast() != null &&
|
||||||
|
_src(context.tree.element!.attributes.cast()) != null &&
|
||||||
|
_src(context.tree.element!.attributes.cast())!.startsWith("asset:") &&
|
||||||
|
!_src(context.tree.element!.attributes.cast())!.endsWith(".svg");
|
||||||
|
|
||||||
|
CustomRenderMatcher textContentElementMatcher() => (context) {
|
||||||
|
return context.tree is TextContentElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
CustomRenderMatcher interactableElementMatcher() => (context) {
|
||||||
|
return context.tree is InteractableElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
CustomRenderMatcher layoutElementMatcher() => (context) {
|
||||||
|
return context.tree is LayoutElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
CustomRenderMatcher verticalAlignMatcher() => (context) {
|
||||||
|
return context.tree.style.verticalAlign != null &&
|
||||||
|
context.tree.style.verticalAlign != VerticalAlign.BASELINE;
|
||||||
|
};
|
||||||
|
|
||||||
|
CustomRenderMatcher fallbackMatcher() => (context) {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
class CustomRender {
|
||||||
|
final InlineSpan Function(RenderContext, List<InlineSpan> Function())?
|
||||||
|
inlineSpan;
|
||||||
|
final Widget Function(RenderContext, List<InlineSpan> Function())? widget;
|
||||||
|
|
||||||
|
CustomRender.inlineSpan({
|
||||||
|
required this.inlineSpan,
|
||||||
|
}) : widget = null;
|
||||||
|
|
||||||
|
CustomRender.widget({
|
||||||
|
required this.widget,
|
||||||
|
}) : inlineSpan = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SelectableCustomRender extends CustomRender {
|
||||||
|
final TextSpan Function(RenderContext, List<TextSpan> Function()) textSpan;
|
||||||
|
|
||||||
|
SelectableCustomRender.fromTextSpan({
|
||||||
|
required this.textSpan,
|
||||||
|
}) : super.inlineSpan(inlineSpan: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomRender blockElementRender({Style? style, List<InlineSpan>? children}) =>
|
||||||
|
CustomRender.inlineSpan(inlineSpan: (context, buildChildren) {
|
||||||
|
if (context.parser.selectable) {
|
||||||
|
return TextSpan(
|
||||||
|
style: context.style.generateTextStyle(),
|
||||||
|
children: (children as List<TextSpan>?) ??
|
||||||
|
context.tree.children
|
||||||
|
.expandIndexed((i, childTree) => [
|
||||||
|
if (childTree.style.display == Display.BLOCK &&
|
||||||
|
i > 0 &&
|
||||||
|
context.tree.children[i - 1] is ReplacedElement)
|
||||||
|
TextSpan(text: "\n"),
|
||||||
|
context.parser.parseTree(context, childTree),
|
||||||
|
if (i != context.tree.children.length - 1 &&
|
||||||
|
childTree.style.display == Display.BLOCK &&
|
||||||
|
childTree.element?.localName != "html" &&
|
||||||
|
childTree.element?.localName != "body")
|
||||||
|
TextSpan(text: "\n"),
|
||||||
|
])
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return WidgetSpan(
|
||||||
|
child: ContainerSpan(
|
||||||
|
key: context.key,
|
||||||
|
newContext: context,
|
||||||
|
style: style ?? context.tree.style,
|
||||||
|
shrinkWrap: context.parser.shrinkWrap,
|
||||||
|
children: children ??
|
||||||
|
context.tree.children
|
||||||
|
.expandIndexed((i, childTree) => [
|
||||||
|
if (context.parser.shrinkWrap &&
|
||||||
|
childTree.style.display == Display.BLOCK &&
|
||||||
|
i > 0 &&
|
||||||
|
context.tree.children[i - 1] is ReplacedElement)
|
||||||
|
TextSpan(text: "\n"),
|
||||||
|
context.parser.parseTree(context, childTree),
|
||||||
|
if (i != context.tree.children.length - 1 &&
|
||||||
|
childTree.style.display == Display.BLOCK &&
|
||||||
|
childTree.element?.localName != "html" &&
|
||||||
|
childTree.element?.localName != "body")
|
||||||
|
TextSpan(text: "\n"),
|
||||||
|
])
|
||||||
|
.toList(),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
CustomRender listElementRender(
|
||||||
|
{Style? style, Widget? child, List<InlineSpan>? children}) =>
|
||||||
|
CustomRender.inlineSpan(
|
||||||
|
inlineSpan: (context, buildChildren) => WidgetSpan(
|
||||||
|
child: ContainerSpan(
|
||||||
|
key: context.key,
|
||||||
|
newContext: context,
|
||||||
|
style: style ?? context.tree.style,
|
||||||
|
shrinkWrap: context.parser.shrinkWrap,
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
textDirection:
|
||||||
|
style?.direction ?? context.tree.style.direction,
|
||||||
|
children: [
|
||||||
|
(style?.listStylePosition ??
|
||||||
|
context.tree.style.listStylePosition) ==
|
||||||
|
ListStylePosition.OUTSIDE
|
||||||
|
? Padding(
|
||||||
|
padding: style?.padding?.nonNegative ??
|
||||||
|
context.tree.style.padding?.nonNegative ??
|
||||||
|
EdgeInsets.only(
|
||||||
|
left: (style?.direction ??
|
||||||
|
context.tree.style.direction) !=
|
||||||
|
TextDirection.rtl
|
||||||
|
? 10.0
|
||||||
|
: 0.0,
|
||||||
|
right: (style?.direction ??
|
||||||
|
context.tree.style.direction) ==
|
||||||
|
TextDirection.rtl
|
||||||
|
? 10.0
|
||||||
|
: 0.0),
|
||||||
|
child: style?.markerContent ??
|
||||||
|
context.style.markerContent)
|
||||||
|
: Container(height: 0, width: 0),
|
||||||
|
Text("\u0020",
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w400)),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: (style?.listStylePosition ??
|
||||||
|
context.tree.style.listStylePosition) ==
|
||||||
|
ListStylePosition.INSIDE
|
||||||
|
? EdgeInsets.only(
|
||||||
|
left: (style?.direction ??
|
||||||
|
context.tree.style.direction) !=
|
||||||
|
TextDirection.rtl
|
||||||
|
? 10.0
|
||||||
|
: 0.0,
|
||||||
|
right: (style?.direction ??
|
||||||
|
context.tree.style.direction) ==
|
||||||
|
TextDirection.rtl
|
||||||
|
? 10.0
|
||||||
|
: 0.0)
|
||||||
|
: EdgeInsets.zero,
|
||||||
|
child: StyledText(
|
||||||
|
textSpan: TextSpan(
|
||||||
|
children: _getListElementChildren(
|
||||||
|
style?.listStylePosition ??
|
||||||
|
context.tree.style.listStylePosition,
|
||||||
|
buildChildren)
|
||||||
|
..insertAll(
|
||||||
|
0,
|
||||||
|
context.tree.style.listStylePosition ==
|
||||||
|
ListStylePosition.INSIDE
|
||||||
|
? [
|
||||||
|
WidgetSpan(
|
||||||
|
alignment:
|
||||||
|
PlaceholderAlignment
|
||||||
|
.middle,
|
||||||
|
child: style?.markerContent ??
|
||||||
|
context.style
|
||||||
|
.markerContent ??
|
||||||
|
Container(
|
||||||
|
height: 0, width: 0))
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
style: style?.generateTextStyle() ??
|
||||||
|
context.style.generateTextStyle(),
|
||||||
|
),
|
||||||
|
style: style ?? context.style,
|
||||||
|
renderContext: context,
|
||||||
|
)))
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
CustomRender replacedElementRender(
|
||||||
|
{PlaceholderAlignment? alignment,
|
||||||
|
TextBaseline? baseline,
|
||||||
|
Widget? child}) =>
|
||||||
|
CustomRender.inlineSpan(
|
||||||
|
inlineSpan: (context, buildChildren) => WidgetSpan(
|
||||||
|
alignment:
|
||||||
|
alignment ?? (context.tree as ReplacedElement).alignment,
|
||||||
|
baseline: baseline ?? TextBaseline.alphabetic,
|
||||||
|
child:
|
||||||
|
child ?? (context.tree as ReplacedElement).toWidget(context)!,
|
||||||
|
));
|
||||||
|
|
||||||
|
CustomRender textContentElementRender({String? text}) =>
|
||||||
|
CustomRender.inlineSpan(
|
||||||
|
inlineSpan: (context, buildChildren) => TextSpan(
|
||||||
|
style: context.style.generateTextStyle(),
|
||||||
|
text: (text ?? (context.tree as TextContentElement).text)
|
||||||
|
.transformed(context.tree.style.textTransform),
|
||||||
|
));
|
||||||
|
|
||||||
|
CustomRender base64ImageRender() =>
|
||||||
|
CustomRender.widget(widget: (context, buildChildren) {
|
||||||
|
final decodedImage = base64.decode(
|
||||||
|
_src(context.tree.element!.attributes.cast())!
|
||||||
|
.split("base64,")[1]
|
||||||
|
.trim());
|
||||||
|
precacheImage(
|
||||||
|
MemoryImage(decodedImage),
|
||||||
|
context.buildContext,
|
||||||
|
onError: (exception, StackTrace? stackTrace) {
|
||||||
|
context.parser.onImageError?.call(exception, stackTrace);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final widget = Image.memory(
|
||||||
|
decodedImage,
|
||||||
|
frameBuilder: (ctx, child, frame, _) {
|
||||||
|
if (frame == null) {
|
||||||
|
return Text(_alt(context.tree.element!.attributes.cast()) ?? "",
|
||||||
|
style: context.style.generateTextStyle());
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return Builder(
|
||||||
|
key: context.key,
|
||||||
|
builder: (buildContext) {
|
||||||
|
return GestureDetector(
|
||||||
|
child: widget,
|
||||||
|
onTap: () {
|
||||||
|
if (MultipleTapGestureDetector.of(buildContext) != null) {
|
||||||
|
MultipleTapGestureDetector.of(buildContext)!.onTap?.call();
|
||||||
|
}
|
||||||
|
context.parser.onImageTap?.call(
|
||||||
|
_src(context.tree.element!.attributes.cast())!
|
||||||
|
.split("base64,")[1]
|
||||||
|
.trim(),
|
||||||
|
context,
|
||||||
|
context.tree.element!.attributes.cast(),
|
||||||
|
context.tree.element);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
CustomRender assetImageRender({
|
||||||
|
double? width,
|
||||||
|
double? height,
|
||||||
|
}) =>
|
||||||
|
CustomRender.widget(widget: (context, buildChildren) {
|
||||||
|
final assetPath = _src(context.tree.element!.attributes.cast())!
|
||||||
|
.replaceFirst('asset:', '');
|
||||||
|
final widget = Image.asset(
|
||||||
|
assetPath,
|
||||||
|
width: width ?? _width(context.tree.element!.attributes.cast()),
|
||||||
|
height: height ?? _height(context.tree.element!.attributes.cast()),
|
||||||
|
frameBuilder: (ctx, child, frame, _) {
|
||||||
|
if (frame == null) {
|
||||||
|
return Text(_alt(context.tree.element!.attributes.cast()) ?? "",
|
||||||
|
style: context.style.generateTextStyle());
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return Builder(
|
||||||
|
key: context.key,
|
||||||
|
builder: (buildContext) {
|
||||||
|
return GestureDetector(
|
||||||
|
child: widget,
|
||||||
|
onTap: () {
|
||||||
|
if (MultipleTapGestureDetector.of(buildContext) != null) {
|
||||||
|
MultipleTapGestureDetector.of(buildContext)!.onTap?.call();
|
||||||
|
}
|
||||||
|
context.parser.onImageTap?.call(
|
||||||
|
assetPath,
|
||||||
|
context,
|
||||||
|
context.tree.element!.attributes.cast(),
|
||||||
|
context.tree.element);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
CustomRender networkImageRender({
|
||||||
|
Map<String, String>? headers,
|
||||||
|
String Function(String?)? mapUrl,
|
||||||
|
double? width,
|
||||||
|
double? height,
|
||||||
|
Widget Function(String?)? altWidget,
|
||||||
|
Widget Function()? loadingWidget,
|
||||||
|
}) =>
|
||||||
|
CustomRender.widget(widget: (context, buildChildren) {
|
||||||
|
final src = mapUrl?.call(_src(context.tree.element!.attributes.cast())) ??
|
||||||
|
_src(context.tree.element!.attributes.cast())!;
|
||||||
|
Completer<Size> completer = Completer();
|
||||||
|
if (context.parser.cachedImageSizes[src] != null) {
|
||||||
|
completer.complete(context.parser.cachedImageSizes[src]);
|
||||||
|
} else {
|
||||||
|
Image image = Image.network(src, frameBuilder: (ctx, child, frame, _) {
|
||||||
|
if (frame == null) {
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.completeError("error");
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
} else {
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ImageStreamListener? listener;
|
||||||
|
listener =
|
||||||
|
ImageStreamListener((ImageInfo imageInfo, bool synchronousCall) {
|
||||||
|
var myImage = imageInfo.image;
|
||||||
|
Size size = Size(myImage.width.toDouble(), myImage.height.toDouble());
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
context.parser.cachedImageSizes[src] = size;
|
||||||
|
completer.complete(size);
|
||||||
|
image.image.resolve(ImageConfiguration()).removeListener(listener!);
|
||||||
|
}
|
||||||
|
}, onError: (object, stacktrace) {
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.completeError(object);
|
||||||
|
image.image.resolve(ImageConfiguration()).removeListener(listener!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
image.image.resolve(ImageConfiguration()).addListener(listener);
|
||||||
|
}
|
||||||
|
final attributes =
|
||||||
|
context.tree.element!.attributes.cast<String, String>();
|
||||||
|
final widget = FutureBuilder<Size>(
|
||||||
|
future: completer.future,
|
||||||
|
initialData: context.parser.cachedImageSizes[src],
|
||||||
|
builder: (BuildContext buildContext, AsyncSnapshot<Size> snapshot) {
|
||||||
|
if (snapshot.hasData) {
|
||||||
|
return Container(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: width ?? _width(attributes) ?? snapshot.data!.width,
|
||||||
|
maxHeight:
|
||||||
|
(width ?? _width(attributes) ?? snapshot.data!.width) /
|
||||||
|
_aspectRatio(attributes, snapshot)),
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: _aspectRatio(attributes, snapshot),
|
||||||
|
child: Image.network(
|
||||||
|
src,
|
||||||
|
headers: headers,
|
||||||
|
width: width ?? _width(attributes) ?? snapshot.data!.width,
|
||||||
|
height: height ?? _height(attributes),
|
||||||
|
frameBuilder: (ctx, child, frame, _) {
|
||||||
|
if (frame == null) {
|
||||||
|
return altWidget?.call(_alt(attributes)) ??
|
||||||
|
Text(_alt(attributes) ?? "",
|
||||||
|
style: context.style.generateTextStyle());
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (snapshot.hasError) {
|
||||||
|
return altWidget
|
||||||
|
?.call(_alt(context.tree.element!.attributes.cast())) ??
|
||||||
|
Text(_alt(context.tree.element!.attributes.cast()) ?? "",
|
||||||
|
style: context.style.generateTextStyle());
|
||||||
|
} else {
|
||||||
|
return loadingWidget?.call() ?? const CircularProgressIndicator();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return Builder(
|
||||||
|
key: context.key,
|
||||||
|
builder: (buildContext) {
|
||||||
|
return GestureDetector(
|
||||||
|
child: widget,
|
||||||
|
onTap: () {
|
||||||
|
if (MultipleTapGestureDetector.of(buildContext) != null) {
|
||||||
|
MultipleTapGestureDetector.of(buildContext)!.onTap?.call();
|
||||||
|
}
|
||||||
|
context.parser.onImageTap?.call(
|
||||||
|
src,
|
||||||
|
context,
|
||||||
|
context.tree.element!.attributes.cast(),
|
||||||
|
context.tree.element);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
CustomRender interactableElementRender({List<InlineSpan>? children}) =>
|
||||||
|
CustomRender.inlineSpan(
|
||||||
|
inlineSpan: (context, buildChildren) => TextSpan(
|
||||||
|
children: children ??
|
||||||
|
(context.tree as InteractableElement)
|
||||||
|
.children
|
||||||
|
.map((tree) => context.parser.parseTree(context, tree))
|
||||||
|
.map((childSpan) {
|
||||||
|
return _getInteractableChildren(
|
||||||
|
context,
|
||||||
|
context.tree as InteractableElement,
|
||||||
|
childSpan,
|
||||||
|
context.style
|
||||||
|
.generateTextStyle()
|
||||||
|
.merge(childSpan.style));
|
||||||
|
}).toList(),
|
||||||
|
));
|
||||||
|
|
||||||
|
CustomRender layoutElementRender({Widget? child}) => CustomRender.inlineSpan(
|
||||||
|
inlineSpan: (context, buildChildren) => WidgetSpan(
|
||||||
|
child: child ?? (context.tree as LayoutElement).toWidget(context)!,
|
||||||
|
));
|
||||||
|
|
||||||
|
CustomRender verticalAlignRender(
|
||||||
|
{double? verticalOffset, Style? style, List<InlineSpan>? children}) =>
|
||||||
|
CustomRender.inlineSpan(
|
||||||
|
inlineSpan: (context, buildChildren) => WidgetSpan(
|
||||||
|
child: Transform.translate(
|
||||||
|
key: context.key,
|
||||||
|
offset: Offset(
|
||||||
|
0, verticalOffset ?? _getVerticalOffset(context.tree)),
|
||||||
|
child: StyledText(
|
||||||
|
textSpan: TextSpan(
|
||||||
|
style: style?.generateTextStyle() ??
|
||||||
|
context.style.generateTextStyle(),
|
||||||
|
children: children ?? buildChildren.call(),
|
||||||
|
),
|
||||||
|
style: context.style,
|
||||||
|
renderContext: context,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
CustomRender fallbackRender({Style? style, List<InlineSpan>? children}) =>
|
||||||
|
CustomRender.inlineSpan(
|
||||||
|
inlineSpan: (context, buildChildren) => TextSpan(
|
||||||
|
style: style?.generateTextStyle() ??
|
||||||
|
context.style.generateTextStyle(),
|
||||||
|
children: context.tree.children
|
||||||
|
.expand((tree) => [
|
||||||
|
context.parser.parseTree(context, tree),
|
||||||
|
if (tree.style.display == Display.BLOCK &&
|
||||||
|
tree.element?.parent?.localName != "th" &&
|
||||||
|
tree.element?.parent?.localName != "td" &&
|
||||||
|
tree.element?.localName != "html" &&
|
||||||
|
tree.element?.localName != "body")
|
||||||
|
TextSpan(text: "\n"),
|
||||||
|
])
|
||||||
|
.toList(),
|
||||||
|
));
|
||||||
|
|
||||||
|
final Map<CustomRenderMatcher, CustomRender> defaultRenders = {
|
||||||
|
blockElementMatcher(): blockElementRender(),
|
||||||
|
listElementMatcher(): listElementRender(),
|
||||||
|
textContentElementMatcher(): textContentElementRender(),
|
||||||
|
dataUriMatcher(): base64ImageRender(),
|
||||||
|
assetUriMatcher(): assetImageRender(),
|
||||||
|
networkSourceMatcher(): networkImageRender(),
|
||||||
|
replacedElementMatcher(): replacedElementRender(),
|
||||||
|
interactableElementMatcher(): interactableElementRender(),
|
||||||
|
layoutElementMatcher(): layoutElementRender(),
|
||||||
|
verticalAlignMatcher(): verticalAlignRender(),
|
||||||
|
fallbackMatcher(): fallbackRender(),
|
||||||
|
};
|
||||||
|
|
||||||
|
List<InlineSpan> _getListElementChildren(
|
||||||
|
ListStylePosition? position, Function() buildChildren) {
|
||||||
|
List<InlineSpan> children = buildChildren.call();
|
||||||
|
if (position == ListStylePosition.INSIDE) {
|
||||||
|
final tabSpan = WidgetSpan(
|
||||||
|
child: Text("\t",
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w400)),
|
||||||
|
);
|
||||||
|
children.insert(0, tabSpan);
|
||||||
|
}
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
InlineSpan _getInteractableChildren(RenderContext context,
|
||||||
|
InteractableElement tree, InlineSpan childSpan, TextStyle childStyle) {
|
||||||
|
if (childSpan is TextSpan) {
|
||||||
|
return TextSpan(
|
||||||
|
text: childSpan.text,
|
||||||
|
children: childSpan.children
|
||||||
|
?.map((e) => _getInteractableChildren(
|
||||||
|
context, tree, e, childStyle.merge(childSpan.style)))
|
||||||
|
.toList(),
|
||||||
|
style: context.style.generateTextStyle().merge(childSpan.style == null
|
||||||
|
? childStyle
|
||||||
|
: childStyle.merge(childSpan.style)),
|
||||||
|
semanticsLabel: childSpan.semanticsLabel,
|
||||||
|
recognizer: TapGestureRecognizer()
|
||||||
|
..onTap = context.parser.internalOnAnchorTap != null
|
||||||
|
? () => context.parser.internalOnAnchorTap!(
|
||||||
|
tree.href, context, tree.attributes, tree.element)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return WidgetSpan(
|
||||||
|
child: MultipleTapGestureDetector(
|
||||||
|
onTap: context.parser.internalOnAnchorTap != null
|
||||||
|
? () => context.parser.internalOnAnchorTap!(
|
||||||
|
tree.href, context, tree.attributes, tree.element)
|
||||||
|
: null,
|
||||||
|
child: GestureDetector(
|
||||||
|
key: context.key,
|
||||||
|
onTap: context.parser.internalOnAnchorTap != null
|
||||||
|
? () => context.parser.internalOnAnchorTap!(
|
||||||
|
tree.href, context, tree.attributes, tree.element)
|
||||||
|
: null,
|
||||||
|
child: (childSpan as WidgetSpan).child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final _dataUriFormat = RegExp(
|
||||||
|
"^(?<scheme>data):(?<mime>image\/[\\w\+\-\.]+)(?<encoding>;base64)?\,(?<data>.*)");
|
||||||
|
|
||||||
|
double _getVerticalOffset(StyledElement tree) {
|
||||||
|
switch (tree.style.verticalAlign) {
|
||||||
|
case VerticalAlign.SUB:
|
||||||
|
return tree.style.fontSize!.size! / 2.5;
|
||||||
|
case VerticalAlign.SUPER:
|
||||||
|
return tree.style.fontSize!.size! / -2.5;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _src(Map<String, String> attributes) {
|
||||||
|
return attributes["src"];
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _alt(Map<String, String> attributes) {
|
||||||
|
return attributes["alt"];
|
||||||
|
}
|
||||||
|
|
||||||
|
double? _height(Map<String, String> attributes) {
|
||||||
|
final heightString = attributes["height"];
|
||||||
|
return heightString == null
|
||||||
|
? heightString as double?
|
||||||
|
: double.tryParse(heightString);
|
||||||
|
}
|
||||||
|
|
||||||
|
double? _width(Map<String, String> attributes) {
|
||||||
|
final widthString = attributes["width"];
|
||||||
|
return widthString == null
|
||||||
|
? widthString as double?
|
||||||
|
: double.tryParse(widthString);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _aspectRatio(
|
||||||
|
Map<String, String> attributes, AsyncSnapshot<Size> calculated) {
|
||||||
|
final heightString = attributes["height"];
|
||||||
|
final widthString = attributes["width"];
|
||||||
|
if (heightString != null && widthString != null) {
|
||||||
|
final height = double.tryParse(heightString);
|
||||||
|
final width = double.tryParse(widthString);
|
||||||
|
return height == null || width == null
|
||||||
|
? calculated.data!.aspectRatio
|
||||||
|
: width / height;
|
||||||
|
}
|
||||||
|
return calculated.data!.aspectRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ClampedEdgeInsets on EdgeInsetsGeometry {
|
||||||
|
EdgeInsetsGeometry get nonNegative =>
|
||||||
|
this.clamp(EdgeInsets.zero, const EdgeInsets.all(double.infinity));
|
||||||
|
}
|
@ -0,0 +1,371 @@
|
|||||||
|
library flutter_html;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import './custom_render.dart';
|
||||||
|
import './html_parser.dart';
|
||||||
|
import './src/html_elements.dart';
|
||||||
|
import './style.dart';
|
||||||
|
import 'package:html/dom.dart' as dom;
|
||||||
|
|
||||||
|
export './custom_render.dart';
|
||||||
|
//export render context api
|
||||||
|
export './html_parser.dart';
|
||||||
|
//export render context api
|
||||||
|
export './html_parser.dart';
|
||||||
|
//export src for advanced custom render uses (e.g. casting context.tree)
|
||||||
|
export './src/anchor.dart';
|
||||||
|
export './src/interactable_element.dart';
|
||||||
|
export './src/layout_element.dart';
|
||||||
|
export './src/replaced_element.dart';
|
||||||
|
export './src/styled_element.dart';
|
||||||
|
//export style api
|
||||||
|
export './style.dart';
|
||||||
|
|
||||||
|
class Html extends StatefulWidget {
|
||||||
|
/// The `Html` widget takes HTML as input and displays a RichText
|
||||||
|
/// tree of the parsed HTML content.
|
||||||
|
///
|
||||||
|
/// **Attributes**
|
||||||
|
/// **data** *required* takes in a String of HTML data (required only for `Html` constructor).
|
||||||
|
/// **document** *required* takes in a Document of HTML data (required only for `Html.fromDom` constructor).
|
||||||
|
///
|
||||||
|
/// **onLinkTap** This function is called whenever a link (`<a href>`)
|
||||||
|
/// is tapped.
|
||||||
|
/// **customRender** This function allows you to return your own widgets
|
||||||
|
/// for existing or custom HTML tags.
|
||||||
|
/// See [its wiki page](https://github.com/Sub6Resources/flutter_html/wiki/All-About-customRender) for more info.
|
||||||
|
///
|
||||||
|
/// **onImageError** This is called whenever an image fails to load or
|
||||||
|
/// display on the page.
|
||||||
|
///
|
||||||
|
/// **shrinkWrap** This makes the Html widget take up only the width it
|
||||||
|
/// needs and no more.
|
||||||
|
///
|
||||||
|
/// **onImageTap** This is called whenever an image is tapped.
|
||||||
|
///
|
||||||
|
/// **tagsList** Tag names in this array will be the only tags rendered. By default all supported HTML tags are rendered.
|
||||||
|
///
|
||||||
|
/// **style** Pass in the style information for the Html here.
|
||||||
|
/// See [its wiki page](https://github.com/Sub6Resources/flutter_html/wiki/Style) for more info.
|
||||||
|
Html({
|
||||||
|
Key? key,
|
||||||
|
GlobalKey? anchorKey,
|
||||||
|
required this.data,
|
||||||
|
this.onLinkTap,
|
||||||
|
this.onAnchorTap,
|
||||||
|
this.customRenders = const {},
|
||||||
|
this.onCssParseError,
|
||||||
|
this.onImageError,
|
||||||
|
this.shrinkWrap = false,
|
||||||
|
this.onImageTap,
|
||||||
|
this.tagsList = const [],
|
||||||
|
this.style = const {},
|
||||||
|
}) : documentElement = null,
|
||||||
|
assert(data != null),
|
||||||
|
_anchorKey = anchorKey ?? GlobalKey(),
|
||||||
|
super(key: key);
|
||||||
|
|
||||||
|
Html.fromDom({
|
||||||
|
Key? key,
|
||||||
|
GlobalKey? anchorKey,
|
||||||
|
@required dom.Document? document,
|
||||||
|
this.onLinkTap,
|
||||||
|
this.onAnchorTap,
|
||||||
|
this.customRenders = const {},
|
||||||
|
this.onCssParseError,
|
||||||
|
this.onImageError,
|
||||||
|
this.shrinkWrap = false,
|
||||||
|
this.onImageTap,
|
||||||
|
this.tagsList = const [],
|
||||||
|
this.style = const {},
|
||||||
|
}) : data = null,
|
||||||
|
assert(document != null),
|
||||||
|
this.documentElement = document!.documentElement,
|
||||||
|
_anchorKey = anchorKey ?? GlobalKey(),
|
||||||
|
super(key: key);
|
||||||
|
|
||||||
|
Html.fromElement({
|
||||||
|
Key? key,
|
||||||
|
GlobalKey? anchorKey,
|
||||||
|
@required this.documentElement,
|
||||||
|
this.onLinkTap,
|
||||||
|
this.onAnchorTap,
|
||||||
|
this.customRenders = const {},
|
||||||
|
this.onCssParseError,
|
||||||
|
this.onImageError,
|
||||||
|
this.shrinkWrap = false,
|
||||||
|
this.onImageTap,
|
||||||
|
this.tagsList = const [],
|
||||||
|
this.style = const {},
|
||||||
|
}) : data = null,
|
||||||
|
assert(documentElement != null),
|
||||||
|
_anchorKey = anchorKey ?? GlobalKey(),
|
||||||
|
super(key: key);
|
||||||
|
|
||||||
|
/// A unique key for this Html widget to ensure uniqueness of anchors
|
||||||
|
final GlobalKey _anchorKey;
|
||||||
|
|
||||||
|
/// The HTML data passed to the widget as a String
|
||||||
|
final String? data;
|
||||||
|
|
||||||
|
/// The HTML data passed to the widget as a pre-processed [dom.Element]
|
||||||
|
final dom.Element? documentElement;
|
||||||
|
|
||||||
|
/// A function that defines what to do when a link is tapped
|
||||||
|
final OnTap? onLinkTap;
|
||||||
|
|
||||||
|
/// A function that defines what to do when an anchor link is tapped. When this value is set,
|
||||||
|
/// the default anchor behaviour is overwritten.
|
||||||
|
final OnTap? onAnchorTap;
|
||||||
|
|
||||||
|
/// A function that defines what to do when CSS fails to parse
|
||||||
|
final OnCssParseError? onCssParseError;
|
||||||
|
|
||||||
|
/// A function that defines what to do when an image errors
|
||||||
|
final ImageErrorListener? onImageError;
|
||||||
|
|
||||||
|
/// A parameter that should be set when the HTML widget is expected to be
|
||||||
|
/// flexible
|
||||||
|
final bool shrinkWrap;
|
||||||
|
|
||||||
|
/// A function that defines what to do when an image is tapped
|
||||||
|
final OnTap? onImageTap;
|
||||||
|
|
||||||
|
/// A list of HTML tags that are the only tags that are rendered. By default, this list is empty and all supported HTML tags are rendered.
|
||||||
|
final List<String> tagsList;
|
||||||
|
|
||||||
|
/// Either return a custom widget for specific node types or return null to
|
||||||
|
/// fallback to the default rendering.
|
||||||
|
final Map<CustomRenderMatcher, CustomRender> customRenders;
|
||||||
|
|
||||||
|
/// An API that allows you to override the default style for any HTML element
|
||||||
|
final Map<String, Style> style;
|
||||||
|
|
||||||
|
static List<String> get tags => new List<String>.from(STYLED_ELEMENTS)
|
||||||
|
..addAll(INTERACTABLE_ELEMENTS)
|
||||||
|
..addAll(REPLACED_ELEMENTS)
|
||||||
|
..addAll(LAYOUT_ELEMENTS)
|
||||||
|
..addAll(TABLE_CELL_ELEMENTS)
|
||||||
|
..addAll(TABLE_DEFINITION_ELEMENTS)
|
||||||
|
..addAll(EXTERNAL_ELEMENTS);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StatefulWidget> createState() => _HtmlState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HtmlState extends State<Html> {
|
||||||
|
late dom.Element documentElement;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
documentElement = widget.data != null
|
||||||
|
? HtmlParser.parseHTML(widget.data!)
|
||||||
|
: widget.documentElement!;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(Html oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if ((widget.data != null && oldWidget.data != widget.data) ||
|
||||||
|
oldWidget.documentElement != widget.documentElement) {
|
||||||
|
documentElement = widget.data != null
|
||||||
|
? HtmlParser.parseHTML(widget.data!)
|
||||||
|
: widget.documentElement!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: widget.shrinkWrap ? null : MediaQuery.of(context).size.width,
|
||||||
|
child: HtmlParser(
|
||||||
|
key: widget._anchorKey,
|
||||||
|
htmlData: documentElement,
|
||||||
|
onLinkTap: widget.onLinkTap,
|
||||||
|
onAnchorTap: widget.onAnchorTap,
|
||||||
|
onImageTap: widget.onImageTap,
|
||||||
|
onCssParseError: widget.onCssParseError,
|
||||||
|
onImageError: widget.onImageError,
|
||||||
|
shrinkWrap: widget.shrinkWrap,
|
||||||
|
selectable: false,
|
||||||
|
style: widget.style,
|
||||||
|
customRenders: {}
|
||||||
|
..addAll(widget.customRenders)
|
||||||
|
..addAll(defaultRenders),
|
||||||
|
tagsList: widget.tagsList.isEmpty ? Html.tags : widget.tagsList,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SelectableHtml extends StatefulWidget {
|
||||||
|
/// The `SelectableHtml` widget takes HTML as input and displays a RichText
|
||||||
|
/// tree of the parsed HTML content (which is selectable)
|
||||||
|
///
|
||||||
|
/// **Attributes**
|
||||||
|
/// **data** *required* takes in a String of HTML data (required only for `Html` constructor).
|
||||||
|
/// **documentElement** *required* takes in a Element of HTML data (required only for `Html.fromDom` and `Html.fromElement` constructor).
|
||||||
|
///
|
||||||
|
/// **onLinkTap** This function is called whenever a link (`<a href>`)
|
||||||
|
/// is tapped.
|
||||||
|
///
|
||||||
|
/// **onAnchorTap** This function is called whenever an anchor (#anchor-id)
|
||||||
|
/// is tapped.
|
||||||
|
///
|
||||||
|
/// **tagsList** Tag names in this array will be the only tags rendered. By default, all tags that support selectable content are rendered.
|
||||||
|
///
|
||||||
|
/// **style** Pass in the style information for the Html here.
|
||||||
|
/// See [its wiki page](https://github.com/Sub6Resources/flutter_html/wiki/Style) for more info.
|
||||||
|
///
|
||||||
|
/// **PLEASE NOTE**
|
||||||
|
///
|
||||||
|
/// There are a few caveats due to Flutter [#38474](https://github.com/flutter/flutter/issues/38474):
|
||||||
|
///
|
||||||
|
/// 1. The list of tags that can be rendered is significantly reduced.
|
||||||
|
/// Key omissions include no support for images/video/audio, table, and ul/ol because they all require widgets and `WidgetSpan`s.
|
||||||
|
///
|
||||||
|
/// 2. No support for `customRender`, `customImageRender`, `onImageError`, `onImageTap`, `onMathError`, and `navigationDelegateForIframe`.
|
||||||
|
///
|
||||||
|
/// 3. Styling support is significantly reduced. Only text-related styling works
|
||||||
|
/// (e.g. bold or italic), while container related styling (e.g. borders or padding/margin)
|
||||||
|
/// do not work because we can't use the `ContainerSpan` class (it needs an enclosing `WidgetSpan`).
|
||||||
|
|
||||||
|
SelectableHtml({
|
||||||
|
Key? key,
|
||||||
|
GlobalKey? anchorKey,
|
||||||
|
required this.data,
|
||||||
|
this.onLinkTap,
|
||||||
|
this.onAnchorTap,
|
||||||
|
this.onCssParseError,
|
||||||
|
this.shrinkWrap = false,
|
||||||
|
this.style = const {},
|
||||||
|
this.customRenders = const {},
|
||||||
|
this.tagsList = const [],
|
||||||
|
this.selectionControls,
|
||||||
|
this.scrollPhysics,
|
||||||
|
}) : documentElement = null,
|
||||||
|
assert(data != null),
|
||||||
|
_anchorKey = anchorKey ?? GlobalKey(),
|
||||||
|
super(key: key);
|
||||||
|
|
||||||
|
SelectableHtml.fromDom({
|
||||||
|
Key? key,
|
||||||
|
GlobalKey? anchorKey,
|
||||||
|
@required dom.Document? document,
|
||||||
|
this.onLinkTap,
|
||||||
|
this.onAnchorTap,
|
||||||
|
this.onCssParseError,
|
||||||
|
this.shrinkWrap = false,
|
||||||
|
this.style = const {},
|
||||||
|
this.customRenders = const {},
|
||||||
|
this.tagsList = const [],
|
||||||
|
this.selectionControls,
|
||||||
|
this.scrollPhysics,
|
||||||
|
}) : data = null,
|
||||||
|
assert(document != null),
|
||||||
|
this.documentElement = document!.documentElement,
|
||||||
|
_anchorKey = anchorKey ?? GlobalKey(),
|
||||||
|
super(key: key);
|
||||||
|
|
||||||
|
SelectableHtml.fromElement({
|
||||||
|
Key? key,
|
||||||
|
GlobalKey? anchorKey,
|
||||||
|
@required this.documentElement,
|
||||||
|
this.onLinkTap,
|
||||||
|
this.onAnchorTap,
|
||||||
|
this.onCssParseError,
|
||||||
|
this.shrinkWrap = false,
|
||||||
|
this.style = const {},
|
||||||
|
this.customRenders = const {},
|
||||||
|
this.tagsList = const [],
|
||||||
|
this.selectionControls,
|
||||||
|
this.scrollPhysics,
|
||||||
|
}) : data = null,
|
||||||
|
assert(documentElement != null),
|
||||||
|
_anchorKey = anchorKey ?? GlobalKey(),
|
||||||
|
super(key: key);
|
||||||
|
|
||||||
|
/// A unique key for this Html widget to ensure uniqueness of anchors
|
||||||
|
final GlobalKey _anchorKey;
|
||||||
|
|
||||||
|
/// The HTML data passed to the widget as a String
|
||||||
|
final String? data;
|
||||||
|
|
||||||
|
/// The HTML data passed to the widget as a pre-processed [dom.Element]
|
||||||
|
final dom.Element? documentElement;
|
||||||
|
|
||||||
|
/// A function that defines what to do when a link is tapped
|
||||||
|
final OnTap? onLinkTap;
|
||||||
|
|
||||||
|
/// A function that defines what to do when an anchor link is tapped. When this value is set,
|
||||||
|
/// the default anchor behaviour is overwritten.
|
||||||
|
final OnTap? onAnchorTap;
|
||||||
|
|
||||||
|
/// A function that defines what to do when CSS fails to parse
|
||||||
|
final OnCssParseError? onCssParseError;
|
||||||
|
|
||||||
|
/// A parameter that should be set when the HTML widget is expected to be
|
||||||
|
/// flexible
|
||||||
|
final bool shrinkWrap;
|
||||||
|
|
||||||
|
/// A list of HTML tags that are the only tags that are rendered. By default, this list is empty and all supported HTML tags are rendered.
|
||||||
|
final List<String> tagsList;
|
||||||
|
|
||||||
|
/// An API that allows you to override the default style for any HTML element
|
||||||
|
final Map<String, Style> style;
|
||||||
|
|
||||||
|
/// Custom Selection controls allows you to override default toolbar and build custom toolbar
|
||||||
|
/// options
|
||||||
|
final TextSelectionControls? selectionControls;
|
||||||
|
|
||||||
|
/// Allows you to override the default scrollPhysics for [SelectableText.rich]
|
||||||
|
final ScrollPhysics? scrollPhysics;
|
||||||
|
|
||||||
|
/// Either return a custom widget for specific node types or return null to
|
||||||
|
/// fallback to the default rendering.
|
||||||
|
final Map<CustomRenderMatcher, SelectableCustomRender> customRenders;
|
||||||
|
|
||||||
|
static List<String> get tags => new List<String>.from(SELECTABLE_ELEMENTS);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StatefulWidget> createState() => _SelectableHtmlState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SelectableHtmlState extends State<SelectableHtml> {
|
||||||
|
late final dom.Element documentElement;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
documentElement = widget.data != null
|
||||||
|
? HtmlParser.parseHTML(widget.data!)
|
||||||
|
: widget.documentElement!;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: widget.shrinkWrap ? null : MediaQuery.of(context).size.width,
|
||||||
|
child: HtmlParser(
|
||||||
|
key: widget._anchorKey,
|
||||||
|
htmlData: documentElement,
|
||||||
|
onLinkTap: widget.onLinkTap,
|
||||||
|
onAnchorTap: widget.onAnchorTap,
|
||||||
|
onImageTap: null,
|
||||||
|
onCssParseError: widget.onCssParseError,
|
||||||
|
onImageError: null,
|
||||||
|
shrinkWrap: widget.shrinkWrap,
|
||||||
|
selectable: true,
|
||||||
|
style: widget.style,
|
||||||
|
customRenders: {}
|
||||||
|
..addAll(widget.customRenders)
|
||||||
|
..addAll(defaultRenders),
|
||||||
|
tagsList:
|
||||||
|
widget.tagsList.isEmpty ? SelectableHtml.tags : widget.tagsList,
|
||||||
|
selectionControls: widget.selectionControls,
|
||||||
|
scrollPhysics: widget.scrollPhysics,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,961 @@
|
|||||||
|
import 'dart:collection';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:csslib/parser.dart' as cssparser;
|
||||||
|
import 'package:csslib/visitor.dart' as css;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import './flutter_html.dart';
|
||||||
|
import './src/css_parser.dart';
|
||||||
|
import './src/html_elements.dart';
|
||||||
|
import './src/utils.dart';
|
||||||
|
import 'package:html/dom.dart' as dom;
|
||||||
|
import 'package:html/parser.dart' as htmlparser;
|
||||||
|
import 'package:numerus/numerus.dart';
|
||||||
|
|
||||||
|
typedef OnTap = void Function(
|
||||||
|
String? url,
|
||||||
|
RenderContext context,
|
||||||
|
Map<String, String> attributes,
|
||||||
|
dom.Element? element,
|
||||||
|
);
|
||||||
|
typedef OnCssParseError = String? Function(
|
||||||
|
String css,
|
||||||
|
List<cssparser.Message> errors,
|
||||||
|
);
|
||||||
|
|
||||||
|
class HtmlParser extends StatelessWidget {
|
||||||
|
final Key? key;
|
||||||
|
final dom.Element htmlData;
|
||||||
|
final OnTap? onLinkTap;
|
||||||
|
final OnTap? onAnchorTap;
|
||||||
|
final OnTap? onImageTap;
|
||||||
|
final OnCssParseError? onCssParseError;
|
||||||
|
final ImageErrorListener? onImageError;
|
||||||
|
final bool shrinkWrap;
|
||||||
|
final bool selectable;
|
||||||
|
|
||||||
|
final Map<String, Style> style;
|
||||||
|
final Map<CustomRenderMatcher, CustomRender> customRenders;
|
||||||
|
final List<String> tagsList;
|
||||||
|
final OnTap? internalOnAnchorTap;
|
||||||
|
final Html? root;
|
||||||
|
final TextSelectionControls? selectionControls;
|
||||||
|
final ScrollPhysics? scrollPhysics;
|
||||||
|
|
||||||
|
final Map<String, Size> cachedImageSizes = {};
|
||||||
|
|
||||||
|
HtmlParser({
|
||||||
|
required this.key,
|
||||||
|
required this.htmlData,
|
||||||
|
required this.onLinkTap,
|
||||||
|
required this.onAnchorTap,
|
||||||
|
required this.onImageTap,
|
||||||
|
required this.onCssParseError,
|
||||||
|
required this.onImageError,
|
||||||
|
required this.shrinkWrap,
|
||||||
|
required this.selectable,
|
||||||
|
required this.style,
|
||||||
|
required this.customRenders,
|
||||||
|
required this.tagsList,
|
||||||
|
this.root,
|
||||||
|
this.selectionControls,
|
||||||
|
this.scrollPhysics,
|
||||||
|
}) : this.internalOnAnchorTap = onAnchorTap != null
|
||||||
|
? onAnchorTap
|
||||||
|
: key != null
|
||||||
|
? _handleAnchorTap(key, onLinkTap)
|
||||||
|
: onLinkTap,
|
||||||
|
super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
Map<String, Map<String, List<css.Expression>>> declarations = _getExternalCssDeclarations(htmlData.getElementsByTagName("style"), onCssParseError);
|
||||||
|
StyledElement lexedTree = lexDomTree(
|
||||||
|
htmlData,
|
||||||
|
customRenders.keys.toList(),
|
||||||
|
tagsList,
|
||||||
|
context,
|
||||||
|
this,
|
||||||
|
);
|
||||||
|
StyledElement? externalCssStyledTree;
|
||||||
|
if (declarations.isNotEmpty) {
|
||||||
|
externalCssStyledTree = _applyExternalCss(declarations, lexedTree);
|
||||||
|
}
|
||||||
|
StyledElement inlineStyledTree = _applyInlineStyles(externalCssStyledTree ?? lexedTree, onCssParseError);
|
||||||
|
StyledElement customStyledTree = _applyCustomStyles(style, inlineStyledTree);
|
||||||
|
StyledElement cascadedStyledTree = _cascadeStyles(style, customStyledTree);
|
||||||
|
StyledElement cleanedTree = cleanTree(cascadedStyledTree);
|
||||||
|
InlineSpan parsedTree = parseTree(
|
||||||
|
RenderContext(
|
||||||
|
buildContext: context,
|
||||||
|
parser: this,
|
||||||
|
tree: cleanedTree,
|
||||||
|
style: cleanedTree.style,
|
||||||
|
),
|
||||||
|
cleanedTree,
|
||||||
|
);
|
||||||
|
|
||||||
|
// This is the final scaling that assumes any other StyledText instances are
|
||||||
|
// using textScaleFactor = 1.0 (which is the default). This ensures the correct
|
||||||
|
// scaling is used, but relies on https://github.com/flutter/flutter/pull/59711
|
||||||
|
// to wrap everything when larger accessibility fonts are used.
|
||||||
|
if (selectable) {
|
||||||
|
return StyledText.selectable(
|
||||||
|
textSpan: parsedTree as TextSpan,
|
||||||
|
style: cleanedTree.style,
|
||||||
|
textScaleFactor: MediaQuery.of(context).textScaleFactor,
|
||||||
|
renderContext: RenderContext(
|
||||||
|
buildContext: context,
|
||||||
|
parser: this,
|
||||||
|
tree: cleanedTree,
|
||||||
|
style: cleanedTree.style,
|
||||||
|
),
|
||||||
|
selectionControls: selectionControls,
|
||||||
|
scrollPhysics: scrollPhysics,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return StyledText(
|
||||||
|
textSpan: parsedTree,
|
||||||
|
style: cleanedTree.style,
|
||||||
|
textScaleFactor: MediaQuery.of(context).textScaleFactor,
|
||||||
|
renderContext: RenderContext(
|
||||||
|
buildContext: context,
|
||||||
|
parser: this,
|
||||||
|
tree: cleanedTree,
|
||||||
|
style: cleanedTree.style,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [parseHTML] converts a string of HTML to a DOM element using the dart `html` library.
|
||||||
|
static dom.Element parseHTML(String data) {
|
||||||
|
return htmlparser.parse(data).documentElement!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [parseCss] converts a string of CSS to a CSS stylesheet using the dart `csslib` library.
|
||||||
|
static css.StyleSheet parseCss(String data) {
|
||||||
|
return cssparser.parse(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [lexDomTree] converts a DOM document to a simplified tree of [StyledElement]s.
|
||||||
|
static StyledElement lexDomTree(
|
||||||
|
dom.Element html,
|
||||||
|
List<CustomRenderMatcher> customRenderMatchers,
|
||||||
|
List<String> tagsList,
|
||||||
|
BuildContext context,
|
||||||
|
HtmlParser parser,
|
||||||
|
) {
|
||||||
|
StyledElement tree = StyledElement(
|
||||||
|
name: "[Tree Root]",
|
||||||
|
children: <StyledElement>[],
|
||||||
|
node: html,
|
||||||
|
style: Style.fromTextStyle(Theme.of(context).textTheme.bodyText2!),
|
||||||
|
);
|
||||||
|
|
||||||
|
html.nodes.forEach((node) {
|
||||||
|
tree.children.add(_recursiveLexer(
|
||||||
|
node,
|
||||||
|
customRenderMatchers,
|
||||||
|
tagsList,
|
||||||
|
context,
|
||||||
|
parser,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [_recursiveLexer] is the recursive worker function for [lexDomTree].
|
||||||
|
///
|
||||||
|
/// It runs the parse functions of every type of
|
||||||
|
/// element and returns a [StyledElement] tree representing the element.
|
||||||
|
static StyledElement _recursiveLexer(
|
||||||
|
dom.Node node,
|
||||||
|
List<CustomRenderMatcher> customRenderMatchers,
|
||||||
|
List<String> tagsList,
|
||||||
|
BuildContext context,
|
||||||
|
HtmlParser parser,
|
||||||
|
) {
|
||||||
|
List<StyledElement> children = <StyledElement>[];
|
||||||
|
|
||||||
|
node.nodes.forEach((childNode) {
|
||||||
|
children.add(_recursiveLexer(
|
||||||
|
childNode,
|
||||||
|
customRenderMatchers,
|
||||||
|
tagsList,
|
||||||
|
context,
|
||||||
|
parser,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
//TODO(Sub6Resources): There's probably a more efficient way to look this up.
|
||||||
|
if (node is dom.Element) {
|
||||||
|
if (!tagsList.contains(node.localName)) {
|
||||||
|
return EmptyContentElement();
|
||||||
|
}
|
||||||
|
if (STYLED_ELEMENTS.contains(node.localName)) {
|
||||||
|
return parseStyledElement(node, children);
|
||||||
|
} else if (INTERACTABLE_ELEMENTS.contains(node.localName)) {
|
||||||
|
return parseInteractableElement(node, children);
|
||||||
|
} else if (REPLACED_ELEMENTS.contains(node.localName)) {
|
||||||
|
return parseReplacedElement(node, children);
|
||||||
|
} else if (LAYOUT_ELEMENTS.contains(node.localName)) {
|
||||||
|
return parseLayoutElement(node, children);
|
||||||
|
} else if (TABLE_CELL_ELEMENTS.contains(node.localName)) {
|
||||||
|
return parseTableCellElement(node, children);
|
||||||
|
} else if (TABLE_DEFINITION_ELEMENTS.contains(node.localName)) {
|
||||||
|
return parseTableDefinitionElement(node, children);
|
||||||
|
} else {
|
||||||
|
final StyledElement tree = parseStyledElement(node, children);
|
||||||
|
for (final entry in customRenderMatchers) {
|
||||||
|
if (entry.call(
|
||||||
|
RenderContext(
|
||||||
|
buildContext: context,
|
||||||
|
parser: parser,
|
||||||
|
tree: tree,
|
||||||
|
style: Style.fromTextStyle(Theme.of(context).textTheme.bodyText2!),
|
||||||
|
),
|
||||||
|
)) {
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return EmptyContentElement();
|
||||||
|
}
|
||||||
|
} else if (node is dom.Text) {
|
||||||
|
return TextContentElement(text: node.text, style: Style(), element: node.parent, node: node);
|
||||||
|
} else {
|
||||||
|
return EmptyContentElement();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, Map<String, List<css.Expression>>> _getExternalCssDeclarations(List<dom.Element> styles, OnCssParseError? errorHandler) {
|
||||||
|
String fullCss = "";
|
||||||
|
for (final e in styles) {
|
||||||
|
fullCss = fullCss + e.innerHtml;
|
||||||
|
}
|
||||||
|
if (fullCss.isNotEmpty) {
|
||||||
|
final declarations = parseExternalCss(fullCss, errorHandler);
|
||||||
|
return declarations;
|
||||||
|
} else {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static StyledElement _applyExternalCss(Map<String, Map<String, List<css.Expression>>> declarations, StyledElement tree) {
|
||||||
|
declarations.forEach((key, style) {
|
||||||
|
try {
|
||||||
|
if (tree.matchesSelector(key)) {
|
||||||
|
tree.style = tree.style.merge(declarationsToStyle(style));
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
tree.children.forEach((e) => _applyExternalCss(declarations, e));
|
||||||
|
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
static StyledElement _applyInlineStyles(StyledElement tree, OnCssParseError? errorHandler) {
|
||||||
|
if (tree.attributes.containsKey("style")) {
|
||||||
|
final newStyle = inlineCssToStyle(tree.attributes['style'], errorHandler);
|
||||||
|
if (newStyle != null) {
|
||||||
|
tree.style = tree.style.merge(newStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tree.children.forEach((e) => _applyInlineStyles(e, errorHandler));
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [applyCustomStyles] applies the [Style] objects passed into the [Html]
|
||||||
|
/// widget onto the [StyledElement] tree, no cascading of styles is done at this point.
|
||||||
|
static StyledElement _applyCustomStyles(Map<String, Style> style, StyledElement tree) {
|
||||||
|
style.forEach((key, style) {
|
||||||
|
try {
|
||||||
|
if (tree.matchesSelector(key)) {
|
||||||
|
tree.style = tree.style.merge(style);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
tree.children.forEach((e) => _applyCustomStyles(style, e));
|
||||||
|
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [_cascadeStyles] cascades all of the inherited styles down the tree, applying them to each
|
||||||
|
/// child that doesn't specify a different style.
|
||||||
|
static StyledElement _cascadeStyles(Map<String, Style> style, StyledElement tree) {
|
||||||
|
tree.children.forEach((child) {
|
||||||
|
child.style = tree.style.copyOnlyInherited(child.style);
|
||||||
|
_cascadeStyles(style, child);
|
||||||
|
});
|
||||||
|
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [cleanTree] optimizes the [StyledElement] tree so all [BlockElement]s are
|
||||||
|
/// on the first level, redundant levels are collapsed, empty elements are
|
||||||
|
/// removed, and specialty elements are processed.
|
||||||
|
static StyledElement cleanTree(StyledElement tree) {
|
||||||
|
tree = _processInternalWhitespace(tree);
|
||||||
|
tree = _processInlineWhitespace(tree);
|
||||||
|
tree = _removeEmptyElements(tree);
|
||||||
|
tree = _processListCharacters(tree);
|
||||||
|
tree = _processBeforesAndAfters(tree);
|
||||||
|
tree = _collapseMargins(tree);
|
||||||
|
tree = _processFontSize(tree);
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [parseTree] converts a tree of [StyledElement]s to an [InlineSpan] tree.
|
||||||
|
///
|
||||||
|
/// [parseTree] is responsible for handling the [customRenders] parameter and
|
||||||
|
/// deciding what different `Style.display` options look like as Widgets.
|
||||||
|
InlineSpan parseTree(RenderContext context, StyledElement tree) {
|
||||||
|
// Merge this element's style into the context so that children
|
||||||
|
// inherit the correct style
|
||||||
|
RenderContext newContext = RenderContext(
|
||||||
|
buildContext: context.buildContext,
|
||||||
|
parser: this,
|
||||||
|
tree: tree,
|
||||||
|
style: context.style.copyOnlyInherited(tree.style),
|
||||||
|
key: AnchorKey.of(key, tree),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final entry in customRenders.keys) {
|
||||||
|
if (entry.call(newContext)) {
|
||||||
|
final buildChildren = () => tree.children.map((tree) => parseTree(newContext, tree)).toList();
|
||||||
|
if (newContext.parser.selectable && customRenders[entry] is SelectableCustomRender) {
|
||||||
|
final selectableBuildChildren = () => tree.children.map((tree) => parseTree(newContext, tree) as TextSpan).toList();
|
||||||
|
return (customRenders[entry] as SelectableCustomRender).textSpan.call(newContext, selectableBuildChildren);
|
||||||
|
}
|
||||||
|
if (newContext.parser.selectable) {
|
||||||
|
return customRenders[entry]!.inlineSpan!.call(newContext, buildChildren) as TextSpan;
|
||||||
|
}
|
||||||
|
if (customRenders[entry]?.inlineSpan != null) {
|
||||||
|
return customRenders[entry]!.inlineSpan!.call(newContext, buildChildren);
|
||||||
|
}
|
||||||
|
return WidgetSpan(
|
||||||
|
child: ContainerSpan(
|
||||||
|
newContext: newContext,
|
||||||
|
style: tree.style,
|
||||||
|
shrinkWrap: newContext.parser.shrinkWrap,
|
||||||
|
child: customRenders[entry]!.widget!.call(newContext, buildChildren),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return WidgetSpan(child: Container(height: 0, width: 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
static OnTap _handleAnchorTap(Key key, OnTap? onLinkTap) =>
|
||||||
|
(String? url, RenderContext context, Map<String, String> attributes, dom.Element? element) {
|
||||||
|
if (url?.startsWith("#") == true) {
|
||||||
|
final anchorContext = AnchorKey.forId(key, url!.substring(1))?.currentContext;
|
||||||
|
if (anchorContext != null) {
|
||||||
|
Scrollable.ensureVisible(anchorContext);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onLinkTap?.call(url, context, attributes, element);
|
||||||
|
};
|
||||||
|
|
||||||
|
/// [processWhitespace] removes unnecessary whitespace from the StyledElement tree.
|
||||||
|
///
|
||||||
|
/// The criteria for determining which whitespace is replaceable is outlined
|
||||||
|
/// at https://www.w3.org/TR/css-text-3/
|
||||||
|
/// and summarized at https://medium.com/@patrickbrosset/when-does-white-space-matter-in-html-b90e8a7cdd33
|
||||||
|
static StyledElement _processInternalWhitespace(StyledElement tree) {
|
||||||
|
if ((tree.style.whiteSpace ?? WhiteSpace.NORMAL) == WhiteSpace.PRE) {
|
||||||
|
// Preserve this whitespace
|
||||||
|
} else if (tree is TextContentElement) {
|
||||||
|
tree.text = _removeUnnecessaryWhitespace(tree.text!);
|
||||||
|
} else {
|
||||||
|
tree.children.forEach(_processInternalWhitespace);
|
||||||
|
}
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [_processInlineWhitespace] is responsible for removing redundant whitespace
|
||||||
|
/// between and among inline elements. It does so by creating a boolean [Context]
|
||||||
|
/// and passing it to the [_processInlineWhitespaceRecursive] function.
|
||||||
|
static StyledElement _processInlineWhitespace(StyledElement tree) {
|
||||||
|
tree = _processInlineWhitespaceRecursive(tree, Context(false));
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [_processInlineWhitespaceRecursive] analyzes the whitespace between and among different
|
||||||
|
/// inline elements, and replaces any instance of two or more spaces with a single space, according
|
||||||
|
/// to the w3's HTML whitespace processing specification linked to above.
|
||||||
|
static StyledElement _processInlineWhitespaceRecursive(
|
||||||
|
StyledElement tree,
|
||||||
|
Context<bool> keepLeadingSpace,
|
||||||
|
) {
|
||||||
|
if (tree is TextContentElement) {
|
||||||
|
/// initialize indices to negative numbers to make conditionals a little easier
|
||||||
|
int textIndex = -1;
|
||||||
|
int elementIndex = -1;
|
||||||
|
/// initialize parent after to a whitespace to account for elements that are
|
||||||
|
/// the last child in the list of elements
|
||||||
|
String parentAfterText = " ";
|
||||||
|
/// find the index of the text in the current tree
|
||||||
|
if ((tree.element?.nodes.length ?? 0) >= 1) {
|
||||||
|
textIndex = tree.element?.nodes.indexWhere((element) => element == tree.node) ?? -1;
|
||||||
|
}
|
||||||
|
/// get the parent nodes
|
||||||
|
dom.NodeList? parentNodes = tree.element?.parent?.nodes;
|
||||||
|
/// find the index of the tree itself in the parent nodes
|
||||||
|
if ((parentNodes?.length ?? 0) >= 1) {
|
||||||
|
elementIndex = parentNodes?.indexWhere((element) => element == tree.element) ?? -1;
|
||||||
|
}
|
||||||
|
/// if the tree is any node except the last node in the node list and the
|
||||||
|
/// next node in the node list is a text node, then get its text. Otherwise
|
||||||
|
/// the next node will be a [dom.Element], so keep unwrapping that until
|
||||||
|
/// we get the underlying text node, and finally get its text.
|
||||||
|
if (elementIndex < (parentNodes?.length ?? 1) - 1 && parentNodes?[elementIndex + 1] is dom.Text) {
|
||||||
|
parentAfterText = parentNodes?[elementIndex + 1].text ?? " ";
|
||||||
|
} else if (elementIndex < (parentNodes?.length ?? 1) - 1) {
|
||||||
|
var parentAfter = parentNodes?[elementIndex + 1];
|
||||||
|
while (parentAfter is dom.Element) {
|
||||||
|
if (parentAfter.nodes.isNotEmpty) {
|
||||||
|
parentAfter = parentAfter.nodes.first;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parentAfterText = parentAfter?.text ?? " ";
|
||||||
|
}
|
||||||
|
/// If the text is the first element in the current tree node list, it
|
||||||
|
/// starts with a whitespace, it isn't a line break, either the
|
||||||
|
/// whitespace is unnecessary or it is a block element, and either it is
|
||||||
|
/// first element in the parent node list or the previous element
|
||||||
|
/// in the parent node list ends with a whitespace, delete it.
|
||||||
|
///
|
||||||
|
/// We should also delete the whitespace at any point in the node list
|
||||||
|
/// if the previous element is a <br> because that tag makes the element
|
||||||
|
/// act like a block element.
|
||||||
|
if (textIndex < 1
|
||||||
|
&& tree.text!.startsWith(' ')
|
||||||
|
&& tree.element?.localName != "br"
|
||||||
|
&& (!keepLeadingSpace.data
|
||||||
|
|| tree.style.display == Display.BLOCK)
|
||||||
|
&& (elementIndex < 1
|
||||||
|
|| (elementIndex >= 1
|
||||||
|
&& parentNodes?[elementIndex - 1] is dom.Text
|
||||||
|
&& parentNodes![elementIndex - 1].text!.endsWith(" ")))
|
||||||
|
) {
|
||||||
|
tree.text = tree.text!.replaceFirst(' ', '');
|
||||||
|
} else if (textIndex >= 1
|
||||||
|
&& tree.text!.startsWith(' ')
|
||||||
|
&& tree.element?.nodes[textIndex - 1] is dom.Element
|
||||||
|
&& (tree.element?.nodes[textIndex - 1] as dom.Element).localName == "br"
|
||||||
|
) {
|
||||||
|
tree.text = tree.text!.replaceFirst(' ', '');
|
||||||
|
}
|
||||||
|
/// If the text is the last element in the current tree node list, it isn't
|
||||||
|
/// a line break, and the next text node starts with a whitespace,
|
||||||
|
/// update the [Context] to signify to that next text node whether it should
|
||||||
|
/// keep its whitespace. This is based on whether the current text ends with a
|
||||||
|
/// whitespace.
|
||||||
|
if (textIndex == (tree.element?.nodes.length ?? 1) - 1
|
||||||
|
&& tree.element?.localName != "br"
|
||||||
|
&& parentAfterText.startsWith(' ')
|
||||||
|
) {
|
||||||
|
keepLeadingSpace.data = !tree.text!.endsWith(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tree.children.forEach((e) => _processInlineWhitespaceRecursive(e, keepLeadingSpace));
|
||||||
|
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [removeUnnecessaryWhitespace] removes "unnecessary" white space from the given String.
|
||||||
|
///
|
||||||
|
/// The steps for removing this whitespace are as follows:
|
||||||
|
/// (1) Remove any whitespace immediately preceding or following a newline.
|
||||||
|
/// (2) Replace all newlines with a space
|
||||||
|
/// (3) Replace all tabs with a space
|
||||||
|
/// (4) Replace any instances of two or more spaces with a single space.
|
||||||
|
static String _removeUnnecessaryWhitespace(String text) {
|
||||||
|
return text
|
||||||
|
.replaceAll(RegExp("\ *(?=\n)"), "\n")
|
||||||
|
.replaceAll(RegExp("(?:\n)\ *"), "\n")
|
||||||
|
.replaceAll("\n", " ")
|
||||||
|
.replaceAll("\t", " ")
|
||||||
|
.replaceAll(RegExp(" {2,}"), " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [processListCharacters] adds list characters to the front of all list items.
|
||||||
|
///
|
||||||
|
/// The function uses the [_processListCharactersRecursive] function to do most of its work.
|
||||||
|
static StyledElement _processListCharacters(StyledElement tree) {
|
||||||
|
final olStack = ListQueue<Context>();
|
||||||
|
tree = _processListCharactersRecursive(tree, olStack);
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [_processListCharactersRecursive] uses a Stack of integers to properly number and
|
||||||
|
/// bullet all list items according to the [ListStyleType] they have been given.
|
||||||
|
static StyledElement _processListCharactersRecursive(
|
||||||
|
StyledElement tree, ListQueue<Context> olStack) {
|
||||||
|
if (tree.style.listStylePosition == null) {
|
||||||
|
tree.style.listStylePosition = ListStylePosition.OUTSIDE;
|
||||||
|
}
|
||||||
|
if (tree.name == 'ol' && tree.style.listStyleType != null && tree.style.listStyleType!.type == "marker") {
|
||||||
|
switch (tree.style.listStyleType!) {
|
||||||
|
case ListStyleType.LOWER_LATIN:
|
||||||
|
case ListStyleType.LOWER_ALPHA:
|
||||||
|
case ListStyleType.UPPER_LATIN:
|
||||||
|
case ListStyleType.UPPER_ALPHA:
|
||||||
|
olStack.add(Context<String>('a'));
|
||||||
|
if ((tree.attributes['start'] != null ? int.tryParse(tree.attributes['start']!) : null) != null) {
|
||||||
|
var start = int.tryParse(tree.attributes['start']!) ?? 1;
|
||||||
|
var x = 1;
|
||||||
|
while (x < start) {
|
||||||
|
olStack.last.data = olStack.last.data.toString().nextLetter();
|
||||||
|
x++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
olStack.add(Context<int>((tree.attributes['start'] != null ? int.tryParse(tree.attributes['start'] ?? "") ?? 1 : 1) - 1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if (tree.style.display == Display.LIST_ITEM && tree.style.listStyleType != null && tree.style.listStyleType!.type == "widget") {
|
||||||
|
tree.style.markerContent = tree.style.listStyleType!.widget!;
|
||||||
|
} else if (tree.style.display == Display.LIST_ITEM && tree.style.listStyleType != null && tree.style.listStyleType!.type == "image") {
|
||||||
|
tree.style.markerContent = Image.network(tree.style.listStyleType!.text);
|
||||||
|
} else if (tree.style.display == Display.LIST_ITEM && tree.style.listStyleType != null) {
|
||||||
|
String marker = "";
|
||||||
|
switch (tree.style.listStyleType!) {
|
||||||
|
case ListStyleType.NONE:
|
||||||
|
break;
|
||||||
|
case ListStyleType.CIRCLE:
|
||||||
|
marker = '○';
|
||||||
|
break;
|
||||||
|
case ListStyleType.SQUARE:
|
||||||
|
marker = '■';
|
||||||
|
break;
|
||||||
|
case ListStyleType.DISC:
|
||||||
|
marker = '•';
|
||||||
|
break;
|
||||||
|
case ListStyleType.DECIMAL:
|
||||||
|
if (olStack.isEmpty) {
|
||||||
|
olStack.add(Context<int>((tree.attributes['start'] != null ? int.tryParse(tree.attributes['start'] ?? "") ?? 1 : 1) - 1));
|
||||||
|
}
|
||||||
|
olStack.last.data += 1;
|
||||||
|
marker = '${olStack.last.data}.';
|
||||||
|
break;
|
||||||
|
case ListStyleType.LOWER_LATIN:
|
||||||
|
case ListStyleType.LOWER_ALPHA:
|
||||||
|
if (olStack.isEmpty) {
|
||||||
|
olStack.add(Context<String>('a'));
|
||||||
|
if ((tree.attributes['start'] != null ? int.tryParse(tree.attributes['start']!) : null) != null) {
|
||||||
|
var start = int.tryParse(tree.attributes['start']!) ?? 1;
|
||||||
|
var x = 1;
|
||||||
|
while (x < start) {
|
||||||
|
olStack.last.data = olStack.last.data.toString().nextLetter();
|
||||||
|
x++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
marker = olStack.last.data.toString() + ".";
|
||||||
|
olStack.last.data = olStack.last.data.toString().nextLetter();
|
||||||
|
break;
|
||||||
|
case ListStyleType.UPPER_LATIN:
|
||||||
|
case ListStyleType.UPPER_ALPHA:
|
||||||
|
if (olStack.isEmpty) {
|
||||||
|
olStack.add(Context<String>('a'));
|
||||||
|
if ((tree.attributes['start'] != null ? int.tryParse(tree.attributes['start']!) : null) != null) {
|
||||||
|
var start = int.tryParse(tree.attributes['start']!) ?? 1;
|
||||||
|
var x = 1;
|
||||||
|
while (x < start) {
|
||||||
|
olStack.last.data = olStack.last.data.toString().nextLetter();
|
||||||
|
x++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
marker = olStack.last.data.toString().toUpperCase() + ".";
|
||||||
|
olStack.last.data = olStack.last.data.toString().nextLetter();
|
||||||
|
break;
|
||||||
|
case ListStyleType.LOWER_ROMAN:
|
||||||
|
if (olStack.isEmpty) {
|
||||||
|
olStack.add(Context<int>((tree.attributes['start'] != null ? int.tryParse(tree.attributes['start'] ?? "") ?? 1 : 1) - 1));
|
||||||
|
}
|
||||||
|
olStack.last.data += 1;
|
||||||
|
if (olStack.last.data <= 0) {
|
||||||
|
marker = '${olStack.last.data}.';
|
||||||
|
} else {
|
||||||
|
marker = (olStack.last.data as int).toRomanNumeralString()!.toLowerCase() + ".";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ListStyleType.UPPER_ROMAN:
|
||||||
|
if (olStack.isEmpty) {
|
||||||
|
olStack.add(Context<int>((tree.attributes['start'] != null ? int.tryParse(tree.attributes['start'] ?? "") ?? 1 : 1) - 1));
|
||||||
|
}
|
||||||
|
olStack.last.data += 1;
|
||||||
|
if (olStack.last.data <= 0) {
|
||||||
|
marker = '${olStack.last.data}.';
|
||||||
|
} else {
|
||||||
|
marker = (olStack.last.data as int).toRomanNumeralString()! + ".";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tree.style.markerContent = Text(
|
||||||
|
marker,
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
style: tree.style.generateTextStyle(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
tree.children.forEach((e) => _processListCharactersRecursive(e, olStack));
|
||||||
|
|
||||||
|
if (tree.name == 'ol') {
|
||||||
|
olStack.removeLast();
|
||||||
|
}
|
||||||
|
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [_processBeforesAndAfters] adds text content to the beginning and end of
|
||||||
|
/// the list of the trees children according to the `before` and `after` Style
|
||||||
|
/// properties.
|
||||||
|
static StyledElement _processBeforesAndAfters(StyledElement tree) {
|
||||||
|
if (tree.style.before != null) {
|
||||||
|
tree.children.insert(
|
||||||
|
0, TextContentElement(text: tree.style.before, style: tree.style.copyWith(beforeAfterNull: true, display: Display.INLINE)));
|
||||||
|
}
|
||||||
|
if (tree.style.after != null) {
|
||||||
|
tree.children
|
||||||
|
.add(TextContentElement(text: tree.style.after, style: tree.style.copyWith(beforeAfterNull: true, display: Display.INLINE)));
|
||||||
|
}
|
||||||
|
|
||||||
|
tree.children.forEach(_processBeforesAndAfters);
|
||||||
|
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [collapseMargins] follows the specifications at https://www.w3.org/TR/CSS21/box.html#collapsing-margins
|
||||||
|
/// for collapsing margins of block-level boxes. This prevents the doubling of margins between
|
||||||
|
/// boxes, and makes for a more correct rendering of the html content.
|
||||||
|
///
|
||||||
|
/// Paraphrased from the CSS specification:
|
||||||
|
/// Margins are collapsed if both belong to vertically-adjacent box edges, i.e form one of the following pairs:
|
||||||
|
/// (1) Top margin of a box and top margin of its first in-flow child
|
||||||
|
/// (2) Bottom margin of a box and top margin of its next in-flow following sibling
|
||||||
|
/// (3) Bottom margin of a last in-flow child and bottom margin of its parent (if the parent's height is not explicit)
|
||||||
|
/// (4) Top and Bottom margins of a box with a height of zero or no in-flow children.
|
||||||
|
static StyledElement _collapseMargins(StyledElement tree) {
|
||||||
|
//Short circuit if we've reached a leaf of the tree
|
||||||
|
if (tree.children.isEmpty) {
|
||||||
|
// Handle case (4) from above.
|
||||||
|
if ((tree.style.height ?? 0) == 0) {
|
||||||
|
tree.style.margin = EdgeInsets.zero;
|
||||||
|
}
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
//Collapsing should be depth-first.
|
||||||
|
tree.children.forEach(_collapseMargins);
|
||||||
|
|
||||||
|
//The root boxes do not collapse.
|
||||||
|
if (tree.name == '[Tree Root]' || tree.name == 'html') {
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle case (1) from above.
|
||||||
|
// Top margins cannot collapse if the element has padding
|
||||||
|
if ((tree.style.padding?.top ?? 0) == 0) {
|
||||||
|
final parentTop = tree.style.margin?.top ?? 0;
|
||||||
|
final firstChildTop = tree.children.first.style.margin?.top ?? 0;
|
||||||
|
final newOuterMarginTop = max(parentTop, firstChildTop);
|
||||||
|
|
||||||
|
// Set the parent's margin
|
||||||
|
if (tree.style.margin == null) {
|
||||||
|
tree.style.margin = EdgeInsets.only(top: newOuterMarginTop);
|
||||||
|
} else {
|
||||||
|
tree.style.margin = tree.style.margin!.copyWith(top: newOuterMarginTop);
|
||||||
|
}
|
||||||
|
|
||||||
|
// And remove the child's margin
|
||||||
|
if (tree.children.first.style.margin == null) {
|
||||||
|
tree.children.first.style.margin = EdgeInsets.zero;
|
||||||
|
} else {
|
||||||
|
tree.children.first.style.margin =
|
||||||
|
tree.children.first.style.margin!.copyWith(top: 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle case (3) from above.
|
||||||
|
// Bottom margins cannot collapse if the element has padding
|
||||||
|
if ((tree.style.padding?.bottom ?? 0) == 0) {
|
||||||
|
final parentBottom = tree.style.margin?.bottom ?? 0;
|
||||||
|
final lastChildBottom = tree.children.last.style.margin?.bottom ?? 0;
|
||||||
|
final newOuterMarginBottom = max(parentBottom, lastChildBottom);
|
||||||
|
|
||||||
|
// Set the parent's margin
|
||||||
|
if (tree.style.margin == null) {
|
||||||
|
tree.style.margin = EdgeInsets.only(bottom: newOuterMarginBottom);
|
||||||
|
} else {
|
||||||
|
tree.style.margin =
|
||||||
|
tree.style.margin!.copyWith(bottom: newOuterMarginBottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
// And remove the child's margin
|
||||||
|
if (tree.children.last.style.margin == null) {
|
||||||
|
tree.children.last.style.margin = EdgeInsets.zero;
|
||||||
|
} else {
|
||||||
|
tree.children.last.style.margin =
|
||||||
|
tree.children.last.style.margin!.copyWith(bottom: 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle case (2) from above.
|
||||||
|
if (tree.children.length > 1) {
|
||||||
|
for (int i = 1; i < tree.children.length; i++) {
|
||||||
|
final previousSiblingBottom =
|
||||||
|
tree.children[i - 1].style.margin?.bottom ?? 0;
|
||||||
|
final thisTop = tree.children[i].style.margin?.top ?? 0;
|
||||||
|
final newInternalMargin = max(previousSiblingBottom, thisTop) / 2;
|
||||||
|
|
||||||
|
if (tree.children[i - 1].style.margin == null) {
|
||||||
|
tree.children[i - 1].style.margin =
|
||||||
|
EdgeInsets.only(bottom: newInternalMargin);
|
||||||
|
} else {
|
||||||
|
tree.children[i - 1].style.margin = tree.children[i - 1].style.margin!
|
||||||
|
.copyWith(bottom: newInternalMargin);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tree.children[i].style.margin == null) {
|
||||||
|
tree.children[i].style.margin =
|
||||||
|
EdgeInsets.only(top: newInternalMargin);
|
||||||
|
} else {
|
||||||
|
tree.children[i].style.margin =
|
||||||
|
tree.children[i].style.margin!.copyWith(top: newInternalMargin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [removeEmptyElements] recursively removes empty elements.
|
||||||
|
///
|
||||||
|
/// An empty element is any [EmptyContentElement], any empty [TextContentElement],
|
||||||
|
/// or any block-level [TextContentElement] that contains only whitespace and doesn't follow
|
||||||
|
/// a block element or a line break.
|
||||||
|
static StyledElement _removeEmptyElements(StyledElement tree) {
|
||||||
|
List<StyledElement> toRemove = <StyledElement>[];
|
||||||
|
bool lastChildBlock = true;
|
||||||
|
tree.children.forEachIndexed((index, child) {
|
||||||
|
if (child is EmptyContentElement || child is EmptyLayoutElement) {
|
||||||
|
toRemove.add(child);
|
||||||
|
} else if (child is TextContentElement
|
||||||
|
&& ((tree.name == "body"
|
||||||
|
&& (index == 0
|
||||||
|
|| index + 1 == tree.children.length
|
||||||
|
|| tree.children[index - 1].style.display == Display.BLOCK
|
||||||
|
|| tree.children[index + 1].style.display == Display.BLOCK))
|
||||||
|
|| tree.name == "ul")
|
||||||
|
&& child.text!.replaceAll(' ', '').isEmpty) {
|
||||||
|
toRemove.add(child);
|
||||||
|
} else if (child is TextContentElement
|
||||||
|
&& child.text!.isEmpty
|
||||||
|
&& child.style.whiteSpace != WhiteSpace.PRE) {
|
||||||
|
toRemove.add(child);
|
||||||
|
} else if (child is TextContentElement &&
|
||||||
|
child.style.whiteSpace != WhiteSpace.PRE &&
|
||||||
|
tree.style.display == Display.BLOCK &&
|
||||||
|
child.text!.isEmpty &&
|
||||||
|
lastChildBlock) {
|
||||||
|
toRemove.add(child);
|
||||||
|
} else if (child.style.display == Display.NONE) {
|
||||||
|
toRemove.add(child);
|
||||||
|
} else {
|
||||||
|
_removeEmptyElements(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is used above to check if the previous element is a block element or a line break.
|
||||||
|
lastChildBlock = (child.style.display == Display.BLOCK ||
|
||||||
|
child.style.display == Display.LIST_ITEM ||
|
||||||
|
(child is TextContentElement && child.text == '\n'));
|
||||||
|
});
|
||||||
|
tree.children.removeWhere((element) => toRemove.contains(element));
|
||||||
|
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [_processFontSize] changes percent-based font sizes (negative numbers in this implementation)
|
||||||
|
/// to pixel-based font sizes.
|
||||||
|
static StyledElement _processFontSize(StyledElement tree) {
|
||||||
|
double? parentFontSize = tree.style.fontSize?.size ?? FontSize.medium.size;
|
||||||
|
|
||||||
|
tree.children.forEach((child) {
|
||||||
|
if ((child.style.fontSize?.size ?? parentFontSize)! < 0) {
|
||||||
|
child.style.fontSize =
|
||||||
|
FontSize(parentFontSize! * -child.style.fontSize!.size!);
|
||||||
|
}
|
||||||
|
|
||||||
|
_processFontSize(child);
|
||||||
|
});
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The [RenderContext] is available when parsing the tree. It contains information
|
||||||
|
/// about the [BuildContext] of the `Html` widget, contains the configuration available
|
||||||
|
/// in the [HtmlParser], and contains information about the [Style] of the current
|
||||||
|
/// tree root.
|
||||||
|
class RenderContext {
|
||||||
|
final BuildContext buildContext;
|
||||||
|
final HtmlParser parser;
|
||||||
|
final StyledElement tree;
|
||||||
|
final Style style;
|
||||||
|
final AnchorKey? key;
|
||||||
|
|
||||||
|
RenderContext({
|
||||||
|
required this.buildContext,
|
||||||
|
required this.parser,
|
||||||
|
required this.tree,
|
||||||
|
required this.style,
|
||||||
|
this.key,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A [ContainerSpan] is a widget with an [InlineSpan] child or children.
|
||||||
|
///
|
||||||
|
/// A [ContainerSpan] can have a border, background color, height, width, padding, and margin
|
||||||
|
/// and can represent either an INLINE or BLOCK-level element.
|
||||||
|
class ContainerSpan extends StatelessWidget {
|
||||||
|
final AnchorKey? key;
|
||||||
|
final Widget? child;
|
||||||
|
final List<InlineSpan>? children;
|
||||||
|
final Style style;
|
||||||
|
final RenderContext newContext;
|
||||||
|
final bool shrinkWrap;
|
||||||
|
|
||||||
|
ContainerSpan({
|
||||||
|
this.key,
|
||||||
|
this.child,
|
||||||
|
this.children,
|
||||||
|
required this.style,
|
||||||
|
required this.newContext,
|
||||||
|
this.shrinkWrap = false,
|
||||||
|
}): super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext _) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: style.border,
|
||||||
|
color: style.backgroundColor,
|
||||||
|
),
|
||||||
|
height: style.height,
|
||||||
|
width: style.width,
|
||||||
|
padding: style.padding?.nonNegative,
|
||||||
|
margin: style.margin?.nonNegative,
|
||||||
|
alignment: shrinkWrap ? null : style.alignment,
|
||||||
|
child: child ??
|
||||||
|
StyledText(
|
||||||
|
textSpan: TextSpan(
|
||||||
|
style: newContext.style.generateTextStyle(),
|
||||||
|
children: children,
|
||||||
|
),
|
||||||
|
style: newContext.style,
|
||||||
|
renderContext: newContext,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StyledText extends StatelessWidget {
|
||||||
|
final InlineSpan textSpan;
|
||||||
|
final Style style;
|
||||||
|
final double textScaleFactor;
|
||||||
|
final RenderContext renderContext;
|
||||||
|
final AnchorKey? key;
|
||||||
|
final bool _selectable;
|
||||||
|
final TextSelectionControls? selectionControls;
|
||||||
|
final ScrollPhysics? scrollPhysics;
|
||||||
|
|
||||||
|
const StyledText({
|
||||||
|
required this.textSpan,
|
||||||
|
required this.style,
|
||||||
|
this.textScaleFactor = 1.0,
|
||||||
|
required this.renderContext,
|
||||||
|
this.key,
|
||||||
|
this.selectionControls,
|
||||||
|
this.scrollPhysics,
|
||||||
|
}) : _selectable = false,
|
||||||
|
super(key: key);
|
||||||
|
|
||||||
|
const StyledText.selectable({
|
||||||
|
required TextSpan textSpan,
|
||||||
|
required this.style,
|
||||||
|
this.textScaleFactor = 1.0,
|
||||||
|
required this.renderContext,
|
||||||
|
this.key,
|
||||||
|
this.selectionControls,
|
||||||
|
this.scrollPhysics,
|
||||||
|
}) : textSpan = textSpan,
|
||||||
|
_selectable = true,
|
||||||
|
super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_selectable) {
|
||||||
|
return SelectableText.rich(
|
||||||
|
textSpan as TextSpan,
|
||||||
|
style: style.generateTextStyle(),
|
||||||
|
textAlign: style.textAlign,
|
||||||
|
textDirection: style.direction,
|
||||||
|
textScaleFactor: textScaleFactor,
|
||||||
|
maxLines: style.maxLines,
|
||||||
|
selectionControls: selectionControls,
|
||||||
|
scrollPhysics: scrollPhysics,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return SizedBox(
|
||||||
|
width: consumeExpandedBlock(style.display, renderContext),
|
||||||
|
child: Text.rich(
|
||||||
|
textSpan,
|
||||||
|
style: style.generateTextStyle(),
|
||||||
|
textAlign: style.textAlign,
|
||||||
|
textDirection: style.direction,
|
||||||
|
textScaleFactor: textScaleFactor,
|
||||||
|
maxLines: style.maxLines,
|
||||||
|
overflow: style.textOverflow,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
double? consumeExpandedBlock(Display? display, RenderContext context) {
|
||||||
|
if ((display == Display.BLOCK || display == Display.LIST_ITEM) && !renderContext.parser.shrinkWrap) {
|
||||||
|
return double.infinity;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension IterateLetters on String {
|
||||||
|
String nextLetter() {
|
||||||
|
String s = this.toLowerCase();
|
||||||
|
if (s == "z") {
|
||||||
|
return String.fromCharCode(s.codeUnitAt(0) - 25) + String.fromCharCode(s.codeUnitAt(0) - 25); // AA or aa
|
||||||
|
} else {
|
||||||
|
var lastChar = s.substring(s.length - 1);
|
||||||
|
var sub = s.substring(0, s.length - 1);
|
||||||
|
if (lastChar == "z") {
|
||||||
|
// If a string of length > 1 ends in Z/z,
|
||||||
|
// increment the string (excluding the last Z/z) recursively,
|
||||||
|
// and append A/a (depending on casing) to it
|
||||||
|
return sub.nextLetter() + 'a';
|
||||||
|
} else {
|
||||||
|
// (take till last char) append with (increment last char)
|
||||||
|
return sub + String.fromCharCode(lastChar.codeUnitAt(0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,42 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import './styled_element.dart';
|
||||||
|
|
||||||
|
class AnchorKey extends GlobalKey {
|
||||||
|
static final Set<AnchorKey> _registry = <AnchorKey>{};
|
||||||
|
|
||||||
|
final Key parentKey;
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
const AnchorKey._(this.parentKey, this.id) : super.constructor();
|
||||||
|
|
||||||
|
static AnchorKey? of(Key? parentKey, StyledElement? id) {
|
||||||
|
final key = forId(parentKey, id?.elementId);
|
||||||
|
if (key == null || _registry.contains(key)) {
|
||||||
|
// Invalid id or already created a key with this id: silently ignore
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
_registry.add(key);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
static AnchorKey? forId(Key? parentKey, String? id) {
|
||||||
|
if (parentKey == null || id == null || id.isEmpty || id == "[[No ID]]") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AnchorKey._(parentKey, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is AnchorKey && runtimeType == other.runtimeType && parentKey == other.parentKey && id == other.id;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => parentKey.hashCode ^ id.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'AnchorKey{parentKey: $parentKey, id: #$id}';
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,981 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:csslib/visitor.dart' as css;
|
||||||
|
import 'package:csslib/parser.dart' as cssparser;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../flutter_html.dart';
|
||||||
|
import './utils.dart';
|
||||||
|
|
||||||
|
Style declarationsToStyle(Map<String, List<css.Expression>> declarations) {
|
||||||
|
Style style = new Style();
|
||||||
|
declarations.forEach((property, value) {
|
||||||
|
if (value.isNotEmpty) {
|
||||||
|
switch (property) {
|
||||||
|
case 'background-color':
|
||||||
|
style.backgroundColor = ExpressionMapping.expressionToColor(value.first) ?? style.backgroundColor;
|
||||||
|
break;
|
||||||
|
case 'border':
|
||||||
|
List<css.LiteralTerm?>? borderWidths = value.whereType<css.LiteralTerm>().toList();
|
||||||
|
/// List<css.LiteralTerm> might include other values than the ones we want for [BorderSide.width], so make sure to remove those before passing it to [ExpressionMapping]
|
||||||
|
borderWidths.removeWhere((element) => element == null || (element.text != "thin"
|
||||||
|
&& element.text != "medium" && element.text != "thick"
|
||||||
|
&& !(element is css.LengthTerm) && !(element is css.PercentageTerm)
|
||||||
|
&& !(element is css.EmTerm) && !(element is css.RemTerm)
|
||||||
|
&& !(element is css.NumberTerm))
|
||||||
|
);
|
||||||
|
List<css.Expression?>? borderColors = value.where((element) => ExpressionMapping.expressionToColor(element) != null).toList();
|
||||||
|
List<css.LiteralTerm?>? potentialStyles = value.whereType<css.LiteralTerm>().toList();
|
||||||
|
/// Currently doesn't matter, as Flutter only supports "solid" or "none", but may support more in the future.
|
||||||
|
List<String> possibleBorderValues = ["dotted", "dashed", "solid", "double", "groove", "ridge", "inset", "outset", "none", "hidden"];
|
||||||
|
/// List<css.LiteralTerm> might include other values than the ones we want for [BorderSide.style], so make sure to remove those before passing it to [ExpressionMapping]
|
||||||
|
potentialStyles.removeWhere((element) => element == null || !possibleBorderValues.contains(element.text));
|
||||||
|
List<css.LiteralTerm?>? borderStyles = potentialStyles;
|
||||||
|
style.border = ExpressionMapping.expressionToBorder(borderWidths, borderStyles, borderColors);
|
||||||
|
break;
|
||||||
|
case 'border-left':
|
||||||
|
List<css.LiteralTerm?>? borderWidths = value.whereType<css.LiteralTerm>().toList();
|
||||||
|
/// List<css.LiteralTerm> might include other values than the ones we want for [BorderSide.width], so make sure to remove those before passing it to [ExpressionMapping]
|
||||||
|
borderWidths.removeWhere((element) => element == null || (element.text != "thin"
|
||||||
|
&& element.text != "medium" && element.text != "thick"
|
||||||
|
&& !(element is css.LengthTerm) && !(element is css.PercentageTerm)
|
||||||
|
&& !(element is css.EmTerm) && !(element is css.RemTerm)
|
||||||
|
&& !(element is css.NumberTerm))
|
||||||
|
);
|
||||||
|
css.LiteralTerm? borderWidth = borderWidths.firstWhereOrNull((element) => element != null);
|
||||||
|
css.Expression? borderColor = value.firstWhereOrNull((element) => ExpressionMapping.expressionToColor(element) != null);
|
||||||
|
List<css.LiteralTerm?>? potentialStyles = value.whereType<css.LiteralTerm>().toList();
|
||||||
|
/// Currently doesn't matter, as Flutter only supports "solid" or "none", but may support more in the future.
|
||||||
|
List<String> possibleBorderValues = ["dotted", "dashed", "solid", "double", "groove", "ridge", "inset", "outset", "none", "hidden"];
|
||||||
|
/// List<css.LiteralTerm> might include other values than the ones we want for [BorderSide.style], so make sure to remove those before passing it to [ExpressionMapping]
|
||||||
|
potentialStyles.removeWhere((element) => element == null || !possibleBorderValues.contains(element.text));
|
||||||
|
css.LiteralTerm? borderStyle = potentialStyles.firstOrNull;
|
||||||
|
Border newBorder = Border(
|
||||||
|
left: style.border?.left.copyWith(
|
||||||
|
width: ExpressionMapping.expressionToBorderWidth(borderWidth),
|
||||||
|
style: ExpressionMapping.expressionToBorderStyle(borderStyle),
|
||||||
|
color: ExpressionMapping.expressionToColor(borderColor),
|
||||||
|
) ?? BorderSide(
|
||||||
|
width: ExpressionMapping.expressionToBorderWidth(borderWidth),
|
||||||
|
style: ExpressionMapping.expressionToBorderStyle(borderStyle),
|
||||||
|
color: ExpressionMapping.expressionToColor(borderColor) ?? Colors.black,
|
||||||
|
),
|
||||||
|
right: style.border?.right ?? BorderSide.none,
|
||||||
|
top: style.border?.top ?? BorderSide.none,
|
||||||
|
bottom: style.border?.bottom ?? BorderSide.none,
|
||||||
|
);
|
||||||
|
style.border = newBorder;
|
||||||
|
break;
|
||||||
|
case 'border-right':
|
||||||
|
List<css.LiteralTerm?>? borderWidths = value.whereType<css.LiteralTerm>().toList();
|
||||||
|
/// List<css.LiteralTerm> might include other values than the ones we want for [BorderSide.width], so make sure to remove those before passing it to [ExpressionMapping]
|
||||||
|
borderWidths.removeWhere((element) => element == null || (element.text != "thin"
|
||||||
|
&& element.text != "medium" && element.text != "thick"
|
||||||
|
&& !(element is css.LengthTerm) && !(element is css.PercentageTerm)
|
||||||
|
&& !(element is css.EmTerm) && !(element is css.RemTerm)
|
||||||
|
&& !(element is css.NumberTerm))
|
||||||
|
);
|
||||||
|
css.LiteralTerm? borderWidth = borderWidths.firstWhereOrNull((element) => element != null);
|
||||||
|
css.Expression? borderColor = value.firstWhereOrNull((element) => ExpressionMapping.expressionToColor(element) != null);
|
||||||
|
List<css.LiteralTerm?>? potentialStyles = value.whereType<css.LiteralTerm>().toList();
|
||||||
|
/// Currently doesn't matter, as Flutter only supports "solid" or "none", but may support more in the future.
|
||||||
|
List<String> possibleBorderValues = ["dotted", "dashed", "solid", "double", "groove", "ridge", "inset", "outset", "none", "hidden"];
|
||||||
|
/// List<css.LiteralTerm> might include other values than the ones we want for [BorderSide.style], so make sure to remove those before passing it to [ExpressionMapping]
|
||||||
|
potentialStyles.removeWhere((element) => element == null || !possibleBorderValues.contains(element.text));
|
||||||
|
css.LiteralTerm? borderStyle = potentialStyles.firstOrNull;
|
||||||
|
Border newBorder = Border(
|
||||||
|
left: style.border?.left ?? BorderSide.none,
|
||||||
|
right: style.border?.right.copyWith(
|
||||||
|
width: ExpressionMapping.expressionToBorderWidth(borderWidth),
|
||||||
|
style: ExpressionMapping.expressionToBorderStyle(borderStyle),
|
||||||
|
color: ExpressionMapping.expressionToColor(borderColor),
|
||||||
|
) ?? BorderSide(
|
||||||
|
width: ExpressionMapping.expressionToBorderWidth(borderWidth),
|
||||||
|
style: ExpressionMapping.expressionToBorderStyle(borderStyle),
|
||||||
|
color: ExpressionMapping.expressionToColor(borderColor) ?? Colors.black,
|
||||||
|
),
|
||||||
|
top: style.border?.top ?? BorderSide.none,
|
||||||
|
bottom: style.border?.bottom ?? BorderSide.none,
|
||||||
|
);
|
||||||
|
style.border = newBorder;
|
||||||
|
break;
|
||||||
|
case 'border-top':
|
||||||
|
List<css.LiteralTerm?>? borderWidths = value.whereType<css.LiteralTerm>().toList();
|
||||||
|
/// List<css.LiteralTerm> might include other values than the ones we want for [BorderSide.width], so make sure to remove those before passing it to [ExpressionMapping]
|
||||||
|
borderWidths.removeWhere((element) => element == null || (element.text != "thin"
|
||||||
|
&& element.text != "medium" && element.text != "thick"
|
||||||
|
&& !(element is css.LengthTerm) && !(element is css.PercentageTerm)
|
||||||
|
&& !(element is css.EmTerm) && !(element is css.RemTerm)
|
||||||
|
&& !(element is css.NumberTerm))
|
||||||
|
);
|
||||||
|
css.LiteralTerm? borderWidth = borderWidths.firstWhereOrNull((element) => element != null);
|
||||||
|
css.Expression? borderColor = value.firstWhereOrNull((element) => ExpressionMapping.expressionToColor(element) != null);
|
||||||
|
List<css.LiteralTerm?>? potentialStyles = value.whereType<css.LiteralTerm>().toList();
|
||||||
|
/// Currently doesn't matter, as Flutter only supports "solid" or "none", but may support more in the future.
|
||||||
|
List<String> possibleBorderValues = ["dotted", "dashed", "solid", "double", "groove", "ridge", "inset", "outset", "none", "hidden"];
|
||||||
|
/// List<css.LiteralTerm> might include other values than the ones we want for [BorderSide.style], so make sure to remove those before passing it to [ExpressionMapping]
|
||||||
|
potentialStyles.removeWhere((element) => element == null || !possibleBorderValues.contains(element.text));
|
||||||
|
css.LiteralTerm? borderStyle = potentialStyles.firstOrNull;
|
||||||
|
Border newBorder = Border(
|
||||||
|
left: style.border?.left ?? BorderSide.none,
|
||||||
|
right: style.border?.right ?? BorderSide.none,
|
||||||
|
top: style.border?.top.copyWith(
|
||||||
|
width: ExpressionMapping.expressionToBorderWidth(borderWidth),
|
||||||
|
style: ExpressionMapping.expressionToBorderStyle(borderStyle),
|
||||||
|
color: ExpressionMapping.expressionToColor(borderColor),
|
||||||
|
) ?? BorderSide(
|
||||||
|
width: ExpressionMapping.expressionToBorderWidth(borderWidth),
|
||||||
|
style: ExpressionMapping.expressionToBorderStyle(borderStyle),
|
||||||
|
color: ExpressionMapping.expressionToColor(borderColor) ?? Colors.black,
|
||||||
|
),
|
||||||
|
bottom: style.border?.bottom ?? BorderSide.none,
|
||||||
|
);
|
||||||
|
style.border = newBorder;
|
||||||
|
break;
|
||||||
|
case 'border-bottom':
|
||||||
|
List<css.LiteralTerm?>? borderWidths = value.whereType<css.LiteralTerm>().toList();
|
||||||
|
/// List<css.LiteralTerm> might include other values than the ones we want for [BorderSide.width], so make sure to remove those before passing it to [ExpressionMapping]
|
||||||
|
borderWidths.removeWhere((element) => element == null || (element.text != "thin"
|
||||||
|
&& element.text != "medium" && element.text != "thick"
|
||||||
|
&& !(element is css.LengthTerm) && !(element is css.PercentageTerm)
|
||||||
|
&& !(element is css.EmTerm) && !(element is css.RemTerm)
|
||||||
|
&& !(element is css.NumberTerm))
|
||||||
|
);
|
||||||
|
css.LiteralTerm? borderWidth = borderWidths.firstWhereOrNull((element) => element != null);
|
||||||
|
css.Expression? borderColor = value.firstWhereOrNull((element) => ExpressionMapping.expressionToColor(element) != null);
|
||||||
|
List<css.LiteralTerm?>? potentialStyles = value.whereType<css.LiteralTerm>().toList();
|
||||||
|
/// Currently doesn't matter, as Flutter only supports "solid" or "none", but may support more in the future.
|
||||||
|
List<String> possibleBorderValues = ["dotted", "dashed", "solid", "double", "groove", "ridge", "inset", "outset", "none", "hidden"];
|
||||||
|
/// List<css.LiteralTerm> might include other values than the ones we want for [BorderSide.style], so make sure to remove those before passing it to [ExpressionMapping]
|
||||||
|
potentialStyles.removeWhere((element) => element == null || !possibleBorderValues.contains(element.text));
|
||||||
|
css.LiteralTerm? borderStyle = potentialStyles.firstOrNull;
|
||||||
|
Border newBorder = Border(
|
||||||
|
left: style.border?.left ?? BorderSide.none,
|
||||||
|
right: style.border?.right ?? BorderSide.none,
|
||||||
|
top: style.border?.top ?? BorderSide.none,
|
||||||
|
bottom: style.border?.bottom.copyWith(
|
||||||
|
width: ExpressionMapping.expressionToBorderWidth(borderWidth),
|
||||||
|
style: ExpressionMapping.expressionToBorderStyle(borderStyle),
|
||||||
|
color: ExpressionMapping.expressionToColor(borderColor),
|
||||||
|
) ?? BorderSide(
|
||||||
|
width: ExpressionMapping.expressionToBorderWidth(borderWidth),
|
||||||
|
style: ExpressionMapping.expressionToBorderStyle(borderStyle),
|
||||||
|
color: ExpressionMapping.expressionToColor(borderColor) ?? Colors.black,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
style.border = newBorder;
|
||||||
|
break;
|
||||||
|
case 'color':
|
||||||
|
style.color = ExpressionMapping.expressionToColor(value.first) ?? style.color;
|
||||||
|
break;
|
||||||
|
case 'direction':
|
||||||
|
style.direction = ExpressionMapping.expressionToDirection(value.first);
|
||||||
|
break;
|
||||||
|
case 'display':
|
||||||
|
style.display = ExpressionMapping.expressionToDisplay(value.first);
|
||||||
|
break;
|
||||||
|
case 'line-height':
|
||||||
|
style.lineHeight = ExpressionMapping.expressionToLineHeight(value.first);
|
||||||
|
break;
|
||||||
|
case 'font-family':
|
||||||
|
style.fontFamily = ExpressionMapping.expressionToFontFamily(value.first) ?? style.fontFamily;
|
||||||
|
break;
|
||||||
|
case 'font-feature-settings':
|
||||||
|
style.fontFeatureSettings = ExpressionMapping.expressionToFontFeatureSettings(value);
|
||||||
|
break;
|
||||||
|
case 'font-size':
|
||||||
|
style.fontSize = ExpressionMapping.expressionToFontSize(value.first) ?? style.fontSize;
|
||||||
|
break;
|
||||||
|
case 'font-style':
|
||||||
|
style.fontStyle = ExpressionMapping.expressionToFontStyle(value.first);
|
||||||
|
break;
|
||||||
|
case 'font-weight':
|
||||||
|
style.fontWeight = ExpressionMapping.expressionToFontWeight(value.first);
|
||||||
|
break;
|
||||||
|
case 'list-style':
|
||||||
|
css.LiteralTerm? position = value.firstWhereOrNull((e) => e is css.LiteralTerm && (e.text == "outside" || e.text == "inside")) as css.LiteralTerm?;
|
||||||
|
css.UriTerm? image = value.firstWhereOrNull((e) => e is css.UriTerm) as css.UriTerm?;
|
||||||
|
css.LiteralTerm? type = value.firstWhereOrNull((e) => e is css.LiteralTerm && e.text != "outside" && e.text != "inside") as css.LiteralTerm?;
|
||||||
|
if (position != null) {
|
||||||
|
switch (position.text) {
|
||||||
|
case 'outside':
|
||||||
|
style.listStylePosition = ListStylePosition.OUTSIDE;
|
||||||
|
break;
|
||||||
|
case 'inside':
|
||||||
|
style.listStylePosition = ListStylePosition.INSIDE;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (image != null) {
|
||||||
|
style.listStyleType = ExpressionMapping.expressionToListStyleType(image) ?? style.listStyleType;
|
||||||
|
} else if (type != null) {
|
||||||
|
style.listStyleType = ExpressionMapping.expressionToListStyleType(type) ?? style.listStyleType;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'list-style-image':
|
||||||
|
if (value.first is css.UriTerm) {
|
||||||
|
style.listStyleType = ExpressionMapping.expressionToListStyleType(value.first as css.UriTerm) ?? style.listStyleType;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'list-style-position':
|
||||||
|
if (value.first is css.LiteralTerm) {
|
||||||
|
switch ((value.first as css.LiteralTerm).text) {
|
||||||
|
case 'outside':
|
||||||
|
style.listStylePosition = ListStylePosition.OUTSIDE;
|
||||||
|
break;
|
||||||
|
case 'inside':
|
||||||
|
style.listStylePosition = ListStylePosition.INSIDE;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'height':
|
||||||
|
style.height = ExpressionMapping.expressionToPaddingLength(value.first) ?? style.height;
|
||||||
|
break;
|
||||||
|
case 'list-style-type':
|
||||||
|
if (value.first is css.LiteralTerm) {
|
||||||
|
style.listStyleType = ExpressionMapping.expressionToListStyleType(value.first as css.LiteralTerm) ?? style.listStyleType;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'margin':
|
||||||
|
List<css.LiteralTerm>? marginLengths = value.whereType<css.LiteralTerm>().toList();
|
||||||
|
/// List<css.LiteralTerm> might include other values than the ones we want for margin length, so make sure to remove those before passing it to [ExpressionMapping]
|
||||||
|
marginLengths.removeWhere((element) => !(element is css.LengthTerm)
|
||||||
|
&& !(element is css.EmTerm)
|
||||||
|
&& !(element is css.RemTerm)
|
||||||
|
&& !(element is css.NumberTerm)
|
||||||
|
);
|
||||||
|
List<double?> margin = ExpressionMapping.expressionToPadding(marginLengths);
|
||||||
|
style.margin = (style.margin ?? EdgeInsets.zero).copyWith(
|
||||||
|
left: margin[0],
|
||||||
|
right: margin[1],
|
||||||
|
top: margin[2],
|
||||||
|
bottom: margin[3],
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'margin-left':
|
||||||
|
style.margin = (style.margin ?? EdgeInsets.zero).copyWith(
|
||||||
|
left: ExpressionMapping.expressionToPaddingLength(value.first));
|
||||||
|
break;
|
||||||
|
case 'margin-right':
|
||||||
|
style.margin = (style.margin ?? EdgeInsets.zero).copyWith(
|
||||||
|
right: ExpressionMapping.expressionToPaddingLength(value.first));
|
||||||
|
break;
|
||||||
|
case 'margin-top':
|
||||||
|
style.margin = (style.margin ?? EdgeInsets.zero).copyWith(
|
||||||
|
top: ExpressionMapping.expressionToPaddingLength(value.first));
|
||||||
|
break;
|
||||||
|
case 'margin-bottom':
|
||||||
|
style.margin = (style.margin ?? EdgeInsets.zero).copyWith(
|
||||||
|
bottom: ExpressionMapping.expressionToPaddingLength(value.first));
|
||||||
|
break;
|
||||||
|
case 'padding':
|
||||||
|
List<css.LiteralTerm>? paddingLengths = value.whereType<css.LiteralTerm>().toList();
|
||||||
|
/// List<css.LiteralTerm> might include other values than the ones we want for padding length, so make sure to remove those before passing it to [ExpressionMapping]
|
||||||
|
paddingLengths.removeWhere((element) => !(element is css.LengthTerm)
|
||||||
|
&& !(element is css.EmTerm)
|
||||||
|
&& !(element is css.RemTerm)
|
||||||
|
&& !(element is css.NumberTerm)
|
||||||
|
);
|
||||||
|
List<double?> padding = ExpressionMapping.expressionToPadding(paddingLengths);
|
||||||
|
style.padding = (style.padding ?? EdgeInsets.zero).copyWith(
|
||||||
|
left: padding[0],
|
||||||
|
right: padding[1],
|
||||||
|
top: padding[2],
|
||||||
|
bottom: padding[3],
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'padding-left':
|
||||||
|
style.padding = (style.padding ?? EdgeInsets.zero).copyWith(
|
||||||
|
left: ExpressionMapping.expressionToPaddingLength(value.first));
|
||||||
|
break;
|
||||||
|
case 'padding-right':
|
||||||
|
style.padding = (style.padding ?? EdgeInsets.zero).copyWith(
|
||||||
|
right: ExpressionMapping.expressionToPaddingLength(value.first));
|
||||||
|
break;
|
||||||
|
case 'padding-top':
|
||||||
|
style.padding = (style.padding ?? EdgeInsets.zero).copyWith(
|
||||||
|
top: ExpressionMapping.expressionToPaddingLength(value.first));
|
||||||
|
break;
|
||||||
|
case 'padding-bottom':
|
||||||
|
style.padding = (style.padding ?? EdgeInsets.zero).copyWith(
|
||||||
|
bottom: ExpressionMapping.expressionToPaddingLength(value.first));
|
||||||
|
break;
|
||||||
|
case 'text-align':
|
||||||
|
style.textAlign = ExpressionMapping.expressionToTextAlign(value.first);
|
||||||
|
break;
|
||||||
|
case 'text-decoration':
|
||||||
|
List<css.LiteralTerm?>? textDecorationList = value.whereType<css.LiteralTerm>().toList();
|
||||||
|
/// List<css.LiteralTerm> might include other values than the ones we want for [textDecorationList], so make sure to remove those before passing it to [ExpressionMapping]
|
||||||
|
textDecorationList.removeWhere((element) => element == null || (element.text != "none"
|
||||||
|
&& element.text != "overline" && element.text != "underline" && element.text != "line-through"));
|
||||||
|
List<css.Expression?>? nullableList = value;
|
||||||
|
css.Expression? textDecorationColor;
|
||||||
|
textDecorationColor = nullableList.firstWhereOrNull(
|
||||||
|
(element) => element is css.HexColorTerm || element is css.FunctionTerm);
|
||||||
|
List<css.LiteralTerm?>? potentialStyles = value.whereType<css.LiteralTerm>().toList();
|
||||||
|
/// List<css.LiteralTerm> might include other values than the ones we want for [textDecorationStyle], so make sure to remove those before passing it to [ExpressionMapping]
|
||||||
|
potentialStyles.removeWhere((element) => element == null || (element.text != "solid"
|
||||||
|
&& element.text != "double" && element.text != "dashed" && element.text != "dotted" && element.text != "wavy"));
|
||||||
|
css.LiteralTerm? textDecorationStyle = potentialStyles.isNotEmpty ? potentialStyles.last : null;
|
||||||
|
style.textDecoration = ExpressionMapping.expressionToTextDecorationLine(textDecorationList);
|
||||||
|
if (textDecorationColor != null) style.textDecorationColor = ExpressionMapping.expressionToColor(textDecorationColor)
|
||||||
|
?? style.textDecorationColor;
|
||||||
|
if (textDecorationStyle != null) style.textDecorationStyle = ExpressionMapping.expressionToTextDecorationStyle(textDecorationStyle);
|
||||||
|
break;
|
||||||
|
case 'text-decoration-color':
|
||||||
|
style.textDecorationColor = ExpressionMapping.expressionToColor(value.first) ?? style.textDecorationColor;
|
||||||
|
break;
|
||||||
|
case 'text-decoration-line':
|
||||||
|
List<css.LiteralTerm?>? textDecorationList = value.whereType<css.LiteralTerm>().toList();
|
||||||
|
style.textDecoration = ExpressionMapping.expressionToTextDecorationLine(textDecorationList);
|
||||||
|
break;
|
||||||
|
case 'text-decoration-style':
|
||||||
|
style.textDecorationStyle = ExpressionMapping.expressionToTextDecorationStyle(value.first as css.LiteralTerm);
|
||||||
|
break;
|
||||||
|
case 'text-shadow':
|
||||||
|
style.textShadow = ExpressionMapping.expressionToTextShadow(value);
|
||||||
|
break;
|
||||||
|
case 'text-transform':
|
||||||
|
final val = (value.first as css.LiteralTerm).text;
|
||||||
|
if (val == 'uppercase') {
|
||||||
|
style.textTransform = TextTransform.uppercase;
|
||||||
|
} else if (val == 'lowercase') {
|
||||||
|
style.textTransform = TextTransform.lowercase;
|
||||||
|
} else if (val == 'capitalize') {
|
||||||
|
style.textTransform = TextTransform.capitalize;
|
||||||
|
} else {
|
||||||
|
style.textTransform = TextTransform.none;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'width':
|
||||||
|
style.width = ExpressionMapping.expressionToPaddingLength(value.first) ?? style.width;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
|
||||||
|
Style? inlineCssToStyle(String? inlineStyle, OnCssParseError? errorHandler) {
|
||||||
|
var errors = <cssparser.Message>[];
|
||||||
|
final sheet = cssparser.parse("*{$inlineStyle}", errors: errors);
|
||||||
|
if (errors.isEmpty) {
|
||||||
|
final declarations = DeclarationVisitor().getDeclarations(sheet);
|
||||||
|
return declarationsToStyle(declarations["*"]!);
|
||||||
|
} else if (errorHandler != null) {
|
||||||
|
String? newCss = errorHandler.call(inlineStyle ?? "", errors);
|
||||||
|
if (newCss != null) {
|
||||||
|
return inlineCssToStyle(newCss, errorHandler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Map<String, List<css.Expression>>> parseExternalCss(String css, OnCssParseError? errorHandler) {
|
||||||
|
var errors = <cssparser.Message>[];
|
||||||
|
final sheet = cssparser.parse(css, errors: errors);
|
||||||
|
if (errors.isEmpty) {
|
||||||
|
return DeclarationVisitor().getDeclarations(sheet);
|
||||||
|
} else if (errorHandler != null) {
|
||||||
|
String? newCss = errorHandler.call(css, errors);
|
||||||
|
if (newCss != null) {
|
||||||
|
return parseExternalCss(newCss, errorHandler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeclarationVisitor extends css.Visitor {
|
||||||
|
Map<String, Map<String, List<css.Expression>>> _result = {};
|
||||||
|
Map<String, List<css.Expression>> _properties = {};
|
||||||
|
late String _selector;
|
||||||
|
late String _currentProperty;
|
||||||
|
|
||||||
|
Map<String, Map<String, List<css.Expression>>> getDeclarations(css.StyleSheet sheet) {
|
||||||
|
sheet.topLevels.forEach((element) {
|
||||||
|
if (element.span != null) {
|
||||||
|
_selector = element.span!.text;
|
||||||
|
element.visit(this);
|
||||||
|
if (_result[_selector] != null) {
|
||||||
|
_properties.forEach((key, value) {
|
||||||
|
if (_result[_selector]![key] != null) {
|
||||||
|
_result[_selector]![key]!.addAll(new List<css.Expression>.from(value));
|
||||||
|
} else {
|
||||||
|
_result[_selector]![key] = new List<css.Expression>.from(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
_result[_selector] = new Map<String, List<css.Expression>>.from(_properties);
|
||||||
|
}
|
||||||
|
_properties.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return _result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void visitDeclaration(css.Declaration node) {
|
||||||
|
_currentProperty = node.property;
|
||||||
|
_properties[_currentProperty] = <css.Expression>[];
|
||||||
|
node.expression!.visit(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void visitExpressions(css.Expressions node) {
|
||||||
|
if (_properties[_currentProperty] != null) {
|
||||||
|
_properties[_currentProperty]!.addAll(node.expressions);
|
||||||
|
} else {
|
||||||
|
_properties[_currentProperty] = node.expressions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//Mapping functions
|
||||||
|
class ExpressionMapping {
|
||||||
|
|
||||||
|
static Border expressionToBorder(List<css.Expression?>? borderWidths, List<css.LiteralTerm?>? borderStyles, List<css.Expression?>? borderColors) {
|
||||||
|
CustomBorderSide left = CustomBorderSide();
|
||||||
|
CustomBorderSide top = CustomBorderSide();
|
||||||
|
CustomBorderSide right = CustomBorderSide();
|
||||||
|
CustomBorderSide bottom = CustomBorderSide();
|
||||||
|
if (borderWidths != null && borderWidths.isNotEmpty) {
|
||||||
|
top.width = expressionToBorderWidth(borderWidths.first);
|
||||||
|
if (borderWidths.length == 4) {
|
||||||
|
right.width = expressionToBorderWidth(borderWidths[1]);
|
||||||
|
bottom.width = expressionToBorderWidth(borderWidths[2]);
|
||||||
|
left.width = expressionToBorderWidth(borderWidths.last);
|
||||||
|
}
|
||||||
|
if (borderWidths.length == 3) {
|
||||||
|
left.width = expressionToBorderWidth(borderWidths[1]);
|
||||||
|
right.width = expressionToBorderWidth(borderWidths[1]);
|
||||||
|
bottom.width = expressionToBorderWidth(borderWidths.last);
|
||||||
|
}
|
||||||
|
if (borderWidths.length == 2) {
|
||||||
|
bottom.width = expressionToBorderWidth(borderWidths.first);
|
||||||
|
left.width = expressionToBorderWidth(borderWidths.last);
|
||||||
|
right.width = expressionToBorderWidth(borderWidths.last);
|
||||||
|
}
|
||||||
|
if (borderWidths.length == 1) {
|
||||||
|
bottom.width = expressionToBorderWidth(borderWidths.first);
|
||||||
|
left.width = expressionToBorderWidth(borderWidths.first);
|
||||||
|
right.width = expressionToBorderWidth(borderWidths.first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (borderStyles != null && borderStyles.isNotEmpty) {
|
||||||
|
top.style = expressionToBorderStyle(borderStyles.first);
|
||||||
|
if (borderStyles.length == 4) {
|
||||||
|
right.style = expressionToBorderStyle(borderStyles[1]);
|
||||||
|
bottom.style = expressionToBorderStyle(borderStyles[2]);
|
||||||
|
left.style = expressionToBorderStyle(borderStyles.last);
|
||||||
|
}
|
||||||
|
if (borderStyles.length == 3) {
|
||||||
|
left.style = expressionToBorderStyle(borderStyles[1]);
|
||||||
|
right.style = expressionToBorderStyle(borderStyles[1]);
|
||||||
|
bottom.style = expressionToBorderStyle(borderStyles.last);
|
||||||
|
}
|
||||||
|
if (borderStyles.length == 2) {
|
||||||
|
bottom.style = expressionToBorderStyle(borderStyles.first);
|
||||||
|
left.style = expressionToBorderStyle(borderStyles.last);
|
||||||
|
right.style = expressionToBorderStyle(borderStyles.last);
|
||||||
|
}
|
||||||
|
if (borderStyles.length == 1) {
|
||||||
|
bottom.style = expressionToBorderStyle(borderStyles.first);
|
||||||
|
left.style = expressionToBorderStyle(borderStyles.first);
|
||||||
|
right.style = expressionToBorderStyle(borderStyles.first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (borderColors != null && borderColors.isNotEmpty) {
|
||||||
|
top.color = expressionToColor(borderColors.first);
|
||||||
|
if (borderColors.length == 4) {
|
||||||
|
right.color = expressionToColor(borderColors[1]);
|
||||||
|
bottom.color = expressionToColor(borderColors[2]);
|
||||||
|
left.color = expressionToColor(borderColors.last);
|
||||||
|
}
|
||||||
|
if (borderColors.length == 3) {
|
||||||
|
left.color = expressionToColor(borderColors[1]);
|
||||||
|
right.color = expressionToColor(borderColors[1]);
|
||||||
|
bottom.color = expressionToColor(borderColors.last);
|
||||||
|
}
|
||||||
|
if (borderColors.length == 2) {
|
||||||
|
bottom.color = expressionToColor(borderColors.first);
|
||||||
|
left.color = expressionToColor(borderColors.last);
|
||||||
|
right.color = expressionToColor(borderColors.last);
|
||||||
|
}
|
||||||
|
if (borderColors.length == 1) {
|
||||||
|
bottom.color = expressionToColor(borderColors.first);
|
||||||
|
left.color = expressionToColor(borderColors.first);
|
||||||
|
right.color = expressionToColor(borderColors.first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Border(
|
||||||
|
top: BorderSide(width: top.width, color: top.color ?? Colors.black, style: top.style),
|
||||||
|
right: BorderSide(width: right.width, color: right.color ?? Colors.black, style: right.style),
|
||||||
|
bottom: BorderSide(width: bottom.width, color: bottom.color ?? Colors.black, style: bottom.style),
|
||||||
|
left: BorderSide(width: left.width, color: left.color ?? Colors.black, style: left.style)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double expressionToBorderWidth(css.Expression? value) {
|
||||||
|
if (value is css.NumberTerm) {
|
||||||
|
return double.tryParse(value.text) ?? 1.0;
|
||||||
|
} else if (value is css.PercentageTerm) {
|
||||||
|
return (double.tryParse(value.text) ?? 400) / 100;
|
||||||
|
} else if (value is css.EmTerm) {
|
||||||
|
return double.tryParse(value.text) ?? 1.0;
|
||||||
|
} else if (value is css.RemTerm) {
|
||||||
|
return double.tryParse(value.text) ?? 1.0;
|
||||||
|
} else if (value is css.LengthTerm) {
|
||||||
|
return double.tryParse(value.text.replaceAll(new RegExp(r'\s+(\d+\.\d+)\s+'), '')) ?? 1.0;
|
||||||
|
} else if (value is css.LiteralTerm) {
|
||||||
|
switch (value.text) {
|
||||||
|
case "thin":
|
||||||
|
return 2.0;
|
||||||
|
case "medium":
|
||||||
|
return 4.0;
|
||||||
|
case "thick":
|
||||||
|
return 6.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 4.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static BorderStyle expressionToBorderStyle(css.LiteralTerm? value) {
|
||||||
|
if (value != null && value.text != "none" && value.text != "hidden") {
|
||||||
|
return BorderStyle.solid;
|
||||||
|
}
|
||||||
|
return BorderStyle.none;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Color? expressionToColor(css.Expression? value) {
|
||||||
|
if (value != null) {
|
||||||
|
if (value is css.HexColorTerm) {
|
||||||
|
return stringToColor(value.text);
|
||||||
|
} else if (value is css.FunctionTerm) {
|
||||||
|
if (value.text == 'rgba' || value.text == 'rgb') {
|
||||||
|
return rgbOrRgbaToColor(value.span!.text);
|
||||||
|
} else if (value.text == 'hsla' || value.text == 'hsl') {
|
||||||
|
return hslToRgbToColor(value.span!.text);
|
||||||
|
}
|
||||||
|
} else if (value is css.LiteralTerm) {
|
||||||
|
return namedColorToColor(value.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static TextDirection expressionToDirection(css.Expression value) {
|
||||||
|
if (value is css.LiteralTerm) {
|
||||||
|
switch(value.text) {
|
||||||
|
case "ltr":
|
||||||
|
return TextDirection.ltr;
|
||||||
|
case "rtl":
|
||||||
|
return TextDirection.rtl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return TextDirection.ltr;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Display expressionToDisplay(css.Expression value) {
|
||||||
|
if (value is css.LiteralTerm) {
|
||||||
|
switch(value.text) {
|
||||||
|
case 'block':
|
||||||
|
return Display.BLOCK;
|
||||||
|
case 'inline-block':
|
||||||
|
return Display.INLINE_BLOCK;
|
||||||
|
case 'inline':
|
||||||
|
return Display.INLINE;
|
||||||
|
case 'list-item':
|
||||||
|
return Display.LIST_ITEM;
|
||||||
|
case 'none':
|
||||||
|
return Display.NONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Display.INLINE;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<FontFeature> expressionToFontFeatureSettings(List<css.Expression> value) {
|
||||||
|
List<FontFeature> fontFeatures = [];
|
||||||
|
for (int i = 0; i < value.length; i++) {
|
||||||
|
css.Expression exp = value[i];
|
||||||
|
if (exp is css.LiteralTerm) {
|
||||||
|
if (exp.text != "on" && exp.text != "off" && exp.text != "1" && exp.text != "0") {
|
||||||
|
if (i < value.length - 1) {
|
||||||
|
css.Expression nextExp = value[i+1];
|
||||||
|
if (nextExp is css.LiteralTerm && (nextExp.text == "on" || nextExp.text == "off" || nextExp.text == "1" || nextExp.text == "0")) {
|
||||||
|
fontFeatures.add(FontFeature(exp.text, nextExp.text == "on" || nextExp.text == "1" ? 1 : 0));
|
||||||
|
} else {
|
||||||
|
fontFeatures.add(FontFeature.enable(exp.text));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fontFeatures.add(FontFeature.enable(exp.text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<FontFeature> finalFontFeatures = fontFeatures.toSet().toList();
|
||||||
|
return finalFontFeatures;
|
||||||
|
}
|
||||||
|
|
||||||
|
static FontSize? expressionToFontSize(css.Expression value) {
|
||||||
|
if (value is css.NumberTerm) {
|
||||||
|
return FontSize(double.tryParse(value.text));
|
||||||
|
} else if (value is css.PercentageTerm) {
|
||||||
|
return FontSize.percent(double.tryParse(value.text)!);
|
||||||
|
} else if (value is css.EmTerm) {
|
||||||
|
return FontSize.em(double.tryParse(value.text));
|
||||||
|
} else if (value is css.RemTerm) {
|
||||||
|
return FontSize.rem(double.tryParse(value.text)!);
|
||||||
|
} else if (value is css.LengthTerm) {
|
||||||
|
return FontSize(double.tryParse(value.text.replaceAll(new RegExp(r'\s+(\d+\.\d+)\s+'), '')));
|
||||||
|
} else if (value is css.LiteralTerm) {
|
||||||
|
switch (value.text) {
|
||||||
|
case "xx-small":
|
||||||
|
return FontSize.xxSmall;
|
||||||
|
case "x-small":
|
||||||
|
return FontSize.xSmall;
|
||||||
|
case "small":
|
||||||
|
return FontSize.small;
|
||||||
|
case "medium":
|
||||||
|
return FontSize.medium;
|
||||||
|
case "large":
|
||||||
|
return FontSize.large;
|
||||||
|
case "x-large":
|
||||||
|
return FontSize.xLarge;
|
||||||
|
case "xx-large":
|
||||||
|
return FontSize.xxLarge;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static FontStyle expressionToFontStyle(css.Expression value) {
|
||||||
|
if (value is css.LiteralTerm) {
|
||||||
|
switch(value.text) {
|
||||||
|
case "italic":
|
||||||
|
case "oblique":
|
||||||
|
return FontStyle.italic;
|
||||||
|
}
|
||||||
|
return FontStyle.normal;
|
||||||
|
}
|
||||||
|
return FontStyle.normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
static FontWeight expressionToFontWeight(css.Expression value) {
|
||||||
|
if (value is css.NumberTerm) {
|
||||||
|
switch (value.text) {
|
||||||
|
case "100":
|
||||||
|
return FontWeight.w100;
|
||||||
|
case "200":
|
||||||
|
return FontWeight.w200;
|
||||||
|
case "300":
|
||||||
|
return FontWeight.w300;
|
||||||
|
case "400":
|
||||||
|
return FontWeight.w400;
|
||||||
|
case "500":
|
||||||
|
return FontWeight.w500;
|
||||||
|
case "600":
|
||||||
|
return FontWeight.w600;
|
||||||
|
case "700":
|
||||||
|
return FontWeight.w700;
|
||||||
|
case "800":
|
||||||
|
return FontWeight.w800;
|
||||||
|
case "900":
|
||||||
|
return FontWeight.w900;
|
||||||
|
}
|
||||||
|
} else if (value is css.LiteralTerm) {
|
||||||
|
switch(value.text) {
|
||||||
|
case "bold":
|
||||||
|
return FontWeight.bold;
|
||||||
|
case "bolder":
|
||||||
|
return FontWeight.w900;
|
||||||
|
case "lighter":
|
||||||
|
return FontWeight.w200;
|
||||||
|
}
|
||||||
|
return FontWeight.normal;
|
||||||
|
}
|
||||||
|
return FontWeight.normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? expressionToFontFamily(css.Expression value) {
|
||||||
|
if (value is css.LiteralTerm) return value.text;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static LineHeight expressionToLineHeight(css.Expression value) {
|
||||||
|
if (value is css.NumberTerm) {
|
||||||
|
return LineHeight.number(double.tryParse(value.text)!);
|
||||||
|
} else if (value is css.PercentageTerm) {
|
||||||
|
return LineHeight.percent(double.tryParse(value.text)!);
|
||||||
|
} else if (value is css.EmTerm) {
|
||||||
|
return LineHeight.em(double.tryParse(value.text)!);
|
||||||
|
} else if (value is css.RemTerm) {
|
||||||
|
return LineHeight.rem(double.tryParse(value.text)!);
|
||||||
|
} else if (value is css.LengthTerm) {
|
||||||
|
return LineHeight(double.tryParse(value.text.replaceAll(new RegExp(r'\s+(\d+\.\d+)\s+'), '')), units: "length");
|
||||||
|
}
|
||||||
|
return LineHeight.normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
static ListStyleType? expressionToListStyleType(css.LiteralTerm value) {
|
||||||
|
if (value is css.UriTerm) {
|
||||||
|
return ListStyleType.fromImage(value.text);
|
||||||
|
}
|
||||||
|
switch (value.text) {
|
||||||
|
case 'disc':
|
||||||
|
return ListStyleType.DISC;
|
||||||
|
case 'circle':
|
||||||
|
return ListStyleType.CIRCLE;
|
||||||
|
case 'decimal':
|
||||||
|
return ListStyleType.DECIMAL;
|
||||||
|
case 'lower-alpha':
|
||||||
|
return ListStyleType.LOWER_ALPHA;
|
||||||
|
case 'lower-latin':
|
||||||
|
return ListStyleType.LOWER_LATIN;
|
||||||
|
case 'lower-roman':
|
||||||
|
return ListStyleType.LOWER_ROMAN;
|
||||||
|
case 'square':
|
||||||
|
return ListStyleType.SQUARE;
|
||||||
|
case 'upper-alpha':
|
||||||
|
return ListStyleType.UPPER_ALPHA;
|
||||||
|
case 'upper-latin':
|
||||||
|
return ListStyleType.UPPER_LATIN;
|
||||||
|
case 'upper-roman':
|
||||||
|
return ListStyleType.UPPER_ROMAN;
|
||||||
|
case 'none':
|
||||||
|
return ListStyleType.NONE;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<double?> expressionToPadding(List<css.Expression>? lengths) {
|
||||||
|
double? left;
|
||||||
|
double? right;
|
||||||
|
double? top;
|
||||||
|
double? bottom;
|
||||||
|
if (lengths != null && lengths.isNotEmpty) {
|
||||||
|
top = expressionToPaddingLength(lengths.first);
|
||||||
|
if (lengths.length == 4) {
|
||||||
|
right = expressionToPaddingLength(lengths[1]);
|
||||||
|
bottom = expressionToPaddingLength(lengths[2]);
|
||||||
|
left = expressionToPaddingLength(lengths.last);
|
||||||
|
}
|
||||||
|
if (lengths.length == 3) {
|
||||||
|
left = expressionToPaddingLength(lengths[1]);
|
||||||
|
right = expressionToPaddingLength(lengths[1]);
|
||||||
|
bottom = expressionToPaddingLength(lengths.last);
|
||||||
|
}
|
||||||
|
if (lengths.length == 2) {
|
||||||
|
bottom = expressionToPaddingLength(lengths.first);
|
||||||
|
left = expressionToPaddingLength(lengths.last);
|
||||||
|
right = expressionToPaddingLength(lengths.last);
|
||||||
|
}
|
||||||
|
if (lengths.length == 1) {
|
||||||
|
bottom = expressionToPaddingLength(lengths.first);
|
||||||
|
left = expressionToPaddingLength(lengths.first);
|
||||||
|
right = expressionToPaddingLength(lengths.first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [left, right, top, bottom];
|
||||||
|
}
|
||||||
|
|
||||||
|
static double? expressionToPaddingLength(css.Expression value) {
|
||||||
|
if (value is css.NumberTerm) {
|
||||||
|
return double.tryParse(value.text);
|
||||||
|
} else if (value is css.EmTerm) {
|
||||||
|
return double.tryParse(value.text);
|
||||||
|
} else if (value is css.RemTerm) {
|
||||||
|
return double.tryParse(value.text);
|
||||||
|
} else if (value is css.LengthTerm) {
|
||||||
|
return double.tryParse(value.text.replaceAll(new RegExp(r'\s+(\d+\.\d+)\s+'), ''));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static TextAlign expressionToTextAlign(css.Expression value) {
|
||||||
|
if (value is css.LiteralTerm) {
|
||||||
|
switch(value.text) {
|
||||||
|
case "center":
|
||||||
|
return TextAlign.center;
|
||||||
|
case "left":
|
||||||
|
return TextAlign.left;
|
||||||
|
case "right":
|
||||||
|
return TextAlign.right;
|
||||||
|
case "justify":
|
||||||
|
return TextAlign.justify;
|
||||||
|
case "end":
|
||||||
|
return TextAlign.end;
|
||||||
|
case "start":
|
||||||
|
return TextAlign.start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return TextAlign.start;
|
||||||
|
}
|
||||||
|
|
||||||
|
static TextDecoration expressionToTextDecorationLine(List<css.LiteralTerm?> value) {
|
||||||
|
List<TextDecoration> decorationList = [];
|
||||||
|
for (css.LiteralTerm? term in value) {
|
||||||
|
if (term != null) {
|
||||||
|
switch(term.text) {
|
||||||
|
case "overline":
|
||||||
|
decorationList.add(TextDecoration.overline);
|
||||||
|
break;
|
||||||
|
case "underline":
|
||||||
|
decorationList.add(TextDecoration.underline);
|
||||||
|
break;
|
||||||
|
case "line-through":
|
||||||
|
decorationList.add(TextDecoration.lineThrough);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
decorationList.add(TextDecoration.none);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (decorationList.contains(TextDecoration.none)) decorationList = [TextDecoration.none];
|
||||||
|
return TextDecoration.combine(decorationList);
|
||||||
|
}
|
||||||
|
|
||||||
|
static TextDecorationStyle expressionToTextDecorationStyle(css.LiteralTerm value) {
|
||||||
|
switch(value.text) {
|
||||||
|
case "wavy":
|
||||||
|
return TextDecorationStyle.wavy;
|
||||||
|
case "dotted":
|
||||||
|
return TextDecorationStyle.dotted;
|
||||||
|
case "dashed":
|
||||||
|
return TextDecorationStyle.dashed;
|
||||||
|
case "double":
|
||||||
|
return TextDecorationStyle.double;
|
||||||
|
default:
|
||||||
|
return TextDecorationStyle.solid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<Shadow> expressionToTextShadow(List<css.Expression> value) {
|
||||||
|
List<Shadow> shadow = [];
|
||||||
|
List<int> indices = [];
|
||||||
|
List<List<css.Expression>> valueList = [];
|
||||||
|
for (css.Expression e in value) {
|
||||||
|
if (e is css.OperatorComma) {
|
||||||
|
indices.add(value.indexOf(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
indices.add(value.length);
|
||||||
|
int previousIndex = 0;
|
||||||
|
for (int i in indices) {
|
||||||
|
valueList.add(value.sublist(previousIndex, i));
|
||||||
|
previousIndex = i + 1;
|
||||||
|
}
|
||||||
|
for (List<css.Expression> list in valueList) {
|
||||||
|
css.Expression? offsetX;
|
||||||
|
css.Expression? offsetY;
|
||||||
|
css.Expression? blurRadius;
|
||||||
|
css.Expression? color;
|
||||||
|
int expressionIndex = 0;
|
||||||
|
list.forEach((element) {
|
||||||
|
if (element is css.HexColorTerm || element is css.FunctionTerm) {
|
||||||
|
color = element;
|
||||||
|
} else if (expressionIndex == 0) {
|
||||||
|
offsetX = element;
|
||||||
|
expressionIndex++;
|
||||||
|
} else if (expressionIndex++ == 1) {
|
||||||
|
offsetY = element;
|
||||||
|
expressionIndex++;
|
||||||
|
} else {
|
||||||
|
blurRadius = element;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
RegExp nonNumberRegex = RegExp(r'\s+(\d+\.\d+)\s+');
|
||||||
|
if (offsetX is css.LiteralTerm && offsetY is css.LiteralTerm) {
|
||||||
|
if (color != null && ExpressionMapping.expressionToColor(color) != null) {
|
||||||
|
shadow.add(Shadow(
|
||||||
|
color: expressionToColor(color)!,
|
||||||
|
offset: Offset(
|
||||||
|
double.tryParse((offsetX as css.LiteralTerm).text.replaceAll(nonNumberRegex, ''))!,
|
||||||
|
double.tryParse((offsetY as css.LiteralTerm).text.replaceAll(nonNumberRegex, ''))!),
|
||||||
|
blurRadius: (blurRadius is css.LiteralTerm) ? double.tryParse((blurRadius as css.LiteralTerm).text.replaceAll(nonNumberRegex, ''))! : 0.0,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
shadow.add(Shadow(
|
||||||
|
offset: Offset(
|
||||||
|
double.tryParse((offsetX as css.LiteralTerm).text.replaceAll(nonNumberRegex, ''))!,
|
||||||
|
double.tryParse((offsetY as css.LiteralTerm).text.replaceAll(nonNumberRegex, ''))!),
|
||||||
|
blurRadius: (blurRadius is css.LiteralTerm) ? double.tryParse((blurRadius as css.LiteralTerm).text.replaceAll(nonNumberRegex, ''))! : 0.0,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<Shadow> finalShadows = shadow.toSet().toList();
|
||||||
|
return finalShadows;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Color stringToColor(String _text) {
|
||||||
|
var text = _text.replaceFirst('#', '');
|
||||||
|
if (text.length == 3)
|
||||||
|
text = text.replaceAllMapped(
|
||||||
|
RegExp(r"[a-f]|\d", caseSensitive: false),
|
||||||
|
(match) => '${match.group(0)}${match.group(0)}'
|
||||||
|
);
|
||||||
|
if (text.length > 6) {
|
||||||
|
text = "0x" + text;
|
||||||
|
} else {
|
||||||
|
text = "0xFF" + text;
|
||||||
|
}
|
||||||
|
return new Color(int.parse(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
static Color? rgbOrRgbaToColor(String text) {
|
||||||
|
final rgbaText = text.replaceAll(')', '').replaceAll(' ', '');
|
||||||
|
try {
|
||||||
|
final rgbaValues =
|
||||||
|
rgbaText.split(',').map((value) => double.parse(value)).toList();
|
||||||
|
if (rgbaValues.length == 4) {
|
||||||
|
return Color.fromRGBO(
|
||||||
|
rgbaValues[0].toInt(),
|
||||||
|
rgbaValues[1].toInt(),
|
||||||
|
rgbaValues[2].toInt(),
|
||||||
|
rgbaValues[3],
|
||||||
|
);
|
||||||
|
} else if (rgbaValues.length == 3) {
|
||||||
|
return Color.fromRGBO(
|
||||||
|
rgbaValues[0].toInt(),
|
||||||
|
rgbaValues[1].toInt(),
|
||||||
|
rgbaValues[2].toInt(),
|
||||||
|
1.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Color hslToRgbToColor(String text) {
|
||||||
|
final hslText = text.replaceAll(')', '').replaceAll(' ', '');
|
||||||
|
final hslValues = hslText.split(',').toList();
|
||||||
|
List<double?> parsedHsl = [];
|
||||||
|
hslValues.forEach((element) {
|
||||||
|
if (element.contains("%") && double.tryParse(element.replaceAll("%", "")) != null) {
|
||||||
|
parsedHsl.add(double.tryParse(element.replaceAll("%", ""))! * 0.01);
|
||||||
|
} else {
|
||||||
|
if (element != hslValues.first && (double.tryParse(element) == null || double.tryParse(element)! > 1)) {
|
||||||
|
parsedHsl.add(null);
|
||||||
|
} else {
|
||||||
|
parsedHsl.add(double.tryParse(element));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (parsedHsl.length == 4 && !parsedHsl.contains(null)) {
|
||||||
|
return HSLColor.fromAHSL(parsedHsl.last!, parsedHsl.first!, parsedHsl[1]!, parsedHsl[2]!).toColor();
|
||||||
|
} else if (parsedHsl.length == 3 && !parsedHsl.contains(null)) {
|
||||||
|
return HSLColor.fromAHSL(1.0, parsedHsl.first!, parsedHsl[1]!, parsedHsl.last!).toColor();
|
||||||
|
} else return Colors.black;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Color? namedColorToColor(String text) {
|
||||||
|
String namedColor = namedColors.keys.firstWhere((element) => element.toLowerCase() == text.toLowerCase(), orElse: () => "");
|
||||||
|
if (namedColor != "") {
|
||||||
|
return stringToColor(namedColors[namedColor]!);
|
||||||
|
} else return null;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,283 @@
|
|||||||
|
export 'styled_element.dart';
|
||||||
|
export 'interactable_element.dart';
|
||||||
|
export 'replaced_element.dart';
|
||||||
|
|
||||||
|
const STYLED_ELEMENTS = [
|
||||||
|
"abbr",
|
||||||
|
"acronym",
|
||||||
|
"address",
|
||||||
|
"b",
|
||||||
|
"bdi",
|
||||||
|
"bdo",
|
||||||
|
"big",
|
||||||
|
"cite",
|
||||||
|
"code",
|
||||||
|
"data",
|
||||||
|
"del",
|
||||||
|
"dfn",
|
||||||
|
"em",
|
||||||
|
"font",
|
||||||
|
"i",
|
||||||
|
"ins",
|
||||||
|
"kbd",
|
||||||
|
"mark",
|
||||||
|
"q",
|
||||||
|
"rt",
|
||||||
|
"s",
|
||||||
|
"samp",
|
||||||
|
"small",
|
||||||
|
"span",
|
||||||
|
"strike",
|
||||||
|
"strong",
|
||||||
|
"sub",
|
||||||
|
"sup",
|
||||||
|
"time",
|
||||||
|
"tt",
|
||||||
|
"u",
|
||||||
|
"var",
|
||||||
|
"wbr",
|
||||||
|
|
||||||
|
//BLOCK ELEMENTS
|
||||||
|
"article",
|
||||||
|
"aside",
|
||||||
|
"blockquote",
|
||||||
|
"body",
|
||||||
|
"center",
|
||||||
|
"dd",
|
||||||
|
"div",
|
||||||
|
"dl",
|
||||||
|
"dt",
|
||||||
|
"figcaption",
|
||||||
|
"figure",
|
||||||
|
"footer",
|
||||||
|
"h1",
|
||||||
|
"h2",
|
||||||
|
"h3",
|
||||||
|
"h4",
|
||||||
|
"h5",
|
||||||
|
"h6",
|
||||||
|
"header",
|
||||||
|
"hr",
|
||||||
|
"html",
|
||||||
|
"li",
|
||||||
|
"main",
|
||||||
|
"nav",
|
||||||
|
"noscript",
|
||||||
|
"ol",
|
||||||
|
"p",
|
||||||
|
"pre",
|
||||||
|
"section",
|
||||||
|
"summary",
|
||||||
|
"ul",
|
||||||
|
];
|
||||||
|
|
||||||
|
const BLOCK_ELEMENTS = [
|
||||||
|
"article",
|
||||||
|
"aside",
|
||||||
|
"blockquote",
|
||||||
|
"body",
|
||||||
|
"center",
|
||||||
|
"dd",
|
||||||
|
"div",
|
||||||
|
"dl",
|
||||||
|
"dt",
|
||||||
|
"figcaption",
|
||||||
|
"figure",
|
||||||
|
"footer",
|
||||||
|
"h1",
|
||||||
|
"h2",
|
||||||
|
"h3",
|
||||||
|
"h4",
|
||||||
|
"h5",
|
||||||
|
"h6",
|
||||||
|
"header",
|
||||||
|
"hr",
|
||||||
|
"html",
|
||||||
|
"li",
|
||||||
|
"main",
|
||||||
|
"nav",
|
||||||
|
"noscript",
|
||||||
|
"ol",
|
||||||
|
"p",
|
||||||
|
"pre",
|
||||||
|
"section",
|
||||||
|
"summary",
|
||||||
|
"ul",
|
||||||
|
];
|
||||||
|
|
||||||
|
const INTERACTABLE_ELEMENTS = [
|
||||||
|
"a",
|
||||||
|
];
|
||||||
|
|
||||||
|
const REPLACED_ELEMENTS = [
|
||||||
|
"br",
|
||||||
|
"template",
|
||||||
|
"rp",
|
||||||
|
"rt",
|
||||||
|
"ruby",
|
||||||
|
];
|
||||||
|
|
||||||
|
const LAYOUT_ELEMENTS = [
|
||||||
|
"details",
|
||||||
|
"tr",
|
||||||
|
"tbody",
|
||||||
|
"tfoot",
|
||||||
|
"thead",
|
||||||
|
];
|
||||||
|
|
||||||
|
const TABLE_CELL_ELEMENTS = ["th", "td"];
|
||||||
|
|
||||||
|
const TABLE_DEFINITION_ELEMENTS = ["col", "colgroup"];
|
||||||
|
|
||||||
|
const EXTERNAL_ELEMENTS = ["audio", "iframe", "img", "math", "svg", "table", "video"];
|
||||||
|
|
||||||
|
const SELECTABLE_ELEMENTS = [
|
||||||
|
"br",
|
||||||
|
"a",
|
||||||
|
"article",
|
||||||
|
"aside",
|
||||||
|
"blockquote",
|
||||||
|
"body",
|
||||||
|
"center",
|
||||||
|
"dd",
|
||||||
|
"div",
|
||||||
|
"dl",
|
||||||
|
"dt",
|
||||||
|
"figcaption",
|
||||||
|
"figure",
|
||||||
|
"footer",
|
||||||
|
"h1",
|
||||||
|
"h2",
|
||||||
|
"h3",
|
||||||
|
"h4",
|
||||||
|
"h5",
|
||||||
|
"h6",
|
||||||
|
"header",
|
||||||
|
"hr",
|
||||||
|
"html",
|
||||||
|
"main",
|
||||||
|
"nav",
|
||||||
|
"noscript",
|
||||||
|
"p",
|
||||||
|
"pre",
|
||||||
|
"section",
|
||||||
|
"summary",
|
||||||
|
"abbr",
|
||||||
|
"acronym",
|
||||||
|
"address",
|
||||||
|
"b",
|
||||||
|
"bdi",
|
||||||
|
"bdo",
|
||||||
|
"big",
|
||||||
|
"cite",
|
||||||
|
"code",
|
||||||
|
"data",
|
||||||
|
"del",
|
||||||
|
"dfn",
|
||||||
|
"em",
|
||||||
|
"font",
|
||||||
|
"i",
|
||||||
|
"ins",
|
||||||
|
"kbd",
|
||||||
|
"mark",
|
||||||
|
"q",
|
||||||
|
"s",
|
||||||
|
"samp",
|
||||||
|
"small",
|
||||||
|
"span",
|
||||||
|
"strike",
|
||||||
|
"strong",
|
||||||
|
"time",
|
||||||
|
"tt",
|
||||||
|
"u",
|
||||||
|
"var",
|
||||||
|
"wbr",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
Here is a list of elements with planned support:
|
||||||
|
a - i [x]
|
||||||
|
abbr - s [x]
|
||||||
|
acronym - s [x]
|
||||||
|
address - s [x]
|
||||||
|
audio - c [x]
|
||||||
|
article - b [x]
|
||||||
|
aside - b [x]
|
||||||
|
b - s [x]
|
||||||
|
bdi - s [x]
|
||||||
|
bdo - s [x]
|
||||||
|
big - s [x]
|
||||||
|
blockquote- b [x]
|
||||||
|
body - b [x]
|
||||||
|
br - b [x]
|
||||||
|
button - i [ ]
|
||||||
|
caption - b [ ]
|
||||||
|
center - b [x]
|
||||||
|
cite - s [x]
|
||||||
|
code - s [x]
|
||||||
|
data - s [x]
|
||||||
|
dd - b [x]
|
||||||
|
del - s [x]
|
||||||
|
dfn - s [x]
|
||||||
|
div - b [x]
|
||||||
|
dl - b [x]
|
||||||
|
dt - b [x]
|
||||||
|
em - s [x]
|
||||||
|
figcaption- b [x]
|
||||||
|
figure - b [x]
|
||||||
|
font - s [x]
|
||||||
|
footer - b [x]
|
||||||
|
h1 - b [x]
|
||||||
|
h2 - b [x]
|
||||||
|
h3 - b [x]
|
||||||
|
h4 - b [x]
|
||||||
|
h5 - b [x]
|
||||||
|
h6 - b [x]
|
||||||
|
head - e [x]
|
||||||
|
header - b [x]
|
||||||
|
hr - b [x]
|
||||||
|
html - b [x]
|
||||||
|
i - s [x]
|
||||||
|
img - c [x]
|
||||||
|
ins - s [x]
|
||||||
|
kbd - s [x]
|
||||||
|
li - b [x]
|
||||||
|
main - b [x]
|
||||||
|
mark - s [x]
|
||||||
|
nav - b [x]
|
||||||
|
noscript - b [x]
|
||||||
|
ol - b [x] post
|
||||||
|
p - b [x]
|
||||||
|
pre - b [x]
|
||||||
|
q - s [x] post
|
||||||
|
rp - s [x]
|
||||||
|
rt - s [x]
|
||||||
|
ruby - s [x]
|
||||||
|
s - s [x]
|
||||||
|
samp - s [x]
|
||||||
|
section - b [x]
|
||||||
|
small - s [x]
|
||||||
|
source - [-] child of content
|
||||||
|
span - s [x]
|
||||||
|
strike - s [x]
|
||||||
|
strong - s [x]
|
||||||
|
sub - s [x]
|
||||||
|
sup - s [x]
|
||||||
|
svg - c [x]
|
||||||
|
table - b [x]
|
||||||
|
tbody - b [x]
|
||||||
|
td - s [ ]
|
||||||
|
template - e [x]
|
||||||
|
tfoot - b [x]
|
||||||
|
th - s [ ]
|
||||||
|
thead - b [x]
|
||||||
|
time - s [x]
|
||||||
|
tr - ? [ ]
|
||||||
|
track - [-] child of content
|
||||||
|
tt - s [x]
|
||||||
|
u - s [x]
|
||||||
|
ul - b [x] post
|
||||||
|
var - s [x]
|
||||||
|
video - c [x]
|
||||||
|
wbr - s [x]
|
||||||
|
*/
|
@ -0,0 +1,61 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import './html_elements.dart';
|
||||||
|
import '../style.dart';
|
||||||
|
import 'package:html/dom.dart' as dom;
|
||||||
|
|
||||||
|
/// An [InteractableElement] is a [StyledElement] that takes user gestures (e.g. tap).
|
||||||
|
class InteractableElement extends StyledElement {
|
||||||
|
String? href;
|
||||||
|
|
||||||
|
InteractableElement({
|
||||||
|
required String name,
|
||||||
|
required List<StyledElement> children,
|
||||||
|
required Style style,
|
||||||
|
required this.href,
|
||||||
|
required dom.Node node,
|
||||||
|
required String elementId,
|
||||||
|
}) : super(name: name, children: children, style: style, node: node as dom.Element?, elementId: elementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A [Gesture] indicates the type of interaction by a user.
|
||||||
|
enum Gesture {
|
||||||
|
TAP,
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledElement parseInteractableElement(
|
||||||
|
dom.Element element, List<StyledElement> children) {
|
||||||
|
switch (element.localName) {
|
||||||
|
case "a":
|
||||||
|
if (element.attributes.containsKey('href')) {
|
||||||
|
return InteractableElement(
|
||||||
|
name: element.localName!,
|
||||||
|
children: children,
|
||||||
|
href: element.attributes['href'],
|
||||||
|
style: Style(
|
||||||
|
color: Colors.blue,
|
||||||
|
textDecoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
|
node: element,
|
||||||
|
elementId: element.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// When <a> tag have no href, it must be non clickable and without decoration.
|
||||||
|
return StyledElement(
|
||||||
|
name: element.localName!,
|
||||||
|
children: children,
|
||||||
|
style: Style(),
|
||||||
|
node: element,
|
||||||
|
elementId: element.id,
|
||||||
|
);
|
||||||
|
/// will never be called, just to suppress missing return warning
|
||||||
|
default:
|
||||||
|
return InteractableElement(
|
||||||
|
name: element.localName!,
|
||||||
|
children: children,
|
||||||
|
node: element,
|
||||||
|
href: '',
|
||||||
|
style: Style(),
|
||||||
|
elementId: "[[No ID]]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,214 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../html_parser.dart';
|
||||||
|
import './anchor.dart';
|
||||||
|
import './html_elements.dart';
|
||||||
|
import './styled_element.dart';
|
||||||
|
import '../style.dart';
|
||||||
|
import 'package:html/dom.dart' as dom;
|
||||||
|
|
||||||
|
/// A [LayoutElement] is an element that breaks the normal Inline flow of
|
||||||
|
/// an html document with a more complex layout. LayoutElements handle
|
||||||
|
abstract class LayoutElement extends StyledElement {
|
||||||
|
LayoutElement({
|
||||||
|
String name = "[[No Name]]",
|
||||||
|
required List<StyledElement> children,
|
||||||
|
String? elementId,
|
||||||
|
dom.Element? node,
|
||||||
|
}) : super(name: name, children: children, style: Style(), node: node, elementId: elementId ?? "[[No ID]]");
|
||||||
|
|
||||||
|
Widget? toWidget(RenderContext context);
|
||||||
|
}
|
||||||
|
|
||||||
|
class TableSectionLayoutElement extends LayoutElement {
|
||||||
|
TableSectionLayoutElement({
|
||||||
|
required String name,
|
||||||
|
required List<StyledElement> children,
|
||||||
|
}) : super(name: name, children: children);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget toWidget(RenderContext context) {
|
||||||
|
// Not rendered; TableLayoutElement will instead consume its children
|
||||||
|
return Container(child: Text("TABLE SECTION"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TableRowLayoutElement extends LayoutElement {
|
||||||
|
TableRowLayoutElement({
|
||||||
|
required String name,
|
||||||
|
required List<StyledElement> children,
|
||||||
|
required dom.Element node,
|
||||||
|
}) : super(name: name, children: children, node: node);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget toWidget(RenderContext context) {
|
||||||
|
// Not rendered; TableLayoutElement will instead consume its children
|
||||||
|
return Container(child: Text("TABLE ROW"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TableCellElement extends StyledElement {
|
||||||
|
int colspan = 1;
|
||||||
|
int rowspan = 1;
|
||||||
|
|
||||||
|
TableCellElement({
|
||||||
|
required String name,
|
||||||
|
required String elementId,
|
||||||
|
required List<String> elementClasses,
|
||||||
|
required List<StyledElement> children,
|
||||||
|
required Style style,
|
||||||
|
required dom.Element node,
|
||||||
|
}) : super(name: name, elementId: elementId, elementClasses: elementClasses, children: children, style: style, node: node) {
|
||||||
|
colspan = _parseSpan(this, "colspan");
|
||||||
|
rowspan = _parseSpan(this, "rowspan");
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _parseSpan(StyledElement element, String attributeName) {
|
||||||
|
final spanValue = element.attributes[attributeName];
|
||||||
|
return spanValue == null ? 1 : int.tryParse(spanValue) ?? 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TableCellElement parseTableCellElement(
|
||||||
|
dom.Element element,
|
||||||
|
List<StyledElement> children,
|
||||||
|
) {
|
||||||
|
final cell = TableCellElement(
|
||||||
|
name: element.localName!,
|
||||||
|
elementId: element.id,
|
||||||
|
elementClasses: element.classes.toList(),
|
||||||
|
children: children,
|
||||||
|
node: element,
|
||||||
|
style: Style(),
|
||||||
|
);
|
||||||
|
if (element.localName == "th") {
|
||||||
|
cell.style = Style(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TableStyleElement extends StyledElement {
|
||||||
|
TableStyleElement({
|
||||||
|
required String name,
|
||||||
|
required List<StyledElement> children,
|
||||||
|
required Style style,
|
||||||
|
required dom.Element node,
|
||||||
|
}) : super(name: name, children: children, style: style, node: node);
|
||||||
|
}
|
||||||
|
|
||||||
|
TableStyleElement parseTableDefinitionElement(
|
||||||
|
dom.Element element,
|
||||||
|
List<StyledElement> children,
|
||||||
|
) {
|
||||||
|
switch (element.localName) {
|
||||||
|
case "colgroup":
|
||||||
|
case "col":
|
||||||
|
return TableStyleElement(
|
||||||
|
name: element.localName!,
|
||||||
|
children: children,
|
||||||
|
node: element,
|
||||||
|
style: Style(),
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return TableStyleElement(
|
||||||
|
name: "[[No Name]]",
|
||||||
|
children: children,
|
||||||
|
node: element,
|
||||||
|
style: Style(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DetailsContentElement extends LayoutElement {
|
||||||
|
List<dom.Element> elementList;
|
||||||
|
|
||||||
|
DetailsContentElement({
|
||||||
|
required String name,
|
||||||
|
required List<StyledElement> children,
|
||||||
|
required dom.Element node,
|
||||||
|
required this.elementList,
|
||||||
|
}) : super(name: name, node: node, children: children, elementId: node.id);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget toWidget(RenderContext context) {
|
||||||
|
List<InlineSpan>? childrenList = children.map((tree) => context.parser.parseTree(context, tree)).toList();
|
||||||
|
List<InlineSpan> toRemove = [];
|
||||||
|
for (InlineSpan child in childrenList) {
|
||||||
|
if (child is TextSpan && child.text != null && child.text!.trim().isEmpty) {
|
||||||
|
toRemove.add(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (InlineSpan child in toRemove) {
|
||||||
|
childrenList.remove(child);
|
||||||
|
}
|
||||||
|
InlineSpan? firstChild = childrenList.isNotEmpty == true ? childrenList.first : null;
|
||||||
|
return ExpansionTile(
|
||||||
|
key: AnchorKey.of(context.parser.key, this),
|
||||||
|
expandedAlignment: Alignment.centerLeft,
|
||||||
|
title: elementList.isNotEmpty == true && elementList.first.localName == "summary" ? StyledText(
|
||||||
|
textSpan: TextSpan(
|
||||||
|
style: style.generateTextStyle(),
|
||||||
|
children: firstChild == null ? [] : [firstChild],
|
||||||
|
),
|
||||||
|
style: style,
|
||||||
|
renderContext: context,
|
||||||
|
) : Text("Details"),
|
||||||
|
children: [
|
||||||
|
StyledText(
|
||||||
|
textSpan: TextSpan(
|
||||||
|
style: style.generateTextStyle(),
|
||||||
|
children: getChildren(childrenList, context, elementList.isNotEmpty == true && elementList.first.localName == "summary" ? firstChild : null)
|
||||||
|
),
|
||||||
|
style: style,
|
||||||
|
renderContext: context,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<InlineSpan> getChildren(List<InlineSpan> children, RenderContext context, InlineSpan? firstChild) {
|
||||||
|
if (firstChild != null) children.removeAt(0);
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EmptyLayoutElement extends LayoutElement {
|
||||||
|
EmptyLayoutElement({required String name}) : super(name: name, children: []);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget? toWidget(_) => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
LayoutElement parseLayoutElement(
|
||||||
|
dom.Element element,
|
||||||
|
List<StyledElement> children,
|
||||||
|
) {
|
||||||
|
switch (element.localName) {
|
||||||
|
case "details":
|
||||||
|
if (children.isEmpty) {
|
||||||
|
return EmptyLayoutElement(name: "empty");
|
||||||
|
}
|
||||||
|
return DetailsContentElement(
|
||||||
|
node: element,
|
||||||
|
name: element.localName!,
|
||||||
|
children: children,
|
||||||
|
elementList: element.children
|
||||||
|
);
|
||||||
|
case "thead":
|
||||||
|
case "tbody":
|
||||||
|
case "tfoot":
|
||||||
|
return TableSectionLayoutElement(
|
||||||
|
name: element.localName!,
|
||||||
|
children: children,
|
||||||
|
);
|
||||||
|
case "tr":
|
||||||
|
return TableRowLayoutElement(
|
||||||
|
name: element.localName!,
|
||||||
|
children: children,
|
||||||
|
node: element,
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return EmptyLayoutElement(name: "[[No Name]]");
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,166 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import '../html_parser.dart';
|
||||||
|
import './anchor.dart';
|
||||||
|
import './html_elements.dart';
|
||||||
|
import '../style.dart';
|
||||||
|
import 'package:html/dom.dart' as dom;
|
||||||
|
|
||||||
|
/// A [ReplacedElement] is a type of [StyledElement] that does not require its [children] to be rendered.
|
||||||
|
///
|
||||||
|
/// A [ReplacedElement] may use its children nodes to determine relevant information
|
||||||
|
/// (e.g. <video>'s <source> tags), but the children nodes will not be saved as [children].
|
||||||
|
abstract class ReplacedElement extends StyledElement {
|
||||||
|
PlaceholderAlignment alignment;
|
||||||
|
|
||||||
|
ReplacedElement({
|
||||||
|
required String name,
|
||||||
|
required Style style,
|
||||||
|
required String elementId,
|
||||||
|
List<StyledElement>? children,
|
||||||
|
dom.Element? node,
|
||||||
|
this.alignment = PlaceholderAlignment.aboveBaseline,
|
||||||
|
}) : super(name: name, children: children ?? [], style: style, node: node, elementId: elementId);
|
||||||
|
|
||||||
|
static List<String?> parseMediaSources(List<dom.Element> elements) {
|
||||||
|
return elements
|
||||||
|
.where((element) => element.localName == 'source')
|
||||||
|
.map((element) {
|
||||||
|
return element.attributes['src'];
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget? toWidget(RenderContext context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [TextContentElement] is a [ContentElement] with plaintext as its content.
|
||||||
|
class TextContentElement extends ReplacedElement {
|
||||||
|
String? text;
|
||||||
|
dom.Node? node;
|
||||||
|
|
||||||
|
TextContentElement({
|
||||||
|
required Style style,
|
||||||
|
required this.text,
|
||||||
|
this.node,
|
||||||
|
dom.Element? element,
|
||||||
|
}) : super(name: "[text]", style: style, node: element, elementId: "[[No ID]]");
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return "\"${text!.replaceAll("\n", "\\n")}\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget? toWidget(_) => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class EmptyContentElement extends ReplacedElement {
|
||||||
|
EmptyContentElement({String name = "empty"}) : super(name: name, style: Style(), elementId: "[[No ID]]");
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget? toWidget(_) => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RubyElement extends ReplacedElement {
|
||||||
|
dom.Element element;
|
||||||
|
|
||||||
|
RubyElement({
|
||||||
|
required this.element,
|
||||||
|
required List<StyledElement> children,
|
||||||
|
String name = "ruby"
|
||||||
|
}) : super(name: name, alignment: PlaceholderAlignment.middle, style: Style(), elementId: element.id, children: children);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget toWidget(RenderContext context) {
|
||||||
|
StyledElement? node;
|
||||||
|
List<Widget> widgets = <Widget>[];
|
||||||
|
final rubySize = context.parser.style['rt']?.fontSize?.size ?? max(9.0, context.style.fontSize!.size! / 2);
|
||||||
|
final rubyYPos = rubySize + rubySize / 2;
|
||||||
|
List<StyledElement> children = [];
|
||||||
|
context.tree.children.forEachIndexed((index, element) {
|
||||||
|
if (!((element is TextContentElement)
|
||||||
|
&& (element.text ?? "").trim().isEmpty
|
||||||
|
&& index > 0
|
||||||
|
&& index + 1 < context.tree.children.length
|
||||||
|
&& !(context.tree.children[index - 1] is TextContentElement)
|
||||||
|
&& !(context.tree.children[index + 1] is TextContentElement))) {
|
||||||
|
children.add(element);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
children.forEach((c) {
|
||||||
|
if (c.name == "rt" && node != null) {
|
||||||
|
final widget = Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: <Widget>[
|
||||||
|
Container(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: Center(
|
||||||
|
child: Transform(
|
||||||
|
transform:
|
||||||
|
Matrix4.translationValues(0, -(rubyYPos), 0),
|
||||||
|
child: ContainerSpan(
|
||||||
|
newContext: RenderContext(
|
||||||
|
buildContext: context.buildContext,
|
||||||
|
parser: context.parser,
|
||||||
|
style: c.style,
|
||||||
|
tree: c,
|
||||||
|
),
|
||||||
|
style: c.style,
|
||||||
|
child: Text(c.element!.innerHtml,
|
||||||
|
style: c.style
|
||||||
|
.generateTextStyle()
|
||||||
|
.copyWith(fontSize: rubySize)),
|
||||||
|
)))),
|
||||||
|
ContainerSpan(
|
||||||
|
newContext: context,
|
||||||
|
style: context.style,
|
||||||
|
child: node is TextContentElement ? Text((node as TextContentElement).text?.trim() ?? "",
|
||||||
|
style: context.style.generateTextStyle()) : null,
|
||||||
|
children: node is TextContentElement ? null : [context.parser.parseTree(context, node!)]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
widgets.add(widget);
|
||||||
|
} else {
|
||||||
|
node = c;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(top: rubySize),
|
||||||
|
child: Wrap(
|
||||||
|
key: AnchorKey.of(context.parser.key, this),
|
||||||
|
runSpacing: rubySize,
|
||||||
|
children: widgets.map((e) => Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
textBaseline: TextBaseline.alphabetic,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [e],
|
||||||
|
)).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ReplacedElement parseReplacedElement(
|
||||||
|
dom.Element element,
|
||||||
|
List<StyledElement> children,
|
||||||
|
) {
|
||||||
|
switch (element.localName) {
|
||||||
|
case "br":
|
||||||
|
return TextContentElement(
|
||||||
|
text: "\n",
|
||||||
|
style: Style(whiteSpace: WhiteSpace.PRE),
|
||||||
|
element: element,
|
||||||
|
node: element
|
||||||
|
);
|
||||||
|
case "ruby":
|
||||||
|
return RubyElement(
|
||||||
|
element: element,
|
||||||
|
children: children,
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return EmptyContentElement(name: element.localName == null ? "[[No Name]]" : element.localName!);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,412 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import './css_parser.dart';
|
||||||
|
import '../style.dart';
|
||||||
|
import 'package:html/dom.dart' as dom;
|
||||||
|
//TODO(Sub6Resources): don't use the internal code of the html package as it may change unexpectedly.
|
||||||
|
//ignore: implementation_imports
|
||||||
|
import 'package:html/src/query_selector.dart';
|
||||||
|
|
||||||
|
/// A [StyledElement] applies a style to all of its children.
|
||||||
|
class StyledElement {
|
||||||
|
final String name;
|
||||||
|
final String elementId;
|
||||||
|
final List<String> elementClasses;
|
||||||
|
List<StyledElement> children;
|
||||||
|
Style style;
|
||||||
|
final dom.Element? _node;
|
||||||
|
|
||||||
|
StyledElement({
|
||||||
|
this.name = "[[No name]]",
|
||||||
|
this.elementId = "[[No ID]]",
|
||||||
|
this.elementClasses = const [],
|
||||||
|
required this.children,
|
||||||
|
required this.style,
|
||||||
|
required dom.Element? node,
|
||||||
|
}) : this._node = node;
|
||||||
|
|
||||||
|
bool matchesSelector(String selector) =>
|
||||||
|
(_node != null && matches(_node!, selector)) || name == selector;
|
||||||
|
|
||||||
|
Map<String, String> get attributes =>
|
||||||
|
_node?.attributes.map((key, value) {
|
||||||
|
return MapEntry(key.toString(), value);
|
||||||
|
}) ??
|
||||||
|
Map<String, String>();
|
||||||
|
|
||||||
|
dom.Element? get element => _node;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
String selfData =
|
||||||
|
"[$name] ${children.length} ${elementClasses.isNotEmpty == true ? 'C:${elementClasses.toString()}' : ''}${elementId.isNotEmpty == true ? 'ID: $elementId' : ''}";
|
||||||
|
children.forEach((child) {
|
||||||
|
selfData += ("\n${child.toString()}")
|
||||||
|
.replaceAll(RegExp("^", multiLine: true), "-");
|
||||||
|
});
|
||||||
|
return selfData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledElement parseStyledElement(
|
||||||
|
dom.Element element, List<StyledElement> children) {
|
||||||
|
StyledElement styledElement = StyledElement(
|
||||||
|
name: element.localName!,
|
||||||
|
elementId: element.id,
|
||||||
|
elementClasses: element.classes.toList(),
|
||||||
|
children: children,
|
||||||
|
node: element,
|
||||||
|
style: Style(),
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (element.localName) {
|
||||||
|
case "abbr":
|
||||||
|
case "acronym":
|
||||||
|
styledElement.style = Style(
|
||||||
|
textDecoration: TextDecoration.underline,
|
||||||
|
textDecorationStyle: TextDecorationStyle.dotted,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "address":
|
||||||
|
continue italics;
|
||||||
|
case "article":
|
||||||
|
styledElement.style = Style(
|
||||||
|
display: Display.BLOCK,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "aside":
|
||||||
|
styledElement.style = Style(
|
||||||
|
display: Display.BLOCK,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
bold:
|
||||||
|
case "b":
|
||||||
|
styledElement.style = Style(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "bdo":
|
||||||
|
TextDirection textDirection =
|
||||||
|
((element.attributes["dir"] ?? "ltr") == "rtl")
|
||||||
|
? TextDirection.rtl
|
||||||
|
: TextDirection.ltr;
|
||||||
|
styledElement.style = Style(
|
||||||
|
direction: textDirection,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "big":
|
||||||
|
styledElement.style = Style(
|
||||||
|
fontSize: FontSize.larger,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "blockquote":
|
||||||
|
//TODO(Sub6Resources) this is a workaround for collapsing margins. Remove.
|
||||||
|
if (element.parent!.localName == "blockquote") {
|
||||||
|
styledElement.style = Style(
|
||||||
|
margin: const EdgeInsets.only(left: 40.0, right: 40.0, bottom: 14.0),
|
||||||
|
display: Display.BLOCK,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
styledElement.style = Style(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 40.0, vertical: 14.0),
|
||||||
|
display: Display.BLOCK,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "body":
|
||||||
|
styledElement.style = Style(
|
||||||
|
margin: EdgeInsets.all(8.0),
|
||||||
|
display: Display.BLOCK,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "center":
|
||||||
|
styledElement.style = Style(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
display: Display.BLOCK,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "cite":
|
||||||
|
continue italics;
|
||||||
|
monospace:
|
||||||
|
case "code":
|
||||||
|
styledElement.style = Style(
|
||||||
|
fontFamily: 'Monospace',
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "dd":
|
||||||
|
styledElement.style = Style(
|
||||||
|
margin: EdgeInsets.only(left: 40.0),
|
||||||
|
display: Display.BLOCK,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
strikeThrough:
|
||||||
|
case "del":
|
||||||
|
styledElement.style = Style(
|
||||||
|
textDecoration: TextDecoration.lineThrough,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "dfn":
|
||||||
|
continue italics;
|
||||||
|
case "div":
|
||||||
|
styledElement.style = Style(
|
||||||
|
margin: EdgeInsets.all(0),
|
||||||
|
display: Display.BLOCK,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "dl":
|
||||||
|
styledElement.style = Style(
|
||||||
|
margin: EdgeInsets.symmetric(vertical: 14.0),
|
||||||
|
display: Display.BLOCK,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "dt":
|
||||||
|
styledElement.style = Style(
|
||||||
|
display: Display.BLOCK,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "em":
|
||||||
|
continue italics;
|
||||||
|
case "figcaption":
|
||||||
|
styledElement.style = Style(
|
||||||
|
display: Display.BLOCK,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "figure":
|
||||||
|
styledElement.style = Style(
|
||||||
|
margin: EdgeInsets.symmetric(vertical: 14.0, horizontal: 40.0),
|
||||||
|
display: Display.BLOCK,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "footer":
|
||||||
|
styledElement.style = Style(
|
||||||
|
display: Display.BLOCK,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "font":
|
||||||
|
styledElement.style = Style(
|
||||||
|
color: element.attributes['color'] != null ?
|
||||||
|
element.attributes['color']!.startsWith("#") ?
|
||||||
|
ExpressionMapping.stringToColor(element.attributes['color']!) :
|
||||||
|
ExpressionMapping.namedColorToColor(element.attributes['color']!) :
|
||||||
|
null,
|
||||||
|
fontFamily: element.attributes['face']?.split(",").first,
|
||||||
|
fontSize: element.attributes['size'] != null ? numberToFontSize(element.attributes['size']!) : null,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "h1":
|
||||||
|
styledElement.style = Style(
|
||||||
|
fontSize: FontSize.xxLarge,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
margin: EdgeInsets.symmetric(vertical: 18.67),
|
||||||
|
display: Display.BLOCK,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "h2":
|
||||||
|
styledElement.style = Style(
|
||||||
|
fontSize: FontSize.xLarge,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
margin: EdgeInsets.symmetric(vertical: 17.5),
|
||||||
|
display: Display.BLOCK,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "h3":
|
||||||
|
styledElement.style = Style(
|
||||||
|
fontSize: FontSize(16.38),
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
margin: EdgeInsets.symmetric(vertical: 16.5),
|
||||||
|
display: Display.BLOCK,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "h4":
|
||||||
|
styledElement.style = Style(
|
||||||
|
fontSize: FontSize.medium,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
margin: EdgeInsets.symmetric(vertical: 18.5),
|
||||||
|
display: Display.BLOCK,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "h5":
|
||||||
|
styledElement.style = Style(
|
||||||
|
fontSize: FontSize(11.62),
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
margin: EdgeInsets.symmetric(vertical: 19.25),
|
||||||
|
display: Display.BLOCK,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "h6":
|
||||||
|
styledElement.style = Style(
|
||||||
|
fontSize: FontSize(9.38),
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
margin: EdgeInsets.symmetric(vertical: 22),
|
||||||
|
display: Display.BLOCK,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "header":
|
||||||
|
styledElement.style = Style(
|
||||||
|
display: Display.BLOCK,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "hr":
|
||||||
|
styledElement.style = Style(
|
||||||
|
margin: EdgeInsets.symmetric(vertical: 7.0),
|
||||||
|
width: double.infinity,
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
display: Display.BLOCK,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "html":
|
||||||
|
styledElement.style = Style(
|
||||||
|
display: Display.BLOCK,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
italics:
|
||||||
|
case "i":
|
||||||
|
styledElement.style = Style(
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "ins":
|
||||||
|
continue underline;
|
||||||
|
case "kbd":
|
||||||
|
continue monospace;
|
||||||
|
case "li":
|
||||||
|
styledElement.style = Style(
|
||||||
|
display: Display.LIST_ITEM,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "main":
|
||||||
|
styledElement.style = Style(
|
||||||
|
display: Display.BLOCK,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "mark":
|
||||||
|
styledElement.style = Style(
|
||||||
|
color: Colors.black,
|
||||||
|
backgroundColor: Colors.yellow,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "nav":
|
||||||
|
styledElement.style = Style(
|
||||||
|
display: Display.BLOCK,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "noscript":
|
||||||
|
styledElement.style = Style(
|
||||||
|
display: Display.BLOCK,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "ol":
|
||||||
|
case "ul":
|
||||||
|
//TODO(Sub6Resources): This is a workaround for collapsed margins. Remove.
|
||||||
|
if (element.parent!.localName == "li") {
|
||||||
|
styledElement.style = Style(
|
||||||
|
// margin: EdgeInsets.only(left: 30.0),
|
||||||
|
display: Display.BLOCK,
|
||||||
|
listStyleType: element.localName == "ol"
|
||||||
|
? ListStyleType.DECIMAL
|
||||||
|
: ListStyleType.DISC,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
styledElement.style = Style(
|
||||||
|
// margin: EdgeInsets.only(left: 30.0, top: 14.0, bottom: 14.0),
|
||||||
|
display: Display.BLOCK,
|
||||||
|
listStyleType: element.localName == "ol"
|
||||||
|
? ListStyleType.DECIMAL
|
||||||
|
: ListStyleType.DISC,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "p":
|
||||||
|
styledElement.style = Style(
|
||||||
|
margin: EdgeInsets.symmetric(vertical: 14.0),
|
||||||
|
display: Display.BLOCK,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "pre":
|
||||||
|
styledElement.style = Style(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
margin: EdgeInsets.symmetric(vertical: 14.0),
|
||||||
|
whiteSpace: WhiteSpace.PRE,
|
||||||
|
display: Display.BLOCK,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "q":
|
||||||
|
styledElement.style = Style(
|
||||||
|
before: "\"",
|
||||||
|
after: "\"",
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "s":
|
||||||
|
continue strikeThrough;
|
||||||
|
case "samp":
|
||||||
|
continue monospace;
|
||||||
|
case "section":
|
||||||
|
styledElement.style = Style(
|
||||||
|
display: Display.BLOCK,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "small":
|
||||||
|
styledElement.style = Style(
|
||||||
|
fontSize: FontSize.smaller,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "strike":
|
||||||
|
continue strikeThrough;
|
||||||
|
case "strong":
|
||||||
|
continue bold;
|
||||||
|
case "sub":
|
||||||
|
styledElement.style = Style(
|
||||||
|
fontSize: FontSize.smaller,
|
||||||
|
verticalAlign: VerticalAlign.SUB,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "sup":
|
||||||
|
styledElement.style = Style(
|
||||||
|
fontSize: FontSize.smaller,
|
||||||
|
verticalAlign: VerticalAlign.SUPER,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "tt":
|
||||||
|
continue monospace;
|
||||||
|
underline:
|
||||||
|
case "u":
|
||||||
|
styledElement.style = Style(
|
||||||
|
textDecoration: TextDecoration.underline,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "var":
|
||||||
|
continue italics;
|
||||||
|
}
|
||||||
|
|
||||||
|
return styledElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef ListCharacter = String Function(int i);
|
||||||
|
|
||||||
|
FontSize numberToFontSize(String num) {
|
||||||
|
switch (num) {
|
||||||
|
case "1":
|
||||||
|
return FontSize.xxSmall;
|
||||||
|
case "2":
|
||||||
|
return FontSize.xSmall;
|
||||||
|
case "3":
|
||||||
|
return FontSize.small;
|
||||||
|
case "4":
|
||||||
|
return FontSize.medium;
|
||||||
|
case "5":
|
||||||
|
return FontSize.large;
|
||||||
|
case "6":
|
||||||
|
return FontSize.xLarge;
|
||||||
|
case "7":
|
||||||
|
return FontSize.xxLarge;
|
||||||
|
}
|
||||||
|
if (num.startsWith("+")) {
|
||||||
|
final relativeNum = double.tryParse(num.substring(1)) ?? 0;
|
||||||
|
return numberToFontSize((3 + relativeNum).toString());
|
||||||
|
}
|
||||||
|
if (num.startsWith("-")) {
|
||||||
|
final relativeNum = double.tryParse(num.substring(1)) ?? 0;
|
||||||
|
return numberToFontSize((3 - relativeNum).toString());
|
||||||
|
}
|
||||||
|
return FontSize.medium;
|
||||||
|
}
|
@ -0,0 +1,88 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../style.dart';
|
||||||
|
|
||||||
|
Map<String, String> namedColors = {
|
||||||
|
"White": "#FFFFFF",
|
||||||
|
"Silver": "#C0C0C0",
|
||||||
|
"Gray": "#808080",
|
||||||
|
"Black": "#000000",
|
||||||
|
"Red": "#FF0000",
|
||||||
|
"Maroon": "#800000",
|
||||||
|
"Yellow": "#FFFF00",
|
||||||
|
"Olive": "#808000",
|
||||||
|
"Lime": "#00FF00",
|
||||||
|
"Green": "#008000",
|
||||||
|
"Aqua": "#00FFFF",
|
||||||
|
"Teal": "#008080",
|
||||||
|
"Blue": "#0000FF",
|
||||||
|
"Navy": "#000080",
|
||||||
|
"Fuchsia": "#FF00FF",
|
||||||
|
"Purple": "#800080",
|
||||||
|
};
|
||||||
|
|
||||||
|
class Context<T> {
|
||||||
|
T data;
|
||||||
|
|
||||||
|
Context(this.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This class is a workaround so that both an image
|
||||||
|
// and a link can detect taps at the same time.
|
||||||
|
class MultipleTapGestureDetector extends InheritedWidget {
|
||||||
|
final void Function()? onTap;
|
||||||
|
|
||||||
|
const MultipleTapGestureDetector({
|
||||||
|
Key? key,
|
||||||
|
required Widget child,
|
||||||
|
required this.onTap,
|
||||||
|
}) : super(key: key, child: child);
|
||||||
|
|
||||||
|
static MultipleTapGestureDetector? of(BuildContext context) {
|
||||||
|
return context.dependOnInheritedWidgetOfExactType<MultipleTapGestureDetector>();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool updateShouldNotify(MultipleTapGestureDetector oldWidget) => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CustomBorderSide {
|
||||||
|
CustomBorderSide({
|
||||||
|
this.color = const Color(0xFF000000),
|
||||||
|
this.width = 1.0,
|
||||||
|
this.style = BorderStyle.none,
|
||||||
|
}) : assert(width >= 0.0);
|
||||||
|
|
||||||
|
Color? color;
|
||||||
|
double width;
|
||||||
|
BorderStyle style;
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TextTransformUtil on String? {
|
||||||
|
String? transformed(TextTransform? transform) {
|
||||||
|
if (this == null) return null;
|
||||||
|
if (transform == TextTransform.uppercase) {
|
||||||
|
return this!.toUpperCase();
|
||||||
|
} else if (transform == TextTransform.lowercase) {
|
||||||
|
return this!.toLowerCase();
|
||||||
|
} else if (transform == TextTransform.capitalize) {
|
||||||
|
final stringBuffer = StringBuffer();
|
||||||
|
|
||||||
|
var capitalizeNext = true;
|
||||||
|
for (final letter in this!.toLowerCase().codeUnits) {
|
||||||
|
// UTF-16: A-Z => 65-90, a-z => 97-122.
|
||||||
|
if (capitalizeNext && letter >= 97 && letter <= 122) {
|
||||||
|
stringBuffer.writeCharCode(letter - 32);
|
||||||
|
capitalizeNext = false;
|
||||||
|
} else {
|
||||||
|
// UTF-16: 32 == space, 46 == period
|
||||||
|
if (letter == 32 || letter == 46) capitalizeNext = true;
|
||||||
|
stringBuffer.writeCharCode(letter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stringBuffer.toString();
|
||||||
|
} else {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,590 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import './flutter_html.dart';
|
||||||
|
import './src/css_parser.dart';
|
||||||
|
|
||||||
|
///This class represents all the available CSS attributes
|
||||||
|
///for this package.
|
||||||
|
class Style {
|
||||||
|
/// CSS attribute "`background-color`"
|
||||||
|
///
|
||||||
|
/// Inherited: no,
|
||||||
|
/// Default: Colors.transparent,
|
||||||
|
Color? backgroundColor;
|
||||||
|
|
||||||
|
/// CSS attribute "`color`"
|
||||||
|
///
|
||||||
|
/// Inherited: yes,
|
||||||
|
/// Default: unspecified,
|
||||||
|
Color? color;
|
||||||
|
|
||||||
|
/// CSS attribute "`direction`"
|
||||||
|
///
|
||||||
|
/// Inherited: yes,
|
||||||
|
/// Default: TextDirection.ltr,
|
||||||
|
TextDirection? direction;
|
||||||
|
|
||||||
|
/// CSS attribute "`display`"
|
||||||
|
///
|
||||||
|
/// Inherited: no,
|
||||||
|
/// Default: unspecified,
|
||||||
|
Display? display;
|
||||||
|
|
||||||
|
/// CSS attribute "`font-family`"
|
||||||
|
///
|
||||||
|
/// Inherited: yes,
|
||||||
|
/// Default: Theme.of(context).style.textTheme.body1.fontFamily
|
||||||
|
String? fontFamily;
|
||||||
|
|
||||||
|
|
||||||
|
/// The list of font families to fall back on when a glyph cannot be found in default font family.
|
||||||
|
///
|
||||||
|
/// Inherited: yes,
|
||||||
|
/// Default: null
|
||||||
|
List<String>? fontFamilyFallback;
|
||||||
|
|
||||||
|
|
||||||
|
/// CSS attribute "`font-feature-settings`"
|
||||||
|
///
|
||||||
|
/// Inherited: yes,
|
||||||
|
/// Default: normal
|
||||||
|
List<FontFeature>? fontFeatureSettings;
|
||||||
|
|
||||||
|
/// CSS attribute "`font-size`"
|
||||||
|
///
|
||||||
|
/// Inherited: yes,
|
||||||
|
/// Default: FontSize.medium
|
||||||
|
FontSize? fontSize;
|
||||||
|
|
||||||
|
/// CSS attribute "`font-style`"
|
||||||
|
///
|
||||||
|
/// Inherited: yes,
|
||||||
|
/// Default: FontStyle.normal,
|
||||||
|
FontStyle? fontStyle;
|
||||||
|
|
||||||
|
/// CSS attribute "`font-weight`"
|
||||||
|
///
|
||||||
|
/// Inherited: yes,
|
||||||
|
/// Default: FontWeight.normal,
|
||||||
|
FontWeight? fontWeight;
|
||||||
|
|
||||||
|
/// CSS attribute "`height`"
|
||||||
|
///
|
||||||
|
/// Inherited: no,
|
||||||
|
/// Default: Unspecified (null),
|
||||||
|
double? height;
|
||||||
|
|
||||||
|
/// CSS attribute "`letter-spacing`"
|
||||||
|
///
|
||||||
|
/// Inherited: yes,
|
||||||
|
/// Default: normal (0),
|
||||||
|
double? letterSpacing;
|
||||||
|
|
||||||
|
/// CSS attribute "`list-style-type`"
|
||||||
|
///
|
||||||
|
/// Inherited: yes,
|
||||||
|
/// Default: ListStyleType.DISC
|
||||||
|
ListStyleType? listStyleType;
|
||||||
|
|
||||||
|
/// CSS attribute "`list-style-position`"
|
||||||
|
///
|
||||||
|
/// Inherited: yes,
|
||||||
|
/// Default: ListStylePosition.OUTSIDE
|
||||||
|
ListStylePosition? listStylePosition;
|
||||||
|
|
||||||
|
/// CSS attribute "`padding`"
|
||||||
|
///
|
||||||
|
/// Inherited: no,
|
||||||
|
/// Default: EdgeInsets.zero
|
||||||
|
EdgeInsets? padding;
|
||||||
|
|
||||||
|
/// CSS attribute "`margin`"
|
||||||
|
///
|
||||||
|
/// Inherited: no,
|
||||||
|
/// Default: EdgeInsets.zero
|
||||||
|
EdgeInsets? margin;
|
||||||
|
|
||||||
|
/// CSS attribute "`text-align`"
|
||||||
|
///
|
||||||
|
/// Inherited: yes,
|
||||||
|
/// Default: TextAlign.start,
|
||||||
|
TextAlign? textAlign;
|
||||||
|
|
||||||
|
/// CSS attribute "`text-decoration`"
|
||||||
|
///
|
||||||
|
/// Inherited: no,
|
||||||
|
/// Default: TextDecoration.none,
|
||||||
|
TextDecoration? textDecoration;
|
||||||
|
|
||||||
|
/// CSS attribute "`text-decoration-color`"
|
||||||
|
///
|
||||||
|
/// Inherited: no,
|
||||||
|
/// Default: Current color
|
||||||
|
Color? textDecorationColor;
|
||||||
|
|
||||||
|
/// CSS attribute "`text-decoration-style`"
|
||||||
|
///
|
||||||
|
/// Inherited: no,
|
||||||
|
/// Default: TextDecorationStyle.solid,
|
||||||
|
TextDecorationStyle? textDecorationStyle;
|
||||||
|
|
||||||
|
/// Loosely based on CSS attribute "`text-decoration-thickness`"
|
||||||
|
///
|
||||||
|
/// Uses a percent modifier based on the font size.
|
||||||
|
///
|
||||||
|
/// Inherited: no,
|
||||||
|
/// Default: 1.0 (specified by font size)
|
||||||
|
// TODO(Sub6Resources): Possibly base this more closely on the CSS attribute.
|
||||||
|
double? textDecorationThickness;
|
||||||
|
|
||||||
|
/// CSS attribute "`text-shadow`"
|
||||||
|
///
|
||||||
|
/// Inherited: yes,
|
||||||
|
/// Default: none,
|
||||||
|
List<Shadow>? textShadow;
|
||||||
|
|
||||||
|
/// CSS attribute "`vertical-align`"
|
||||||
|
///
|
||||||
|
/// Inherited: no,
|
||||||
|
/// Default: VerticalAlign.BASELINE,
|
||||||
|
VerticalAlign? verticalAlign;
|
||||||
|
|
||||||
|
/// CSS attribute "`white-space`"
|
||||||
|
///
|
||||||
|
/// Inherited: yes,
|
||||||
|
/// Default: WhiteSpace.NORMAL,
|
||||||
|
WhiteSpace? whiteSpace;
|
||||||
|
|
||||||
|
/// CSS attribute "`width`"
|
||||||
|
///
|
||||||
|
/// Inherited: no,
|
||||||
|
/// Default: unspecified (null)
|
||||||
|
double? width;
|
||||||
|
|
||||||
|
/// CSS attribute "`word-spacing`"
|
||||||
|
///
|
||||||
|
/// Inherited: yes,
|
||||||
|
/// Default: normal (0)
|
||||||
|
double? wordSpacing;
|
||||||
|
|
||||||
|
/// CSS attribute "`line-height`"
|
||||||
|
///
|
||||||
|
/// Supported values: double values
|
||||||
|
///
|
||||||
|
/// Unsupported values: normal, 80%, ..
|
||||||
|
///
|
||||||
|
/// Inherited: no,
|
||||||
|
/// Default: Unspecified (null),
|
||||||
|
LineHeight? lineHeight;
|
||||||
|
|
||||||
|
//TODO modify these to match CSS styles
|
||||||
|
String? before;
|
||||||
|
String? after;
|
||||||
|
Border? border;
|
||||||
|
Alignment? alignment;
|
||||||
|
Widget? markerContent;
|
||||||
|
|
||||||
|
/// MaxLine
|
||||||
|
///
|
||||||
|
///
|
||||||
|
///
|
||||||
|
///
|
||||||
|
int? maxLines;
|
||||||
|
|
||||||
|
/// TextOverflow
|
||||||
|
///
|
||||||
|
///
|
||||||
|
///
|
||||||
|
///
|
||||||
|
TextOverflow? textOverflow;
|
||||||
|
|
||||||
|
TextTransform? textTransform;
|
||||||
|
|
||||||
|
Style({
|
||||||
|
this.backgroundColor = Colors.transparent,
|
||||||
|
this.color,
|
||||||
|
this.direction,
|
||||||
|
this.display,
|
||||||
|
this.fontFamily,
|
||||||
|
this.fontFamilyFallback,
|
||||||
|
this.fontFeatureSettings,
|
||||||
|
this.fontSize,
|
||||||
|
this.fontStyle,
|
||||||
|
this.fontWeight,
|
||||||
|
this.height,
|
||||||
|
this.lineHeight,
|
||||||
|
this.letterSpacing,
|
||||||
|
this.listStyleType,
|
||||||
|
this.listStylePosition,
|
||||||
|
this.padding,
|
||||||
|
this.margin,
|
||||||
|
this.textAlign,
|
||||||
|
this.textDecoration,
|
||||||
|
this.textDecorationColor,
|
||||||
|
this.textDecorationStyle,
|
||||||
|
this.textDecorationThickness,
|
||||||
|
this.textShadow,
|
||||||
|
this.verticalAlign,
|
||||||
|
this.whiteSpace,
|
||||||
|
this.width,
|
||||||
|
this.wordSpacing,
|
||||||
|
this.before,
|
||||||
|
this.after,
|
||||||
|
this.border,
|
||||||
|
this.alignment,
|
||||||
|
this.markerContent,
|
||||||
|
this.maxLines,
|
||||||
|
this.textOverflow,
|
||||||
|
this.textTransform = TextTransform.none,
|
||||||
|
}) {
|
||||||
|
if (this.alignment == null &&
|
||||||
|
(display == Display.BLOCK || display == Display.LIST_ITEM)) {
|
||||||
|
this.alignment = Alignment.centerLeft;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, Style> fromThemeData(ThemeData theme) => {
|
||||||
|
'h1': Style.fromTextStyle(theme.textTheme.headline1!),
|
||||||
|
'h2': Style.fromTextStyle(theme.textTheme.headline2!),
|
||||||
|
'h3': Style.fromTextStyle(theme.textTheme.headline3!),
|
||||||
|
'h4': Style.fromTextStyle(theme.textTheme.headline4!),
|
||||||
|
'h5': Style.fromTextStyle(theme.textTheme.headline5!),
|
||||||
|
'h6': Style.fromTextStyle(theme.textTheme.headline6!),
|
||||||
|
'body': Style.fromTextStyle(theme.textTheme.bodyText2!),
|
||||||
|
};
|
||||||
|
|
||||||
|
static Map<String, Style> fromCss(String css, OnCssParseError? onCssParseError) {
|
||||||
|
final declarations = parseExternalCss(css, onCssParseError);
|
||||||
|
Map<String, Style> styleMap = {};
|
||||||
|
declarations.forEach((key, value) {
|
||||||
|
styleMap[key] = declarationsToStyle(value);
|
||||||
|
});
|
||||||
|
return styleMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
TextStyle generateTextStyle() {
|
||||||
|
return TextStyle(
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
color: color,
|
||||||
|
decoration: textDecoration,
|
||||||
|
decorationColor: textDecorationColor,
|
||||||
|
decorationStyle: textDecorationStyle,
|
||||||
|
decorationThickness: textDecorationThickness,
|
||||||
|
fontFamily: fontFamily,
|
||||||
|
fontFamilyFallback: fontFamilyFallback,
|
||||||
|
fontFeatures: fontFeatureSettings,
|
||||||
|
fontSize: fontSize?.size,
|
||||||
|
fontStyle: fontStyle,
|
||||||
|
fontWeight: fontWeight,
|
||||||
|
letterSpacing: letterSpacing,
|
||||||
|
shadows: textShadow,
|
||||||
|
wordSpacing: wordSpacing,
|
||||||
|
height: lineHeight?.size ?? 1.0,
|
||||||
|
//TODO background
|
||||||
|
//TODO textBaseline
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return "Style";
|
||||||
|
}
|
||||||
|
|
||||||
|
Style merge(Style other) {
|
||||||
|
return copyWith(
|
||||||
|
backgroundColor: other.backgroundColor,
|
||||||
|
color: other.color,
|
||||||
|
direction: other.direction,
|
||||||
|
display: other.display,
|
||||||
|
fontFamily: other.fontFamily,
|
||||||
|
fontFamilyFallback: other.fontFamilyFallback,
|
||||||
|
fontFeatureSettings: other.fontFeatureSettings,
|
||||||
|
fontSize: other.fontSize,
|
||||||
|
fontStyle: other.fontStyle,
|
||||||
|
fontWeight: other.fontWeight,
|
||||||
|
height: other.height,
|
||||||
|
lineHeight: other.lineHeight,
|
||||||
|
letterSpacing: other.letterSpacing,
|
||||||
|
listStyleType: other.listStyleType,
|
||||||
|
listStylePosition: other.listStylePosition,
|
||||||
|
padding: other.padding,
|
||||||
|
//TODO merge EdgeInsets
|
||||||
|
margin: other.margin,
|
||||||
|
//TODO merge EdgeInsets
|
||||||
|
textAlign: other.textAlign,
|
||||||
|
textDecoration: other.textDecoration,
|
||||||
|
textDecorationColor: other.textDecorationColor,
|
||||||
|
textDecorationStyle: other.textDecorationStyle,
|
||||||
|
textDecorationThickness: other.textDecorationThickness,
|
||||||
|
textShadow: other.textShadow,
|
||||||
|
verticalAlign: other.verticalAlign,
|
||||||
|
whiteSpace: other.whiteSpace,
|
||||||
|
width: other.width,
|
||||||
|
wordSpacing: other.wordSpacing,
|
||||||
|
|
||||||
|
before: other.before,
|
||||||
|
after: other.after,
|
||||||
|
border: other.border,
|
||||||
|
//TODO merge border
|
||||||
|
alignment: other.alignment,
|
||||||
|
markerContent: other.markerContent,
|
||||||
|
maxLines: other.maxLines,
|
||||||
|
textOverflow: other.textOverflow,
|
||||||
|
textTransform: other.textTransform,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Style copyOnlyInherited(Style child) {
|
||||||
|
FontSize? finalFontSize = child.fontSize != null ?
|
||||||
|
fontSize != null && child.fontSize?.units == "em" ?
|
||||||
|
FontSize(child.fontSize!.size! * fontSize!.size!) : child.fontSize
|
||||||
|
: fontSize != null && fontSize!.size! < 0 ?
|
||||||
|
FontSize.percent(100) : fontSize;
|
||||||
|
LineHeight? finalLineHeight = child.lineHeight != null ?
|
||||||
|
child.lineHeight?.units == "length" ?
|
||||||
|
LineHeight(child.lineHeight!.size! / (finalFontSize == null ? 14 : finalFontSize.size!) * 1.2) : child.lineHeight
|
||||||
|
: lineHeight;
|
||||||
|
return child.copyWith(
|
||||||
|
backgroundColor: child.backgroundColor != Colors.transparent ?
|
||||||
|
child.backgroundColor : backgroundColor,
|
||||||
|
color: child.color ?? color,
|
||||||
|
direction: child.direction ?? direction,
|
||||||
|
display: display == Display.NONE ? display : child.display,
|
||||||
|
fontFamily: child.fontFamily ?? fontFamily,
|
||||||
|
fontFamilyFallback: child.fontFamilyFallback ?? fontFamilyFallback,
|
||||||
|
fontFeatureSettings: child.fontFeatureSettings ?? fontFeatureSettings,
|
||||||
|
fontSize: finalFontSize,
|
||||||
|
fontStyle: child.fontStyle ?? fontStyle,
|
||||||
|
fontWeight: child.fontWeight ?? fontWeight,
|
||||||
|
lineHeight: finalLineHeight,
|
||||||
|
letterSpacing: child.letterSpacing ?? letterSpacing,
|
||||||
|
listStyleType: child.listStyleType ?? listStyleType,
|
||||||
|
listStylePosition: child.listStylePosition ?? listStylePosition,
|
||||||
|
textAlign: child.textAlign ?? textAlign,
|
||||||
|
textDecoration: TextDecoration.combine(
|
||||||
|
[child.textDecoration ?? TextDecoration.none,
|
||||||
|
textDecoration ?? TextDecoration.none]),
|
||||||
|
textShadow: child.textShadow ?? textShadow,
|
||||||
|
whiteSpace: child.whiteSpace ?? whiteSpace,
|
||||||
|
wordSpacing: child.wordSpacing ?? wordSpacing,
|
||||||
|
maxLines: child.maxLines ?? maxLines,
|
||||||
|
textOverflow: child.textOverflow ?? textOverflow,
|
||||||
|
textTransform: child.textTransform ?? textTransform,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Style copyWith({
|
||||||
|
Color? backgroundColor,
|
||||||
|
Color? color,
|
||||||
|
TextDirection? direction,
|
||||||
|
Display? display,
|
||||||
|
String? fontFamily,
|
||||||
|
List<String>? fontFamilyFallback,
|
||||||
|
List<FontFeature>? fontFeatureSettings,
|
||||||
|
FontSize? fontSize,
|
||||||
|
FontStyle? fontStyle,
|
||||||
|
FontWeight? fontWeight,
|
||||||
|
double? height,
|
||||||
|
LineHeight? lineHeight,
|
||||||
|
double? letterSpacing,
|
||||||
|
ListStyleType? listStyleType,
|
||||||
|
ListStylePosition? listStylePosition,
|
||||||
|
EdgeInsets? padding,
|
||||||
|
EdgeInsets? margin,
|
||||||
|
TextAlign? textAlign,
|
||||||
|
TextDecoration? textDecoration,
|
||||||
|
Color? textDecorationColor,
|
||||||
|
TextDecorationStyle? textDecorationStyle,
|
||||||
|
double? textDecorationThickness,
|
||||||
|
List<Shadow>? textShadow,
|
||||||
|
VerticalAlign? verticalAlign,
|
||||||
|
WhiteSpace? whiteSpace,
|
||||||
|
double? width,
|
||||||
|
double? wordSpacing,
|
||||||
|
String? before,
|
||||||
|
String? after,
|
||||||
|
Border? border,
|
||||||
|
Alignment? alignment,
|
||||||
|
Widget? markerContent,
|
||||||
|
int? maxLines,
|
||||||
|
TextOverflow? textOverflow,
|
||||||
|
TextTransform? textTransform,
|
||||||
|
bool? beforeAfterNull,
|
||||||
|
}) {
|
||||||
|
return Style(
|
||||||
|
backgroundColor: backgroundColor ?? this.backgroundColor,
|
||||||
|
color: color ?? this.color,
|
||||||
|
direction: direction ?? this.direction,
|
||||||
|
display: display ?? this.display,
|
||||||
|
fontFamily: fontFamily ?? this.fontFamily,
|
||||||
|
fontFamilyFallback: fontFamilyFallback ?? this.fontFamilyFallback,
|
||||||
|
fontFeatureSettings: fontFeatureSettings ?? this.fontFeatureSettings,
|
||||||
|
fontSize: fontSize ?? this.fontSize,
|
||||||
|
fontStyle: fontStyle ?? this.fontStyle,
|
||||||
|
fontWeight: fontWeight ?? this.fontWeight,
|
||||||
|
height: height ?? this.height,
|
||||||
|
lineHeight: lineHeight ?? this.lineHeight,
|
||||||
|
letterSpacing: letterSpacing ?? this.letterSpacing,
|
||||||
|
listStyleType: listStyleType ?? this.listStyleType,
|
||||||
|
listStylePosition: listStylePosition ?? this.listStylePosition,
|
||||||
|
padding: padding ?? this.padding,
|
||||||
|
margin: margin ?? this.margin,
|
||||||
|
textAlign: textAlign ?? this.textAlign,
|
||||||
|
textDecoration: textDecoration ?? this.textDecoration,
|
||||||
|
textDecorationColor: textDecorationColor ?? this.textDecorationColor,
|
||||||
|
textDecorationStyle: textDecorationStyle ?? this.textDecorationStyle,
|
||||||
|
textDecorationThickness:
|
||||||
|
textDecorationThickness ?? this.textDecorationThickness,
|
||||||
|
textShadow: textShadow ?? this.textShadow,
|
||||||
|
verticalAlign: verticalAlign ?? this.verticalAlign,
|
||||||
|
whiteSpace: whiteSpace ?? this.whiteSpace,
|
||||||
|
width: width ?? this.width,
|
||||||
|
wordSpacing: wordSpacing ?? this.wordSpacing,
|
||||||
|
before: beforeAfterNull == true ? null : before ?? this.before,
|
||||||
|
after: beforeAfterNull == true ? null : after ?? this.after,
|
||||||
|
border: border ?? this.border,
|
||||||
|
alignment: alignment ?? this.alignment,
|
||||||
|
markerContent: markerContent ?? this.markerContent,
|
||||||
|
maxLines: maxLines ?? this.maxLines,
|
||||||
|
textOverflow: textOverflow ?? this.textOverflow,
|
||||||
|
textTransform: textTransform ?? this.textTransform,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Style.fromTextStyle(TextStyle textStyle) {
|
||||||
|
this.backgroundColor = textStyle.backgroundColor;
|
||||||
|
this.color = textStyle.color;
|
||||||
|
this.textDecoration = textStyle.decoration;
|
||||||
|
this.textDecorationColor = textStyle.decorationColor;
|
||||||
|
this.textDecorationStyle = textStyle.decorationStyle;
|
||||||
|
this.textDecorationThickness = textStyle.decorationThickness;
|
||||||
|
this.fontFamily = textStyle.fontFamily;
|
||||||
|
this.fontFamilyFallback = textStyle.fontFamilyFallback;
|
||||||
|
this.fontFeatureSettings = textStyle.fontFeatures;
|
||||||
|
this.fontSize = FontSize(textStyle.fontSize);
|
||||||
|
this.fontStyle = textStyle.fontStyle;
|
||||||
|
this.fontWeight = textStyle.fontWeight;
|
||||||
|
this.letterSpacing = textStyle.letterSpacing;
|
||||||
|
this.textShadow = textStyle.shadows;
|
||||||
|
this.wordSpacing = textStyle.wordSpacing;
|
||||||
|
this.lineHeight = LineHeight(textStyle.height ?? 1.2);
|
||||||
|
this.textTransform = TextTransform.none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Display {
|
||||||
|
BLOCK,
|
||||||
|
INLINE,
|
||||||
|
INLINE_BLOCK,
|
||||||
|
LIST_ITEM,
|
||||||
|
NONE,
|
||||||
|
}
|
||||||
|
|
||||||
|
class FontSize {
|
||||||
|
final double? size;
|
||||||
|
final String units;
|
||||||
|
|
||||||
|
const FontSize(this.size, {this.units = ""});
|
||||||
|
|
||||||
|
/// A percentage of the parent style's font size.
|
||||||
|
factory FontSize.percent(double percent) {
|
||||||
|
return FontSize(percent / -100.0, units: "%");
|
||||||
|
}
|
||||||
|
|
||||||
|
factory FontSize.em(double? em) {
|
||||||
|
return FontSize(em, units: "em");
|
||||||
|
}
|
||||||
|
|
||||||
|
factory FontSize.rem(double rem) {
|
||||||
|
return FontSize(rem * 16 - 2, units: "rem");
|
||||||
|
}
|
||||||
|
// These values are calculated based off of the default (`medium`)
|
||||||
|
// being 14px.
|
||||||
|
//
|
||||||
|
// TODO(Sub6Resources): This seems to override Flutter's accessibility text scaling.
|
||||||
|
//
|
||||||
|
// Negative values are computed during parsing to be a percentage of
|
||||||
|
// the parent style's font size.
|
||||||
|
static const xxSmall = FontSize(7.875);
|
||||||
|
static const xSmall = FontSize(8.75);
|
||||||
|
static const small = FontSize(11.375);
|
||||||
|
static const medium = FontSize(14.0);
|
||||||
|
static const large = FontSize(15.75);
|
||||||
|
static const xLarge = FontSize(21.0);
|
||||||
|
static const xxLarge = FontSize(28.0);
|
||||||
|
static const smaller = FontSize(-0.83);
|
||||||
|
static const larger = FontSize(-1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
class LineHeight {
|
||||||
|
final double? size;
|
||||||
|
final String units;
|
||||||
|
|
||||||
|
const LineHeight(this.size, {this.units = ""});
|
||||||
|
|
||||||
|
factory LineHeight.percent(double percent) {
|
||||||
|
return LineHeight(percent / 100.0 * 1.2, units: "%");
|
||||||
|
}
|
||||||
|
|
||||||
|
factory LineHeight.em(double em) {
|
||||||
|
return LineHeight(em * 1.2, units: "em");
|
||||||
|
}
|
||||||
|
|
||||||
|
factory LineHeight.rem(double rem) {
|
||||||
|
return LineHeight(rem * 1.2, units: "rem");
|
||||||
|
}
|
||||||
|
|
||||||
|
factory LineHeight.number(double num) {
|
||||||
|
return LineHeight(num * 1.2, units: "number");
|
||||||
|
}
|
||||||
|
|
||||||
|
static const normal = LineHeight(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ListStyleType {
|
||||||
|
final String text;
|
||||||
|
final String type;
|
||||||
|
final Widget? widget;
|
||||||
|
|
||||||
|
const ListStyleType(this.text, {this.type = "marker", this.widget});
|
||||||
|
|
||||||
|
factory ListStyleType.fromImage(String url) => ListStyleType(url, type: "image");
|
||||||
|
|
||||||
|
factory ListStyleType.fromWidget(Widget widget) => ListStyleType("", widget: widget, type: "widget");
|
||||||
|
|
||||||
|
static const LOWER_ALPHA = ListStyleType("LOWER_ALPHA");
|
||||||
|
static const UPPER_ALPHA = ListStyleType("UPPER_ALPHA");
|
||||||
|
static const LOWER_LATIN = ListStyleType("LOWER_LATIN");
|
||||||
|
static const UPPER_LATIN = ListStyleType("UPPER_LATIN");
|
||||||
|
static const CIRCLE = ListStyleType("CIRCLE");
|
||||||
|
static const DISC = ListStyleType("DISC");
|
||||||
|
static const DECIMAL = ListStyleType("DECIMAL");
|
||||||
|
static const LOWER_ROMAN = ListStyleType("LOWER_ROMAN");
|
||||||
|
static const UPPER_ROMAN = ListStyleType("UPPER_ROMAN");
|
||||||
|
static const SQUARE = ListStyleType("SQUARE");
|
||||||
|
static const NONE = ListStyleType("NONE");
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ListStylePosition {
|
||||||
|
OUTSIDE,
|
||||||
|
INSIDE,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TextTransform {
|
||||||
|
uppercase,
|
||||||
|
lowercase,
|
||||||
|
capitalize,
|
||||||
|
none,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum VerticalAlign {
|
||||||
|
BASELINE,
|
||||||
|
SUB,
|
||||||
|
SUPER,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WhiteSpace {
|
||||||
|
NORMAL,
|
||||||
|
PRE,
|
||||||
|
}
|
Loading…
Reference in new issue