feat: export document to PDF (#695)
* feat: create initial parser for PDF format * chore: add comments to explain code. * chore: commit missing file * feat: init commit for pdf parser * chore: bind dependancy to git repo fork for fixes * feat: get basic text from parser * feat: get textstyle working with various texts * fix: ensure formatting(bold, italics) doesnt break paragraphs - Remove additional comments. * chore: remove htmltopdfwidgets dependancy * chore: clean up redundant code and add helper functions * chore: update example files to include pdf option for export * fix: remove html exportFileType * feat: add in image support * feat: add image support for network & local image * feat: enable table support * fix: enable tables in pdf parser - Tables are enabled but lists within a table are not working yet. - There is also the case of strikethrough not working, probably it didnt get rendered as a tag. - Cleaned up example.dart * chore: clean up comments in files & unused imports. * chore: remove unused file. --------- Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
This commit is contained in:
parent
1f986ce1bc
commit
f6ee2fc02c
7 changed files with 698 additions and 7 deletions
|
|
@ -18,12 +18,13 @@ import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:printing/printing.dart';
|
||||||
import 'package:universal_html/html.dart' as html;
|
import 'package:universal_html/html.dart' as html;
|
||||||
|
|
||||||
enum ExportFileType {
|
enum ExportFileType {
|
||||||
documentJson,
|
documentJson,
|
||||||
markdown,
|
markdown,
|
||||||
html,
|
pdf,
|
||||||
delta,
|
delta,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,8 +36,8 @@ extension on ExportFileType {
|
||||||
return 'json';
|
return 'json';
|
||||||
case ExportFileType.markdown:
|
case ExportFileType.markdown:
|
||||||
return 'md';
|
return 'md';
|
||||||
case ExportFileType.html:
|
case ExportFileType.pdf:
|
||||||
return 'html';
|
return 'pdf';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -145,7 +146,6 @@ class _HomePageState extends State<HomePage> {
|
||||||
final htmlString =
|
final htmlString =
|
||||||
await rootBundle.loadString('assets/example.html');
|
await rootBundle.loadString('assets/example.html');
|
||||||
final html = htmlToDocument(htmlString);
|
final html = htmlToDocument(htmlString);
|
||||||
// final html = HTMLToNodesConverter(htmlString).toDocument();
|
|
||||||
final jsonString = Future<String>.value(
|
final jsonString = Future<String>.value(
|
||||||
jsonEncode(
|
jsonEncode(
|
||||||
html.toJson(),
|
html.toJson(),
|
||||||
|
|
@ -250,6 +250,10 @@ class _HomePageState extends State<HomePage> {
|
||||||
_exportFile(_editorState, ExportFileType.markdown);
|
_exportFile(_editorState, ExportFileType.markdown);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
_buildListTile(context, 'Export to PDF', () {
|
||||||
|
_exportFile(_editorState, ExportFileType.pdf);
|
||||||
|
}),
|
||||||
|
|
||||||
// Decoder Demo
|
// Decoder Demo
|
||||||
_buildSeparator(context, 'Import From X Demo'),
|
_buildSeparator(context, 'Import From X Demo'),
|
||||||
_buildListTile(context, 'Import From Document JSON', () {
|
_buildListTile(context, 'Import From Document JSON', () {
|
||||||
|
|
@ -339,7 +343,10 @@ class _HomePageState extends State<HomePage> {
|
||||||
case ExportFileType.markdown:
|
case ExportFileType.markdown:
|
||||||
result = documentToMarkdown(editorState.document);
|
result = documentToMarkdown(editorState.document);
|
||||||
break;
|
break;
|
||||||
case ExportFileType.html:
|
case ExportFileType.pdf:
|
||||||
|
result = documentToMarkdown(editorState.document);
|
||||||
|
break;
|
||||||
|
|
||||||
case ExportFileType.delta:
|
case ExportFileType.delta:
|
||||||
throw UnimplementedError();
|
throw UnimplementedError();
|
||||||
}
|
}
|
||||||
|
|
@ -374,6 +381,17 @@ class _HomePageState extends State<HomePage> {
|
||||||
);
|
);
|
||||||
if (path != null) {
|
if (path != null) {
|
||||||
await File(path).writeAsString(result);
|
await File(path).writeAsString(result);
|
||||||
|
if (fileType == ExportFileType.pdf) {
|
||||||
|
final pdf = await PdfHTMLEncoder(
|
||||||
|
fontFallback: [
|
||||||
|
await PdfGoogleFonts.notoColorEmoji(),
|
||||||
|
await PdfGoogleFonts.notoColorEmojiRegular(),
|
||||||
|
],
|
||||||
|
).convert(result);
|
||||||
|
|
||||||
|
await File(path).writeAsBytes(await pdf.save());
|
||||||
|
}
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
|
|
@ -419,7 +437,7 @@ class _HomePageState extends State<HomePage> {
|
||||||
final document = quillDeltaEncoder.convert(delta);
|
final document = quillDeltaEncoder.convert(delta);
|
||||||
jsonString = jsonEncode(document.toJson());
|
jsonString = jsonEncode(document.toJson());
|
||||||
break;
|
break;
|
||||||
case ExportFileType.html:
|
case ExportFileType.pdf:
|
||||||
throw UnimplementedError();
|
throw UnimplementedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ dependencies:
|
||||||
highlight: ^0.7.0
|
highlight: ^0.7.0
|
||||||
http: ^1.1.0
|
http: ^1.1.0
|
||||||
show_fps: ^1.0.6
|
show_fps: ^1.0.6
|
||||||
|
printing: ^5.11.1
|
||||||
appflowy_editor_plugins: ^0.0.6
|
appflowy_editor_plugins: ^0.0.6
|
||||||
markdown:
|
markdown:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -476,7 +476,9 @@ class HTMLTags {
|
||||||
static const h1 = 'h1';
|
static const h1 = 'h1';
|
||||||
static const h2 = 'h2';
|
static const h2 = 'h2';
|
||||||
static const h3 = 'h3';
|
static const h3 = 'h3';
|
||||||
static const br = 'br';
|
static const h4 = 'h4';
|
||||||
|
static const h5 = 'h5';
|
||||||
|
static const h6 = 'h6';
|
||||||
static const orderedList = 'ol';
|
static const orderedList = 'ol';
|
||||||
static const unorderedList = 'ul';
|
static const unorderedList = 'ul';
|
||||||
static const list = 'li';
|
static const list = 'li';
|
||||||
|
|
@ -491,6 +493,7 @@ class HTMLTags {
|
||||||
static const del = 'del';
|
static const del = 'del';
|
||||||
static const strong = 'strong';
|
static const strong = 'strong';
|
||||||
static const checkbox = 'input';
|
static const checkbox = 'input';
|
||||||
|
static const br = 'br';
|
||||||
static const span = 'span';
|
static const span = 'span';
|
||||||
static const code = 'code';
|
static const code = 'code';
|
||||||
static const blockQuote = 'blockquote';
|
static const blockQuote = 'blockquote';
|
||||||
|
|
@ -523,6 +526,9 @@ class HTMLTags {
|
||||||
HTMLTags.h1,
|
HTMLTags.h1,
|
||||||
HTMLTags.h2,
|
HTMLTags.h2,
|
||||||
HTMLTags.h3,
|
HTMLTags.h3,
|
||||||
|
HTMLTags.h4,
|
||||||
|
HTMLTags.h5,
|
||||||
|
HTMLTags.h6,
|
||||||
HTMLTags.unorderedList,
|
HTMLTags.unorderedList,
|
||||||
HTMLTags.orderedList,
|
HTMLTags.orderedList,
|
||||||
HTMLTags.div,
|
HTMLTags.div,
|
||||||
|
|
|
||||||
54
lib/src/plugins/pdf/extension/color_ext.dart
Normal file
54
lib/src/plugins/pdf/extension/color_ext.dart
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import 'package:pdf/pdf.dart';
|
||||||
|
|
||||||
|
extension ColorExt on PdfColor {
|
||||||
|
static PdfColor? fromRgbaString(String colorString) {
|
||||||
|
final regex =
|
||||||
|
RegExp(r'rgba\((\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)');
|
||||||
|
final match = regex.firstMatch(colorString);
|
||||||
|
|
||||||
|
if (match == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (match.groupCount < 4) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final redColor = match.group(1);
|
||||||
|
final greenColor = match.group(2);
|
||||||
|
final blueColor = match.group(3);
|
||||||
|
final alphaColor = match.group(4);
|
||||||
|
|
||||||
|
final red = redColor != null ? int.tryParse(redColor) : null;
|
||||||
|
final green = greenColor != null ? int.tryParse(greenColor) : null;
|
||||||
|
final blue = blueColor != null ? int.tryParse(blueColor) : null;
|
||||||
|
final alpha = alphaColor != null ? int.tryParse(alphaColor) : null;
|
||||||
|
|
||||||
|
if (red == null || green == null || blue == null || alpha == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return PdfColor.fromInt(
|
||||||
|
rgbaToHex(red, green, blue, opacity: alpha.toDouble()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String toRgbaString() {
|
||||||
|
return 'rgba($red, $green, $blue, $alpha)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int rgbaToHex(int red, int green, int blue, {double opacity = 1}) {
|
||||||
|
red = (red < 0) ? -red : red;
|
||||||
|
green = (green < 0) ? -green : green;
|
||||||
|
blue = (blue < 0) ? -blue : blue;
|
||||||
|
opacity = (opacity < 0) ? -opacity : opacity;
|
||||||
|
opacity = (opacity > 0) ? -255 : opacity * 255;
|
||||||
|
red = (red > 255) ? 255 : red;
|
||||||
|
green = (green > 255) ? 255 : green;
|
||||||
|
blue = (blue > 255) ? 255 : blue;
|
||||||
|
int alpha = opacity.toInt();
|
||||||
|
|
||||||
|
return int.parse(
|
||||||
|
'0x${alpha.toRadixString(16)}${red.toRadixString(16)}${green.toRadixString(16)}${blue.toRadixString(16)}',
|
||||||
|
);
|
||||||
|
}
|
||||||
608
lib/src/plugins/pdf/html_to_pdf_encoder.dart
Normal file
608
lib/src/plugins/pdf/html_to_pdf_encoder.dart
Normal file
|
|
@ -0,0 +1,608 @@
|
||||||
|
import 'dart:collection';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
|
import 'package:html/dom.dart' as dom;
|
||||||
|
import 'package:html/parser.dart' show parse;
|
||||||
|
import 'package:pdf/widgets.dart' as pw;
|
||||||
|
import 'package:pdf/pdf.dart' as pdf;
|
||||||
|
import 'extension/color_ext.dart';
|
||||||
|
import 'package:http/http.dart';
|
||||||
|
import 'package:markdown/markdown.dart' as md;
|
||||||
|
|
||||||
|
/// This class handles conversion from html to pdf
|
||||||
|
class PdfHTMLEncoder {
|
||||||
|
final pw.Font? font;
|
||||||
|
final List<pw.Font> fontFallback;
|
||||||
|
|
||||||
|
PdfHTMLEncoder({
|
||||||
|
this.font,
|
||||||
|
required this.fontFallback,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<pw.Document> convert(String input) async {
|
||||||
|
final htmlx = md.markdownToHtml(
|
||||||
|
input,
|
||||||
|
blockSyntaxes: const [
|
||||||
|
md.TableSyntax(),
|
||||||
|
],
|
||||||
|
inlineSyntaxes: [
|
||||||
|
md.InlineHtmlSyntax(),
|
||||||
|
md.ImageSyntax(),
|
||||||
|
md.StrikethroughSyntax(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final document = parse(htmlx);
|
||||||
|
final body = document.body;
|
||||||
|
if (body == null) {
|
||||||
|
final blank = pw.Document();
|
||||||
|
blank.addPage(
|
||||||
|
pw.Page(
|
||||||
|
build: (pw.Context context) {
|
||||||
|
return pw.Column(children: [pw.SizedBox.shrink()]);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return blank;
|
||||||
|
}
|
||||||
|
final nodes = await _parseElement(body.nodes);
|
||||||
|
final newPdf = pw.Document();
|
||||||
|
newPdf.addPage(
|
||||||
|
pw.MultiPage(build: (pw.Context context) => nodes.toList()),
|
||||||
|
);
|
||||||
|
return newPdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<pw.Widget>> _parseElement(
|
||||||
|
Iterable<dom.Node> domNodes, {
|
||||||
|
String? type,
|
||||||
|
pw.TextAlign? textAlign,
|
||||||
|
}) async {
|
||||||
|
final textSpan = <pw.TextSpan>[];
|
||||||
|
final nodes = <pw.Widget>[];
|
||||||
|
for (final domNode in domNodes) {
|
||||||
|
if (domNode is dom.Element) {
|
||||||
|
final localName = domNode.localName;
|
||||||
|
if (localName == HTMLTags.br) {
|
||||||
|
textSpan.add(const pw.TextSpan(text: '\n'));
|
||||||
|
} else if (HTMLTags.formattingElements.contains(localName)) {
|
||||||
|
final attributes = _parserFormattingElementAttributes(domNode);
|
||||||
|
nodes.add(pw.Paragraph(text: domNode.text, style: attributes.$2));
|
||||||
|
} else if (HTMLTags.specialElements.contains(localName)) {
|
||||||
|
if (textSpan.isNotEmpty) {
|
||||||
|
final newTextSpanList = List<pw.TextSpan>.from(textSpan);
|
||||||
|
nodes.add(
|
||||||
|
(pw.SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: pw.RichText(
|
||||||
|
textAlign: textAlign,
|
||||||
|
text: pw.TextSpan(children: newTextSpanList),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
textAlign = null;
|
||||||
|
textSpan.clear();
|
||||||
|
}
|
||||||
|
nodes.addAll(
|
||||||
|
await _parseSpecialElements(
|
||||||
|
domNode,
|
||||||
|
type: type ?? ParagraphBlockKeys.type,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (domNode is dom.Text) {
|
||||||
|
if (domNode.text.trim().isNotEmpty && textSpan.isNotEmpty) {
|
||||||
|
final newTextSpanList = List<pw.TextSpan>.from(textSpan);
|
||||||
|
nodes.add(
|
||||||
|
(pw.SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: pw.RichText(
|
||||||
|
textAlign: textAlign,
|
||||||
|
text: pw.TextSpan(children: newTextSpanList),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
textAlign = null;
|
||||||
|
textSpan.clear();
|
||||||
|
}
|
||||||
|
nodes.add(
|
||||||
|
pw.Text(
|
||||||
|
domNode.text,
|
||||||
|
style: pw.TextStyle(font: font, fontFallback: fontFallback),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert(false, 'Unknown node type: $domNode');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (textSpan.isNotEmpty) {
|
||||||
|
final newTextSpanList = List<pw.TextSpan>.from(textSpan);
|
||||||
|
nodes.add(
|
||||||
|
(pw.SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: pw.RichText(
|
||||||
|
textAlign: textAlign,
|
||||||
|
text: pw.TextSpan(children: newTextSpanList),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Iterable<pw.Widget>> _parseSpecialElements(
|
||||||
|
dom.Element element, {
|
||||||
|
required String type,
|
||||||
|
}) async {
|
||||||
|
final localName = element.localName;
|
||||||
|
switch (localName) {
|
||||||
|
case HTMLTags.h1:
|
||||||
|
return [_parseHeadingElement(element, level: 1)];
|
||||||
|
case HTMLTags.h2:
|
||||||
|
return [_parseHeadingElement(element, level: 2)];
|
||||||
|
case HTMLTags.h3:
|
||||||
|
return [_parseHeadingElement(element, level: 3)];
|
||||||
|
case HTMLTags.h4:
|
||||||
|
return [_parseHeadingElement(element, level: 4)];
|
||||||
|
case HTMLTags.h5:
|
||||||
|
return [_parseHeadingElement(element, level: 5)];
|
||||||
|
case HTMLTags.h6:
|
||||||
|
return [_parseHeadingElement(element, level: 6)];
|
||||||
|
case HTMLTags.unorderedList:
|
||||||
|
return _parseUnOrderListElement(element);
|
||||||
|
case HTMLTags.orderedList:
|
||||||
|
return _parseOrderListElement(element);
|
||||||
|
case HTMLTags.table:
|
||||||
|
return _parseRawTableData(element);
|
||||||
|
case HTMLTags.list:
|
||||||
|
return [
|
||||||
|
_parseListElement(
|
||||||
|
element,
|
||||||
|
type: type,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
case HTMLTags.paragraph:
|
||||||
|
return [await _parseParagraphElement(element)];
|
||||||
|
case HTMLTags.image:
|
||||||
|
return [await _parseImageElement(element)];
|
||||||
|
default:
|
||||||
|
return [await _parseParagraphElement(element)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Iterable<pw.Widget>> _parseRawTableData(dom.Element element) async {
|
||||||
|
List<pw.TableRow> tableRows = [];
|
||||||
|
|
||||||
|
for (dom.Element row in element.querySelectorAll('tr')) {
|
||||||
|
List<pw.Widget> rowData = [];
|
||||||
|
for (final dom.Element cell in row.children) {
|
||||||
|
List<pw.Widget> cellContent = [];
|
||||||
|
//NOTE: Handle nested HTML tags within table cells
|
||||||
|
for (final dom.Node node in cell.nodes) {
|
||||||
|
if (node.nodeType == dom.Node.ELEMENT_NODE) {
|
||||||
|
dom.Element element = node as dom.Element;
|
||||||
|
if (HTMLTags.formattingElements.contains(element.localName)) {
|
||||||
|
final attributes = _parserFormattingElementAttributes(element);
|
||||||
|
cellContent.add(pw.Text(element.text, style: attributes.$2));
|
||||||
|
}
|
||||||
|
if (HTMLTags.specialElements.contains(element.localName)) {
|
||||||
|
cellContent.addAll(
|
||||||
|
await _parseSpecialElements(
|
||||||
|
element,
|
||||||
|
type: BuiltInAttributeKey.bulletedList,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (node.nodeType == dom.Node.TEXT_NODE) {
|
||||||
|
cellContent.add(
|
||||||
|
pw.Text(
|
||||||
|
(node as dom.Text).data,
|
||||||
|
style: pw.TextStyle(font: font, fontFallback: fontFallback),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rowData.add(pw.Wrap(children: cellContent));
|
||||||
|
}
|
||||||
|
tableRows.add(pw.TableRow(children: rowData));
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
pw.Table(
|
||||||
|
children: tableRows,
|
||||||
|
border: pw.TableBorder.all(color: pdf.PdfColors.black),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
(pw.TextAlign?, pw.TextStyle) _parserFormattingElementAttributes(
|
||||||
|
dom.Element element,
|
||||||
|
) {
|
||||||
|
final localName = element.localName;
|
||||||
|
pw.TextAlign? textAlign;
|
||||||
|
pw.TextStyle attributes =
|
||||||
|
pw.TextStyle(fontFallback: fontFallback, font: font);
|
||||||
|
final List<pw.TextDecoration> decoration = [];
|
||||||
|
|
||||||
|
switch (localName) {
|
||||||
|
case HTMLTags.bold || HTMLTags.strong:
|
||||||
|
attributes = attributes.copyWith(fontWeight: pw.FontWeight.bold);
|
||||||
|
break;
|
||||||
|
case HTMLTags.italic || HTMLTags.em:
|
||||||
|
attributes = attributes.copyWith(fontStyle: pw.FontStyle.italic);
|
||||||
|
break;
|
||||||
|
case HTMLTags.underline:
|
||||||
|
decoration.add(pw.TextDecoration.underline);
|
||||||
|
break;
|
||||||
|
case HTMLTags.del:
|
||||||
|
attributes =
|
||||||
|
attributes.copyWith(decoration: pw.TextDecoration.lineThrough);
|
||||||
|
break;
|
||||||
|
/*
|
||||||
|
case HTMLTags.span || HTMLTags.mark:
|
||||||
|
final deltaAttributes = _getDeltaAttributesFromHTMLAttributes(
|
||||||
|
element.attributes,
|
||||||
|
) ??
|
||||||
|
{};
|
||||||
|
attributes.addAll(deltaAttributes);
|
||||||
|
break;
|
||||||
|
*/
|
||||||
|
case HTMLTags.anchor:
|
||||||
|
final href = element.attributes['href'];
|
||||||
|
if (href != null) {
|
||||||
|
decoration.add(pw.TextDecoration.underline);
|
||||||
|
attributes = attributes.copyWith(color: pdf.PdfColors.blue);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case HTMLTags.code:
|
||||||
|
attributes = attributes.copyWith(
|
||||||
|
background: const pw.BoxDecoration(color: pdf.PdfColors.grey),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
for (final child in element.children) {
|
||||||
|
final formattedAttrs = _parserFormattingElementAttributes(child);
|
||||||
|
attributes = attributes.merge(formattedAttrs.$2);
|
||||||
|
if (formattedAttrs.$2.decoration != null) {
|
||||||
|
decoration.add(formattedAttrs.$2.decoration!);
|
||||||
|
textAlign = formattedAttrs.$1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
textAlign,
|
||||||
|
attributes.copyWith(decoration: pw.TextDecoration.combine(decoration))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pw.Widget _parseHeadingElement(
|
||||||
|
dom.Element element, {
|
||||||
|
required int level,
|
||||||
|
}) {
|
||||||
|
pw.TextAlign? textAlign;
|
||||||
|
final textSpan = <pw.TextSpan>[];
|
||||||
|
final children = element.nodes.toList();
|
||||||
|
for (final child in children) {
|
||||||
|
if (child is dom.Element) {
|
||||||
|
final attributes = _parserFormattingElementAttributes(child);
|
||||||
|
textAlign = attributes.$1;
|
||||||
|
textSpan.add(pw.TextSpan(text: child.text, style: attributes.$2));
|
||||||
|
} else {
|
||||||
|
textSpan.add(
|
||||||
|
pw.TextSpan(
|
||||||
|
text: child.text,
|
||||||
|
style: pw.TextStyle(font: font, fontFallback: fontFallback),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pw.Header(
|
||||||
|
level: level,
|
||||||
|
child: pw.RichText(
|
||||||
|
textAlign: textAlign,
|
||||||
|
text: pw.TextSpan(
|
||||||
|
children: textSpan,
|
||||||
|
style: pw.TextStyle(
|
||||||
|
fontSize: level.getHeadingSize,
|
||||||
|
fontWeight: pw.FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Iterable<pw.Widget> _parseUnOrderListElement(dom.Element element) {
|
||||||
|
final findTodos =
|
||||||
|
element.children.where((element) => element.text.contains('['));
|
||||||
|
if (findTodos.isNotEmpty) {
|
||||||
|
return element.children
|
||||||
|
.map(
|
||||||
|
(child) => _parseListElement(child, type: TodoListBlockKeys.type),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
} else {
|
||||||
|
return element.children
|
||||||
|
.map(
|
||||||
|
(child) =>
|
||||||
|
_parseListElement(child, type: BulletedListBlockKeys.type),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Iterable<pw.Widget> _parseOrderListElement(dom.Element element) {
|
||||||
|
return element.children
|
||||||
|
.map(
|
||||||
|
(child) => _parseListElement(child, type: NumberedListBlockKeys.type),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
pw.Widget _parseListElement(
|
||||||
|
dom.Element element, {
|
||||||
|
required String type,
|
||||||
|
}) {
|
||||||
|
//TODO: Handle Numbered Lists & Handle nested lists
|
||||||
|
if (type == TodoListBlockKeys.type) {
|
||||||
|
final bracketRightIndex = element.text.indexOf(']') + 1;
|
||||||
|
final strippedString =
|
||||||
|
element.text.substring(bracketRightIndex, element.text.length);
|
||||||
|
bool condition = false;
|
||||||
|
if (element.text.contains('[x]')) {
|
||||||
|
condition = true;
|
||||||
|
}
|
||||||
|
return pw.Row(
|
||||||
|
children: [
|
||||||
|
pw.Checkbox(
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
name: element.text.substring(3, 6),
|
||||||
|
value: condition,
|
||||||
|
),
|
||||||
|
pw.Text(
|
||||||
|
strippedString,
|
||||||
|
style: pw.TextStyle(font: font, fontFallback: fontFallback),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return pw.Bullet(text: element.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<pw.Widget> _parseParagraphElement(dom.Element element) {
|
||||||
|
final textSpan = _parseDeltaElement(element);
|
||||||
|
return textSpan;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<pw.Widget> _parseImageElement(dom.Element element) async {
|
||||||
|
final src = element.attributes['src'];
|
||||||
|
try {
|
||||||
|
if (src != null) {
|
||||||
|
if (src.startsWith('https')) {
|
||||||
|
final networkImage = await _fetchImage(src);
|
||||||
|
return pw.Image(pw.MemoryImage(networkImage));
|
||||||
|
} else {
|
||||||
|
File localImage = File(src);
|
||||||
|
return pw.Image(pw.MemoryImage(await localImage.readAsBytes()));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return pw.Text('');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return pw.Text(e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Uint8List> _fetchImage(String url) async {
|
||||||
|
try {
|
||||||
|
final Response response = await get(Uri.parse(url));
|
||||||
|
return response.bodyBytes;
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<pw.Widget> _parseDeltaElement(
|
||||||
|
dom.Element element,
|
||||||
|
) async {
|
||||||
|
final textSpan = <pw.TextSpan>[];
|
||||||
|
final children = element.nodes.toList();
|
||||||
|
final subNodes = <pw.Widget>[];
|
||||||
|
pw.TextAlign? textAlign;
|
||||||
|
|
||||||
|
for (final child in children) {
|
||||||
|
if (child is dom.Element) {
|
||||||
|
if (child.children.isNotEmpty &&
|
||||||
|
HTMLTags.formattingElements.contains(child.localName) == false) {
|
||||||
|
//NOTES:
|
||||||
|
//rich editor for webs do this so handling that case for href <a href="https://www.google.com" rel="noopener noreferrer" target="_blank"><strong><em><u>demo</u></em></strong></a>
|
||||||
|
//nodes.addAll(_parseElement(child.children, type: type));
|
||||||
|
subNodes.addAll(await _parseElement(child.children));
|
||||||
|
} else {
|
||||||
|
if (HTMLTags.specialElements.contains(child.localName)) {
|
||||||
|
subNodes.addAll(
|
||||||
|
await _parseSpecialElements(
|
||||||
|
child,
|
||||||
|
type: ParagraphBlockKeys.type,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (child.localName == HTMLTags.br) {
|
||||||
|
textSpan.add(
|
||||||
|
const pw.TextSpan(text: '\n'),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final attributes = _parserFormattingElementAttributes(child);
|
||||||
|
textAlign = attributes.$1;
|
||||||
|
textSpan.add(
|
||||||
|
pw.TextSpan(
|
||||||
|
text: child.text.replaceAll(RegExp(r'\n+$'), ''),
|
||||||
|
style: attributes.$2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final attributes =
|
||||||
|
_getDeltaAttributesFromHTMLAttributes(element.attributes);
|
||||||
|
textAlign = attributes.$1;
|
||||||
|
textSpan.add(
|
||||||
|
pw.TextSpan(
|
||||||
|
text: child.text?.replaceAll(RegExp(r'\n+$'), '') ?? '',
|
||||||
|
style:
|
||||||
|
attributes.$2.copyWith(font: font, fontFallback: fontFallback),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pw.Wrap(
|
||||||
|
children: [
|
||||||
|
pw.SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: pw.RichText(
|
||||||
|
textAlign: textAlign,
|
||||||
|
text: pw.TextSpan(children: textSpan),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...subNodes,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static pw.TextStyle _assignTextDecorations(
|
||||||
|
pw.TextStyle style,
|
||||||
|
String decorationStr,
|
||||||
|
) {
|
||||||
|
final decorations = decorationStr.split(" ");
|
||||||
|
final textDecorations = <pw.TextDecoration>[];
|
||||||
|
for (final type in decorations) {
|
||||||
|
if (type == 'line-through') {
|
||||||
|
textDecorations.add(pw.TextDecoration.lineThrough);
|
||||||
|
} else if (type == 'underline') {
|
||||||
|
textDecorations.add(pw.TextDecoration.underline);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return style.copyWith(
|
||||||
|
decoration: pw.TextDecoration.combine(
|
||||||
|
textDecorations,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
(pw.TextAlign?, pw.TextStyle) _getDeltaAttributesFromHTMLAttributes(
|
||||||
|
LinkedHashMap<Object, String> htmlAttributes,
|
||||||
|
) {
|
||||||
|
pw.TextStyle style = const pw.TextStyle();
|
||||||
|
pw.TextAlign? textAlign;
|
||||||
|
final cssInlineStyle = htmlAttributes['style'];
|
||||||
|
final cssMap = _getCssFromString(cssInlineStyle);
|
||||||
|
|
||||||
|
// font weight
|
||||||
|
final fontWeight = cssMap['font-weight'];
|
||||||
|
if (fontWeight != null) {
|
||||||
|
if (fontWeight == 'bold') {
|
||||||
|
style = style.copyWith(fontWeight: pw.FontWeight.bold);
|
||||||
|
} else {
|
||||||
|
final weight = int.tryParse(fontWeight);
|
||||||
|
if (weight != null && weight >= 500) {
|
||||||
|
style = style.copyWith(fontWeight: pw.FontWeight.bold);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// decoration
|
||||||
|
final textDecoration = cssMap['text-decoration'];
|
||||||
|
if (textDecoration != null) {
|
||||||
|
style = style.copyWith(
|
||||||
|
decoration: _assignTextDecorations(style, textDecoration).decoration,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// background color
|
||||||
|
final backgroundColor = cssMap['background-color'];
|
||||||
|
if (backgroundColor != null) {
|
||||||
|
final highlightColor = ColorExt.fromRgbaString(backgroundColor);
|
||||||
|
if (highlightColor != null) {
|
||||||
|
style = style.copyWith(color: highlightColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// color
|
||||||
|
final color = cssMap['color'];
|
||||||
|
if (color != null) {
|
||||||
|
final textColor = ColorExt.fromRgbaString(color);
|
||||||
|
if (textColor != null) {
|
||||||
|
style = style.copyWith(color: textColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// italic
|
||||||
|
final fontStyle = cssMap['font-style'];
|
||||||
|
if (fontStyle == 'italic') {
|
||||||
|
style = style.copyWith(fontStyle: pw.FontStyle.italic);
|
||||||
|
}
|
||||||
|
final alignment = cssMap['text-align'];
|
||||||
|
if (alignment != null) {
|
||||||
|
textAlign = _alignText(alignment);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (textAlign, style);
|
||||||
|
}
|
||||||
|
|
||||||
|
static pw.TextAlign _alignText(String alignment) {
|
||||||
|
switch (alignment) {
|
||||||
|
case 'right':
|
||||||
|
return pw.TextAlign.right;
|
||||||
|
case 'center':
|
||||||
|
return pw.TextAlign.center;
|
||||||
|
case 'left':
|
||||||
|
return pw.TextAlign.left;
|
||||||
|
case 'justify':
|
||||||
|
return pw.TextAlign.justify;
|
||||||
|
default:
|
||||||
|
return pw.TextAlign.left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, String> _getCssFromString(String? cssString) {
|
||||||
|
final Map<String, String> result = {};
|
||||||
|
if (cssString == null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
final entries = cssString.split(';');
|
||||||
|
for (final entry in entries) {
|
||||||
|
final tuples = entry.split(':');
|
||||||
|
if (tuples.length < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result[tuples[0].trim()] = tuples[1].trim();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HeaderSize on int {
|
||||||
|
double get getHeadingSize {
|
||||||
|
switch (this) {
|
||||||
|
case 1:
|
||||||
|
return 32;
|
||||||
|
case 2:
|
||||||
|
return 28;
|
||||||
|
case 3:
|
||||||
|
return 20;
|
||||||
|
case 4:
|
||||||
|
return 17;
|
||||||
|
case 5:
|
||||||
|
return 14;
|
||||||
|
case 6:
|
||||||
|
return 10;
|
||||||
|
default:
|
||||||
|
return 32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,3 +12,4 @@ export 'markdown/encoder/document_markdown_encoder.dart';
|
||||||
export 'markdown/encoder/parser/parser.dart';
|
export 'markdown/encoder/parser/parser.dart';
|
||||||
export 'quill_delta/quill_delta_encoder.dart';
|
export 'quill_delta/quill_delta_encoder.dart';
|
||||||
export 'word_count/word_counter_service.dart';
|
export 'word_count/word_counter_service.dart';
|
||||||
|
export 'pdf/html_to_pdf_encoder.dart';
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,9 @@ dependencies:
|
||||||
string_validator: ^1.0.0
|
string_validator: ^1.0.0
|
||||||
tuple: ^2.0.1
|
tuple: ^2.0.1
|
||||||
universal_html: ^2.2.4
|
universal_html: ^2.2.4
|
||||||
|
pdf: ^3.10.7
|
||||||
|
http: ^1.2.0
|
||||||
|
printing: ^5.11.1
|
||||||
url_launcher: ^6.1.11
|
url_launcher: ^6.1.11
|
||||||
visibility_detector: ^0.4.0+2
|
visibility_detector: ^0.4.0+2
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue