pdf_manipulator 1.0.3 copy "pdf_manipulator: ^1.0.3" to clipboard
pdf_manipulator: ^1.0.3 copied to clipboard

Cross-platform PDF toolkit for Flutter. Merge, split, render, extract, search, sign, encrypt, convert, build from scratch. Rust engine, off the main thread.

example/lib/main.dart

import 'dart:developer';
import 'dart:typed_data';

import 'package:flutter/material.dart';

import 'package:file_picker/file_picker.dart';
import 'package:pdf_manipulator/pdf_manipulator.dart';

// ─── In-memory DataSource / DataSink ──────────────────────────────

class MemorySource implements DataSource {
  MemorySource(this._data);
  final Uint8List _data;
  @override
  int get length => _data.length;
  @override
  Uint8List readAt(int offset, int count) {
    if (offset >= _data.length) return Uint8List(0);
    final end = (offset + count).clamp(0, _data.length);
    return Uint8List.sublistView(_data, offset, end);
  }
}

class MemorySink implements DataSink {
  final _builder = BytesBuilder(copy: false);
  @override
  void write(Uint8List chunk) => _builder.add(chunk);
  Uint8List takeBytes() => _builder.takeBytes();
  int get length => _builder.length;
}

// ─── Minimal test PDF (1 blank A4 page) ───────────────────────────

final minimalPdf = Uint8List.fromList(
  '%PDF-1.4\n'
  '1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n'
  '2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n'
  '3 0 obj\n<< /Type /Page /Parent 2 0 R '
  '/MediaBox [0 0 595 842] >>\nendobj\n'
  'xref\n0 4\n'
  '0000000000 65535 f \n'
  '0000000009 00000 n \n'
  '0000000058 00000 n \n'
  '0000000115 00000 n \n'
  'trailer\n<< /Size 4 /Root 1 0 R >>\n'
  'startxref\n190\n%%EOF\n'
      .codeUnits,
);

// ─── Test certificate for signing demos ───────────────────────────

const testCertPem = '''
-----BEGIN CERTIFICATE-----
MIIDSTCCAjGgAwIBAgIUexfMwJDl5Rlv9CCPzlG2ZMWIrigwDQYJKoZIhvcNAQEL
BQAwNDEWMBQGA1UEAwwNcGRmb3hpZGUtdGVzdDENMAsGA1UECgwEVGVzdDELMAkG
A1UEBhMCVVMwHhcNMjYwNDI0MDg1NDA1WhcNMzYwNDIxMDg1NDA1WjA0MRYwFAYD
VQQDDA1wZGZveGlkZS10ZXN0MQ0wCwYDVQQKDARUZXN0MQswCQYDVQQGEwJVUzCC
ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMvG5TAigvrKPPkWY4CAoye5
SXu32oTJZkzXZWMWETPosyhUxzu1UAezQpgosBV3oH1tTq7BjWEol1dI1eYplXWF
4Rpry3meJIGkiAbBLTn64UbP886sxQvplpAYcGLeWbT6LTdCvI1BOk55w9eC1RjF
Vx/ib/YYsgHyBFXIWSpz3d+eZOFnS5PwdkUaj0zk/KTHIFIXE7GoeaAGtDkKLmfP
ZOh0HMXTRslPF8n/ls42OGPiB9nB5f6Gd4mptU6kLxmh8KTsfSTWxiqmisX2u5kO
HL4t+7Ld9Y5vJAHfAN6QMWhmI3ESzZPp9i6+MuLGOhjmnGV2Si0i/uaS6vvURPsC
AwEAAaNTMFEwHQYDVR0OBBYEFGGx/fXllkuIQcEuZQPTJV7qJ43dMB8GA1UdIwQY
MBaAFGGx/fXllkuIQcEuZQPTJV7qJ43dMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI
hvcNAQELBQADggEBAHavjpwAV1Dq3XBEMz/+X9bGfZ8i5zfMjeywhhUUAnGSzl24
c3tKigiknq45lJ2ITZDxuLXqdG8oU549hnrGw3Ja0RdKvHSBCvOTBs0APnNX07V4
aoq9gdNQnXKynVlOeFiccvtYeu9o9OGFTttfQbpB0Dpe568YH7NhV3DxEdtsKoK+
rTUImAGg+mebrEe6ts9FV/lEwnMOJnCdvH9c215yuIWK+fCn3qcPmzWWv08oEr4w
8Xy/7D8D6MtVlWFXT3YgogusJECJUXioAai4XUI3bAoNwSTw4vwGnnA9+82Nv5qU
2YWiHsI5E2QSTEFR4Njsmjjrj0FkQtyKdDBOQKs=
-----END CERTIFICATE-----''';

const testKeyPem = '''
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDLxuUwIoL6yjz5
FmOAgKMnuUl7t9qEyWZM12VjFhEz6LMoVMc7tVAHs0KYKLAVd6B9bU6uwY1hKJdX
SNXmKZV1heEaa8t5niSBpIgGwS05+uFGz/POrMUL6ZaQGHBi3lm0+i03QryNQTpO
ecPXgtUYxVcf4m/2GLIB8gRVyFkqc93fnmThZ0uT8HZFGo9M5PykxyBSFxOxqHmg
BrQ5Ci5nz2TodBzF00bJTxfJ/5bONjhj4gfZweX+hneJqbVOpC8ZofCk7H0k1sYq
porF9ruZDhy+Lfuy3fWObyQB3wDekDFoZiNxEs2T6fYuvjLixjoY5pxldkotIv7m
kur71ET7AgMBAAECggEABgy78Hzlzz+X0Hv7V5ekwGDThKpwVgbgqaFaGjdP1W8F
ep9httfALipX7MqFx8K1xLjXtX/Q4bSMdwPrjcS9nNqXqerPWfykRXu2qh9WvLpC
W+bVQdUxQxRGlbU+s5YPAGd5ANios7eJsptngWDEpifzA+L7bfP3vO+2yeaDzIB3
JQ+wYST0SWm3x3FGM7SCwA9wpNBA38igTJmlppdZIoifzTsb/Q34NmWUEqijVTB8
DbofDAVW2c9qW/78VABLvDGSOcQUqclDPInBvVjQ8nzv1BaagkyXeKIwRSR3t6Py
WVJge9fEirgv8nnqKbpLqfPrYTJPit3revUHeheHgQKBgQD9yDujq4Ty3VQq3ajd
/GoV0NCaABaCeKfmIiiFo7NJRpCS8lrxMvDRfnsl6fCBFPfK8YU4HUxh6s0Rmxx6
UvDlD/7w3DFeUj/h+2/N3Nj+rYCFTJNens7lvvZS2ftt56d0DBk1JLTxQh+N/OX5
t82ZMRS/R4f/ibuWFvyJAAGFQQKBgQDNjsodPy6aXVCR82NxEJ3XLuZWCwJoFC7s
XMMWpjmXCLduBxVIJdCd21L74zX892o1uBLwsuQZZPVUd0zCmCXhonxkhpit3I4S
qAs86zxmZE9QsoBDk6ECDZ6t5OiBMA6AwdMH4e8GOkZDLcizfc2CINipb8+U0qqC
tBCwmvvPOwKBgCM2FfhGgwLDbLsp2BU8wWdXeqnzWywtG3aVxLOOHAENtl99Gtse
a0VV3DZNeB4gz6Sr0AUSI5fuYReRQulB+sR9bKz0kDD7DnwHS+LvQnhLkGpuToAx
XpmH3ltufTEplBVI3HKALk7PEtu7fBkixHb91VgYz6jH7mwLsmw7wPpBAoGAEvNp
Gs0qZLzZorsHnfLkOmRug9w7+pBxywS6T6o/gPciwhgRFDe4RfVkbyiBX7MHrbAs
vtgfQ2AVZhYhk4cnZufuA+6MwOqmhn3Lm3Asf1wcG9p5DMHdhCzxRiLmdJKTo7c6
120y9iYFOEhOSo38llSk5OoT/yp04dvr9fwz3uUCgYEA59f1MW3cQ/p1+w/k44e+
s16ZGDxNc06rhp4oTpM+Ey5RtGgkWh0R10EbeXQEEpmfy4tf3OoKb9UCgSLYvGxP
jIPhvqZJe3pGRdwJG55rJLtS466z5MKG/WKmqFecLejDcVg9qblh9AW5PSvyzcQW
gT7yGRIIu9uNETw/d7mV+7Y=
-----END PRIVATE KEY-----''';

