custom_pdf_editor 0.2.0
custom_pdf_editor: ^0.2.0 copied to clipboard
Flutter PDF viewer and on-canvas text editor for iOS (native PDFKit), plus PDF↔Word conversion. Requires a companion self-hosted backend (included in server/).
example/lib/main.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:custom_pdf_editor/custom_pdf_editor.dart';
import 'package:path_provider/path_provider.dart';
import 'package:file_picker/file_picker.dart';
/// Point this at your own deployed PDF backend (see `server/app.py`).
const String kBackendUrl = 'https://example-123.up.railway.app';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'PDF Editor Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
useMaterial3: true,
),
home: const HomePage(),
);
}
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final _customPdfEditorPlugin = CustomPdfEditor();
String _platformVersion = 'Unknown';
@override
void initState() {
super.initState();
_initPlatformState();
}
Future<void> _initPlatformState() async {
String platformVersion;
try {
platformVersion = await _customPdfEditorPlugin.getPlatformVersion() ??
'Unknown platform version';
} on PlatformException {
platformVersion = 'Failed to get platform version.';
}
if (!mounted) return;
setState(() {
_platformVersion = platformVersion;
});
}
Future<void> _openSamplePDF() async {
// For demo purposes, you would load a real PDF here
// This navigates to the PDF viewer screen
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const PDFViewerScreen(),
),
);
}
Future<void> _pickPDF() async {
try {
FilePickerResult? result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf'],
);
if (result != null && result.files.single.path != null) {
final file = File(result.files.single.path!);
if (mounted) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PDFViewerScreen(pdfFile: file),
),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error picking file: $e')),
);
}
}
}
/// Pick a PDF, compress it on the backend, and report the size reduction.
Future<void> _pickForCompress() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf'],
);
if (result == null || result.files.single.path == null) return;
final bytes = await File(result.files.single.path!).readAsBytes();
if (mounted) {
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const AlertDialog(
content: Row(children: [
CircularProgressIndicator(),
SizedBox(width: 16),
Text('Compressing...'),
]),
),
);
}
final compressed = await PdfCompressionService(baseUrl: kBackendUrl)
.compressPdf(bytes, level: CompressionLevel.medium);
if (!mounted) return;
Navigator.of(context).pop(); // close progress
if (compressed == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('❌ Compression failed')),
);
return;
}
// Save the compressed copy next to a temp location.
final dir = await getTemporaryDirectory();
final outPath = '${dir.path}/compressed.pdf';
await File(outPath).writeAsBytes(compressed);
final before = (bytes.length / 1024).toStringAsFixed(0);
final after = (compressed.length / 1024).toStringAsFixed(0);
final saved =
(100 * (1 - compressed.length / bytes.length)).toStringAsFixed(1);
if (!mounted) return;
showDialog(
context: context,
builder: (_) => AlertDialog(
title: const Text('Compression complete'),
content: Text(
'Before: $before KB\nAfter: $after KB\nSaved: $saved%\n\n'
'Saved to:\n$outPath',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'),
),
],
),
);
} catch (e) {
if (mounted) {
Navigator.of(context).maybePop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
}
}
/// Pick a PDF and open the Acrobat-style paragraph editor.
Future<void> _pickForParagraphMode() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf'],
);
if (result == null || result.files.single.path == null) return;
final bytes = await File(result.files.single.path!).readAsBytes();
if (!mounted) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ParagraphEditorWidget(
pdfBytes: bytes,
backendUrl: kBackendUrl,
onBack: () => Navigator.pop(context),
),
),
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('PDF Editor Demo'),
elevation: 2,
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.picture_as_pdf,
size: 100,
color: Theme.of(context).primaryColor,
),
const SizedBox(height: 16),
Text(
'Custom PDF Editor',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 8),
Text(
'Running on: $_platformVersion',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _openSamplePDF,
icon: const Icon(Icons.folder_open),
label: const Text('Open Sample PDF'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(16),
),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _pickPDF,
icon: const Icon(Icons.file_upload),
label: const Text('Select PDF from Files'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.all(16),
),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _pickForParagraphMode,
icon: const Icon(Icons.dashboard_customize),
label: const Text('Edit Paragraphs (beta)'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.all(16),
),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _pickForCompress,
icon: const Icon(Icons.compress),
label: const Text('Compress PDF'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.all(16),
),
),
),
const SizedBox(height: 8),
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Features',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
_buildFeatureItem(Icons.zoom_in, 'Zoom & Pan'),
_buildFeatureItem(Icons.edit, 'Text Editing'),
_buildFeatureItem(Icons.draw, 'Annotations'),
_buildFeatureItem(Icons.image, 'Image Insertion'),
_buildFeatureItem(Icons.search, 'Text Search'),
_buildFeatureItem(Icons.pages, 'Page Management'),
],
),
),
),
],
),
),
),
);
}
Widget _buildFeatureItem(IconData icon, String text) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
children: [
Icon(icon, size: 20, color: Colors.green),
const SizedBox(width: 12),
Text(text),
],
),
);
}
}
class PDFViewerScreen extends StatefulWidget {
final File? pdfFile;
const PDFViewerScreen({super.key, this.pdfFile});
@override
State<PDFViewerScreen> createState() => _PDFViewerScreenState();
}
class _PDFViewerScreenState extends State<PDFViewerScreen> {
late PdfController _pdfController;
final PdfConversionService _conversionService =
PdfConversionService(baseUrl: kBackendUrl);
bool _isLoading = true;
String? _error;
String _currentTool = 'view';
bool _isInEditMode = false;
File? _currentDocxFile;
@override
void initState() {
super.initState();
_pdfController = PdfController(
config: const PdfEditorConfig(
enableTextEditing: true,
enableAnnotations: true,
enableImageEditing: true,
enableSearch: true,
enableThumbnails: true,
showToolbar: true,
enableZoom: true,
minZoom: 0.5,
maxZoom: 5.0,
enableUndoRedo: true,
),
);
_checkPythonSetup();
_loadSamplePDF();
}
Future<void> _checkPythonSetup() async {
final available = await _conversionService.checkPythonAvailability();
if (!available && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'⚠️ Python not found. Edit mode requires Python.\n'
'Install: pip3 install pdf2docx python-docx reportlab',
),
duration: Duration(seconds: 8),
backgroundColor: Colors.orange,
),
);
}
}
Future<void> _enterAdvancedEditMode() async {
if (!_pdfController.hasDocument) return;
final pdfBytes = _pdfController.document?.bytes;
if (pdfBytes == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('❌ No PDF data available'),
backgroundColor: Colors.red,
),
);
return;
}
// Navigate to advanced editor
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AdvancedPdfEditorWidget(
pdfBytes: pdfBytes,
backendUrl: kBackendUrl,
onBack: () => Navigator.pop(context),
),
),
);
}
Future<void> _enterEditMode() async {
if (!_pdfController.hasDocument) return;
setState(() {
_isLoading = true;
});
try {
// Get PDF bytes
final pdfBytes = _pdfController.document?.bytes;
if (pdfBytes == null) {
throw Exception('No PDF data available');
}
// Show progress
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Row(
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(Colors.white),
),
),
SizedBox(width: 12),
Text('Converting PDF to DOCX...'),
],
),
duration: Duration(seconds: 30),
),
);
}
// Convert to DOCX
final docxFile = await _conversionService.pdfToDocx(
pdfBytes,
'document_${DateTime.now().millisecondsSinceEpoch}',
);
if (docxFile != null) {
setState(() {
_currentDocxFile = docxFile;
_isInEditMode = true;
_isLoading = false;
});
if (mounted) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('✅ Converted successfully! Edit your document.'),
backgroundColor: Colors.green,
duration: Duration(seconds: 3),
),
);
}
} else {
throw Exception('Failed to convert PDF to DOCX');
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('❌ Error: $e'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 5),
),
);
}
setState(() {
_isLoading = false;
});
}
}
Future<void> _onPdfGenerated(Uint8List pdfBytes) async {
// Load the new PDF
await _pdfController.loadFromBytes(pdfBytes);
setState(() {
_isInEditMode = false;
_currentDocxFile = null;
});
}
Future<void> _loadSamplePDF() async {
try {
final Uint8List bytes;
if (widget.pdfFile != null) {
bytes = await widget.pdfFile!.readAsBytes();
} else {
// Load a sample PDF from assets
final ByteData data = await rootBundle.load('assets/sample.pdf');
bytes = data.buffer.asUint8List();
}
debugPrint('PDF bytes loaded: ${bytes.length} bytes');
await _pdfController.loadFromBytes(bytes);
setState(() {
_isLoading = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('PDF loaded successfully'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
debugPrint('Error loading PDF: $e');
setState(() {
_isLoading = false;
_error = e.toString();
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error loading PDF: $e'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 5),
),
);
}
}
}
Future<void> _addHighlight() async {
final annotation = PdfAnnotation(
id: DateTime.now().millisecondsSinceEpoch.toString(),
type: PdfAnnotationType.highlight,
pageIndex: _pdfController.currentPage,
bounds: const PdfRect(x: 100, y: 100, width: 200, height: 50),
color: PdfColor.yellow,
opacity: 0.5,
createdAt: DateTime.now(),
);
await _pdfController.addAnnotation(annotation);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Highlight added')),
);
}
}
Future<void> _addTextNote() async {
final textEdit = PdfTextEdit(
id: DateTime.now().millisecondsSinceEpoch.toString(),
pageIndex: _pdfController.currentPage,
bounds: const PdfRect(x: 100, y: 200, width: 300, height: 100),
text: 'Sample text annotation',
fontSize: 14.0,
textColor: PdfColor.black,
alignment: PdfTextAlignment.left,
);
await _pdfController.addText(textEdit);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Text added')),
);
}
}
Future<void> _addSquare() async {
final annotation = PdfAnnotation(
id: DateTime.now().millisecondsSinceEpoch.toString(),
type: PdfAnnotationType.square,
pageIndex: _pdfController.currentPage,
bounds: const PdfRect(x: 150, y: 300, width: 150, height: 150),
color: PdfColor.red,
opacity: 0.7,
borderWidth: 2.0,
createdAt: DateTime.now(),
);
await _pdfController.addAnnotation(annotation);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Square added')),
);
}
}
Future<void> _searchText() async {
final controller = TextEditingController();
await showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Search Text'),
content: TextField(
controller: controller,
decoration: const InputDecoration(
hintText: 'Enter search query',
border: OutlineInputBorder(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () async {
Navigator.pop(context);
final results = await _pdfController.searchText(controller.text);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Found ${results.length} results'),
),
);
}
},
child: const Text('Search'),
),
],
),
);
}
Future<void> _savePDF() async {
try {
final directory = await getApplicationDocumentsDirectory();
final outputPath =
'${directory.path}/edited_pdf_${DateTime.now().millisecondsSinceEpoch}.pdf';
final success = await _pdfController.save(outputPath: outputPath);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
success
? 'PDF saved to: $outputPath'
: 'Failed to save PDF',
),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error saving PDF: $e')),
);
}
}
}
@override
void dispose() {
_pdfController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
_isInEditMode
? 'Edit Mode'
: _pdfController.hasDocument
? 'Page ${_pdfController.currentPage + 1} of ${_pdfController.pageCount}'
: 'PDF Viewer',
),
actions: [
if (!_isInEditMode) ...[
IconButton(
icon: const Icon(Icons.edit_document),
onPressed: _pdfController.hasDocument ? _enterEditMode : null,
tooltip: 'Edit (Convert to DOCX)',
),
IconButton(
icon: const Icon(Icons.auto_fix_high),
onPressed: _pdfController.hasDocument ? _enterAdvancedEditMode : null,
tooltip: 'Advanced Edit (Keep Format)',
),
IconButton(
icon: const Icon(Icons.undo),
onPressed: _pdfController.hasDocument
? () => _pdfController.undo()
: null,
tooltip: 'Undo',
),
IconButton(
icon: const Icon(Icons.redo),
onPressed: _pdfController.hasDocument
? () => _pdfController.redo()
: null,
tooltip: 'Redo',
),
IconButton(
icon: const Icon(Icons.search),
onPressed: _pdfController.hasDocument ? _searchText : null,
tooltip: 'Search',
),
IconButton(
icon: const Icon(Icons.save),
onPressed: _pdfController.hasDocument ? _savePDF : null,
tooltip: 'Save',
),
],
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text('Error: $_error'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => Navigator.pop(context),
child: const Text('Go Back'),
),
],
),
)
: _isInEditMode && _currentDocxFile != null
? DocxEditorWidget(
docxFile: _currentDocxFile!,
backendUrl: kBackendUrl,
onPdfGenerated: _onPdfGenerated,
onCancel: () {
setState(() {
_isInEditMode = false;
_currentDocxFile = null;
});
},
)
: Column(
children: [
// PDF Viewer
Expanded(
child: PdfViewerWidget(
controller: _pdfController,
onPageChanged: (page) {
setState(() {});
},
onDocumentLoaded: () {
debugPrint('Document loaded');
},
onError: (error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $error')),
);
},
),
),
// Tool Bar
Container(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8.0,
vertical: 8.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildToolButton(
Icons.visibility,
'View',
'view',
),
_buildToolButton(
Icons.highlight,
'Highlight',
'highlight',
onPressed: _addHighlight,
),
_buildToolButton(
Icons.text_fields,
'Text',
'text',
onPressed: _addTextNote,
),
_buildToolButton(
Icons.crop_square,
'Shape',
'shape',
onPressed: _addSquare,
),
],
),
),
),
// Navigation Bar
Container(
color: Colors.grey[200],
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: _pdfController.currentPage > 0
? () => _pdfController.previousPage()
: null,
),
Text(
'Page ${_pdfController.currentPage + 1} / ${_pdfController.pageCount}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: _pdfController.currentPage <
_pdfController.pageCount - 1
? () => _pdfController.nextPage()
: null,
),
const SizedBox(width: 16),
IconButton(
icon: const Icon(Icons.zoom_out),
onPressed: () {
final newZoom = _pdfController.currentZoom - 0.2;
_pdfController.setZoom(newZoom);
},
),
Text('${(_pdfController.currentZoom * 100).toInt()}%'),
IconButton(
icon: const Icon(Icons.zoom_in),
onPressed: () {
final newZoom = _pdfController.currentZoom + 0.2;
_pdfController.setZoom(newZoom);
},
),
],
),
),
],
),
);
}
Widget _buildToolButton(
IconData icon,
String label,
String tool, {
VoidCallback? onPressed,
}) {
final isSelected = _currentTool == tool;
return InkWell(
onTap: () {
setState(() {
_currentTool = tool;
});
onPressed?.call();
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isSelected ? Colors.blue.withValues(alpha: 0.1) : Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSelected ? Colors.blue : Colors.transparent,
width: 2,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
color: isSelected ? Colors.blue : Colors.grey[700],
),
const SizedBox(height: 4),
Text(
label,
style: TextStyle(
fontSize: 12,
color: isSelected ? Colors.blue : Colors.grey[700],
),
),
],
),
),
);
}
}