dart_pdf_editor 0.1.0 copy "dart_pdf_editor: ^0.1.0" to clipboard
dart_pdf_editor: ^0.1.0 copied to clipboard

Flutter PDF viewer and editor rendered natively in Dart — zooming viewer, text selection and search, annotation authoring, form filling, signatures, and page management. No platform plugins.

example/lib/main.dart

import 'dart:async';

import 'package:file_selector/file_selector.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pdf_document/pdf_document.dart';
import 'package:dart_pdf_editor/dart_pdf_editor.dart';
import 'package:share_plus/share_plus.dart';

import 'demo_document.dart';

/// One filter, every platform: desktop and web match on the extension,
/// Android on the MIME type, iOS/macOS on the uniform type identifier —
/// a type group missing the field a platform filters by throws there.
const _pdfTypeGroup = XTypeGroup(
  label: 'PDF documents',
  extensions: ['pdf'],
  mimeTypes: ['application/pdf'],
  uniformTypeIdentifiers: ['com.adobe.pdf'],
);

/// Images the form tool's push-button fill accepts.
const _imageTypeGroup = XTypeGroup(
  label: 'Images',
  extensions: ['png', 'jpg', 'jpeg'],
  mimeTypes: ['image/png', 'image/jpeg'],
  uniformTypeIdentifiers: ['public.png', 'public.jpeg'],
);

/// The form tool's image picker: tapped push-button fields (signature
/// and logo slots in templates) fill with the chosen PNG or JPEG.
Future<Uint8List?> _pickFormImage(BuildContext context, PdfFormField field) =>
    openFile(acceptedTypeGroups: const [_imageTypeGroup])
        .then((file) => file?.readAsBytes());

void main() => runApp(const ViewerApp());

class ViewerApp extends StatefulWidget {
  const ViewerApp({super.key});

  @override
  State<ViewerApp> createState() => _ViewerAppState();
}

class _ViewerAppState extends State<ViewerApp> {
  /// UI preferences saved on this device — tool styles, which panels
  /// are open, and the theme mode. Owned here so the MaterialApp can
  /// follow the persisted light/dark choice; the screen below shares
  /// the same instance with every editing session.
  final _prefs = PdfEditingPreferences();

  @override
  void dispose() {
    _prefs.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: _prefs,
      builder: (context, _) => MaterialApp(
        title: 'dart-pdf viewer',
        theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
        darkTheme: ThemeData(
          colorSchemeSeed: Colors.indigo,
          brightness: Brightness.dark,
          useMaterial3: true,
        ),
        themeMode: _prefs.themeMode,
        home: ViewerScreen(prefs: _prefs),
      ),
    );
  }
}

class ViewerScreen extends StatefulWidget {
  const ViewerScreen({super.key, required this.prefs});

  final PdfEditingPreferences prefs;

  @override
  State<ViewerScreen> createState() => _ViewerScreenState();
}

class _ViewerScreenState extends State<ViewerScreen> {
  final _controller = PdfViewerController();

  PdfEditingPreferences get _prefs => widget.prefs;

  /// The open document's bytes — PdfEditorView/PdfReader own the rest
  /// (edit session, search, panels, toolbar).
  Uint8List? _bytes;
  String _title = '';
  String? _error;

  /// Demo of the two drop-in widgets: the toggle swaps the full
  /// [PdfEditorView] for the view-only [PdfReader].
  bool _readOnly = false;

  // app state the interactive demo's PDF links and overlays manipulate
  bool _isDemo = false;
  int _counter = 0;
  bool _switchOn = false;
  final _noteField = TextEditingController();

  /// GoTo and the standard named page actions never get here (the viewer
  /// follows them itself). Custom-scheme URIs are dispatched as app
  /// commands — the conventional way a PDF drives its host app — and
  /// anything else just gets described in a snackbar.
  void _onAction(PdfAction action, PdfAnnotation annotation) {
    if (action is PdfUriAction) {
      final uri = Uri.tryParse(action.uri);
      if (uri?.scheme == 'app') {
        switch (uri!.host) {
          case 'counter':
            setState(() => _counter++);
            return;
          case 'message':
            _toast(uri.queryParameters['text'] ?? 'No message');
            return;
        }
      }
    }
    _toast(switch (action) {
      PdfUriAction(:final uri) => 'Link: $uri',
      PdfJavaScriptAction(:final script) =>
        'JavaScript surfaced to the app: $script',
      PdfNamedAction(:final name) => 'Named action: $name',
      PdfUnknownAction(:final type) => 'Unhandled action type: $type',
      PdfGoToAction() => 'GoTo', // unreachable
    });
  }

