Skip to content

Commit

Permalink
pdf: use screenshots of canvas instead of vectors
Browse files Browse the repository at this point in the history
Vector rendering was inconsistent with the in-editor canvas rendering, and was unsustainable in the long-run as every feature needed to be implemented twice. There were also issues due to pdfs not supporting transparency at all which means highlighters would be opaque against background images.
  • Loading branch information
adil192 committed Jan 30, 2023
1 parent 6cf6108 commit 5a8bfda
Show file tree
Hide file tree
Showing 7 changed files with 75 additions and 283 deletions.
Binary file removed assets/fonts/Neucha/Neucha-Regular.ttf
Binary file not shown.
93 changes: 0 additions & 93 deletions assets/fonts/Neucha/OFL.txt

This file was deleted.

11 changes: 10 additions & 1 deletion lib/components/canvas/inner_canvas.dart
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ class _InnerCanvasState extends State<InnerCanvas> {

final page = widget.coreInfo.pages[widget.pageIndex];

Locale? locale;
try {
locale = TranslationProvider.of(context).flutterLocale;
} catch (e) {
/// Error is thrown when using the [screenshot] package
/// as we don't have a TranslationProvider
locale = null;
}

Widget quillEditor = Transform.scale(
scale: InnerCanvas.quillScale,
alignment: Alignment.topLeft,
Expand All @@ -85,7 +94,7 @@ class _InnerCanvasState extends State<InnerCanvas> {
bottom: widget.coreInfo.lineHeight * 0.5 / InnerCanvas.quillScale,
),
customStyles: _getQuillStyles(context, invert: invert),
locale: TranslationProvider.of(context).flutterLocale,
locale: locale,
placeholder: widget.textEditing ? t.editor.quill.typeSomething : null,
showCursor: true,
keyboardAppearance: invert ? Brightness.dark : Brightness.light,
Expand Down
241 changes: 54 additions & 187 deletions lib/data/editor/editor_exporter.dart
Original file line number Diff line number Diff line change
@@ -1,216 +1,83 @@
import 'dart:typed_data';

import 'package:flutter/material.dart' show Size;
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter_quill/flutter_quill.dart' show QuillController;
import 'package:html/dom.dart' as html;
import 'package:html/parser.dart' show parse;
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:saber/components/canvas/_canvas_background_painter.dart';
import 'package:saber/components/canvas/_editor_image.dart';
import 'package:saber/components/canvas/_stroke.dart';
import 'package:saber/components/canvas/tools/highlighter.dart';
import 'package:saber/components/canvas/inner_canvas.dart';
import 'package:saber/data/editor/editor_core_info.dart';
import 'package:vsc_quill_delta_to_html/vsc_quill_delta_to_html.dart';
import 'package:screenshot/screenshot.dart';

abstract class EditorExporter {
static pw.TtfFont? _neuchaFont;

static Future<pw.Document> generatePdf(EditorCoreInfo coreInfo) async {
static Future<pw.Document> generatePdf(EditorCoreInfo coreInfo, BuildContext context) async {
final pw.Document pdf = pw.Document();

_neuchaFont ??= pw.TtfFont(await rootBundle.load("assets/fonts/Neucha/Neucha-Regular.ttf"));
ScreenshotController screenshotController = ScreenshotController();

for (int pageIndex = 0; pageIndex < coreInfo.pages.length; pageIndex++) {
// Don't export the empty last page
if (pageIndex == coreInfo.pages.length - 1 && coreInfo.pages[pageIndex].isEmpty) {
continue;
}

Size pageSize = coreInfo.pages[pageIndex].size;
Uint8List pageImage = await screenshotPage(
coreInfo: coreInfo,
pageIndex: pageIndex,
screenshotController: screenshotController,
context: context,
);

pdf.addPage(
_generatePdfPage(coreInfo, pageIndex)
pw.Page(
pageFormat: PdfPageFormat(pageSize.width, pageSize.height),
build: (pw.Context context) {
return pw.Image(
pw.MemoryImage(pageImage),
fit: pw.BoxFit.contain,
width: pageSize.width,
height: pageSize.height,
);
}
)
);
}

return pdf;
}

static pw.Page _generatePdfPage(EditorCoreInfo coreInfo, int pageIndex) {
/// Blue at 0.2 opacity against white
const PdfColor primaryColor = PdfColor(0.8, 0.8, 1);
/// Red at 0.2 opacity against white
const PdfColor secondaryColor = PdfColor(1, 0.8, 0.8);

final Size pageSize = coreInfo.pages[pageIndex].size;

final PdfColor backgroundColor;
if (coreInfo.backgroundColor != null) {
backgroundColor = PdfColor.fromInt(coreInfo.backgroundColor!.value);
} else {
backgroundColor = PdfColors.white;
}

return pw.Page(
pageFormat: PdfPageFormat(pageSize.width, pageSize.height),
build: (pw.Context context) {
return pw.FittedBox(
child: pw.SizedBox(
static Future<Uint8List> screenshotPage({
required EditorCoreInfo coreInfo,
required int pageIndex,
required ScreenshotController screenshotController,
required BuildContext context,
}) async {
final pageSize = coreInfo.pages[pageIndex].size;
return await screenshotController.captureFromWidget(
Localizations( // needed to avoid errors with Quill, but not actually used
locale: const Locale("en", "US"),
delegates: GlobalMaterialLocalizations.delegates,
child: Theme(
data: ThemeData(
brightness: Brightness.light,
colorScheme: const ColorScheme.light(
primary: Colors.blue,
secondary: Colors.red,
),
),
child: InnerCanvas(
pageIndex: pageIndex,
width: pageSize.width,
height: pageSize.height,
child: pw.CustomPaint(
size: PdfPoint(pageSize.width, pageSize.height),
painter: (PdfGraphics pdfGraphics, PdfPoint pdfPoint) {
if (coreInfo.backgroundColor != null) {
pdfGraphics.drawRect(0, 0, pageSize.width, pageSize.height);
pdfGraphics.setFillColor(PdfColor.fromInt(coreInfo.backgroundColor!.value));
pdfGraphics.fillPath();
}
for (PatternElement element in CanvasBackgroundPainter.getPatternElements(coreInfo.backgroundPattern, pageSize, coreInfo.lineHeight)) {
if (element.isLine) {
pdfGraphics.drawLine(
element.start.dx, pageSize.height - element.start.dy,
element.end.dx, pageSize.height - element.end.dy,
);
} else {
pdfGraphics.drawEllipse(
element.start.dx, pageSize.height - element.start.dy,
4, 4,
);
}
if (element.secondaryColor) {
pdfGraphics.setColor(secondaryColor);
} else {
pdfGraphics.setColor(primaryColor);
}
pdfGraphics.setLineWidth(3);
pdfGraphics.strokePath();
}
},
foregroundPainter: (PdfGraphics pdfGraphics, PdfPoint pdfPoint) {
void drawStroke(Stroke stroke) {
pdfGraphics.drawShape(stroke.toSvgPath(pageSize));
pdfGraphics.setFillColor(PdfColor.fromInt(stroke.strokeProperties.color.value).flatten(background: backgroundColor));
pdfGraphics.fillPath();
}

final page = coreInfo.pages[pageIndex];
for (Stroke stroke in page.strokes) {
if (stroke.penType != (Highlighter).toString()) continue;
drawStroke(stroke);
}
for (Stroke stroke in page.strokes) {
if (stroke.penType == (Highlighter).toString()) continue;
drawStroke(stroke);
}
},
child: pw.Stack(
children: [
pw.Positioned.fill(
top: coreInfo.lineHeight * 1.2,
left: coreInfo.lineHeight * 0.5,
right: coreInfo.lineHeight * 0.5,
bottom: coreInfo.lineHeight * 0.5,
child: _pdfQuill(
coreInfo.pages[pageIndex].quill.controller,
coreInfo.lineHeight,
backgroundColor,
),
),
for (EditorImage image in coreInfo.pages[pageIndex].images)
pw.Positioned(
left: image.dstRect.left,
top: image.dstRect.top,
child: pw.Image(
pw.MemoryImage(image.bytes),
width: image.dstRect.width,
height: image.dstRect.height,
),
),
],
),
),
isPreview: true,
textEditing: false,
coreInfo: coreInfo,
currentStroke: null,
),
);
},
);
}

static pw.Widget _pdfQuill(QuillController controller, num lineHeight, PdfColor backgroundColor) {
final converter = QuillDeltaToHtmlConverter(
controller.document.toDelta().toJson().cast(),
);
final html.Document document = parse(converter.convert());

final html.Element? body = document.body;
if (body == null) return pw.SizedBox.shrink();

return pw.RichText(
text: pw.TextSpan(
style: pw.TextStyle(
font: _neuchaFont!,
),
children: [
_htmlNodeToTextSpan(body, lineHeight, backgroundColor),
],
),
);
}

static pw.TextSpan _htmlNodeToTextSpan(html.Node node, num lineHeight, PdfColor backgroundColor) {
if (node is html.Text) {
return pw.TextSpan(
text: node.text,
);
} else if (node is! html.Element) {
return const pw.TextSpan();
}

if (node.localName == "br") {
return const pw.TextSpan(text: "\n");
}

final bool isHeading = node.localName == "h1"
|| node.localName == "h2"
|| node.localName == "h3";

/// Block elements have a newline at the end
final bool isBlock = node.localName == "p"
|| node.localName == "div"
|| node.localName == "ul"
|| node.localName == "ol"
|| node.localName == "li"
|| node.localName == "blockquote"
|| node.localName == "pre"
|| isHeading;

/// Font size as a multiple of the line height
final double fontSizePercent = {
"h1": 1.15,
"h2": 1.0,
"h3": 0.9,
}[node.localName] ?? 0.7;

final PdfColor? underlineColor = {
"h1": const PdfColor.fromInt(0x99000000),
"h2": const PdfColor.fromInt(0x80000000),
"h3": const PdfColor.fromInt(0x66000000),
}[node.localName];

return pw.TextSpan(
style: pw.TextStyle(
fontSize: lineHeight * fontSizePercent,
height: 1 / fontSizePercent,
fontWeight: node.localName == "b" ? pw.FontWeight.bold : null,
fontStyle: node.localName == "i" ? pw.FontStyle.italic : null,
decoration: isHeading ? pw.TextDecoration.underline : null,
decorationColor: underlineColor?.flatten(background: backgroundColor),
decorationThickness: isHeading ? 3 : null,
),
children: [
...node.nodes.map((node) => _htmlNodeToTextSpan(node, lineHeight, backgroundColor)),

if (isBlock) const pw.TextSpan(text: "\n"),
],
context: context,
pixelRatio: 2,
targetSize: pageSize,
);
}
}
2 changes: 1 addition & 1 deletion lib/pages/editor/editor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,7 @@ class _EditorState extends State<Editor> {
}

Future exportAsPdf() async {
final pdf = await EditorExporter.generatePdf(coreInfo);
final pdf = await EditorExporter.generatePdf(coreInfo, context);
await FileManager.exportFile("$_filename.pdf", await pdf.save());
}
Future exportAsSbn() async {
Expand Down
Loading

0 comments on commit 5a8bfda

Please sign in to comment.