voyagehandbook/lib/util/render.dart
2023-04-03 19:09:51 +02:00

651 lines
20 KiB
Dart

import 'dart:math';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:html/parser.dart';
import 'package:html/dom.dart' as dom;
import 'package:url_launcher/url_launcher.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:voyagehandbook/api/classes.dart';
import 'package:voyagehandbook/util/styles.dart';
import 'package:voyagehandbook/util/widgets/warning.dart';
import 'package:html_unescape/html_unescape_small.dart';
import 'package:voyagehandbook/views/pageview.dart';
import 'package:widget_zoom/widget_zoom.dart';
/*
Voyage Handbook - The open-source WikiVoyage reader
Copyright (C) 2023 Matyáš Caras
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License version 3 as published by
the Free Software Foundation.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
final _ignoredTags = ["style", "script"];
class PageRenderer {
final ColorScheme scheme;
final double height;
final double width;
final BuildContext context;
PageRenderer(this.scheme, this.height, this.width, this.context);
/// Used to create Widgets from raw HTML from WM API
ListView renderFromPageHTML(RawPage page) {
var out = <Widget>[
const SizedBox(
height: 10,
),
SelectableText(
page.title,
textAlign: TextAlign.center,
style: PageStyles.h1,
),
const SizedBox(
height: 10,
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SelectableText("From"),
const SizedBox(
width: 5,
),
SelectableText(
"WikiVoyage",
onTap: () => launchUrl(
Uri.parse("https://en.wikivoyage.org/wiki/${page.key}"),
mode: LaunchMode.externalApplication),
style: const TextStyle(
decoration: TextDecoration.underline,
fontWeight: FontWeight.bold,
),
),
],
),
Row(
children: [
const SelectableText("under"),
const SizedBox(
width: 5,
),
SelectableText(
page.license.title,
onTap: () => launchUrl(Uri.parse(page.license.url),
mode: LaunchMode.externalApplication),
style: const TextStyle(
decoration: TextDecoration.underline,
),
)
],
)
];
var document = parse(page.html);
var sections = document.body!.getElementsByTagName("section");
for (var sec in sections) {
if (sec.localName == "section") {
out.addAll(_renderSection(sec));
}
}
var l = ListView.builder(
padding: EdgeInsets.zero,
itemBuilder: (c, i) => out[i],
itemCount: out.length,
);
return l;
}
List<Widget> _renderSection(dom.Element sec) {
var out = <Widget>[];
// Get Section Title
var headings = sec.children.where(
(element) => ["h2", "h3", "h4", "h5"].contains(element.localName));
var sectionTitle = (headings.isNotEmpty) ? headings.first : null;
if (sectionTitle != null) {
switch (sectionTitle.localName) {
case "h2":
out.add(
const SizedBox(
height: 10,
),
);
out.add(SelectableText(
sectionTitle.text,
style: PageStyles.h2,
textAlign: TextAlign.center,
));
break;
case "h3":
out.add(
Align(
alignment: Alignment.centerLeft,
child: SelectableText(
sectionTitle.text,
style: PageStyles.h3,
),
),
);
break;
case "h4":
out.add(
Align(
alignment: Alignment.centerLeft,
child: SelectableText(sectionTitle.text, style: PageStyles.h4),
),
);
break;
case "h5":
out.add(
Align(
alignment: Alignment.centerLeft,
child: SelectableText(sectionTitle.text, style: PageStyles.h5),
),
);
break;
default:
out.add(
Align(
alignment: Alignment.centerLeft,
child: SelectableText(sectionTitle.text),
),
);
break;
}
// out.add(
// const SizedBox(
// height: 5,
// ),
// );
}
// create TextSpans from text content
for (var element in sec.children) {
// Go through all section's children
for (var t in _ignoredTags) {
var ignored = element.getElementsByTagName(t);
if (ignored.isNotEmpty) {
// Remove ignored tags
for (var element in ignored) {
element.remove();
}
}
}
switch (element.localName) {
case "p":
case "link":
if (element
.getElementsByClassName("mw-kartographer-maplink")
.isNotEmpty) break;
out.add(
SelectableText.rich(
TextSpan(
children: _renderText(element.innerHtml),
),
style: const TextStyle(height: 1.2),
textAlign: TextAlign.justify,
),
); // add paragraph spans as single rich text
out.add(
const SizedBox(
height: 5,
),
); // space paragraphs
break;
case "figure":
out.addAll(_renderImageFigure(element));
out.add(
const SizedBox(
height: 10,
),
);
break;
case "section":
out.addAll(_renderSection(element));
break;
case "dl":
var dd = element.getElementsByTagName("dd").first;
var link = RegExp(r'<a .+? href="\.\/(.+?)".+?>(.+?)<', dotAll: true)
.firstMatch(dd.innerHtml);
if (link == null) {
break; // TODO: handle `dd` which are not "see also" links
}
out.add(
Row(
children: [
const SelectableText(
"See also:",
style: TextStyle(),
),
const SizedBox(
width: 5,
),
SelectableText(
link.group(2)!,
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ArticleView(
pageKey: link.group(1)!, name: link.group(2)!),
),
),
style: const TextStyle(
fontStyle: FontStyle.italic,
decoration: TextDecoration.underline),
)
],
),
);
out.add(
const SizedBox(
height: 5,
),
);
break;
case "ul":
out.add(_renderList(element));
out.add(
const SizedBox(
height: 10,
),
);
break;
case "div":
if (element.attributes["class"] != null &&
element.attributes["class"] == "pp_cautionbox") {
out.add(_renderWarning(element));
out.add(
const SizedBox(
height: 10,
),
);
} else if (element.id == "region_list") {
var inner = parse(
element.innerHtml.replaceAll(RegExp(r'<\/?span.+?>'), ""),
);
for (var e in inner.body!.children) {
if (e.localName == "figure") {
// render image
out.addAll(_renderImageFigure(e));
out.add(
const SizedBox(
height: 5,
),
);
} else if (e.localName == "table") {
Color? boxColor;
var text = <InlineSpan>[];
for (var td in e
.getElementsByTagName("tr")
.first
.getElementsByTagName("td")) {
if (td.attributes["style"] != null) {
var colorMatch = RegExp(r'background-color:#(.+?);')
.firstMatch(td.attributes["style"]!);
boxColor = Color(int.parse("0xff${colorMatch!.group(1)}"));
} else {
text.addAll(_renderText(td.innerHtml));
}
}
out.add(
const SizedBox(
height: 10,
),
);
out.add(
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (boxColor != null)
Container(
color: boxColor,
width: 25,
height: 25,
),
const SizedBox(
width: 10,
),
Flexible(
child: SelectableText.rich(
TextSpan(children: text),
),
)
],
),
);
}
}
out.add(
const SizedBox(
height: 10,
),
);
} else if (element.classes.contains("mw-kartographer-container")) {
var imgs = element
.getElementsByTagName("div")
.first
.getElementsByTagName("a")
.first
.getElementsByTagName("img");
if (imgs.isEmpty) break; // load maps that have a static image
var img = imgs[0];
var cap = element.getElementsByClassName("thumbcaption")[0];
out.add(const SizedBox(
height: 10,
));
out.add(
SizedBox(
width: width * 0.8,
height: height * 0.3,
child: WidgetZoom(
zoomWidget:
CachedNetworkImage(imageUrl: img.attributes["src"]!),
heroAnimationTag: 'tag',
),
),
);
out.add(
const SizedBox(
height: 3,
),
);
out.add(
SelectableText(
cap.text,
textAlign: TextAlign.center,
),
);
out.add(
const SizedBox(
height: 10,
),
);
}
break;
default:
break;
}
element.remove();
}
out.add(
const SizedBox(
height: 5,
),
);
return out;
}
List<Widget> _renderImageFigure(dom.Element element) {
var out = <Widget>[];
/// Image figure
var imgs = element.getElementsByTagName("img");
if (imgs.isEmpty) return [];
var img = imgs.first; // get image element
if (img.attributes["src"] == null || img.attributes["src"] == "") return [];
var figcap = element.getElementsByTagName("figcaption"); // get caption
String? caption;
if (figcap.isNotEmpty) {
caption = figcap.first.text; // TODO: handle links
}
out.add(const SizedBox(
height: 10,
));
out.add(
SizedBox(
// TODO: open wikimedia page?
width: width * 0.8,
height: height * 0.3,
child: WidgetZoom(
heroAnimationTag: 'img${Random().nextInt(999)}',
zoomWidget: CachedNetworkImage(
imageUrl: img.attributes["src"]!.replaceAll("//", "https://"),
progressIndicatorBuilder: (context, url, downloadProgress) =>
LinearProgressIndicator(value: downloadProgress.progress),
errorWidget: (context, url, error) => Icon(
Icons.error,
color: scheme.error,
),
),
),
),
); // load image
if (caption != null) {
// Add caption when available
out.add(
const SizedBox(
height: 3,
),
);
out.add(
SelectableText(
caption,
textAlign: TextAlign.center,
),
);
}
return out;
}
// used to render warning box
Widget _renderWarning(dom.Element element) {
var content = <InlineSpan>[];
for (var tr in element
.getElementsByTagName("table")
.first
.getElementsByTagName("tbody")
.first
.getElementsByTagName("tr")) {
for (var e in tr.getElementsByTagName("td")) {
// Get to table data
content.addAll(
_renderText(
e.innerHtml.replaceAll(RegExp(r'<img.+?\/?>'), ""),
),
);
}
}
return Warning(content: content);
}
/// Used to render basic text with links, bold and italic text
List<InlineSpan> _renderText(String innerHtml) {
var unescape = HtmlUnescape();
innerHtml = unescape.convert(innerHtml);
var content = <InlineSpan>[];
var input = innerHtml
.replaceAll(
RegExp(r"<(?!(?:\/b)|(?:\/i)|(?:\/a)|(?:b)|(?:i)|(?:a )).+?>",
dotAll: true),
"")
.replaceAll("\\n", "\n")
.replaceAll("<br>", "\n");
// first find links
var linkMatch = RegExp(
r'(?:<(?:i|b).*?>)*<a .+? href="(.+?)" .+?>(.+?)<\/a>(?:\/<(?:i|b)>)*',
dotAll: true)
.allMatches(input)
.toList();
var nonLink = input.split(RegExp(
r'(?:<(?:i|b).*?>)*<a .+? href="(.+?)" .+?>(.+?)<\/a>(?:\/<(?:i|b)>)*',
dotAll: true));
for (var match in linkMatch) {
// format text before link first
content.addAll(_formatText(nonLink[linkMatch.indexOf(match)]));
// create link
content.add(
WidgetSpan(
child: SelectableText(
match.group(2)!,
onTap: () {
if (match.group(0)!.contains('rel="mw:ExtLink')) {
// handle as an external link
launchUrlString(match.group(1)!,
mode: LaunchMode.externalApplication);
} else {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ArticleView(
pageKey: match.group(1)!.replaceAll("./", ""),
name: match.group(2)!),
),
);
}
},
style: TextStyle(
decoration: TextDecoration.underline,
fontWeight: (match.group(0)!.contains("<b") ||
match.group(0)!.contains("</b>"))
? FontWeight.bold
: null,
fontStyle: (match.group(0)!.contains("<i") ||
match.group(0)!.contains("</i>"))
? FontStyle.italic
: null),
),
),
);
}
content.addAll(_formatText(nonLink.last)); // add last
return content;
}
/// Formats text (bold, italics)
List<InlineSpan> _formatText(String input) {
var content = <InlineSpan>[];
var noFormatting =
input.split(RegExp(r"(?:<b.*?>.+?<\/b>)|<i.*?>.+?<\/i>", dotAll: true));
var needToFormat =
RegExp(r"(?:<b.*?>.+?<\/b>)|<i.*?>.+?<\/i>", dotAll: true)
.allMatches(input)
.toList();
for (var s in needToFormat) {
content.add(TextSpan(
text:
noFormatting[needToFormat.indexOf(s)])); // add text before styled
var raw = s.group(0)!;
content.add(
TextSpan(
text: RegExp(r">(?!<)(.+?)<").firstMatch(raw)!.group(1)!.replaceAll(
RegExp(r'<(?:(?:\/b)|(?:\/i)|(?:\/a)|(?:b)|(?:i)|(?:a )).*?>'),
""), // replace stray tags
style: TextStyle(
fontWeight: (raw.contains("<b") || raw.contains("</b>"))
? FontWeight.bold
: null,
fontStyle: (raw.contains("<i") || raw.contains("</i>"))
? FontStyle.italic
: null,
),
),
); // add styled
}
content.add(TextSpan(
text: noFormatting.last.replaceAll(
RegExp(r'<(?:(?:\/b)|(?:\/i)|(?:\/a)|(?:b)|(?:i)|(?:a )).*?>'),
""))); // add last
return content;
}
final _extraColorsLight = {
"blue": const Color.fromARGB(255, 34, 34, 157),
"red": const Color.fromARGB(255, 152, 33, 33)
};
final _extraColorsDark = {
"blue": const Color.fromARGB(255, 84, 95, 247),
"red": const Color.fromARGB(255, 242, 69, 69)
};
SingleChildScrollView _renderList(dom.Element element) {
var out = <Widget>[];
var i = 0;
for (var item in element.getElementsByTagName("li")) {
i++;
String? title = (item.getElementsByClassName("listing-name").isNotEmpty)
? item.getElementsByClassName("listing-name")[0].text
: null;
Color bubbleColor;
if (item.innerHtml.contains("background: #0000FF")) {
bubbleColor =
(MediaQuery.of(context).platformBrightness == Brightness.dark)
? _extraColorsDark["blue"]!
: _extraColorsLight["blue"]!;
} else if (item.innerHtml.contains("background: #800000")) {
bubbleColor =
(MediaQuery.of(context).platformBrightness == Brightness.dark)
? _extraColorsDark["red"]!
: _extraColorsLight["red"]!;
} else {
bubbleColor = scheme.secondary;
}
var rest = (title != null)
? item.text.replaceAll(item.getElementsByTagName("span")[0].text, "")
: item.text;
out.add(const SizedBox(
height: 5,
));
out.add(
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), color: bubbleColor),
width: 30,
height: 30,
child: SelectableText(
i.toString(),
style: TextStyle(
color: scheme.background,
fontWeight: FontWeight.bold,
fontSize: 17),
textAlign: TextAlign.center,
),
),
const SizedBox(
width: 10,
),
SelectableText.rich(
TextSpan(
children: [
if (title != null)
TextSpan(
text: title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
TextSpan(text: rest)
],
),
)
],
),
);
}
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: out,
),
);
}
}