  /// The app's own entries in the annotation right-click menu — here a
  /// "Copy text" action when the clicked annotation carries any.
  List<PdfAnnotationMenuItem> _annotationMenuActions(
      BuildContext context, PdfAnnotationMenuRequest request) {
    final contents = request.primary?.contents;
    if (contents == null || contents.isEmpty) return const [];
    return [
      PdfAnnotationMenuItem(
        label: 'Copy text',
        icon: Icons.copy_outlined,
        onSelected: (request) {
          Clipboard.setData(ClipboardData(text: contents));
          _toast('Annotation text copied');
        },
      ),
    ];
  }

  void _toast(String message) {
    ScaffoldMessenger.of(context)
      ..clearSnackBars()
      ..showSnackBar(SnackBar(
        content: Text(message),
        duration: const Duration(seconds: 2),
      ));
  }

  void _openBytes(Uint8List bytes, String title, {bool isDemo = false}) {
    setState(() {
      _bytes = bytes;
      _title = title;
      _error = null;
      _isDemo = isDemo;
      if (isDemo) _counter = 0;
    });
  }

  void _openDemo() =>
      _openBytes(buildDemoPdf(), 'Feature showcase', isDemo: true);

  /// Pins [child] into a page slot at its design size in PDF points and
  /// lets it scale with the page, so the overlays hold together at any
  /// zoom level and on any screen size.
  Widget _slot(PdfPageGeometry geometry, PdfRect rect, Widget child) =>
      Positioned.fromRect(
        rect: geometry.toViewRect(rect),
        child: FittedBox(
          child: SizedBox(width: rect.width, height: rect.height, child: child),
        ),
      );

  /// Flutter widgets pinned into the slots the demo document draws.
  List<Widget> _demoOverlays(
      BuildContext context, int pageIndex, PdfPageGeometry geometry) {
    switch (pageIndex) {
      case 0:
        return [
          _slot(geometry, DemoLayout.counterBadge,
              _CounterBadge(count: _counter)),
        ];
      case 1:
        return [
          _slot(geometry, DemoLayout.clock, const _ClockTile()),
          _slot(
            geometry,
            DemoLayout.counter,
            _CounterControl(
              count: _counter,
              onChanged: (value) => setState(() => _counter = value),
            ),
          ),
          _slot(
            geometry,
            DemoLayout.toggle,
            FittedBox(
              child: Switch(
                value: _switchOn,
                onChanged: (value) => setState(() => _switchOn = value),
              ),
            ),
          ),
          _slot(
            geometry,
            DemoLayout.note,
            Material(
              color: const Color(0xF2FFFFFF),
              shape: RoundedRectangleBorder(
                side: BorderSide(color: Colors.indigo.shade200),
                borderRadius: BorderRadius.circular(4),
              ),
              child: TextField(
                key: const ValueKey('demo-note'),
                controller: _noteField,
                decoration: const InputDecoration(
                  hintText: 'Type here - this text box floats above the page',
                  isDense: true,
                  contentPadding: EdgeInsets.all(10),
                  border: InputBorder.none,
                ),
              ),
            ),
          ),
        ];
      default:
        return const [];
    }
  }

  @override
  void initState() {
    super.initState();
    // open a file straight away with:
    //   flutter run -d macos --dart-define=PDF=/path/to/file.pdf
    const preset = String.fromEnvironment('PDF');
    if (preset.isNotEmpty) {
      _openPath(preset);
    } else {
      _openDemo();
    }
  }

  @override
  void dispose() {
    _controller.dispose();
    _noteField.dispose();
    super.dispose();
  }

  Future<void> _pickFile() async {
    final file = await openFile(acceptedTypeGroups: const [_pdfTypeGroup]);
    if (file == null) return;
    try {
      _openBytes(await file.readAsBytes(), file.name);
    } catch (e) {
      setState(() => _error = 'Could not open ${file.name}\n$e');
    }
  }

