pdf_manipulator 2.0.2-dev.0 copy "pdf_manipulator: ^2.0.2-dev.0" to clipboard
pdf_manipulator: ^2.0.2-dev.0 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

// pdf_manipulator example — every capability, one file.
//
// ONE file on purpose — pub.dev renders example/lib/main.dart as the
// package's Example tab. Splitting it hides everything past main.dart
// from that page. Do not modularize.
//
// Seven tabs, one per API surface:
//   Runtime    — the lane architecture: I/O mode, parallel ops,
//                per-op cancellation, instant dispose
//   Doc        — PdfDoc read-only queries
//   Sugar      — one-shot convenience ops (PdfSugar)
//   Standalone — source in → sink out, no handle (PdfStandalone)
//   Editor     — parse once, mutate N times, save once (PdfEditor)
//   Builder    — create PDFs from scratch (PdfBuilder)
//   Merge      — reorderable multi-file merge
//
// Every engine method returns a PdfTask — a Future you can cancel.
// The status bar shows a Cancel button whenever a cancellable task
// is in flight, on every tab.

import 'dart:async';
import 'dart:typed_data';

import 'package:flutter/material.dart';

import 'package:pdf_manipulator/pdf_manipulator.dart';

// ─── Custom DataSource / DataSink — the real-app pattern ──────────
//
// A real app's bytes rarely sit in a Uint8List — they live in files,
// network streams, pickers, your own store. Implementing the two tiny
// interfaces is how you wire any of those in. These are this example's
// own implementations, used throughout the app.
//
// For the quick path, the package SHIPS MemorySource / MemorySink
// (`package:pdf_manipulator/pdf_manipulator.dart`) and FileSource /
// FileSink (`package:pdf_manipulator/io.dart`). The Sugar tab shows
// the shipped MemorySink in use; everything else uses these custom
// impls to show the interface is open.

/// Random-access source over bytes already in memory.
class DemoSource implements DataSource {
  DemoSource(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);
  }
}

/// Collects output chunks; hand the bytes off with [takeBytes].
class DemoSink implements DataSink {
  final _builder = BytesBuilder(copy: false);

  @override
  void write(Uint8List chunk) => _builder.add(chunk);

  Uint8List takeBytes() => _builder.takeBytes();
  int get length => _builder.length;
}

// ─── Built-in fixtures (the app works without picking any file) ───

/// Minimal valid PDF: one 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,
);

/// Self-signed test certificate for the signing demo.
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';
}

/// Navigator key — lets the demo picker open its sheet from anywhere.
final demoNavigatorKey = GlobalKey<NavigatorState>();

// ─── Demo picker — sample PDFs generated in memory ────────────────
//
// No file system, no platform picker, no permissions: every sample is
// produced on demand by the package's own builder (so the picker
// doubles as a live builder showcase) and works identically on every
// platform including web.

class _DemoSample {
  const _DemoSample(this.name, this.icon, this.build);
  final String name;
  final IconData icon;
  final Future<Uint8List> Function(Pdf pdf) build;
}

final List<_DemoSample> _demoSamples = [
  _DemoSample(
      'Tiny blank page', Icons.crop_portrait, (pdf) async => minimalPdf),
  _DemoSample('Text report (5 pages)', Icons.article, (pdf) async {
    final b = await pdf.build();
    await b.setTitle('Demo Report');
    for (var i = 1; i <= 5; i++) {
      final p = await b.addA4Page();
      await p.heading(2, 'Section $i');
      await p.space(8);
      await p
          .paragraph('Demo paragraph for section $i. The quick brown fox jumps '
              'over the lazy dog while the engine streams every byte.');
      await p.done();
    }
    final sink = DemoSink();
    await b.save(sink);
    await b.dispose();
    return sink.takeBytes();
  }),
  _DemoSample('Long document (40 pages)', Icons.menu_book,
      (pdf) => buildSamplePdf(pdf)),
  _DemoSample('Form sample', Icons.fact_check, (pdf) async {
    final b = await pdf.build();
    await b.setTitle('Demo Form');
    final p = await b.addA4Page();
    await p.heading(1, 'Application Form');
    await p.space(10);
    await p.text('Name:');
    await p.textField(
        'name', const PdfRect(x: 100, y: 680, width: 200, height: 20));
    await p.text('Agree:');
    await p.checkbox(
        'agree', const PdfRect(x: 100, y: 640, width: 14, height: 14));
    await p.done();
    final sink = DemoSink();
    await b.save(sink);
    await b.dispose();
    return sink.takeBytes();
  }),
];

/// Demo replacement for a platform file picker: a sheet of sample
/// PDFs, generated in memory on selection. Same signature as before —
/// call sites are untouched.
Future<List<Uint8List>?> pickPdfBytes({bool multiple = false}) async {
  final context = demoNavigatorKey.currentContext;
  if (context == null) return null;
  final picked = await showModalBottomSheet<List<_DemoSample>>(
    context: context,
    showDragHandle: true,
    builder: (sheetCtx) => _DemoSampleSheet(multiple: multiple),
  );
  if (picked == null || picked.isEmpty) return null;
  final pdf = Pdf();
  try {
    return [for (final s in picked) await s.build(pdf)];
  } finally {
    await pdf.dispose();
  }
}

class _DemoSampleSheet extends StatefulWidget {
  const _DemoSampleSheet({required this.multiple});
  final bool multiple;
  @override
  State<_DemoSampleSheet> createState() => _DemoSampleSheetState();
}

class _DemoSampleSheetState extends State<_DemoSampleSheet> {
  final _selected = <_DemoSample>{};

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: Column(mainAxisSize: MainAxisSize.min, children: [
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
          child: Row(children: [
            Icon(Icons.auto_awesome,
                color: Theme.of(context).colorScheme.primary),
            const SizedBox(width: 8),
            Text(
                widget.multiple
                    ? 'Pick demo PDFs (built in memory)'
                    : 'Pick a demo PDF (built in memory)',
                style: Theme.of(context).textTheme.titleMedium),
          ]),
        ),
        for (final sample in _demoSamples)
          widget.multiple
              ? CheckboxListTile(
                  secondary: Icon(sample.icon),
                  title: Text(sample.name),
                  value: _selected.contains(sample),
                  onChanged: (v) => setState(() => v == true
                      ? _selected.add(sample)
                      : _selected.remove(sample)),
                )
              : ListTile(
                  leading: Icon(sample.icon),
                  title: Text(sample.name),
                  onTap: () => Navigator.pop(context, [sample]),
                ),
        if (widget.multiple)
          Padding(
            padding: const EdgeInsets.all(12),
            child: FilledButton.icon(
              key: const ValueKey('demo-picker-use'),
              onPressed: _selected.isEmpty
                  ? null
                  : () => Navigator.pop(context, _selected.toList()),
              icon: const Icon(Icons.check),
              label: Text('Use ${_selected.length} files'),
            ),
          ),
      ]),
    );
  }
}