// ─── Helpers ──────────────────────────────────────────────────────

String fmtSize(int bytes) {
  if (bytes < 1024) return '$bytes B';
  if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
  return '${(bytes / 1024 / 1024).toStringAsFixed(2)} MB';
}

Future<List<Uint8List>?> pickPdfBytes({bool multiple = false}) async {
  final result = await FilePicker.platform.pickFiles(
    allowMultiple: multiple, type: FileType.custom,
    allowedExtensions: ['pdf'], withData: true,
  );
  if (result == null) return null;
  return result.files.where((f) => f.bytes != null).map((f) => f.bytes!).toList();
}

Future<List<({String name, Uint8List bytes, int size})>?> pickImageBytes() async {
  final result = await FilePicker.platform.pickFiles(
    allowMultiple: true, type: FileType.image, withData: true,
  );
  if (result == null) return null;
  return result.files
      .where((f) => f.bytes != null)
      .map((f) => (name: f.name, bytes: f.bytes!, size: f.size))
      .toList();
}

Future<String?> saveBytes(Uint8List bytes, String name) =>
    FilePicker.platform.saveFile(dialogTitle: 'Save $name', fileName: name, bytes: bytes);

// ─── App ──────────────────────────────────────────────────────────

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

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'PDF Manipulator',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.deepPurple),
      darkTheme: ThemeData(useMaterial3: true, brightness: Brightness.dark, colorSchemeSeed: Colors.deepPurple),
      themeMode: ThemeMode.system,
      home: const HomePage(),
    );
  }
}

class HomePage extends StatefulWidget {
  const HomePage({super.key});
  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  int _tab = 0;
  final _pages = const <Widget>[
    _DocTab(),
    _SugarTab(),
    _StandaloneTab(),
    _EditorTab(),
    _BuilderTab(),
    _MergeTab(),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: _pages[_tab],
      bottomNavigationBar: NavigationBar(
        selectedIndex: _tab,
        onDestinationSelected: (i) => setState(() => _tab = i),
        destinations: const [
          NavigationDestination(icon: Icon(Icons.description), label: 'Doc'),
          NavigationDestination(icon: Icon(Icons.auto_fix_high), label: 'Sugar'),
          NavigationDestination(icon: Icon(Icons.output), label: 'Standalone'),
          NavigationDestination(icon: Icon(Icons.edit_document), label: 'Editor'),
          NavigationDestination(icon: Icon(Icons.note_add), label: 'Builder'),
          NavigationDestination(icon: Icon(Icons.merge), label: 'Merge'),
        ],
      ),
    );
  }
}

// ─── Shared widgets ───────────────────────────────────────────────

class _Status extends StatelessWidget {
  final bool loading;
  final String? message;
  final VoidCallback? onDismiss;
  const _Status({this.loading = false, this.message, this.onDismiss});

  @override
  Widget build(BuildContext context) {
    if (!loading && message == null) return const SizedBox.shrink();
    final cs = Theme.of(context).colorScheme;
    final isErr = message?.startsWith('Error') == true;
    final bg = loading ? cs.primaryContainer : (isErr ? cs.errorContainer : cs.tertiaryContainer);
    final fg = loading ? cs.onPrimaryContainer : (isErr ? cs.onErrorContainer : cs.onTertiaryContainer);
    return Container(
      width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), color: bg,
      child: Row(children: [
        if (loading) ...[SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: fg)), const SizedBox(width: 12)],
        Expanded(child: Text(message ?? '', style: TextStyle(color: fg, fontSize: 13), maxLines: 4)),
        if (!loading && onDismiss != null) GestureDetector(onTap: onDismiss, child: Icon(Icons.close, size: 16, color: fg)),
      ]),
    );
  }
}

class _Section extends StatelessWidget {
  final String text;
  const _Section(this.text);
  @override
  Widget build(BuildContext context) => Padding(
    padding: const EdgeInsets.only(top: 14, bottom: 4, left: 4),
    child: Text(text.toUpperCase(), style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 1.2, color: Theme.of(context).colorScheme.primary)),
  );
}

class _Op extends StatelessWidget {
  final IconData icon;
  final String title;
  final String? subtitle;
  final VoidCallback onRun;
  final bool loading;
  const _Op({required this.icon, required this.title, this.subtitle, required this.onRun, this.loading = false});

  @override
  Widget build(BuildContext context) => Card(
    margin: const EdgeInsets.symmetric(vertical: 2),
    child: ListTile(
      leading: Icon(icon, size: 22),
      title: Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
      subtitle: subtitle != null ? Text(subtitle!, style: const TextStyle(fontSize: 12)) : null,
      trailing: SizedBox(width: 64, height: 34, child: FilledButton.tonal(
        onPressed: loading ? null : onRun,
        style: FilledButton.styleFrom(padding: EdgeInsets.zero, textStyle: const TextStyle(fontSize: 13)),
        child: const Text('Run'),
      )),
      dense: true, visualDensity: VisualDensity.compact,
    ),
  );
}

// ─── Tab 1: PdfDoc — read-only queries ────────────────────────────

class _DocTab extends StatefulWidget {
  const _DocTab();
  @override
  State<_DocTab> createState() => _DocTabState();
}

class _DocTabState extends State<_DocTab> {
  final _pdf = Pdf();
  Uint8List? _bytes;
  PdfDoc? _doc;
  bool _loading = false;
  String? _status;

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

  Future<void> _pick() async {
    final r = await pickPdfBytes();
    if (r == null) return;
    setState(() { _loading = true; _status = 'Opening...'; });
    try {
      _doc?.dispose();
      final doc = await _pdf.open(MemorySource(r.first));
      setState(() { _bytes = r.first; _doc = doc; _status = '${doc.pageCount} pages, v${doc.version}'; });
    } catch (e) { setState(() => _status = 'Error: $e'); }
    finally { setState(() => _loading = false); }
  }

  Future<void> _run(String label, Future<void> Function(PdfDoc doc) op) async {
    if (_doc == null) return;
    setState(() { _loading = true; _status = '$label...'; });
    try { await op(_doc!); } catch (e) { setState(() => _status = 'Error: $e'); log('$label: $e'); }
    finally { if (mounted) setState(() => _loading = false); }
  }

