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: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: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 . */ final _ignoredTags = ["style", "script"]; class PageRenderer { final ColorScheme scheme; final double height; final double width; PageRenderer(this.scheme, this.height, this.width); /// Used to create Widgets from raw HTML from WM API ListView renderFromPageHTML(RawPage page) { var out = [ const SizedBox( height: 10, ), Text( page.title, textAlign: TextAlign.center, style: PageStyles.h1, ), const SizedBox( height: 10, ), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text("From"), const SizedBox( width: 5, ), GestureDetector( onTap: () => launchUrl( Uri.parse("https://en.wikivoyage.org/wiki/${page.key}"), mode: LaunchMode.externalApplication), child: const Text( "WikiVoyage", style: TextStyle( decoration: TextDecoration.underline, fontWeight: FontWeight.bold, ), ), ), ], ), Row( children: [ const Text("under"), const SizedBox( width: 5, ), GestureDetector( onTap: () => launchUrl(Uri.parse(page.license.url), mode: LaunchMode.externalApplication), child: Text( page.license.title, 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 _renderSection(dom.Element sec) { 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(Text( sectionTitle.text, style: PageStyles.h2, textAlign: TextAlign.center, )); break; case "h3": out.add( Align( alignment: Alignment.centerLeft, child: Text( sectionTitle.text, style: PageStyles.h3, ), ), ); break; case "h4": out.add( Align( alignment: Alignment.centerLeft, child: Text(sectionTitle.text, style: PageStyles.h4), ), ); break; case "h5": out.add( Align( alignment: Alignment.centerLeft, child: Text(sectionTitle.text, style: PageStyles.h5), ), ); break; default: out.add( Align( alignment: Alignment.centerLeft, child: Text(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": out.add( RichText( text: TextSpan(children: _renderText(element.innerHtml)), 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 "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 = []; 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: RichText( text: TextSpan(children: text), ), ) ], ), ); } } out.add( const SizedBox( height: 10, ), ); } else if (element.id == "thumbinner") { var imgs = element.getElementsByTagName("img"); if (imgs.isEmpty) break; 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(Text(cap.text)); out.add(const SizedBox( height: 10, )); } break; default: break; } element.remove(); } out.add( const SizedBox( height: 5, ), ); return out; } List _renderImageFigure(dom.Element element) { 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 } 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( Text( 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 bold and italic formatting List _renderText(String innerHtml) { var unescape = HtmlUnescape(); innerHtml = unescape.convert(innerHtml); var content = []; var input = innerHtml .replaceAll(RegExp(r"<(?!(?:\/b)|(?:\/i)|b|i).+?>", dotAll: true), "") .replaceAll("\\n", "\n") .replaceAll("
", "\n"); 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)])); // add text before styled var raw = s.group(0)!; content.add( TextSpan( text: RegExp(r">(?!<)(.+?)<").firstMatch(raw)!.group(1), style: TextStyle( fontWeight: (raw.contains("