/// Tiny valid 1×1 PNG — the demo stand-in for picked images.
final Uint8List demoPng = Uint8List.fromList(const [
  0x89,
  0x50,
  0x4E,
  0x47,
  0x0D,
  0x0A,
  0x1A,
  0x0A,
  0x00,
  0x00,
  0x00,
  0x0D,
  0x49,
  0x48,
  0x44,
  0x52,
  0x00,
  0x00,
  0x00,
  0x01,
  0x00,
  0x00,
  0x00,
  0x01,
  0x08,
  0x02,
  0x00,
  0x00,
  0x00,
  0x90,
  0x77,
  0x53,
  0xDE,
  0x00,
  0x00,
  0x00,
  0x0C,
  0x49,
  0x44,
  0x41,
  0x54,
  0x08,
  0xD7,
  0x63,
  0xF8,
  0xCF,
  0xC0,
  0x00,
  0x00,
  0x00,
  0x03,
  0x00,
  0x01,
  0xCE,
  0xCC,
  0x09,
  0x4B,
  0x00,
  0x00,
  0x00,
  0x00,
  0x49,
  0x45,
  0x4E,
  0x44,
  0xAE,
  0x42,
  0x60,
  0x82,
]);

/// Demo replacement for image picking — returns the bundled sample.
Future<List<({String name, Uint8List bytes, int size})>?>
    pickImageBytes() async =>
        [(name: 'demo.png', bytes: demoPng, size: demoPng.length)];

/// Demo replacement for save dialogs: outputs stay in memory; report
/// the size instead of touching the file system.
Future<String?> saveBytes(Uint8List bytes, String name) async {
  final context = demoNavigatorKey.currentContext;
  if (context != null) {
    ScaffoldMessenger.of(context).showSnackBar(SnackBar(
        content:
            Text('$name ready — ${fmtSize(bytes.length)} (kept in memory)')));
  }
  return name;
}

/// Builds a [pages]-page text PDF with the given instance — gives the
/// heavy demos something real to chew on without picking a file.
Future<Uint8List> buildSamplePdf(Pdf pdf, {int pages = 40}) async {
  final b = await pdf.build();
  try {
    await b.setTitle('Sample ($pages pages)');
    for (var i = 0; i < pages; i++) {
      final p = await b.addA4Page();
      await p.heading(2, 'Page ${i + 1} of $pages');
      await p.space(10);
      for (var j = 0; j < 6; j++) {
        await p.paragraph(
            'Lorem ipsum dolor sit amet, consectetur adipiscing elit. '
            'Sed do eiusmod tempor incididunt ut labore et dolore magna '
            'aliqua. Ut enim ad minim veniam, quis nostrud exercitation.');
        await p.space(6);
      }
      await p.done();
    }
    final sink = DemoSink();
    await b.save(sink);
    return sink.takeBytes();
  } finally {
    await b.dispose();
  }
}

// ─── App shell ────────────────────────────────────────────────────

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

class ExampleApp extends StatelessWidget {
  const ExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      navigatorKey: demoNavigatorKey,
      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 StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 7,
      child: Scaffold(
        appBar: AppBar(
          title: const Text('PDF Manipulator'),
          actions: const [_IoModeChip(), SizedBox(width: 12)],
          bottom: const TabBar(
            isScrollable: true,
            tabAlignment: TabAlignment.start,
            tabs: [
              Tab(icon: Icon(Icons.bolt, size: 20), text: 'Runtime'),
              Tab(icon: Icon(Icons.description, size: 20), text: 'Doc'),
              Tab(icon: Icon(Icons.auto_fix_high, size: 20), text: 'Sugar'),
              Tab(icon: Icon(Icons.output, size: 20), text: 'Standalone'),
              Tab(icon: Icon(Icons.edit_document, size: 20), text: 'Editor'),
              Tab(icon: Icon(Icons.note_add, size: 20), text: 'Builder'),
              Tab(icon: Icon(Icons.merge, size: 20), text: 'Merge'),
            ],
          ),
        ),
        body: const TabBarView(children: [
          _RuntimeTab(),
          _DocTab(),
          _SugarTab(),
          _StandaloneTab(),
          _EditorTab(),
          _BuilderTab(),
          _MergeTab(),
        ]),
      ),
    );
  }
}

/// Live chip showing the detected I/O mode (native / jspi / atomics /
/// opfs). Its own tiny Pdf instance — instances are cheap and fully
/// independent.
class _IoModeChip extends StatefulWidget {
  const _IoModeChip();
  @override
  State<_IoModeChip> createState() => _IoModeChipState();
}

class _IoModeChipState extends State<_IoModeChip> {
  final _probe = Pdf();
  PdfIoMode? _mode;

  @override
  void initState() {
    super.initState();
    _probe.ensureInitialized().then((m) {
      if (mounted) setState(() => _mode = m);
    });
  }

  @override
  void dispose() {
    unawaited(_probe.dispose());
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    return Center(
      child: Container(
        padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
        decoration: BoxDecoration(
          color: cs.primaryContainer,
          borderRadius: BorderRadius.circular(12),
        ),
        child: Text(
          _mode?.name.toUpperCase() ?? '…',
          style: TextStyle(
              fontSize: 11,
              fontWeight: FontWeight.w700,
              letterSpacing: 0.5,
              color: cs.onPrimaryContainer),
        ),
      ),
    );
  }
}

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

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

  @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: 8),
      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 ?? '',
                key: const ValueKey('status-text'),
                style: TextStyle(color: fg, fontSize: 13),
                maxLines: 4)),
        if (loading && onCancel != null)
          TextButton.icon(
            onPressed: onCancel,
            icon: Icon(Icons.cancel, size: 16, color: fg),
            label: Text('Cancel', style: TextStyle(color: fg, fontSize: 12)),
          ),
        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(
                // Stable handle for integration tests ("run:<title>").
                key: ValueKey('run:$title'),
                onPressed: loading ? null : onRun,
                style: FilledButton.styleFrom(
                    padding: EdgeInsets.zero,
                    textStyle: const TextStyle(fontSize: 13)),
                child: const Text('Run'),
              )),
          dense: true,
          visualDensity: VisualDensity.compact,
        ),
      );
}

class _EmptyState extends StatelessWidget {
  final IconData icon;
  final String text;
  final VoidCallback? onPick;
  final String pickLabel;
  const _EmptyState(
      {required this.icon,
      required this.text,
      this.onPick,
      this.pickLabel = 'Open PDF'});

  @override
  Widget build(BuildContext context) => Center(
        child: Column(mainAxisSize: MainAxisSize.min, children: [
          Icon(icon,
              size: 56,
              color:
                  Theme.of(context).colorScheme.primary.withValues(alpha: 0.5)),
          const SizedBox(height: 16),
          Text(text),
          if (onPick != null) ...[
            const SizedBox(height: 16),
            FilledButton.icon(
                onPressed: onPick,
                icon: const Icon(Icons.file_open),
                label: Text(pickLabel)),
          ],
        ]),
      );
}