  void _showText(String title, String text) {
    showModalBottomSheet(context: context, isScrollControlled: true, useSafeArea: true,
      builder: (ctx) => DraggableScrollableSheet(expand: false, initialChildSize: 0.7, maxChildSize: 0.95,
        builder: (ctx, sc) => Column(children: [
          Padding(padding: const EdgeInsets.fromLTRB(16, 16, 8, 8), child: Row(children: [
            Expanded(child: Text(title, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16))),
            IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(ctx)),
          ])),
          const Divider(height: 1),
          Expanded(child: SingleChildScrollView(controller: sc, padding: const EdgeInsets.all(16),
            child: SelectableText(text.isEmpty ? '(empty)' : text, style: const TextStyle(fontSize: 13, fontFamily: 'monospace', height: 1.5)))),
        ])));
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('PdfDoc — Read-Only Queries')),
      body: Column(children: [
        _Status(loading: _loading, message: _status, onDismiss: () => setState(() => _status = null)),
        Expanded(child: _doc == null
          ? Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
              Icon(Icons.description, size: 56, color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5)),
              const SizedBox(height: 16), const Text('Open a PDF to query it'),
              const SizedBox(height: 16), FilledButton.icon(onPressed: _loading ? null : _pick, icon: const Icon(Icons.file_open), label: const Text('Open PDF')),
            ]))
          : ListView(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), children: [
              // Info card
              Card(child: Padding(padding: const EdgeInsets.all(16), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
                Row(children: [
                  Icon(Icons.picture_as_pdf, color: Theme.of(context).colorScheme.primary, size: 28),
                  const SizedBox(width: 12),
                  Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
                    Text('${_doc!.pageCount} pages  •  ${fmtSize(_bytes!.length)}  •  v${_doc!.version}', style: const TextStyle(fontSize: 13)),
                    if (_doc!.title != null) Text('Title: ${_doc!.title}', style: const TextStyle(fontSize: 12)),
                    if (_doc!.author != null) Text('Author: ${_doc!.author}', style: const TextStyle(fontSize: 12)),
                  ])),
                  IconButton.filledTonal(onPressed: _pick, icon: const Icon(Icons.swap_horiz, size: 20)),
                ]),
                const SizedBox(height: 8),
                Wrap(spacing: 6, runSpacing: 4, children: [
                  if (_doc!.isEncrypted) _chip('Encrypted', true),
                  if (_doc!.isTagged) _chip('Tagged', false),
                  if (_doc!.subject != null) _chip(_doc!.subject!, false),
                  if (_doc!.keywords != null) _chip(_doc!.keywords!, false),
                ]),
              ]))),

              // Page dimensions
              _Section('Page Dimensions'),
              ...List.generate(_doc!.pageCount.clamp(0, 20), (i) => Padding(
                padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 12),
                child: Text('Page ${i + 1}: ${_doc!.pages[i].effectiveWidth.toStringAsFixed(0)} × ${_doc!.pages[i].effectiveHeight.toStringAsFixed(0)} pt'
                    '${_doc!.pages[i].rotation != 0 ? '  (${_doc!.pages[i].rotation}°)' : ''}', style: const TextStyle(fontSize: 13)),
              )),
              if (_doc!.pageCount > 20) Text('+ ${_doc!.pageCount - 20} more', style: const TextStyle(fontSize: 12)),

              // Text extraction
              _Section('Text Extraction'),
              _Op(icon: Icons.text_snippet, title: 'Extract all (plain text)', loading: _loading,
                onRun: () => _run('Extract', (d) async {
                  final t = await d.extract(pages: const PdfPages.all());
                  _showText('Plain Text (${t.length} chars)', t);
                  setState(() => _status = '${t.length} chars');
                })),
              _Op(icon: Icons.text_snippet_outlined, title: 'Extract page 1 only', loading: _loading,
                onRun: () => _run('Extract p1', (d) async {
                  final t = await d.extract(pages: const PdfPages.single(0));
                  _showText('Page 1', t); setState(() => _status = 'Page 1: ${t.length} chars');
                })),
              _Op(icon: Icons.code, title: 'Extract as Markdown', loading: _loading,
                onRun: () => _run('Markdown', (d) async {
                  final t = await d.extract(pages: const PdfPages.all(), format: PdfExtractionFormat.markdown);
                  _showText('Markdown', t); setState(() => _status = 'MD: ${t.length} chars');
                })),
              _Op(icon: Icons.html, title: 'Extract as HTML (page 1)', loading: _loading,
                onRun: () => _run('HTML', (d) async {
                  final t = await d.extract(pages: const PdfPages.single(0), format: PdfExtractionFormat.html);
                  _showText('HTML', t); setState(() => _status = 'HTML: ${t.length} chars');
                })),

              // Search
              _Section('Search'),
              _Op(icon: Icons.search, title: 'Search "the" on page 1', loading: _loading,
                onRun: () => _run('Search p1', (d) async {
                  final r = await d.search(query: 'the', pages: const PdfPages.single(0));
                  setState(() => _status = '${r.length} hits on page 1');
                })),
              _Op(icon: Icons.manage_search, title: 'Search "the" all pages', loading: _loading,
                onRun: () => _run('Search all', (d) async {
                  final r = await d.search(query: 'the', pages: const PdfPages.all());
                  setState(() => _status = '${r.length} hits total');
                })),

              // Render
              _Section('Render'),
              _Op(icon: Icons.image, title: 'Render page 1 to image', loading: _loading,
                onRun: () => _run('Render', (d) async {
                  var count = 0;
                  await for (final page in d.render(pages: const PdfPages.single(0))) {
                    count++;
                    setState(() => _status = 'Rendered: ${page.width}×${page.height} (${fmtSize(page.data.length)})');
                  }
                  if (count == 0) setState(() => _status = 'No pages rendered');
                })),
              _Op(icon: Icons.photo_library, title: 'Extract images from page 1', loading: _loading,
                onRun: () => _run('Images', (d) async {
                  var count = 0;
                  await for (final img in d.extractImages(pages: const PdfPages.single(0))) {
                    count++; log('Image: ${img.width}×${img.height} ${img.format}');
                  }
                  setState(() => _status = '$count image(s) on page 1');
                })),

              // Signatures
              _Section('Signatures'),
              _Op(icon: Icons.draw, title: 'List signatures', loading: _loading,
                onRun: () => _run('Sigs', (d) async {
                  final sigs = await d.getSignatures();
                  setState(() => _status = '${sigs.length} signature(s)');
                })),
              _Op(icon: Icons.verified_user, title: 'Verify signatures', loading: _loading,
                onRun: () => _run('Verify', (d) async {
                  final ok = await d.verifySignatures();
                  setState(() => _status = ok ? 'Valid ✓' : 'Invalid or none ✗');
                })),

              // Validation
              _Section('Validation'),
              _Op(icon: Icons.verified, title: 'Validate PDF/A', loading: _loading,
                onRun: () => _run('PDF/A', (d) async {
                  final r = await d.validatePdfA();
                  setState(() => _status = '${r.compliant ? "Compliant ✓" : "Not compliant"} (${r.errors}e ${r.warnings}w)');
                })),
              _Op(icon: Icons.accessibility, title: 'Validate PDF/UA', loading: _loading,
                onRun: () => _run('PDF/UA', (d) async {
                  final r = await d.validatePdfUa();
                  setState(() => _status = r ? 'Accessible ✓' : 'Not accessible ✗');
                })),

              // Classification
              _Section('Classification'),
              _Op(icon: Icons.category, title: 'Classify page 1', loading: _loading,
                onRun: () => _run('Classify page', (d) async {
                  final r = await d.classifyPage(0);
                  setState(() => _status = 'Page 1: ${r.type}');
                })),
              _Op(icon: Icons.analytics, title: 'Classify document', loading: _loading,
                onRun: () => _run('Classify doc', (d) async {
                  final r = await d.classifyDocument();
                  setState(() => _status = 'Document: ${r.type}');
                })),

              // Bookmarks
              _Section('Bookmarks'),
              _Op(icon: Icons.bookmark, title: 'Plan split by bookmarks', loading: _loading,
                onRun: () => _run('Bookmarks', (d) async {
                  try {
                    final s = await d.planSplitByBookmarks();
                    setState(() => _status = '${s.length} segments: ${s.map((x) => x.title).join(', ')}');
                  } catch (_) { setState(() => _status = 'No bookmarks'); }
                })),

              const SizedBox(height: 40),
            ]),
        ),
      ]),
    );
  }

  Widget _chip(String text, bool warn) {
    final cs = Theme.of(context).colorScheme;
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
      decoration: BoxDecoration(color: warn ? cs.errorContainer : cs.surfaceContainerHighest, borderRadius: BorderRadius.circular(12)),
      child: Text(text, style: TextStyle(fontSize: 11, color: warn ? cs.onErrorContainer : cs.onSurfaceVariant)),
    );
  }
}

// ─── Tab 2: PdfSugar — one-shot convenience ops ───────────────────

class _SugarTab extends StatefulWidget {
  const _SugarTab();
  @override
  State<_SugarTab> createState() => _SugarTabState();
}

class _SugarTabState extends State<_SugarTab> {
  final _pdf = Pdf();
  Uint8List? _bytes;
  bool _loading = false;
  String? _status;

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

  Future<void> _pick() async {
    final r = await pickPdfBytes();
    if (r == null) return;
    setState(() { _bytes = r.first; _status = 'Loaded (${fmtSize(r.first.length)})'; });
  }

  DataSource get _src => MemorySource(_bytes!);

  Future<void> _run(String label, Future<void> Function() op) async {
    setState(() { _loading = true; _status = '$label...'; });
    try { await op(); } catch (e) { setState(() => _status = 'Error: $e'); }
    finally { if (mounted) setState(() => _loading = false); }
  }

  Future<void> _save(Uint8List result, String name) async {
    final p = await saveBytes(result, name);
    setState(() => _status = p != null ? 'Saved $name (${fmtSize(result.length)})' : 'Cancelled');
  }