  Future<void> _openPath(String path) async {
    try {
      final bytes = await XFile(path).readAsBytes();
      _openBytes(bytes, path.split(RegExp(r'[/\\]')).last);
    } catch (e) {
      setState(() => _error = 'Could not open $path\n$e');
    }
  }

  /// Saves with whatever the platform offers: a save dialog on desktop,
  /// a browser download on the web, the share sheet on phones and
  /// tablets (where apps can't write outside their sandbox directly).
  Future<void> _saveAs(Uint8List bytes) async {
    const name = 'annotated.pdf';
    final file = XFile.fromData(bytes, mimeType: 'application/pdf');
    if (kIsWeb) {
      await file.saveTo(name);
      _toast('Downloaded $name');
      return;
    }
    switch (defaultTargetPlatform) {
      case TargetPlatform.android || TargetPlatform.iOS:
        final box = context.findRenderObject() as RenderBox?;
        final origin =
            box == null ? null : box.localToGlobal(Offset.zero) & box.size;
        await SharePlus.instance.share(ShareParams(
          files: [file],
          fileNameOverrides: const [name],
          // required on iPad: the share popover anchors to this rect
          sharePositionOrigin: origin ?? const Rect.fromLTWH(0, 0, 1, 1),
        ));
      default:
        final location = await getSaveLocation(
          suggestedName: name,
          acceptedTypeGroups: const [_pdfTypeGroup],
        );
        if (location == null) return;
        try {
          await file.saveTo(location.path);
          _toast('Saved to ${location.path}');
        } catch (e) {
          _toast('Save failed: $e');
        }
    }
  }

  @override
  Widget build(BuildContext context) {
    final bytes = _bytes;
    return Scaffold(
      appBar: AppBar(
        title: Text(_title.isEmpty ? 'dart-pdf viewer' : _title,
            overflow: TextOverflow.ellipsis),
        actions: [
          ListenableBuilder(
            listenable: _controller,
            builder: (context, _) => !_controller.hasSelection
                ? const SizedBox.shrink()
                : IconButton(
                    icon: const Icon(Icons.copy),
                    tooltip: 'Copy selected text (⌘C)',
                    onPressed: () async {
                      await _controller.copySelection();
                      if (!context.mounted) return;
                      ScaffoldMessenger.of(context).showSnackBar(
                        const SnackBar(
                          content: Text('Copied to clipboard'),
                          duration: Duration(seconds: 1),
                        ),
                      );
                    },
                  ),
          ),
          // every plain action is compact: the row overflows an 800px
          // window (the widget-test viewport included) at full density
          IconButton(
            visualDensity: VisualDensity.compact,
            icon: Icon(_readOnly ? Icons.edit_off : Icons.edit),
            tooltip: _readOnly
                ? 'Read-only (PdfReader) — tap to edit'
                : 'Editing (PdfEditorView) — tap for read-only',
            onPressed: () => setState(() => _readOnly = !_readOnly),
          ),
          ListenableBuilder(
            listenable: _prefs,
            builder: (context, _) => IconButton(
              visualDensity: VisualDensity.compact,
              icon: Icon(switch (_prefs.themeMode) {
                ThemeMode.system => Icons.brightness_auto,
                ThemeMode.light => Icons.light_mode,
                ThemeMode.dark => Icons.dark_mode,
              }),
              tooltip: switch (_prefs.themeMode) {
                ThemeMode.system => 'Theme: system — tap for light',
                ThemeMode.light => 'Theme: light — tap for dark',
                ThemeMode.dark => 'Theme: dark — tap for system',
              },
              onPressed: () => _prefs.themeMode = switch (_prefs.themeMode) {
                ThemeMode.system => ThemeMode.light,
                ThemeMode.light => ThemeMode.dark,
                ThemeMode.dark => ThemeMode.system,
              },
            ),
          ),
          IconButton(
            visualDensity: VisualDensity.compact,
            icon: const Icon(Icons.auto_awesome),
            tooltip: 'Open the interactive demo',
            onPressed: _openDemo,
          ),
          IconButton(
            visualDensity: VisualDensity.compact,
            icon: const Icon(Icons.folder_open),
            tooltip: 'Open PDF',
            onPressed: _pickFile,
          ),
        ],
      ),
      body: switch ((bytes, _error)) {
        (_, final String error) => Center(
            child: Text(error, textAlign: TextAlign.center),
          ),
        (null, _) => Center(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                FilledButton.icon(
                  onPressed: _pickFile,
                  icon: const Icon(Icons.folder_open),
                  label: const Text('Open a PDF'),
                ),
                const SizedBox(height: 12),
                FilledButton.tonalIcon(
                  onPressed: _openDemo,
                  icon: const Icon(Icons.auto_awesome),
                  label: const Text('Try the interactive demo'),
                ),
              ],
            ),
          ),
        // the two drop-in widgets carry all the PDF chrome (search,
        // page number, panels, toolbar) — the app only supplies bytes,
        // its file handling, and the demo's app-side wiring
        (final Uint8List data, _) => _readOnly
            ? PdfReader(
                bytes: data,
                controller: _controller,
                preferences: _prefs,
                onAction: _onAction,
                pageOverlayBuilder: _isDemo ? _demoOverlays : null,
              )
            : PdfEditorView(
                bytes: data,
                viewerController: _controller,
                preferences: _prefs,
                onSave: (saved) => unawaited(_saveAs(saved)),
                onAction: _onAction,
                pageOverlayBuilder: _isDemo ? _demoOverlays : null,
                annotationMenuBuilder: _annotationMenuActions,
                formImagePicker: _pickFormImage,
              ),
      },
    );
  }
}