void showTextSheet(BuildContext context, String title, String text) {
  showModalBottomSheet<void>(
    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)),
          ),
        ),
      ]),
    ),
  );
}

// ─── The op runner — cancellation-aware, shared by every tab ──────

/// Per-tab op execution: loading state, status line, and a live
/// Cancel button whenever the running op is a single [PdfTask].
mixin _OpsRunner<T extends StatefulWidget> on State<T> {
  bool loading = false;
  String? status;
  PdfTask<Object?>? _task;

  /// Runs one cancellable engine task. Returns true on success,
  /// false on cancellation or error (already reported in [status]).
  Future<bool> runTask<R>(String label, PdfTask<R> Function() start,
      {String Function(R result)? done}) async {
    final task = start();
    setState(() {
      loading = true;
      status = '$label…';
      _task = task;
    });
    try {
      final result = await task;
      setState(() => status = done?.call(result) ?? '$label done');
      return true;
    } on PdfCancelled {
      setState(() => status =
          'Cancelled — the instance and every other handle keep working');
      return false;
    } catch (e) {
      setState(() => status = 'Error: $e');
      return false;
    } finally {
      if (mounted) {
        setState(() {
          loading = false;
          _task = null;
        });
      }
    }
  }

  /// Runs a multi-step flow (not cancellable as a unit). Returns
  /// true on success.
  Future<bool> runFlow(String label, Future<void> Function() body) async {
    setState(() {
      loading = true;
      status = '$label…';
    });
    try {
      await body();
      return true;
    } on PdfCancelled {
      setState(() => status = 'Cancelled');
      return false;
    } catch (e) {
      setState(() => status = 'Error: $e');
      return false;
    } finally {
      if (mounted) setState(() => loading = false);
    }
  }

  Future<void> saveResult(Uint8List bytes, String name) async {
    final p = await saveBytes(bytes, name);
    setState(() => status = p != null
        ? 'Saved $name (${fmtSize(bytes.length)})'
        : 'Save cancelled');
  }

  Widget statusBar() => _Status(
        loading: loading,
        message: status,
        onCancel: _task == null ? null : () => _task?.cancel(),
        onDismiss: () => setState(() => status = null),
      );
}

/// Shared "pick a PDF" state for tabs that operate on one input file.
mixin _PdfPicker<T extends StatefulWidget> on State<T> {
  Uint8List? bytes;

  DataSource get src => DemoSource(bytes!);

  Future<void> pick(void Function(Uint8List picked) onPicked) async {
    final r = await pickPdfBytes();
    if (r == null || r.isEmpty) return;
    setState(() => bytes = r.first);
    onPicked(r.first);
  }

  Widget fileCard(BuildContext context, VoidCallback onSwap) => Card(
        child: ListTile(
          leading: Icon(Icons.picture_as_pdf,
              color: Theme.of(context).colorScheme.primary),
          title: Text(fmtSize(bytes!.length)),
          trailing: IconButton.filledTonal(
              onPressed: onSwap, icon: const Icon(Icons.swap_horiz, size: 20)),
        ),
      );
}

// ─── Tab 1: Runtime — the lane architecture, live ─────────────────

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

class _RuntimeTabState extends State<_RuntimeTab>
    with _OpsRunner, AutomaticKeepAliveClientMixin {
  final _pdf = Pdf();
  Uint8List? _sample;

  @override
  bool get wantKeepAlive => true;

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

  /// 40-page sample, built once, reused by the heavy demos.
  Future<Uint8List> _ensureSample() async =>
      _sample ??= await buildSamplePdf(_pdf);

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return Column(children: [
      statusBar(),
      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.bolt,
                      color: Theme.of(context).colorScheme.primary, size: 28),
                  const SizedBox(width: 12),
                  const Expanded(
                    child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          Text('The lane architecture',
                              style: TextStyle(
                                  fontWeight: FontWeight.w600, fontSize: 15)),
                          Text(
                              'Every op runs on an isolated lane. Cancel one '
                              'op, or kill the whole instance instantly — '
                              'no demo here needs a file.',
                              style: TextStyle(fontSize: 12)),
                        ]),
                  ),
                ]),
              ),
            ),
            _Section('Per-op cancellation (PdfTask)'),
            _Op(
                icon: Icons.cancel_schedule_send,
                title: 'Heavy merge — hit Cancel while it runs',
                subtitle: 'merge of 6 × 40-page docs; Cancel appears above',
                loading: loading,
                onRun: () async {
                  final sample = await _ensureSample();
                  await runTask(
                      'Heavy merge (tap Cancel!)',
                      () => _pdf.merge(
                          List.filled(6, DemoSource(sample)), DemoSink()),
                      done: (_) => 'Merge finished before you cancelled');
                }),
            _Op(
                icon: Icons.timer_off,
                title: 'Cancel before the job even starts',
                subtitle: 'task.cancel() on the same tick → PdfCancelled',
                loading: loading,
                onRun: () => runFlow('Cancel-before-start', () async {
                      final task = _pdf.open(DemoSource(minimalPdf));
                      task.cancel();
                      try {
                        final doc = await task;
                        await doc.dispose();
                        setState(() =>
                            status = 'Completed before the cancel landed');
                      } on PdfCancelled {
                        setState(() => status =
                            '✓ PdfCancelled — the job never reached a lane');
                      }
                    })),
            _Op(
                icon: Icons.healing,
                title: 'Cancelled op leaves siblings untouched',
                subtitle: 'cancel an extract; the open doc keeps working',
                loading: loading,
                onRun: () => runFlow('Sibling survival', () async {
                      final sample = await _ensureSample();
                      final doc = await _pdf.open(DemoSource(sample));
                      try {
                        final task = doc.extract(pages: const PdfPages.all());
                        task.cancel();
                        try {
                          await task;
                        } on PdfCancelled {
                          // expected — now prove the doc still works:
                        }
                        final page0 =
                            await doc.extract(pages: const PdfPages.single(0));
                        setState(() =>
                            status = '✓ extract cancelled; same doc then read '
                                '${page0.length} chars from page 1');
                      } finally {
                        await doc.dispose();
                      }
                    })),
            _Section('Parallel ops'),
            _Op(
                icon: Icons.call_split,
                title: 'Open 4 documents in parallel',
                subtitle: 'Future.wait — each lands on its own lane',
                loading: loading,
                onRun: () => runFlow('Parallel opens', () async {
                      final sw = Stopwatch()..start();
                      final docs = await Future.wait(List.generate(
                          4, (_) => _pdf.open(DemoSource(minimalPdf))));
                      sw.stop();
                      final pages = docs.map((d) => d.pageCount).join(', ');
                      for (final d in docs) {
                        await d.dispose();
                      }
                      setState(() =>
                          status = '4 docs open in ${sw.elapsedMilliseconds}ms '
                              '(pages: $pages)');
                    })),
            _Section('Instant dispose'),
            _Op(
                icon: Icons.power_settings_new,
                title: 'Dispose mid-flight — measure it',
                subtitle:
                    'starts a heavy merge on a scratch instance, then kills it',
                loading: loading,
                onRun: () => runFlow('Instant dispose', () async {
                      final sample = await _ensureSample();
                      final lab = Pdf();
                      final inflight = lab.merge(
                          List.filled(6, DemoSource(sample)), DemoSink());
                      await Future<void>.delayed(
                          const Duration(milliseconds: 30));
                      final sw = Stopwatch()..start();
                      await lab.dispose();
                      sw.stop();
                      var outcome = 'op finished first';
                      try {
                        await inflight;
                      } on PdfCancelled {
                        outcome = 'in-flight op resolved as PdfCancelled';
                      }
                      setState(() => status =
                          '✓ dispose() returned in ${sw.elapsedMicroseconds / 1000}ms; '
                              '$outcome');
                    })),
            _Op(
                icon: Icons.fiber_new,
                title: 'Fresh instance works after an abrupt kill',
                loading: loading,
                onRun: () => runFlow('Fresh after kill', () async {
                      final lab = Pdf();
                      unawaited(lab.open(DemoSource(minimalPdf)));
                      await lab.dispose(); // killed mid-open — fine
                      final lab2 = Pdf();
                      try {
                        final doc = await lab2.open(DemoSource(minimalPdf));
                        setState(() => status =
                            '✓ new instance opened ${doc.pageCount} page(s) '
                                'right after the kill');
                        await doc.dispose();
                      } finally {
                        await lab2.dispose();
                      }
                    })),
            const SizedBox(height: 40),
          ],
        ),
      ),
    ]);
  }
}

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

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