  @override
  Widget build(BuildContext context) {
    final has = _bytes != null;
    return Scaffold(
      appBar: AppBar(title: const Text('PdfSugar — One-Shot Ops')),
      body: Column(children: [
        _Status(loading: _loading, message: _status, onDismiss: () => setState(() => _status = null)),
        Expanded(child: !has
          ? Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
              Icon(Icons.auto_fix_high, size: 56, color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5)),
              const SizedBox(height: 16), const Text('Pick a PDF for sugar ops'),
              const SizedBox(height: 16), FilledButton.icon(onPressed: _pick, icon: const Icon(Icons.file_open), label: const Text('Open PDF')),
            ]))
          : ListView(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), children: [
              Card(child: ListTile(
                leading: Icon(Icons.picture_as_pdf, color: Theme.of(context).colorScheme.primary),
                title: Text(fmtSize(_bytes!.length)), trailing: IconButton.filledTonal(onPressed: _pick, icon: const Icon(Icons.swap_horiz, size: 20)),
              )),

              _Section('Structural'),
              _Op(icon: Icons.content_cut, title: 'Split every 2 pages', loading: _loading,
                onRun: () => _run('Split', () async {
                  final sinks = <MemorySink>[];
                  await _pdf.split(_src, (i) { final s = MemorySink(); sinks.add(s); return s; }, every: 2);
                  setState(() => _status = '${sinks.length} chunks: ${sinks.map((s) => fmtSize(s.length)).join(', ')}');
                })),
              _Op(icon: Icons.straighten, title: 'Split by size (500 KB)', loading: _loading,
                onRun: () => _run('SplitBySize', () async {
                  final sinks = <MemorySink>[];
                  await _pdf.splitBySize(_src, (i) { final s = MemorySink(); sinks.add(s); return s; }, maxBytes: 500000);
                  setState(() => _status = '${sinks.length} chunks: ${sinks.map((s) => fmtSize(s.length)).join(', ')}');
                })),
              _Op(icon: Icons.call_split, title: 'Split by bookmarks', loading: _loading,
                onRun: () => _run('SplitBookmarks', () async {
                  try {
                    final sinks = <MemorySink>[];
                    await _pdf.splitByBookmarks(_src, (i) { final s = MemorySink(); sinks.add(s); return s; });
                    setState(() => _status = '${sinks.length} parts');
                  } catch (_) { setState(() => _status = 'No bookmarks'); }
                })),
              _Op(icon: Icons.file_copy_outlined, title: 'Extract pages 0–1', loading: _loading,
                onRun: () => _run('Extract', () async {
                  final sink = MemorySink(); await _pdf.extractPages(_src, sink, pages: [0, 1]);
                  await _save(sink.takeBytes(), 'extracted.pdf');
                })),
              _Op(icon: Icons.delete_outline, title: 'Delete page 0', loading: _loading,
                onRun: () => _run('Delete', () async {
                  final sink = MemorySink(); await _pdf.deletePages(_src, sink, pages: [0]);
                  await _save(sink.takeBytes(), 'deleted.pdf');
                })),
              _Op(icon: Icons.swap_vert, title: 'Reverse page order', loading: _loading,
                onRun: () => _run('Reorder', () async {
                  final doc = await _pdf.open(_src); final n = doc.pageCount; await doc.dispose();
                  final sink = MemorySink();
                  await _pdf.reorderPages(_src, sink, order: List.generate(n, (i) => n - 1 - i));
                  await _save(sink.takeBytes(), 'reversed.pdf');
                })),
              _Op(icon: Icons.move_down, title: 'Move page 0 → last', loading: _loading,
                onRun: () => _run('Move', () async {
                  final doc = await _pdf.open(_src); final n = doc.pageCount; await doc.dispose();
                  final sink = MemorySink(); await _pdf.movePage(_src, sink, from: 0, to: n - 1);
                  await _save(sink.takeBytes(), 'moved.pdf');
                })),

              _Section('Rotation'),
              _Op(icon: Icons.rotate_right, title: 'Rotate all 90°', loading: _loading,
                onRun: () => _run('RotateAll', () async {
                  final sink = MemorySink(); await _pdf.rotateAllPages(_src, sink, degrees: 90);
                  await _save(sink.takeBytes(), 'rotated_all.pdf');
                })),
              _Op(icon: Icons.rotate_left, title: 'Rotate page 0 → 180°', loading: _loading,
                onRun: () => _run('RotatePage', () async {
                  final sink = MemorySink(); await _pdf.rotatePages(_src, sink, pages: {0: 180});
                  await _save(sink.takeBytes(), 'rotated_p0.pdf');
                })),

              _Section('Compression'),
              _Op(icon: Icons.compress, title: 'Compress (quality 75)', loading: _loading,
                onRun: () => _run('Compress', () async {
                  final sink = MemorySink(); await _pdf.compress(_src, sink, imageQuality: 75);
                  final r = sink.takeBytes();
                  final pct = ((1 - r.length / _bytes!.length) * 100).toStringAsFixed(1);
                  setState(() => _status = '${fmtSize(_bytes!.length)} → ${fmtSize(r.length)} ($pct%)');
                })),

              _Section('Watermark'),
              _Op(icon: Icons.water_drop, title: 'Center "DRAFT"', loading: _loading,
                onRun: () => _run('Watermark', () async {
                  final sink = MemorySink();
                  await _pdf.watermark(_src, sink, text: 'DRAFT', style: const PdfWatermarkStyle(opacity: 0.3));
                  await _save(sink.takeBytes(), 'watermarked.pdf');
                })),
              _Op(icon: Icons.grid_4x4, title: 'Tiled 3×4 "COPY"', loading: _loading,
                onRun: () => _run('Tiled', () async {
                  final sink = MemorySink();
                  await _pdf.watermark(_src, sink, text: 'COPY',
                    style: const PdfWatermarkStyle(opacity: 0.15, fontSize: 24, rotation: 30),
                    position: const PdfWatermarkPosition.tiled(columns: 3, rows: 4));
                  await _save(sink.takeBytes(), 'tiled.pdf');
                })),
              _Op(icon: Icons.arrow_outward, title: 'Corner top-right "SAMPLE"', loading: _loading,
                onRun: () => _run('Corner', () async {
                  final sink = MemorySink();
                  await _pdf.watermark(_src, sink, text: 'SAMPLE',
                    style: const PdfWatermarkStyle(opacity: 0.4, fontSize: 18, rotation: 0),
                    position: const PdfWatermarkPosition.corner(PdfCorner.topRight));
                  await _save(sink.takeBytes(), 'corner.pdf');
                })),
              _Op(icon: Icons.layers, title: 'Background layer', loading: _loading,
                onRun: () => _run('Background', () async {
                  final sink = MemorySink();
                  await _pdf.watermark(_src, sink, text: 'BACKGROUND',
                    style: const PdfWatermarkStyle(opacity: 0.2), layer: PdfWatermarkLayer.background);
                  await _save(sink.takeBytes(), 'bg_watermark.pdf');
                })),

              _Section('Security'),
              _Op(icon: Icons.lock, title: 'Encrypt (pw: secret)', loading: _loading,
                onRun: () => _run('Encrypt', () async {
                  final sink = MemorySink();
                  await _pdf.encrypt(_src, sink, encryption: const PdfEncryptionConfig(ownerPassword: 'secret'));
                  await _save(sink.takeBytes(), 'encrypted.pdf');
                })),
              _Op(icon: Icons.lock_open, title: 'Decrypt (pw: secret)', loading: _loading,
                onRun: () => _run('Decrypt', () async {
                  final sink = MemorySink();
                  await _pdf.decrypt(_src, sink, password: 'secret');
                  await _save(sink.takeBytes(), 'decrypted.pdf');
                })),

              _Section('Forms & Annotations'),
              _Op(icon: Icons.layers_clear, title: 'Flatten forms', loading: _loading,
                onRun: () => _run('Flatten', () async {
                  final sink = MemorySink(); await _pdf.flattenForms(_src, sink);
                  await _save(sink.takeBytes(), 'flattened.pdf');
                })),
              _Op(icon: Icons.rule, title: 'Apply redactions', loading: _loading,
                onRun: () => _run('Redact', () async {
                  final sink = MemorySink(); await _pdf.applyRedactions(_src, sink);
                  await _save(sink.takeBytes(), 'redacted.pdf');
                })),

              _Section('Content'),
              _Op(icon: Icons.attach_file, title: 'Embed text file', loading: _loading,
                onRun: () => _run('Embed', () async {
                  final sink = MemorySink();
                  await _pdf.embedFile(_src, sink, name: 'readme.txt',
                    fileData: MemorySource(Uint8List.fromList('Hello from pdf_manipulator!'.codeUnits)));
                  await _save(sink.takeBytes(), 'embedded.pdf');
                })),
              _Op(icon: Icons.format_paint, title: 'Erase region on page 0', loading: _loading,
                onRun: () => _run('Erase', () async {
                  final sink = MemorySink();
                  await _pdf.eraseRegions(_src, sink, page: 0, regions: [const PdfRect(x: 50, y: 700, width: 200, height: 30)]);
                  await _save(sink.takeBytes(), 'erased.pdf');
                })),

              _Section('Stamps'),
              _Op(icon: Icons.approval, title: 'Approved stamp on page 0', loading: _loading,
                onRun: () => _run('Stamp', () async {
                  final sink = MemorySink();
                  await _pdf.addStamp(_src, sink, page: 0, type: PdfStampType.approved,
                    rect: const PdfRect(x: 50, y: 50, width: 200, height: 60));
                  await _save(sink.takeBytes(), 'stamped.pdf');
                })),
              _Op(icon: Icons.add_photo_alternate, title: 'Image stamp (pick image)', loading: _loading,
                onRun: () => _run('ImageStamp', () async {
                  final imgs = await pickImageBytes();
                  if (imgs == null || imgs.isEmpty) return;
                  final sink = MemorySink();
                  await _pdf.addImageStamp(_src, sink, page: 0, imageData: MemorySource(imgs.first.bytes),
                    rect: const PdfRect(x: 100, y: 100, width: 150, height: 150));
                  await _save(sink.takeBytes(), 'image_stamped.pdf');
                })),

              _Section('Compliance'),
              _Op(icon: Icons.verified, title: 'Convert to PDF/A', loading: _loading,
                onRun: () => _run('PDF/A', () async {
                  final sink = MemorySink(); await _pdf.convertToPdfA(_src, sink);
                  await _save(sink.takeBytes(), 'pdfa.pdf');
                })),

              _Section('Images → PDF'),
              _Op(icon: Icons.photo_library, title: 'Images to PDF (pick images)', loading: _loading,
                onRun: () => _run('ImgToPdf', () async {
                  final imgs = await pickImageBytes();
                  if (imgs == null || imgs.isEmpty) return;
                  final sink = MemorySink();
                  await _pdf.imagesToPdf(imgs.map((i) => MemorySource(i.bytes) as DataSource).toList(), sink);
                  await _save(sink.takeBytes(), 'from_images.pdf');
                })),

              const SizedBox(height: 40),
            ]),
        ),
      ]),
    );
  }
}

