import 'dart:math'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:html/parser.dart'; import 'package:html/dom.dart' as dom; import 'package:latlong2/latlong.dart'; import 'package:logger/logger.dart'; 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/storage.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'; import 'package:flutter_gen/gen_l10n/app_localizations.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 . */ final _ignoredTags = ["style", "script"]; final logger = Logger(printer: PrettyPrinter()); class PageRenderer { final ColorScheme scheme; final double height; final double width; final BuildContext context; final AppLocalizations loc; /// For offline downloads; don't bother rendering the widget tree final bool offline; /// HTML for offline download / caching String outHtml = ""; String? document; PageRenderer(this.scheme, this.height, this.width, this.context, this.loc, {this.offline = false}); /// Used to create Widgets from raw HTML from WM API Future renderFromPageHTML(RawPage page) async { var out = [ const SizedBox( height: 10, ), SelectableText( page.title, textAlign: TextAlign.center, style: PageStyles.h1, ), const SizedBox( height: 10, ), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ SelectableText(loc.attrFrom), 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: [ SelectableText(loc.attrUnder), const SizedBox( width: 2, ), Flexible( child: SelectableText( page.license.title, textAlign: TextAlign.center, onTap: () => launchUrl(Uri.parse(page.license.url), mode: LaunchMode.externalApplication), style: const TextStyle( decoration: TextDecoration.underline, ), ), ) ], ) ]; outHtml = ""; var document = parse(page.html); var sections = document.body!.getElementsByTagName("section"); this.document = page.html; for (var sec in sections) { if (sec.localName == "section") { if (!offline) out.addAll(await _renderSection(sec)); outHtml += sec.outerHtml; } } var l = ListView.builder( padding: EdgeInsets.zero, itemBuilder: (c, i) => out[i], itemCount: out.length, ); return l; } Future> _renderSection(dom.Element sec) async { var out = []; // 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(await _renderImageFigure(element)); out.add( const SizedBox( height: 10, ), ); break; case "section": out.addAll(await _renderSection(element)); break; case "dl": var dd = element.getElementsByTagName("dd").first; var link = RegExp(r'(.+?)<', 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(await _renderImageFigure(e)); out.add( const SizedBox( height: 5, ), ); } else if (e.localName == "table") { Color? boxColor; var text = []; 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")) { logger.i("Found map container"); var imgs = element .getElementsByTagName("div") .first .getElementsByTagName("a") .first .getElementsByTagName("img"); if (imgs.isEmpty || (imgs.first.attributes["src"] ?.startsWith("https://maps.wikimedia.org") ?? false)) { logger.i("Rendering with FlutterMap"); // render map using FlutterMap var dataElement = element .getElementsByClassName("thumbinner") .first .getElementsByClassName("mw-kartographer-map") .first; var pointsRaw = RegExp( r"""(.+?)<\/abbr>(.+?)<\/abbr>.+?}'>(\d+)<""") .allMatches(document!); // find markers logger.i("Found ${pointsRaw.length} markers"); if (pointsRaw.isEmpty) break; var points = []; for (var point in pointsRaw) { // convert to FlutterMap markers if (point.groups([1, 2]).any((element) => element == null)) { logger.w("No lat/lon found in pointer"); continue; } // assign marker color var colorMatch = RegExp(r'background: #(.+?);') .firstMatch(point.group(0)!) ?.group(1); Color bubbleColor = (colorMatch == null) ? scheme.secondary : Color(int.parse("0xff$colorMatch")); points.add( Marker( builder: (c) => Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(4), color: bubbleColor), child: Padding( padding: const EdgeInsets.all(8.0), child: Text(point.group(3) ?? "X"), ), ), point: LatLng( double.parse(point.group(1)!), double.parse(point.group(2)!), ), ), ); } out.add(const SizedBox( height: 10, )); out.add( SizedBox( width: MediaQuery.of(context).size.width * 0.8, height: (MediaQuery.of(context).size.width * 0.8 / 4) * 3, child: FlutterMap( options: MapOptions( center: LatLng( double.parse( dataElement.attributes["data-lat"] ?? "00.0"), double.parse( dataElement.attributes["data-lon"] ?? "00.0"), ), zoom: double.parse( dataElement.attributes["data-zoom"] ?? "1"), ), nonRotatedChildren: [ RichAttributionWidget( attributions: [ TextSourceAttribution( "OpenStreetMap contributors", onTap: () => launchUrlString( "https://openstreetmap.org/copyright"), ) ], ) ], children: [ TileLayer( urlTemplate: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", userAgentPackageName: "cafe.caras.voyagehandbook", ), MarkerLayer( markers: points, ) ], ), ), ); } else { var img = imgs[0]; var cap = element.getElementsByClassName("thumbcaption")[0]; out.add(const SizedBox( height: 10, )); var offlineImage = await StorageAccess.getOfflineImage(img .attributes["src"]! .split('/') .last .replaceAll(RegExp(r"(?!\..+?)\?.+"), "")); out.add( SizedBox( width: width * 0.8, height: height * 0.3, child: WidgetZoom( zoomWidget: CachedNetworkImage( imageUrl: img.attributes["src"]!, errorWidget: (context, url, error) => (offlineImage != null) ? Image.file(offlineImage) : Flexible( child: Text(loc.imageError), ), ), 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; } Future> _renderImageFigure(dom.Element element) async { var out = []; /// 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 } var offlineImage = await StorageAccess.getOfflineImage(img .attributes["src"]! .split('/') .last .replaceAll(RegExp(r"(?!\..+?)\?.+"), "")); 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) => (offlineImage != null) ? Image.file(offlineImage) : Flexible( child: Text(loc.imageError), ), ), ), ), ); // 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 = []; 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''), ""), ), ); } } return Warning(content: content); } /// Used to render basic text with links, bold and italic text List _renderText(String innerHtml) { var unescape = HtmlUnescape(); innerHtml = unescape.convert(innerHtml); var content = []; var input = innerHtml .replaceAll( RegExp(r"<(?!(?:\/b)|(?:\/i)|(?:\/a)|(?:b)|(?:i)|(?:a )).+?>", dotAll: true), "") .replaceAll("\\n", "\n") .replaceAll("
", "\n"); // first find links var linkMatch = RegExp( r'(?:<(?:i|b).*?>)*
(.+?)<\/a>(?:\/<(?:i|b)>)*', dotAll: true) .allMatches(input) .toList(); var nonLink = input.split(RegExp( r'(?:<(?:i|b).*?>)*(.+?)<\/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)!.replaceAll( RegExp(r'<(?:(?:\/b)|(?:\/i)|(?:\/a)|(?:b)|(?:i)|(?:a )).*?>'), ""), 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("")) ? FontWeight.bold : null, fontStyle: (match.group(0)!.contains("")) ? FontStyle.italic : null), ), ), ); } content.addAll(_formatText(nonLink.last)); // add last return content; } /// Formats text (bold, italics) List _formatText(String input) { var content = []; var noFormatting = input.split(RegExp(r"(?:.+?<\/b>)|.+?<\/i>", dotAll: true)); var needToFormat = RegExp(r"(?:.+?<\/b>)|.+?<\/i>", dotAll: true) .allMatches(input) .toList(); for (var s in needToFormat) { content.add(TextSpan( text: noFormatting[needToFormat.indexOf(s)].replaceAll( RegExp(r'<(?:(?:\/b)|(?:\/i)|(?:\/a)|(?:b)|(?:i)|(?:a )).*?>'), ""))); // 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("")) ? FontWeight.bold : null, fontStyle: (raw.contains("")) ? FontStyle.italic : null, ), ), ); // add styled } content.add(TextSpan( text: noFormatting.last.replaceAll( RegExp(r'<(?:(?:\/b)|(?:\/i)|(?:\/a)|(?:b)|(?:i)|(?:a )).*?>'), ""))); // add last return content; } SingleChildScrollView _renderList(dom.Element element) { var out = []; 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; var colorMatch = RegExp(r'background: #(.+?);').firstMatch(item.innerHtml)?.group(1); Color bubbleColor = (colorMatch == null) ? scheme.secondary : Color(int.parse("0xff$colorMatch")); if (element.getElementsByClassName("geo").isNotEmpty) { element.getElementsByClassName("geo").first.remove(); } var rest = ((title != null) ? item.text.replaceAll(title, "") : item.text); if (rest.startsWith("1")) { rest = rest.substring(1); // TODO: figure out how to remove it better? } 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, ), ); } }