class _DocTabState extends State<_DocTab>
    with _OpsRunner, _PdfPicker, AutomaticKeepAliveClientMixin {
  final _pdf = Pdf();
  PdfDoc? _doc;

  @override
  bool get wantKeepAlive => true;

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

  Future<void> _open() => pick((picked) {
        unawaited(runFlow('Opening', () async {
          final old = _doc;
          _doc = null;
          if (old != null) await old.dispose();
          final doc = await _pdf.open(DemoSource(picked));
          setState(() {
            _doc = doc;
            status = '${doc.pageCount} pages, v${doc.version}';
          });
        }));
      });

  @override
  Widget build(BuildContext context) {
    super.build(context);
    final doc = _doc;
    return Column(children: [
      statusBar(),
      Expanded(
        child: doc == null
            ? _EmptyState(
                icon: Icons.description,
                text: 'Open a PDF to query it',
                onPick: loading ? null : _open)
            : ListView(
                padding:
                    const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
                children: [
                  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: _open,
                                  icon: const Icon(Icons.swap_horiz, size: 20)),
                            ]),
                            const SizedBox(height: 8),
                            Wrap(spacing: 6, runSpacing: 4, children: [
                              if (doc.isEncrypted)
                                _chip(context, 'Encrypted', warn: true),
                              if (doc.isTagged) _chip(context, 'Tagged'),
                              if (doc.subject != null)
                                _chip(context, doc.subject!),
                              if (doc.keywords != null)
                                _chip(context, doc.keywords!),
                            ]),
                          ]),
                    ),
                  ),
                  _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)),
                  _Section('Text Extraction'),
                  _Op(
                      icon: Icons.text_snippet,
                      title: 'Extract all (plain text)',
                      loading: loading,
                      onRun: () => runTask('Extract',
                              () => doc.extract(pages: const PdfPages.all()),
                              done: (t) {
                            showTextSheet(
                                context, 'Plain Text (${t.length} chars)', t);
                            return '${t.length} chars';
                          })),
                  _Op(
                      icon: Icons.text_snippet_outlined,
                      title: 'Extract page 1 only',
                      loading: loading,
                      onRun: () => runTask(
                              'Extract p1',
                              () =>
                                  doc.extract(pages: const PdfPages.single(0)),
                              done: (t) {
                            showTextSheet(context, 'Page 1', t);
                            return 'Page 1: ${t.length} chars';
                          })),
                  _Op(
                      icon: Icons.code,
                      title: 'Extract as Markdown',
                      loading: loading,
                      onRun: () => runTask(
                              'Markdown',
                              () => doc.extract(
                                  pages: const PdfPages.all(),
                                  format: PdfExtractionFormat.markdown),
                              done: (t) {
                            showTextSheet(context, 'Markdown', t);
                            return 'MD: ${t.length} chars';
                          })),
                  _Op(
                      icon: Icons.html,
                      title: 'Extract as HTML (page 1)',
                      loading: loading,
                      onRun: () => runTask(
                              'HTML',
                              () => doc.extract(
                                  pages: const PdfPages.single(0),
                                  format: PdfExtractionFormat.html), done: (t) {
                            showTextSheet(context, 'HTML', t);
                            return 'HTML: ${t.length} chars';
                          })),
                  _Section('Search'),
                  _Op(
                      icon: Icons.search,
                      title: 'Search "the" on page 1',
                      loading: loading,
                      onRun: () => runTask(
                          'Search p1',
                          () => doc.search(
                              query: 'the', pages: const PdfPages.single(0)),
                          done: (r) => '${r.length} hits on page 1')),
                  _Op(
                      icon: Icons.manage_search,
                      title: 'Search "the" all pages',
                      loading: loading,
                      onRun: () => runTask(
                          'Search all',
                          () => doc.search(
                              query: 'the', pages: const PdfPages.all()),
                          done: (r) => '${r.length} hits total')),
                  _Section('Render & Images'),
                  _Op(
                      icon: Icons.image,
                      title: 'Render page 1 to image',
                      loading: loading,
                      onRun: () => runFlow('Render', () async {
                            var info = 'No pages rendered';
                            await for (final page in doc.render(
                                pages: const PdfPages.single(0))) {
                              info = 'Rendered ${page.width}×${page.height} '
                                  '(${fmtSize(page.data.length)})';
                            }
                            setState(() => status = info);
                          })),
                  _Op(
                      icon: Icons.photo_library,
                      title: 'Extract images from page 1',
                      loading: loading,
                      onRun: () => runFlow('Images', () async {
                            var count = 0;
                            await for (final _ in doc.extractImages(
                                pages: const PdfPages.single(0))) {
                              count++;
                            }
                            setState(
                                () => status = '$count image(s) on page 1');
                          })),
                  _Section('Signatures'),
                  _Op(
                      icon: Icons.draw,
                      title: 'List signatures',
                      loading: loading,
                      onRun: () => runTask(
                          'Signatures', () => doc.getSignatures(),
                          done: (sigs) => '${sigs.length} signature(s)')),
                  _Op(
                      icon: Icons.verified_user,
                      title: 'Verify signatures',
                      loading: loading,
                      onRun: () => runTask(
                          'Verify', () => doc.verifySignatures(),
                          done: (ok) => ok ? 'Valid ✓' : 'Invalid or none ✗')),
                  _Section('Validation'),
                  _Op(
                      icon: Icons.verified,
                      title: 'Validate PDF/A',
                      loading: loading,
                      onRun: () => runTask('PDF/A', () => doc.validatePdfA(),
                          done: (r) =>
                              '${r.compliant ? "Compliant ✓" : "Not compliant"} '
                              '(${r.errors}e ${r.warnings}w)')),
                  _Op(
                      icon: Icons.accessibility,
                      title: 'Validate PDF/UA',
                      loading: loading,
                      onRun: () => runTask('PDF/UA', () => doc.validatePdfUa(),
                          done: (r) =>
                              r ? 'Accessible ✓' : 'Not accessible ✗')),
                  _Section('Classification'),
                  _Op(
                      icon: Icons.category,
                      title: 'Classify page 1',
                      loading: loading,
                      onRun: () => runTask(
                          'Classify page', () => doc.classifyPage(0),
                          done: (r) => 'Page 1: ${r.type}')),
                  _Op(
                      icon: Icons.analytics,
                      title: 'Classify document',
                      loading: loading,
                      onRun: () => runTask(
                          'Classify doc', () => doc.classifyDocument(),
                          done: (r) => 'Document: ${r.type}')),
                  _Section('Bookmarks'),
                  _Op(
                      icon: Icons.bookmark,
                      title: 'Plan split by bookmarks',
                      loading: loading,
                      onRun: () => runFlow('Bookmarks', () async {
                            try {
                              final s = await doc.planSplitByBookmarks();
                              setState(() => status = '${s.length} segments: '
                                  '${s.map((x) => x.title).join(', ')}');
                            } catch (_) {
                              setState(() => status = 'No bookmarks');
                            }
                          })),
                  const SizedBox(height: 40),
                ],
              ),
      ),
    ]);
  }

  Widget _chip(BuildContext context, String text, {bool warn = false}) {
    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 3: PdfSugar — one-shot convenience ops ───────────────────

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

class _SugarTabState extends State<_SugarTab>
    with _OpsRunner, _PdfPicker, AutomaticKeepAliveClientMixin {
  final _pdf = Pdf();

  @override
  bool get wantKeepAlive => true;

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

  void _pickFile() => unawaited(
      pick((p) => setState(() => status = 'Loaded (${fmtSize(p.length)})')));

  /// One-shot op into a sink, then offer to save the result.
  ///
  /// This tab uses the shipped MemorySink (from the package) rather than
  /// the example's own DemoSink — the quick path, no plumbing to write.
  Future<void> _opToFile(String label, String filename,
      PdfTask<void> Function(MemorySink sink) start) async {
    final sink = MemorySink();
    if (await runTask(label, () => start(sink))) {
      await saveResult(sink.takeBytes(), filename);
    }
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return Column(children: [
      statusBar(),
      Expanded(
        child: bytes == null
            ? _EmptyState(
                icon: Icons.auto_fix_high,
                text: 'Pick a PDF for one-shot ops',
                onPick: _pickFile)
            : ListView(
                padding:
                    const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
                children: [
                  fileCard(context, _pickFile),
                  _Section('Structural'),
                  _Op(
                      icon: Icons.content_cut,
                      title: 'Split every 2 pages',
                      loading: loading,
                      onRun: () => runFlow('Split', () async {
                            final sinks = <DemoSink>[];
                            await _pdf.split(src, (i) {
                              final s = DemoSink();
                              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: () => runFlow('SplitBySize', () async {
                            final sinks = <DemoSink>[];
                            await _pdf.splitBySize(src, (i) {
                              final s = DemoSink();
                              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: () => runFlow('SplitBookmarks', () async {
                            try {
                              final sinks = <DemoSink>[];
                              await _pdf.splitByBookmarks(src, (i) {
                                final s = DemoSink();
                                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: () => _opToFile(
                          'Extract',
                          'extracted.pdf',
                          (sink) =>
                              _pdf.extractPages(src, sink, pages: [0, 1]))),
                  _Op(
                      icon: Icons.delete_outline,
                      title: 'Delete page 0',
                      loading: loading,
                      onRun: () => _opToFile('Delete', 'deleted.pdf',
                          (sink) => _pdf.deletePages(src, sink, pages: [0]))),
                  _Op(
                      icon: Icons.swap_vert,
                      title: 'Reverse page order',
                      loading: loading,
                      onRun: () => runFlow('Reverse', () async {
                            final doc = await _pdf.open(src);
                            final n = doc.pageCount;
                            await doc.dispose();
                            final sink = DemoSink();
                            await _pdf.reorderPages(src, sink,
                                order: List.generate(n, (i) => n - 1 - i));
                            await saveResult(sink.takeBytes(), 'reversed.pdf');
                          })),
                  _Op(
                      icon: Icons.move_down,
                      title: 'Move page 0 → last',
                      loading: loading,
                      onRun: () => runFlow('Move', () async {
                            final doc = await _pdf.open(src);
                            final n = doc.pageCount;
                            await doc.dispose();
                            final sink = DemoSink();
                            await _pdf.movePage(src, sink, from: 0, to: n - 1);
                            await saveResult(sink.takeBytes(), 'moved.pdf');
                          })),
                  _Section('Rotation'),
                  _Op(
                      icon: Icons.rotate_right,
                      title: 'Rotate all 90°',
                      loading: loading,
                      onRun: () => _opToFile(
                          'RotateAll',
                          'rotated_all.pdf',
                          (sink) =>
                              _pdf.rotateAllPages(src, sink, degrees: 90))),
                  _Op(
                      icon: Icons.rotate_left,
                      title: 'Rotate page 0 → 180°',
                      loading: loading,
                      onRun: () => _opToFile(
                          'RotatePage',
                          'rotated_p0.pdf',
                          (sink) =>
                              _pdf.rotatePages(src, sink, pages: {0: 180}))),
                  _Section('Compression'),
                  _Op(
                      icon: Icons.compress,
                      title: 'Compress (quality 75)',
                      loading: loading,
                      onRun: () => runFlow('Compress', () async {
                            final sink = DemoSink();
                            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: () => _opToFile(
                          'Watermark',
                          'watermarked.pdf',
                          (sink) => _pdf.watermark(src, sink,
                              text: 'DRAFT',
                              style: const PdfWatermarkStyle(opacity: 0.3)))),
                  _Op(
                      icon: Icons.grid_4x4,
                      title: 'Tiled 3×4 "COPY"',
                      loading: loading,
                      onRun: () => _opToFile(
                          'Tiled',
                          'tiled.pdf',
                          (sink) => _pdf.watermark(src, sink,
                              text: 'COPY',
                              style: const PdfWatermarkStyle(
                                  opacity: 0.15, fontSize: 24, rotation: 30),
                              position: const PdfWatermarkPosition.tiled(
                                  columns: 3, rows: 4)))),
                  _Op(
                      icon: Icons.arrow_outward,
                      title: 'Corner top-right "SAMPLE"',
                      loading: loading,
                      onRun: () => _opToFile(
                          'Corner',
                          'corner.pdf',
                          (sink) => _pdf.watermark(src, sink,
                              text: 'SAMPLE',
                              style: const PdfWatermarkStyle(
                                  opacity: 0.4, fontSize: 18, rotation: 0),
                              position: const PdfWatermarkPosition.corner(
                                  PdfCorner.topRight)))),
                  _Op(
                      icon: Icons.layers,
                      title: 'Background layer',
                      loading: loading,
                      onRun: () => _opToFile(
                          'Background',
                          'bg_watermark.pdf',
                          (sink) => _pdf.watermark(src, sink,
                              text: 'BACKGROUND',
                              style: const PdfWatermarkStyle(opacity: 0.2),
                              layer: PdfWatermarkLayer.background))),
                  _Section('Security'),
                  _Op(
                      icon: Icons.lock,
                      title: 'Encrypt (pw: secret)',
                      loading: loading,
                      onRun: () => _opToFile(
                          'Encrypt',
                          'encrypted.pdf',
                          (sink) => _pdf.encrypt(src, sink,
                              encryption: const PdfEncryptionConfig(
                                  ownerPassword: 'secret')))),
                  _Op(
                      icon: Icons.lock_open,
                      title: 'Decrypt (pw: secret)',
                      loading: loading,
                      onRun: () => _opToFile(
                          'Decrypt',
                          'decrypted.pdf',
                          (sink) =>
                              _pdf.decrypt(src, sink, password: 'secret'))),
                  _Section('Forms & Annotations'),
                  _Op(
                      icon: Icons.layers_clear,
                      title: 'Flatten forms',
                      loading: loading,
                      onRun: () => _opToFile('Flatten', 'flattened.pdf',
                          (sink) => _pdf.flattenForms(src, sink))),
                  _Op(
                      icon: Icons.rule,
                      title: 'Apply redactions',
                      loading: loading,
                      onRun: () => _opToFile('Redact', 'redacted.pdf',
                          (sink) => _pdf.applyRedactions(src, sink))),
                  _Section('Content'),
                  _Op(
                      icon: Icons.attach_file,
                      title: 'Embed text file',
                      loading: loading,
                      onRun: () => _opToFile(
                          'Embed',
                          'embedded.pdf',
                          (sink) => _pdf.embedFile(src, sink,
                              name: 'readme.txt',
                              fileData: DemoSource(Uint8List.fromList(
                                  'Hello from pdf_manipulator!'.codeUnits))))),
                  _Op(
                      icon: Icons.format_paint,
                      title: 'Erase region on page 0',
                      loading: loading,
                      onRun: () => _opToFile(
                          'Erase',
                          'erased.pdf',
                          (sink) => _pdf.eraseRegions(src, sink,
                                  page: 0,
                                  regions: [
                                    const PdfRect(
                                        x: 50, y: 700, width: 200, height: 30)
                                  ]))),
                  _Section('Stamps'),
                  _Op(
                      icon: Icons.approval,
                      title: 'Approved stamp on page 0',
                      loading: loading,
                      onRun: () => _opToFile(
                          'Stamp',
                          'stamped.pdf',
                          (sink) => _pdf.addStamp(src, sink,
                              page: 0,
                              type: PdfStampType.approved,
                              rect: const PdfRect(
                                  x: 50, y: 50, width: 200, height: 60)))),
                  _Op(
                      icon: Icons.add_photo_alternate,
                      title: 'Image stamp (pick image)',
                      loading: loading,
                      onRun: () => runFlow('ImageStamp', () async {
                            final imgs = await pickImageBytes();
                            if (imgs == null || imgs.isEmpty) return;
                            final sink = DemoSink();
                            await _pdf.addImageStamp(src, sink,
                                page: 0,
                                imageData: DemoSource(imgs.first.bytes),
                                rect: const PdfRect(
                                    x: 100, y: 100, width: 150, height: 150));
                            await saveResult(
                                sink.takeBytes(), 'image_stamped.pdf');
                          })),
                  _Section('Compliance'),
                  _Op(
                      icon: Icons.verified,
                      title: 'Convert to PDF/A',
                      loading: loading,
                      onRun: () => _opToFile('PDF/A', 'pdfa.pdf',
                          (sink) => _pdf.convertToPdfA(src, sink))),
                  _Section('Images → PDF'),
                  _Op(
                      icon: Icons.photo_library,
                      title: 'Images to PDF (pick images)',
                      loading: loading,
                      onRun: () => runFlow('ImgToPdf', () async {
                            final imgs = await pickImageBytes();
                            if (imgs == null || imgs.isEmpty) return;
                            final sink = DemoSink();
                            await _pdf.imagesToPdf(
                                imgs
                                    .map((i) =>
                                        DemoSource(i.bytes) as DataSource)
                                    .toList(),
                                sink);
                            await saveResult(
                                sink.takeBytes(), 'from_images.pdf');
                          })),
                  const SizedBox(height: 40),
                ],
              ),
      ),
    ]);
  }
}

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

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

class _StandaloneTabState extends State<_StandaloneTab>
    with _OpsRunner, _PdfPicker, AutomaticKeepAliveClientMixin {
  final _pdf = Pdf();

  @override
  bool get wantKeepAlive => true;

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

  void _pickFile() => unawaited(
      pick((p) => setState(() => status = 'Loaded (${fmtSize(p.length)})')));

  Future<void> _opToFile(String label, String filename,
      PdfTask<void> Function(DemoSink sink) start) async {
    final sink = DemoSink();
    if (await runTask(label, () => start(sink))) {
      await saveResult(sink.takeBytes(), filename);
    }
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return Column(children: [
      statusBar(),
      Expanded(
        child: bytes == null
            ? _EmptyState(
                icon: Icons.output,
                text: 'Source in → sink out, no handle',
                onPick: _pickFile)
            : ListView(
                padding:
                    const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
                children: [
                  fileCard(context, _pickFile),
                  _Section('Sign'),
                  _Op(
                      icon: Icons.verified_user_outlined,
                      title: 'Sign PDF (PEM cert)',
                      loading: loading,
                      onRun: () => _opToFile(
                          'Sign',
                          'signed.pdf',
                          (sink) => _pdf.sign(src, sink,
                              credentials: const PdfSigningCredentials.pem(
                                  testCertPem, testKeyPem)))),
                  _Section('Convert'),
                  _Op(
                      icon: Icons.description,
                      title: 'PDF → DOCX',
                      loading: loading,
                      onRun: () => _opToFile(
                          'DOCX',
                          'converted.docx',
                          (sink) => _pdf.convertTo(src, sink,
                              format: PdfDocumentFormat.docx))),
                  _Op(
                      icon: Icons.slideshow,
                      title: 'PDF → PPTX',
                      loading: loading,
                      onRun: () => _opToFile(
                          'PPTX',
                          'converted.pptx',
                          (sink) => _pdf.convertTo(src, sink,
                              format: PdfDocumentFormat.pptx))),
                  _Op(
                      icon: Icons.table_chart,
                      title: 'PDF → XLSX',
                      loading: loading,
                      onRun: () => _opToFile(
                          'XLSX',
                          'converted.xlsx',
                          (sink) => _pdf.convertTo(src, sink,
                              format: PdfDocumentFormat.xlsx))),
                  _Op(
                      icon: Icons.picture_as_pdf,
                      title: 'DOCX → PDF (round-trip)',
                      subtitle: 'Converts to DOCX first, then back to PDF',
                      loading: loading,
                      onRun: () => runFlow('DOCX→PDF', () async {
                            final docxSink = DemoSink();
                            await _pdf.convertTo(src, docxSink,
                                format: PdfDocumentFormat.docx);
                            final pdfSink = DemoSink();
                            await _pdf.convertToPdf(
                                DemoSource(docxSink.takeBytes()), pdfSink,
                                format: PdfDocumentFormat.docx);
                            await saveResult(
                                pdfSink.takeBytes(), 'roundtrip.pdf');
                          })),
                  _Section('Extract Pages'),
                  _Op(
                      icon: Icons.file_copy_outlined,
                      title: 'Extract pages 0–1',
                      loading: loading,
                      onRun: () => _opToFile(
                          'Extract',
                          'extracted_standalone.pdf',
                          (sink) =>
                              _pdf.extractPages(src, sink, pages: [0, 1]))),
                  const SizedBox(height: 40),
                ],
              ),
      ),
    ]);
  }
}

// ─── Tab 5: PdfEditor — parse once, mutate N times, save once ─────

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

class _EditorTabState extends State<_EditorTab>
    with _OpsRunner, _PdfPicker, AutomaticKeepAliveClientMixin {
  final _pdf = Pdf();

  @override
  bool get wantKeepAlive => true;

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

  void _pickFile() => unawaited(
      pick((p) => setState(() => status = 'Loaded (${fmtSize(p.length)})')));

  /// Opens an editor, runs [work], saves the produced bytes.
  Future<void> _edit(
      String label, Future<Uint8List> Function(PdfEditor e) work) async {
    await runFlow(label, () async {
      final editor = await _pdf.edit(src);
      try {
        final result = await work(editor);
        await saveResult(
            result, '${label.toLowerCase().replaceAll(' ', '_')}.pdf');
      } finally {
        await editor.dispose();
      }
    });
  }

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

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return Column(children: [
      statusBar(),
      Expanded(
        child: bytes == null
            ? _EmptyState(
                icon: Icons.edit_document,
                text: 'Parse once, mutate N times, save once',
                onPick: _pickFile)
            : ListView(
                padding:
                    const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
                children: [
                  fileCard(context, _pickFile),
                  _Section('Metadata'),
                  _Op(
                      icon: Icons.title,
                      title: 'Set title + author + subject + keywords',
                      loading: loading,
                      onRun: () => _edit('Metadata', (e) async {
                            await e.setTitle('Example');
                            await e.setAuthor('pdf_manipulator');
                            await e.setSubject('Demo');
                            await e.setKeywords('test, dart, pdf');
                            return _saveEditor(e);
                          })),
                  _Op(
                      icon: Icons.cleaning_services,
                      title: 'Scrub metadata',
                      loading: loading,
                      onRun: () => _edit('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: () => _edit('Rotate', (e) async {
                            await e.rotatePage(0, degrees: 90);
                            return _saveEditor(e);
                          })),
                  _Op(
                      icon: Icons.rotate_right,
                      title: 'Rotate all → 90°',
                      loading: loading,
                      onRun: () => _edit('RotateAll', (e) async {
                            await e.rotateAllPages(degrees: 90);
                            return _saveEditor(e);
                          })),
                  _Op(
                      icon: Icons.delete_outline,
                      title: 'Delete last page',
                      loading: loading,
                      onRun: () => _edit('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: () => _edit('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: () => _edit('Select', (e) async {
                            await e.selectPages([0, 1]);
                            return _saveEditor(e);
                          })),
                  _Op(
                      icon: Icons.straighten,
                      title: 'Get page 0 media box',
                      loading: loading,
                      onRun: () => _edit('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: () => _edit('Merge', (e) async {
                            await e.mergeFrom(DemoSource(bytes!));
                            return _saveEditor(e);
                          })),
                  _Section('Optimization'),
                  _Op(
                      icon: Icons.compress,
                      title: 'Optimize images (q60)',
                      loading: loading,
                      onRun: () => _edit('OptimizeImg', (e) async {
                            final n = await e.optimizeImages(quality: 60);
                            setState(() => status = '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: () => _edit('UnembedFonts', (e) async {
                            final n = await e.unembedStandardFonts();
                            setState(() => status = 'Unembedded $n fonts');
                            return _saveEditor(e);
                          })),
                  _Section('Watermark & Stamps'),
                  _Op(
                      icon: Icons.water_drop,
                      title: 'Watermark all pages "SAMPLE"',
                      loading: loading,
                      onRun: () => _edit('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: () => _edit('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: () => _edit('ImgStamp', (e) async {
                            final imgs = await pickImageBytes();
                            if (imgs == null || imgs.isEmpty) {
                              return _saveEditor(e);
                            }
                            await e.addImageStamp(
                                0, DemoSource(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: () => _edit('Embed', (e) async {
                            await e.embedFile(
                                'note.txt',
                                DemoSource(
                                    Uint8List.fromList('Hello!'.codeUnits)));
                            return _saveEditor(e);
                          })),
                  _Op(
                      icon: Icons.format_paint,
                      title: 'Erase region on page 0',
                      loading: loading,
                      onRun: () => _edit('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: () => _edit('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: () => _edit('Flatten', (e) async {
                            await e.flattenForms();
                            return _saveEditor(e);
                          })),
                  _Op(
                      icon: Icons.layers_clear_outlined,
                      title: 'Flatten all annotations',
                      loading: loading,
                      onRun: () => _edit('FlattenAnnot', (e) async {
                            await e.flattenAllAnnotations();
                            return _saveEditor(e);
                          })),
                  _Op(
                      icon: Icons.edit_note,
                      title: 'Set form field value',
                      subtitle: 'fieldName="name", value="John"',
                      loading: loading,
                      onRun: () => _edit('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: () => _edit('Redact', (e) async {
                            await e.addRedaction(
                                0,
                                const PdfRect(
                                    x: 50, y: 700, width: 200, height: 30));
                            final n = await e.redactionCount(0);
                            setState(() => status = 'Redactions on page 0: $n');
                            await e.applyRedactions();
                            return _saveEditor(e);
                          })),
                  _Section('Compliance'),
                  _Op(
                      icon: Icons.verified,
                      title: 'Convert to PDF/A',
                      loading: loading,
                      onRun: () => _edit('PDF-A', (e) async {
                            await e.convertToPdfA();
                            return _saveEditor(e);
                          })),
                  _Section('Security'),
                  _Op(
                      icon: Icons.lock,
                      title: 'Save encrypted (pw: test)',
                      loading: loading,
                      onRun: () => _edit('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: () => _edit('RemoveEncrypt', (e) async {
                            return _saveEditor(
                                e,
                                const PdfSaveOptions.fullRewrite(
                                    encryption: PdfEncryption.remove()));
                          })),
                  _Op(
                      icon: Icons.save_alt,
                      title: 'Incremental save',
                      loading: loading,
                      onRun: () => _edit('Incremental', (e) async {
                            return _saveEditor(
                                e, const PdfSaveOptions.incremental());
                          })),
                  _Section('Chained'),
                  _Op(
                      icon: Icons.auto_fix_high,
                      title: 'Rotate + watermark + compress + encrypt',
                      subtitle: 'Multiple mutations in one parse-save cycle',
                      loading: loading,
                      onRun: () => _edit('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 6: PdfBuilder — create from scratch ──────────────────────

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

class _BuilderTabState extends State<_BuilderTab>
    with _OpsRunner, AutomaticKeepAliveClientMixin {
  final _pdf = Pdf();

  @override
  bool get wantKeepAlive => true;

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

  /// Builds with a fresh PdfBuilder, saves the result, always
  /// disposes the builder — even when the body throws.
  Future<void> _build(String label, String filename,
      Future<void> Function(PdfBuilder b) body) async {
    await runFlow(label, () async {
      final b = await _pdf.build();
      try {
        await body(b);
        final sink = DemoSink();
        await b.save(sink);
        await saveResult(sink.takeBytes(), filename);
      } finally {
        await b.dispose();
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return Column(children: [
      statusBar(),
      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)),
                        ]),
                  ),
                ]),
              ),
            ),
            const SizedBox(height: 8),
            _Section('Documents'),
            _Op(
                icon: Icons.text_fields,
                title: 'Text document (A4, 2 pages)',
                subtitle: 'Headings, paragraphs, columns, horizontal rule',
                loading: loading,
                onRun: () => _build('Text', 'built_text.pdf', (b) async {
                      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();
                    })),
            _Op(
                icon: Icons.crop_16_9,
                title: 'Letter page',
                subtitle: 'US Letter (612×792 pt)',
                loading: loading,
                onRun: () => _build('Letter', 'letter.pdf', (b) async {
                      final p = await b.addLetterPage();
                      await p.heading(1, 'US Letter Page');
                      await p.paragraph('612 × 792 points.');
                      await p.done();
                    })),
            _Op(
                icon: Icons.aspect_ratio,
                title: 'Custom size page',
                subtitle: '400×300 pt landscape',
                loading: loading,
                onRun: () => _build('Custom', 'custom_size.pdf', (b) async {
                      final p = await b.addPage(width: 400, height: 300);
                      await p.heading(1, 'Custom Size');
                      await p.text('400 × 300 points');
                      await p.done();
                    })),
            _Section('Form Fields'),
            _Op(
                icon: Icons.edit_note,
                title: 'Full form (all field types)',
                subtitle:
                    'TextField, checkbox, combo, radio, button, signature',
                loading: loading,
                onRun: () => _build('Form', 'built_form.pdf', (b) async {
                      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();
                    })),
            _Section('Links & References'),
            _Op(
                icon: Icons.link,
                title: 'URL link + page link + footnote',
                loading: loading,
                onRun: () => _build('Links', 'built_links.pdf', (b) async {
                      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();
                    })),
            _Section('Watermark & Image'),
            _Op(
                icon: Icons.water_drop,
                title: 'Page with watermark',
                loading: loading,
                onRun: () =>
                    _build('Watermark', 'built_watermark.pdf', (b) async {
                      final p = await b.addA4Page();
                      await p.heading(1, 'Confidential Report');
                      await p.paragraph('Sensitive information.');
                      await p.watermark('CONFIDENTIAL');
                      await p.done();
                    })),
            _Op(
                icon: Icons.image,
                title: 'Page with image (pick)',
                loading: loading,
                onRun: () async {
                  final imgs = await pickImageBytes();
                  if (imgs == null || imgs.isEmpty) return;
                  await _build('Image', 'built_image.pdf', (b) async {
                    final p = await b.addA4Page();
                    await p.heading(1, 'Image Demo');
                    await p.image(DemoSource(imgs.first.bytes),
                        const PdfRect(x: 72, y: 500, width: 200, height: 200));
                    await p.done();
                  });
                }),
            _Section('Multi-Page'),
            _Op(
                icon: Icons.library_books,
                title: 'newline + newPageSameSize',
                loading: loading,
                onRun: () => _build('MultiPage', 'built_multi.pdf', (b) async {
                      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();
                    })),
            _Section('Font'),
            _Op(
                icon: Icons.font_download,
                title: 'Custom font + size',
                loading: loading,
                onRun: () => _build('Font', 'built_fonts.pdf', (b) async {
                      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();
                    })),
            const SizedBox(height: 40),
          ],
        ),
      ),
    ]);
  }
}

// ─── Tab 7: Merge — reorderable multi-file merge ──────────────────

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

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

  @override
  bool get wantKeepAlive => true;

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

  Future<void> _add() async {
    final picked = await pickPdfBytes(multiple: true);
    if (picked == null) return;
    for (final bytes in picked) {
      _files.add((
        name: 'demo-${_files.length + 1}.pdf',
        bytes: bytes,
        size: bytes.length,
      ));
    }
    setState(() {
      _merged = null;
      status = '${_files.length} files';
    });
  }

  Future<void> _merge() async {
    if (_files.length < 2) return;
    final sink = DemoSink();
    final ok = await runTask(
        'Merging ${_files.length} files',
        () => _pdf.merge(
            _files.map((f) => DemoSource(f.bytes) as DataSource).toList(),
            sink));
    if (ok) {
      final r = sink.takeBytes();
      setState(() {
        _merged = r;
        status = 'Merged: ${fmtSize(r.length)}';
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return Column(children: [
      statusBar(),
      Expanded(
        child: _files.isEmpty
            ? _EmptyState(
                icon: Icons.merge,
                text: 'Pick 2+ PDFs to merge — drag to reorder',
                onPick: _add,
                pickLabel: 'Pick PDFs')
            : ReorderableListView.builder(
                padding: const EdgeInsets.all(16),
                itemCount: _files.length,
                // onReorderItem pre-adjusts newIndex for the removed
                // slot — no manual decrement needed.
                onReorderItem: (oldIndex, newIndex) => setState(() {
                  final item = _files.removeAt(oldIndex);
                  _files.insert(newIndex, 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))
                          : const Icon(Icons.merge, size: 18),
                      label: Text('Merge ${_files.length}')),
            ),
          ]),
        ),
    ]);
  }
}
71
likes
0
points
2.89k
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