// ─── Tab 3: PdfStandalone — source in, sink out, no handle ────────

class _StandaloneTab extends StatefulWidget {
  const _StandaloneTab();
  @override
  State<_StandaloneTab> createState() => _StandaloneTabState();
}

class _StandaloneTabState extends State<_StandaloneTab> {
  final _pdf = Pdf();
  Uint8List? _bytes;
  bool _loading = false;
  String? _status;

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

  Future<void> _pick() async {
    final r = await pickPdfBytes();
    if (r == null) return;
    setState(() { _bytes = r.first; _status = 'Loaded (${fmtSize(r.first.length)})'; });
  }

  DataSource get _src => MemorySource(_bytes!);

  Future<void> _run(String label, Future<void> Function() op) async {
    setState(() { _loading = true; _status = '$label...'; });
    try { await op(); } catch (e) { setState(() => _status = 'Error: $e'); }
    finally { if (mounted) setState(() => _loading = false); }
  }

  Future<void> _save(Uint8List result, String name) async {
    final p = await saveBytes(result, name);
    setState(() => _status = p != null ? 'Saved $name (${fmtSize(result.length)})' : 'Cancelled');
  }

  @override
  Widget build(BuildContext context) {
    final has = _bytes != null;
    return Scaffold(
      appBar: AppBar(title: const Text('PdfStandalone — No Handle')),
      body: Column(children: [
        _Status(loading: _loading, message: _status, onDismiss: () => setState(() => _status = null)),
        Expanded(child: !has
          ? Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
              Icon(Icons.output, size: 56, color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5)),
              const SizedBox(height: 16), const Text('Source in → sink out, no handle'),
              const SizedBox(height: 16), FilledButton.icon(onPressed: _pick, icon: const Icon(Icons.file_open), label: const Text('Open PDF')),
            ]))
          : ListView(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), children: [
              Card(child: ListTile(
                leading: Icon(Icons.picture_as_pdf, color: Theme.of(context).colorScheme.primary),
                title: Text(fmtSize(_bytes!.length)), trailing: IconButton.filledTonal(onPressed: _pick, icon: const Icon(Icons.swap_horiz, size: 20)),
              )),

              _Section('Sign'),
              _Op(icon: Icons.verified_user_outlined, title: 'Sign PDF (PEM cert)', loading: _loading,
                onRun: () => _run('Sign', () async {
                  final sink = MemorySink();
                  await _pdf.sign(_src, sink, credentials: const PdfSigningCredentials.pem(testCertPem, testKeyPem));
                  await _save(sink.takeBytes(), 'signed.pdf');
                })),

              _Section('Convert'),
              _Op(icon: Icons.description, title: 'PDF → DOCX', loading: _loading,
                onRun: () => _run('DOCX', () async {
                  final sink = MemorySink();
                  await _pdf.convertTo(_src, sink, format: PdfDocumentFormat.docx);
                  await _save(sink.takeBytes(), 'converted.docx');
                })),
              _Op(icon: Icons.slideshow, title: 'PDF → PPTX', loading: _loading,
                onRun: () => _run('PPTX', () async {
                  final sink = MemorySink();
                  await _pdf.convertTo(_src, sink, format: PdfDocumentFormat.pptx);
                  await _save(sink.takeBytes(), 'converted.pptx');
                })),
              _Op(icon: Icons.table_chart, title: 'PDF → XLSX', loading: _loading,
                onRun: () => _run('XLSX', () async {
                  final sink = MemorySink();
                  await _pdf.convertTo(_src, sink, format: PdfDocumentFormat.xlsx);
                  await _save(sink.takeBytes(), 'converted.xlsx');
                })),
              _Op(icon: Icons.picture_as_pdf, title: 'DOCX → PDF (round-trip)', loading: _loading, subtitle: 'Converts to DOCX first, then back to PDF',
                onRun: () => _run('DOCX→PDF', () async {
                  final docxSink = MemorySink();
                  await _pdf.convertTo(_src, docxSink, format: PdfDocumentFormat.docx);
                  final pdfSink = MemorySink();
                  await _pdf.convertToPdf(MemorySource(docxSink.takeBytes()), pdfSink, format: PdfDocumentFormat.docx);
                  await _save(pdfSink.takeBytes(), 'roundtrip.pdf');
                })),

              _Section('Extract Pages'),
              _Op(icon: Icons.file_copy_outlined, title: 'Extract pages 0–1', loading: _loading,
                onRun: () => _run('Extract', () async {
                  final sink = MemorySink();
                  await _pdf.extractPages(_src, sink, pages: [0, 1]);
                  await _save(sink.takeBytes(), 'extracted_standalone.pdf');
                })),

              const SizedBox(height: 40),
            ]),
        ),
      ]),
    );
  }
}

// ─── Tab 4: PdfEditor — batch mutations ───────────────────────────

class _EditorTab extends StatefulWidget {
  const _EditorTab();
  @override
  State<_EditorTab> createState() => _EditorTabState();
}

class _EditorTabState extends State<_EditorTab> {
  final _pdf = Pdf();
  Uint8List? _bytes;
  bool _loading = false;
  String? _status;

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

  Future<void> _pick() async {
    final r = await pickPdfBytes();
    if (r == null) return;
    setState(() { _bytes = r.first; _status = 'Loaded (${fmtSize(r.first.length)})'; });
  }

  Future<void> _run(String label, Future<Uint8List> Function(PdfEditor e) work) async {
    if (_bytes == null) return;
    setState(() { _loading = true; _status = '$label...'; });
    try {
      final editor = await _pdf.edit(MemorySource(_bytes!));
      try {
        final result = await work(editor);
        final p = await saveBytes(result, '${label.toLowerCase().replaceAll(' ', '_')}.pdf');
        setState(() => _status = p != null ? 'Saved (${fmtSize(result.length)})' : 'Cancelled');
      } finally { editor.dispose(); }
    } catch (e) { setState(() => _status = 'Error: $e'); }
    finally { if (mounted) setState(() => _loading = false); }
  }

