Dart / Flutter
In this chapter, you’ll integrate Oicana into a Flutter app. Oicana compiles the template on the device, so the app creates PDFs without a server or an internet connection. We’ll build a small app that shows a live preview of your template and hands the document to the user as a PDF file.
Let’s start with a fresh Flutter project. Create it in a new directory, separate from your template directory:
flutter create my_pdf_appcd my_pdf_appYou can test it by running flutter run and picking a device, for example your desktop or a running emulator. You should see the counter app that Flutter generated.
Showing the template
Section titled “Showing the template”We will replace the counter with a preview of the Oicana template.
-
Add the
oicanapackage as a dependency withflutter pub add oicana. -
Create a new directory in the Flutter project called
templatesand copyexample-0.1.0.zipinto that directory. -
Declare the directory as an asset in the
fluttersection ofpubspec.yaml, so the template is bundled with the app:pubspec.yaml flutter:uses-material-design: trueassets:- templates/ -
Replace the content of
lib/main.dartwith a page that loads the template and shows it as an image:lib/main.dart import 'package:flutter/material.dart';import 'package:flutter/services.dart';import 'package:oicana/oicana.dart';void main() {runApp(const MaterialApp(title: 'My PDF App', home: DocumentPage()));}class DocumentPage extends StatefulWidget {const DocumentPage({super.key});@overrideState<DocumentPage> createState() => _DocumentPageState();}class _DocumentPageState extends State<DocumentPage> {Template? _template;Uint8List? _preview;@overridevoid initState() {super.initState();_loadTemplate();}@overridevoid dispose() {_template?.dispose();super.dispose();}Future<void> _loadTemplate() async {final bytes = await rootBundle.load('templates/example-0.1.0.zip');final template = await Template.load(bytes.buffer.asUint8List());if (!mounted) {template.dispose();return;}_template = template;await _updatePreview();}Future<void> _updatePreview() async {final png = await _template!.exportPng(mode: CompilationMode.development,pixelsPerPt: 2,);if (!mounted) return;setState(() => _preview = png);}@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: const Text('My PDF App')),body: Padding(padding: const EdgeInsets.all(16),child: _preview == null? const Center(child: CircularProgressIndicator()): Image.memory(_preview!),),);}} -
Delete
test/widget_test.dart. Flutter generated it for the counter app, and it references theMyAppclass that no longer exists, soflutter analyzeandflutter testwould fail if we keep it.
Run the app again with flutter run. It loads the template once, when the page opens, and shows the document as a PNG image. We explicitly pass the compilation mode with exportPng(mode: CompilationMode.development) so the template uses the development value you defined for the info input ({ "name": "Chuck Norris" }). In a follow-up step, we will set an input value instead.
The first build for a native target downloads the native Oicana library for exactly that target from GitHub and bundles it with the app. Web builds use the WebAssembly build of Oicana that comes with the package instead.
About performance
Section titled “About performance”Rendering the preview should not take longer than a couple of milliseconds.
Template.load compiles the template once to warm up the Typst cache, so load a template once and keep it around instead of loading it for every document. Repeated compilations are fast because Typst memoizes its work in a global cache. The cache management guide explains how that cache is evicted and when it pays off to tune it.
On Android, iOS, and desktop, methods without the Sync suffix, like Template.load and exportPng, run on a separate isolate. The UI stays responsive while Oicana compiles, even for large documents.
Passing inputs from Dart
Section titled “Passing inputs from Dart”Our preview is currently compiled in development mode. Now we’ll add a text field for the name, pass its value as input, and switch to production mode. Add a _name field and an _inputs getter to the state, and use them in _updatePreview:
String _name = 'Baby Yoda';int _previewRequest = 0;
Map<String, Object?> get _inputs => { 'info': {'name': _name},};
bool _isCurrent(int request) => mounted && request == _previewRequest;
Future<void> _updatePreview() async { final request = ++_previewRequest; final png = await _template!.exportPng(jsonInputs: _inputs, pixelsPerPt: 2); if (!_isCurrent(request)) return; setState(() => _preview = png);}Then show the text field above the preview, and update the preview whenever the name changes:
body: Padding( padding: const EdgeInsets.all(16), child: Column( children: [ TextFormField( initialValue: _name, decoration: const InputDecoration(labelText: 'Name'), onChanged: (name) { _name = name; _updatePreview(); }, ), const SizedBox(height: 16), Expanded( child: _preview == null ? const Center(child: CircularProgressIndicator()) : Image.memory(_preview!), ), ], ),),Every keystroke starts a new export, and exports can finish out of order. The request counter drops a result once a newer export has started, so a slow export of an old name never replaces the preview of the current one.
JSON input values can be Maps, Lists, and any other value jsonEncode accepts, or Strings that already hold JSON. To pass a JSON string value, encode it first, for example jsonEncode('Baby Yoda').
Your explicit input value takes precedence over the development value in either mode, so it is what changes the output here. Notice that we removed the explicit mode argument. The export methods default to CompilationMode.production when no mode is specified. Production mode is the recommended default for all document compilation in your application: it ensures you never accidentally generate a document with test data. In production mode, the template will never fall back to development values. If an input value is missing in production mode and the input does not have a default value, the compilation will fail unless your template handles none values for that input.
The preview now shows “Baby Yoda” instead of “Chuck Norris” and follows every change in the text field. Building on this minimal app, you could fill input values from a form or a local database.
For inputs other than JSON, see Template inputs, which documents blob inputs with examples for every integration.
Saving or sharing the PDF
Section titled “Saving or sharing the PDF”The preview is a PNG image, but the document we want to hand to the user is a PDF. How to hand over a file depends on the platform: mobile apps open the share sheet, while desktop apps ask where to save the file. We’ll use the share_plus package for the share sheet and the file_selector package for the save dialog. Add both with flutter pub add share_plus file_selector and import them at the top of lib/main.dart:
import 'package:file_selector/file_selector.dart';import 'package:flutter/foundation.dart';import 'package:share_plus/share_plus.dart';Add a getter that tells desktop apps apart, and a method that exports the PDF with the same inputs as the preview and then saves or shares it:
bool get _savesToFile => !kIsWeb && switch (defaultTargetPlatform) { TargetPlatform.linux || TargetPlatform.macOS || TargetPlatform.windows => true, _ => false, };
Future<void> _exportPdf() async { final pdf = await _template!.exportPdf(jsonInputs: _inputs); final file = XFile.fromData( pdf, mimeType: 'application/pdf', name: 'example.pdf', );
if (_savesToFile) { final location = await getSaveLocation( suggestedName: 'example.pdf', acceptedTypeGroups: const [ XTypeGroup(label: 'PDF', extensions: ['pdf']), ], ); if (location != null) await file.saveTo(location.path); return; }
await SharePlus.instance.share( ShareParams(files: [file], fileNameOverrides: ['example.pdf']), );}Then call it from a button in the Scaffold:
floatingActionButton: FloatingActionButton.extended( onPressed: _template == null ? null : _exportPdf, icon: Icon(_savesToFile ? Icons.save_alt : Icons.share), label: Text(_savesToFile ? 'Save PDF' : 'Share PDF'),),On Linux, macOS, and Windows, the button opens a save dialog and writes example.pdf to the chosen location. On Android and iOS, it opens the share sheet, so users can save the PDF, print it, or send it to another app. On the web, it uses the share sheet of the browser where one exists and downloads the PDF otherwise. share_plus cannot share files on Linux, which is one more reason to save files on desktop instead.
On iPad, the share sheet is a popover. Without a sharePositionOrigin, it appears in the middle of the screen; the Flutter example app anchors it to the button instead.
Handling compilation errors
Section titled “Handling compilation errors”A missing required input or an input that fails schema validation makes the compilation fail. The tutorial’s example template declares no JSON schema, so only the first case can happen here. Oicana throws an OicanaException, which we catch to show an error instead of the preview. Add an _error field to the state and update _updatePreview:
String? _error;
Future<void> _updatePreview() async { final request = ++_previewRequest; try { final png = await _template!.exportPng(jsonInputs: _inputs, pixelsPerPt: 2); if (!_isCurrent(request)) return; setState(() { _preview = png; _error = null; }); } on OicanaException catch (error) { if (!_isCurrent(request)) return; debugPrint('Failed to compile template: ${error.message}'); setState(() => _error = 'Failed to generate the document'); }}In build, show the error in place of the preview:
Expanded( child: _error != null ? Text(_error!) : _preview == null ? const Center(child: CircularProgressIndicator()) : Image.memory(_preview!),),Exporting the PDF fails the same way. Catch the exception at the start of _exportPdf and tell the user with a snackbar:
Future<void> _exportPdf() async { final Uint8List pdf; try { pdf = await _template!.exportPdf(jsonInputs: _inputs); } on OicanaException catch (error) { debugPrint('Failed to export the PDF: ${error.message}'); if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('Failed to export the PDF'))); return; } final file = XFile.fromData( pdf, mimeType: 'application/pdf', name: 'example.pdf', ); // Saving or sharing stays as before.The message of the exception contains the full Typst diagnostic, including the lines of your template that caused the problem. On native targets, the exception is one of the subclasses of OicanaException, so you can tell the cases apart. For example, InvalidInputException covers inputs that fail schema validation or that the template does not declare, while a missing required input fails the compilation itself and throws a CompilationException. On the web, every error is a plain OicanaException.
Take a look at the open source Flutter example app on GitHub for a more complete showcase of the Oicana Dart integration, including blob inputs, schema validation, and saving files on desktop.
Complete code at the end of this chapter
import 'package:file_selector/file_selector.dart';import 'package:flutter/foundation.dart';import 'package:flutter/material.dart';import 'package:flutter/services.dart';import 'package:oicana/oicana.dart';import 'package:share_plus/share_plus.dart';
void main() { runApp(const MaterialApp(title: 'My PDF App', home: DocumentPage()));}
class DocumentPage extends StatefulWidget { const DocumentPage({super.key});
@override State<DocumentPage> createState() => _DocumentPageState();}
class _DocumentPageState extends State<DocumentPage> { Template? _template; Uint8List? _preview; String? _error; String _name = 'Baby Yoda'; int _previewRequest = 0;
@override void initState() { super.initState(); _loadTemplate(); }
@override void dispose() { _template?.dispose(); super.dispose(); }
Future<void> _loadTemplate() async { final bytes = await rootBundle.load('templates/example-0.1.0.zip'); final template = await Template.load(bytes.buffer.asUint8List()); if (!mounted) { template.dispose(); return; } _template = template; await _updatePreview(); }
Map<String, Object?> get _inputs => { 'info': {'name': _name}, };
bool _isCurrent(int request) => mounted && request == _previewRequest;
Future<void> _updatePreview() async { final request = ++_previewRequest; try { final png = await _template!.exportPng( jsonInputs: _inputs, pixelsPerPt: 2, ); if (!_isCurrent(request)) return; setState(() { _preview = png; _error = null; }); } on OicanaException catch (error) { if (!_isCurrent(request)) return; debugPrint('Failed to compile template: ${error.message}'); setState(() => _error = 'Failed to generate the document'); } }
bool get _savesToFile => !kIsWeb && switch (defaultTargetPlatform) { TargetPlatform.linux || TargetPlatform.macOS || TargetPlatform.windows => true, _ => false, };
Future<void> _exportPdf() async { final Uint8List pdf; try { pdf = await _template!.exportPdf(jsonInputs: _inputs); } on OicanaException catch (error) { debugPrint('Failed to export the PDF: ${error.message}'); if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('Failed to export the PDF'))); return; } final file = XFile.fromData( pdf, mimeType: 'application/pdf', name: 'example.pdf', );
if (_savesToFile) { final location = await getSaveLocation( suggestedName: 'example.pdf', acceptedTypeGroups: const [ XTypeGroup(label: 'PDF', extensions: ['pdf']), ], ); if (location != null) await file.saveTo(location.path); return; }
await SharePlus.instance.share( ShareParams(files: [file], fileNameOverrides: ['example.pdf']), ); }
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('My PDF App')), body: Padding( padding: const EdgeInsets.all(16), child: Column( children: [ TextFormField( initialValue: _name, decoration: const InputDecoration(labelText: 'Name'), onChanged: (name) { _name = name; _updatePreview(); }, ), const SizedBox(height: 16), Expanded( child: _error != null ? Text(_error!) : _preview == null ? const Center(child: CircularProgressIndicator()) : Image.memory(_preview!), ), ], ), ), floatingActionButton: FloatingActionButton.extended( onPressed: _template == null ? null : _exportPdf, icon: Icon(_savesToFile ? Icons.save_alt : Icons.share), label: Text(_savesToFile ? 'Save PDF' : 'Share PDF'), ), ); }}dependencies: flutter: sdk: flutter file_selector: ^1.1.0 oicana: ^0.10.0 share_plus: ^13.3.1
flutter: uses-material-design: true assets: - templates/