/// Shows the counter the PDF's "Increment" link annotation drives —
/// PDF → app state → widget, completing the loop on the same page.
class _CounterBadge extends StatelessWidget {
  const _CounterBadge({required this.count});

  final int count;

  @override
  Widget build(BuildContext context) {
    return Material(
      color: Colors.indigo,
      borderRadius: BorderRadius.circular(6),
      child: Center(
        child: Text(
          '$count',
          style: const TextStyle(
            color: Colors.white,
            fontSize: 20,
            fontWeight: FontWeight.bold,
          ),
        ),
      ),
    );
  }
}

/// Ticks every second — proof the overlay is a live widget, not artwork.
class _ClockTile extends StatefulWidget {
  const _ClockTile();

  @override
  State<_ClockTile> createState() => _ClockTileState();
}

class _ClockTileState extends State<_ClockTile> {
  late final Timer _timer;

  @override
  void initState() {
    super.initState();
    _timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
  }

  @override
  void dispose() {
    _timer.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final now = DateTime.now();
    String pad(int v) => v.toString().padLeft(2, '0');
    return Material(
      color: Colors.black87,
      borderRadius: BorderRadius.circular(6),
      child: Center(
        child: Text(
          '${pad(now.hour)}:${pad(now.minute)}:${pad(now.second)}',
          style: const TextStyle(
            color: Colors.greenAccent,
            fontSize: 18,
            fontFeatures: [FontFeature.tabularFigures()],
          ),
        ),
      ),
    );
  }
}

/// Edits the same counter the PDF link on page 1 increments.
class _CounterControl extends StatelessWidget {
  const _CounterControl({required this.count, required this.onChanged});

  final int count;
  final ValueChanged<int> onChanged;

  @override
  Widget build(BuildContext context) {
    return Material(
      color: const Color(0xF2FFFFFF),
      shape: RoundedRectangleBorder(
        side: BorderSide(color: Colors.indigo.shade200),
        borderRadius: BorderRadius.circular(4),
      ),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
        children: [
          IconButton(
            icon: const Icon(Icons.remove),
            onPressed: () => onChanged(count - 1),
          ),
          Text('$count', style: Theme.of(context).textTheme.titleMedium),
          IconButton(
            icon: const Icon(Icons.add),
            onPressed: () => onChanged(count + 1),
          ),
        ],
      ),
    );
  }
}
2
likes
150
points
44
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Flutter PDF viewer and editor rendered natively in Dart — zooming viewer, text selection and search, annotation authoring, form filling, signatures, and page management. No platform plugins.

Repository (GitHub)
View/report issues
Contributing

Topics

#pdf #pdf-viewer #pdf-editor #annotations #form

License

Apache-2.0 (license)

Dependencies

flutter, pdf_cos, pdf_document, pdf_graphics, shared_preferences

More

Packages that depend on dart_pdf_editor