  Future<Uint8List> _saveEditor(PdfEditor e, [PdfSaveOptions options = const PdfSaveOptions.fullRewrite()]) async {
    final sink = MemorySink(); await e.save(sink, options: options); return sink.takeBytes();
  }

  @override
  Widget build(BuildContext context) {
    final has = _bytes != null;
    return Scaffold(
      appBar: AppBar(title: const Text('PdfEditor — Batch Mutations')),
      body: Column(children: [
        _Status(loading: _loading, message: _status, onDismiss: () => setState(() => _status = null)),
        Expanded(child: !has
          ? Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
              Icon(Icons.edit_document, size: 56, color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5)),
              const SizedBox(height: 16), const Text('Parse once, mutate N times, save once'),
              const SizedBox(height: 16), FilledButton.icon(onPressed: _pick, icon: const Icon(Icons.file_open), label: const Text('Open PDF')),
            ]))
          : ListView(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), children: [
              Card(child: ListTile(
                leading: Icon(Icons.picture_as_pdf, color: Theme.of(context).colorScheme.primary),
                title: Text(fmtSize(_bytes!.length)), trailing: IconButton.filledTonal(onPressed: _pick, icon: const Icon(Icons.swap_horiz, size: 20)),
              )),

              _Section('Metadata'),
              _Op(icon: Icons.title, title: 'Set title + author + subject + keywords', loading: _loading,
                onRun: () => _run('Metadata', (e) async {
                  await e.setTitle('Example'); await e.setAuthor('pdf_manipulator');
                  await e.setSubject('Demo'); await e.setKeywords('test, dart, pdf');
                  final t = await e.getTitle(); final a = await e.getAuthor();
                  final s = await e.getSubject(); final k = await e.getKeywords();
                  log('Title=$t Author=$a Subject=$s Keywords=$k');
                  log('isModified=${await e.isModified}');
                  return _saveEditor(e);
                })),
              _Op(icon: Icons.cleaning_services, title: 'Scrub metadata', loading: _loading,
                onRun: () => _run('Scrub', (e) async { await e.scrubMetadata(); return _saveEditor(e); })),

              _Section('Page Operations'),
              _Op(icon: Icons.rotate_right, title: 'Rotate page 0 → 90°', loading: _loading,
                onRun: () => _run('Rotate', (e) async { await e.rotatePage(0, degrees: 90); return _saveEditor(e); })),
              _Op(icon: Icons.rotate_right, title: 'Rotate all → 90°', loading: _loading,
                onRun: () => _run('RotateAll', (e) async { await e.rotateAllPages(degrees: 90); return _saveEditor(e); })),
              _Op(icon: Icons.delete_outline, title: 'Delete last page', loading: _loading,
                onRun: () => _run('Delete', (e) async {
                  final n = await e.pageCount; if (n > 1) await e.deletePage(n - 1); return _saveEditor(e);
                })),
              _Op(icon: Icons.move_down, title: 'Move page 0 → 1', loading: _loading,
                onRun: () => _run('Move', (e) async {
                  if (await e.pageCount >= 2) await e.movePage(from: 0, to: 1); return _saveEditor(e);
                })),
              _Op(icon: Icons.filter_list, title: 'Select pages 0,1 only', loading: _loading,
                onRun: () => _run('Select', (e) async { await e.selectPages([0, 1]); return _saveEditor(e); })),
              _Op(icon: Icons.straighten, title: 'Get page 0 media box', loading: _loading,
                onRun: () => _run('MediaBox', (e) async {
                  final r = await e.getPageMediaBox(0);
                  setState(() => _status = 'Page 0: ${r.width.toStringAsFixed(0)}×${r.height.toStringAsFixed(0)} at (${r.x.toStringAsFixed(0)},${r.y.toStringAsFixed(0)})');
                  return _saveEditor(e);
                })),

              _Section('Merge'),
              _Op(icon: Icons.merge, title: 'Merge with self', loading: _loading,
                onRun: () => _run('Merge', (e) async {
                  await e.mergeFrom(MemorySource(_bytes!)); return _saveEditor(e);
                })),

              _Section('Optimization'),
              _Op(icon: Icons.compress, title: 'Optimize images (q60)', loading: _loading,
                onRun: () => _run('OptimizeImg', (e) async {
                  final n = await e.optimizeImages(quality: 60);
                  log('Optimized $n images');
                  return _saveEditor(e, const PdfSaveOptions.fullRewrite(compress: true, garbageCollect: true));
                })),
              _Op(icon: Icons.font_download_off, title: 'Unembed standard fonts', loading: _loading,
                onRun: () => _run('UnembedFonts', (e) async {
                  final n = await e.unembedStandardFonts();
                  log('Unembedded $n fonts'); return _saveEditor(e);
                })),

              _Section('Watermark & Stamps'),
              _Op(icon: Icons.water_drop, title: 'Watermark all pages "SAMPLE"', loading: _loading,
                onRun: () => _run('Watermark', (e) async {
                  final n = await e.pageCount;
                  for (var i = 0; i < n; i++) {
                    await e.addWatermark(i, 'SAMPLE', style: const PdfWatermarkStyle(opacity: 0.25));
                  }
                  return _saveEditor(e);
                })),
              _Op(icon: Icons.approval, title: 'Add stamp on page 0', loading: _loading,
                onRun: () => _run('Stamp', (e) async {
                  await e.addStamp(0, type: PdfStampType.draft, rect: const PdfRect(x: 50, y: 50, width: 200, height: 60));
                  return _saveEditor(e);
                })),
              _Op(icon: Icons.add_photo_alternate, title: 'Add image stamp (pick)', loading: _loading,
                onRun: () => _run('ImgStamp', (e) async {
                  final imgs = await pickImageBytes();
                  if (imgs == null || imgs.isEmpty) return _saveEditor(e);
                  await e.addImageStamp(0, MemorySource(imgs.first.bytes), rect: const PdfRect(x: 100, y: 100, width: 150, height: 150));
                  return _saveEditor(e);
                })),

              _Section('Content'),
              _Op(icon: Icons.attach_file, title: 'Embed file', loading: _loading,
                onRun: () => _run('Embed', (e) async {
                  await e.embedFile('note.txt', MemorySource(Uint8List.fromList('Hello!'.codeUnits)));
                  return _saveEditor(e);
                })),
              _Op(icon: Icons.format_paint, title: 'Erase region on page 0', loading: _loading,
                onRun: () => _run('Erase', (e) async {
                  await e.eraseRegions(0, [const PdfRect(x: 50, y: 700, width: 200, height: 30)]);
                  return _saveEditor(e);
                })),
              _Op(icon: Icons.crop, title: 'Crop margins', loading: _loading,
                onRun: () => _run('Crop', (e) async {
                  await e.cropMargins(left: 20, right: 20, top: 20, bottom: 20); return _saveEditor(e);
                })),

              _Section('Forms'),
              _Op(icon: Icons.layers_clear, title: 'Flatten forms', loading: _loading,
                onRun: () => _run('Flatten', (e) async { await e.flattenForms(); return _saveEditor(e); })),
              _Op(icon: Icons.layers_clear_outlined, title: 'Flatten all annotations', loading: _loading,
                onRun: () => _run('FlattenAnnot', (e) async { await e.flattenAllAnnotations(); return _saveEditor(e); })),
              _Op(icon: Icons.edit_note, title: 'Set form field value', loading: _loading, subtitle: 'fieldName="name", value="John"',
                onRun: () => _run('SetField', (e) async {
                  await e.setFormFieldValue('name', 'John'); return _saveEditor(e);
                })),

              _Section('Redaction'),
              _Op(icon: Icons.remove_red_eye_outlined, title: 'Add redaction + count + apply', loading: _loading,
                onRun: () => _run('Redact', (e) async {
                  await e.addRedaction(0, const PdfRect(x: 50, y: 700, width: 200, height: 30));
                  final n = await e.redactionCount(0);
                  log('Redaction count on page 0: $n');
                  await e.applyRedactions();
                  return _saveEditor(e);
                })),

              _Section('Compliance'),
              _Op(icon: Icons.verified, title: 'Convert to PDF/A', loading: _loading,
                onRun: () => _run('PDF/A', (e) async { await e.convertToPdfA(); return _saveEditor(e); })),

              _Section('Security'),
              _Op(icon: Icons.lock, title: 'Save encrypted (pw: test)', loading: _loading,
                onRun: () => _run('Encrypt', (e) async {
                  return _saveEditor(e, const PdfSaveOptions.fullRewrite(encryption: PdfEncryption.config(ownerPassword: 'test')));
                })),
              _Op(icon: Icons.lock_open, title: 'Save with encryption removed', loading: _loading,
                onRun: () => _run('RemoveEncrypt', (e) async {
                  return _saveEditor(e, const PdfSaveOptions.fullRewrite(encryption: PdfEncryption.remove()));
                })),
              _Op(icon: Icons.save_alt, title: 'Incremental save', loading: _loading,
                onRun: () => _run('Incremental', (e) async { return _saveEditor(e, const PdfSaveOptions.incremental()); })),

              _Section('Chained'),
              _Op(icon: Icons.auto_fix_high, title: 'Rotate + watermark + compress + encrypt', loading: _loading, subtitle: 'Multiple mutations in one parse-save cycle',
                onRun: () => _run('Chain', (e) async {
                  await e.rotateAllPages(degrees: 90);
                  final n = await e.pageCount;
                  for (var i = 0; i < n; i++) {
                    await e.addWatermark(i, 'PROCESSED', style: const PdfWatermarkStyle(opacity: 0.15, fontSize: 40));
                  }
                  await e.optimizeImages(quality: 70);
                  await e.setTitle('Processed');
                  return _saveEditor(e, const PdfSaveOptions.fullRewrite(compress: true, garbageCollect: true,
                    encryption: PdfEncryption.config(ownerPassword: 'chain')));
                })),

              const SizedBox(height: 40),
            ]),
        ),
      ]),
    );
  }
}

// ─── Tab 5: PdfBuilder — create from scratch ──────────────────────

class _BuilderTab extends StatefulWidget {
  const _BuilderTab();
  @override
  State<_BuilderTab> createState() => _BuilderTabState();
}

class _BuilderTabState extends State<_BuilderTab> {
  final _pdf = Pdf();
  bool _loading = false;
  String? _status;

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

  Future<void> _run(String label, Future<void> Function() op) async {
    setState(() { _loading = true; _status = '$label...'; });
    try { await op(); } catch (e) { setState(() => _status = 'Error: $e'); }
    finally { if (mounted) setState(() => _loading = false); }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('PdfBuilder — Create from Scratch')),
      body: Column(children: [
        _Status(loading: _loading, message: _status, onDismiss: () => setState(() => _status = null)),
        Expanded(child: ListView(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), children: [
          Card(child: Padding(padding: const EdgeInsets.all(16), child: Row(children: [
            Icon(Icons.note_add, color: Theme.of(context).colorScheme.primary, size: 28),
            const SizedBox(width: 12),
            const Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
              Text('Create PDFs from scratch', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15)),
              Text('Text, images, forms, links, watermarks', style: TextStyle(fontSize: 12, color: Colors.grey)),
            ])),
          ]))),
          const SizedBox(height: 8),

          _Section('Documents'),
          _Op(icon: Icons.text_fields, title: 'Text document (A4, 2 pages)', loading: _loading, subtitle: 'Headings, paragraphs, columns, horizontal rule',
            onRun: () => _run('Text', () async {
              final b = await _pdf.build();
              await b.setTitle('Example'); await b.setAuthor('pdf_manipulator');
              await b.setSubject('Demo'); await b.setKeywords('test, dart');

              final p1 = await b.addA4Page();
              await p1.heading(1, 'Hello from PdfBuilder');
              await p1.space(10);
              await p1.paragraph('This PDF was created from Dart code. No source PDF needed.');
              await p1.space(20);
              await p1.heading(2, 'Features');
              await p1.paragraph('• Text, headings, paragraphs\n• Images, watermarks\n• Form fields, links, footnotes\n• Multi-column layout');
              await p1.space(20);
              await p1.horizontalRule();
              await p1.space(10);
              await p1.text('Page 1 of 2');
              await p1.done();

              final p2 = await b.addA4Page();
              await p2.heading(1, 'Page Two');
              await p2.paragraph('Multi-page documents work seamlessly.');
              await p2.space(20);
              await p2.heading(3, 'Columns');
              await p2.columns(2, 20, 'This text flows across two columns. The PdfBuilder API supports multi-column layout.');
              await p2.done();

              final sink = MemorySink(); await b.save(sink); await b.dispose();
              final r = sink.takeBytes();
              final p = await saveBytes(r, 'built_text.pdf');
              setState(() => _status = p != null ? 'Saved (${fmtSize(r.length)})' : 'Cancelled');
            })),

          _Op(icon: Icons.crop_16_9, title: 'Letter page', loading: _loading, subtitle: 'US Letter (612×792 pt)',
            onRun: () => _run('Letter', () async {
              final b = await _pdf.build();
              final p = await b.addLetterPage();
              await p.heading(1, 'US Letter Page');
              await p.paragraph('612 × 792 points.');
              await p.done();
              final sink = MemorySink(); await b.save(sink); await b.dispose();
              final r = sink.takeBytes();
              await saveBytes(r, 'letter.pdf');
              setState(() => _status = 'Saved (${fmtSize(r.length)})');
            })),

          _Op(icon: Icons.aspect_ratio, title: 'Custom size page', loading: _loading, subtitle: '400×300 pt landscape',
            onRun: () => _run('Custom', () async {
              final b = await _pdf.build();
              final p = await b.addPage(width: 400, height: 300);
              await p.heading(1, 'Custom Size');
              await p.text('400 × 300 points');
              await p.done();
              final sink = MemorySink(); await b.save(sink); await b.dispose();
              final r = sink.takeBytes();
              await saveBytes(r, 'custom_size.pdf');
              setState(() => _status = 'Saved (${fmtSize(r.length)})');
            })),

          _Section('Form Fields'),
          _Op(icon: Icons.edit_note, title: 'Full form (all field types)', loading: _loading,
            subtitle: 'TextField, checkbox, combo, radio, button, signature',
            onRun: () => _run('Form', () async {
              final b = await _pdf.build();
              await b.setTitle('Registration Form');

              final p = await b.addA4Page();
              await p.heading(1, 'Registration Form');
              await p.space(20);
              await p.text('Name:');
              await p.textField('name', const PdfRect(x: 72, y: 680, width: 250, height: 20), defaultValue: 'John Doe');
              await p.space(40);
              await p.text('Email:');
              await p.textField('email', const PdfRect(x: 72, y: 630, width: 250, height: 20));
              await p.space(40);
              await p.text('Country:');
              await p.comboBox('country', const PdfRect(x: 72, y: 580, width: 150, height: 20), ['USA', 'UK', 'India', 'Germany', 'Japan'], selected: 'India');
              await p.space(40);
              await p.text('Terms:');
              await p.checkbox('agree', const PdfRect(x: 72, y: 530, width: 14, height: 14));
              await p.space(40);
              await p.text('Plan:');
              await p.radioGroup('plan', [
                (value: 'free', rect: const PdfRect(x: 72, y: 480, width: 14, height: 14)),
                (value: 'pro', rect: const PdfRect(x: 72, y: 460, width: 14, height: 14)),
                (value: 'enterprise', rect: const PdfRect(x: 72, y: 440, width: 14, height: 14)),
              ], selected: 'pro');
              await p.space(40);
              await p.pushButton('submit', const PdfRect(x: 72, y: 390, width: 80, height: 30), 'Submit');
              await p.space(50);
              await p.signatureField('sig', const PdfRect(x: 72, y: 320, width: 200, height: 60));
              await p.space(40);

              // JavaScript actions on fields
              await p.fieldKeystroke('AFNumber_Keystroke(2, 0, 0, 0, "", true)');
              await p.fieldFormat('AFNumber_Format(2, 0, 0, 0, "", true)');
              await p.fieldValidate('AFRange_Validate(true, 0, true, 100)');
              await p.fieldCalculate('AFSimple_Calculate("SUM", new Array("field1", "field2"))');

              await p.done();

              final sink = MemorySink(); await b.save(sink); await b.dispose();
              final r = sink.takeBytes();
              await saveBytes(r, 'built_form.pdf');
              setState(() => _status = 'Saved (${fmtSize(r.length)})');
            })),

          _Section('Links & References'),
          _Op(icon: Icons.link, title: 'URL link + page link + footnote', loading: _loading,
            onRun: () => _run('Links', () async {
              final b = await _pdf.build();
              await b.setTitle('Links Demo');

              final p1 = await b.addA4Page();
              await p1.heading(1, 'Links & References');
              await p1.space(10);
              await p1.text('Visit:');
              await p1.linkUrl('https://pub.dev/packages/pdf_manipulator');
              await p1.space(20);
              await p1.text('Jump to page 2:');
              await p1.linkPage(1);
              await p1.space(20);
              await p1.text('With a footnote.');
              await p1.footnote('1', 'This is the footnote text.');
              await p1.done();

              final p2 = await b.addA4Page();
              await p2.heading(2, 'Page 2 — Link Target');
              await p2.paragraph('You arrived here via the page link.');
              await p2.done();

              final sink = MemorySink(); await b.save(sink); await b.dispose();
              final r = sink.takeBytes();
              await saveBytes(r, 'built_links.pdf');
              setState(() => _status = 'Saved (${fmtSize(r.length)})');
            })),

          _Section('Watermark & Image'),
          _Op(icon: Icons.water_drop, title: 'Page with watermark', loading: _loading,
            onRun: () => _run('Watermark', () async {
              final b = await _pdf.build();
              final p = await b.addA4Page();
              await p.heading(1, 'Confidential Report');
              await p.paragraph('Sensitive information.');
              await p.watermark('CONFIDENTIAL');
              await p.done();
              final sink = MemorySink(); await b.save(sink); await b.dispose();
              final r = sink.takeBytes();
              await saveBytes(r, 'built_watermark.pdf');
              setState(() => _status = 'Saved (${fmtSize(r.length)})');
            })),
          _Op(icon: Icons.image, title: 'Page with image (pick)', loading: _loading,
            onRun: () => _run('Image', () async {
              final imgs = await pickImageBytes();
              if (imgs == null || imgs.isEmpty) return;
              final b = await _pdf.build();
              final p = await b.addA4Page();
              await p.heading(1, 'Image Demo');
              await p.image(MemorySource(imgs.first.bytes), const PdfRect(x: 72, y: 500, width: 200, height: 200));
              await p.done();
              final sink = MemorySink(); await b.save(sink); await b.dispose();
              final r = sink.takeBytes();
              await saveBytes(r, 'built_image.pdf');
              setState(() => _status = 'Saved (${fmtSize(r.length)})');
            })),

          _Section('Multi-Page'),
          _Op(icon: Icons.library_books, title: 'newline + newPageSameSize', loading: _loading,
            onRun: () => _run('MultiPage', () async {
              final b = await _pdf.build();
              final p = await b.addA4Page();
              await p.heading(1, 'Page 1');
              await p.paragraph('Some content on page 1.');
              await p.newline();
              await p.text('After a newline.');
              await p.newPageSameSize();
              await p.heading(1, 'Page 2 (same size)');
              await p.paragraph('Created via newPageSameSize().');
              await p.done();
              final sink = MemorySink(); await b.save(sink); await b.dispose();
              final r = sink.takeBytes();
              await saveBytes(r, 'built_multi.pdf');
              setState(() => _status = 'Saved (${fmtSize(r.length)})');
            })),

          _Section('Font'),
          _Op(icon: Icons.font_download, title: 'Custom font + size', loading: _loading,
            onRun: () => _run('Font', () async {
              final b = await _pdf.build();
              final p = await b.addA4Page();
              await p.font('Helvetica', 24);
              await p.text('Large Helvetica');
              await p.font('Courier', 12);
              await p.text('Small Courier');
              await p.font('Times-Roman', 18);
              await p.text('Medium Times');
              await p.done();
              final sink = MemorySink(); await b.save(sink); await b.dispose();
              final r = sink.takeBytes();
              await saveBytes(r, 'built_fonts.pdf');
              setState(() => _status = 'Saved (${fmtSize(r.length)})');
            })),

          const SizedBox(height: 40),
        ])),
      ]),
    );
  }
}

// ─── Tab 6: Merge ─────────────────────────────────────────────────

class _MergeTab extends StatefulWidget {
  const _MergeTab();
  @override
  State<_MergeTab> createState() => _MergeTabState();
}

class _MergeTabState extends State<_MergeTab> {
  final _pdf = Pdf();
  final _files = <({String name, Uint8List bytes, int size})>[];
  Uint8List? _merged;
  bool _loading = false;
  String? _status;

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

  Future<void> _add() async {
    final result = await FilePicker.platform.pickFiles(allowMultiple: true, type: FileType.custom, allowedExtensions: ['pdf'], withData: true);
    if (result == null) return;
    for (final f in result.files) {
      if (f.bytes != null) _files.add((name: f.name, bytes: f.bytes!, size: f.size));
    }
    setState(() { _merged = null; _status = '${_files.length} files'; });
  }

  Future<void> _merge() async {
    if (_files.length < 2) return;
    setState(() { _loading = true; _status = 'Merging...'; });
    try {
      final sink = MemorySink();
      await _pdf.merge(_files.map((f) => MemorySource(f.bytes) as DataSource).toList(), sink);
      final r = sink.takeBytes();
      setState(() { _merged = r; _status = 'Merged: ${fmtSize(r.length)}'; });
    } catch (e) { setState(() => _status = 'Error: $e'); }
    finally { setState(() => _loading = false); }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Merge PDFs')),
      body: Column(children: [
        _Status(loading: _loading, message: _status, onDismiss: () => setState(() => _status = null)),
        Expanded(child: _files.isEmpty
          ? Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
              Icon(Icons.merge, size: 56, color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5)),
              const SizedBox(height: 16), const Text('Pick 2+ PDFs to merge'),
              const SizedBox(height: 16), FilledButton.icon(onPressed: _add, icon: const Icon(Icons.add), label: const Text('Pick PDFs')),
            ]))
          : ReorderableListView.builder(
              padding: const EdgeInsets.all(16), itemCount: _files.length,
              onReorderItem: (old, to) => setState(() { final item = _files.removeAt(old); _files.insert(to, item); _merged = null; }),
              itemBuilder: (_, i) => Card(
                key: ValueKey('${_files[i].name}_$i'),
                margin: const EdgeInsets.symmetric(vertical: 3),
                child: ListTile(
                  leading: CircleAvatar(radius: 16, child: Text('${i + 1}', style: const TextStyle(fontSize: 13))),
                  title: Text(_files[i].name, style: const TextStyle(fontSize: 14)),
                  subtitle: Text(fmtSize(_files[i].size)),
                  trailing: IconButton(icon: const Icon(Icons.close, size: 18), onPressed: () => setState(() { _files.removeAt(i); _merged = null; })),
                  dense: true,
                ),
              ),
            ),
        ),
        if (_files.isNotEmpty) Padding(padding: const EdgeInsets.all(16), child: Row(children: [
          OutlinedButton.icon(onPressed: _loading ? null : _add, icon: const Icon(Icons.add, size: 18), label: const Text('Add')),
          const SizedBox(width: 8),
          Expanded(child: _merged != null
            ? FilledButton.icon(onPressed: () async {
                final p = await saveBytes(_merged!, 'merged.pdf');
                if (p != null) setState(() => _status = 'Saved');
              }, icon: const Icon(Icons.save, size: 18), label: const Text('Save'))
            : FilledButton.icon(
                onPressed: _files.length >= 2 && !_loading ? _merge : null,
                icon: _loading ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) : const Icon(Icons.merge, size: 18),
                label: Text('Merge ${_files.length}'))),
        ])),
      ]),
    );
  }
}
69
likes
0
points
1.37k
downloads

Publisher

verified publisherwhuppi.com

Weekly Downloads

Cross-platform PDF toolkit for Flutter. Merge, split, render, extract, search, sign, encrypt, convert, build from scratch. Rust engine, off the main thread.

Repository (GitHub)
View/report issues

Topics

#pdf #ffi #wasm #document #encryption

License

unknown (license)

Dependencies

code_assets, crypto, ffi, hooks, logging, meta, package_config, path, web

More

Packages that depend on pdf_manipulator