Package versions
When a PDF is edited and saved, reopening the saved PDF and editing it again and save causes the application to crash on some Android devices. In some cases, previously saved annotations/highlights are also missing after reopening the generated PDF.
pdf: 3.11.3
pdfx: 2.9.2
image: 4.8.0
Platform
Android only
Tested on:
- Android 12
- Android 16
- Samsung devices
- Motorola devices
- Vivo devices
The issue does NOT occur on iOS.
Steps to reproduce
- Open any PDF.
- Add text or annotations.
- Save the PDF.
- Open the saved PDF again.
- Add another annotation.
- Save again.
Expected behavior
The edited PDF should save successfully every time.
Actual behavior
The application crashes while generating/saving the PDF.
This issue occurs only on some Android devices.
Additionally, in some cases previously saved annotations disappear after reopening the generated PDF.
Notes
- Happens with every PDF.
- First save succeeds.
- Second save causes the crash.
- iOS works correctly.
Sample code
Future _save() async {
if (_isSaving) return;
setState(() => _isSaving = true);
_commitText();
await SchedulerBinding.instance.endOfFrame;
setState(() => _saving = true);
bool loadingDialogShown = false;
if (mounted) {
loadingDialogShown = true;
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const PopScope(
canPop: false,
child: Dialog(
backgroundColor: Color(0xFF1C1C1E),
child: Padding(
padding: EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(color: Color(0xFF4FC3F7)),
SizedBox(height: 16),
Text(
'Saving PDF, please wait...',
style: TextStyle(color: Colors.white, fontSize: 14),
),
],
),
),
),
),
);
}
try {
// ── Ensure un-rendered pages are rendered (render in parallel batches) ──
await _ensureAllPagesRendered();
// ✅ FIX 3: Build the page list using cached composites where available,
// then fall back to in-memory compositing for remaining dirty pages —
// all done in parallel batches of 3.
final List<Uint8List?> slots = List.filled(_totalPages, null);
const batchSize = 3;
for (int start = 0; start < _totalPages; start += batchSize) {
final end = (start + batchSize).clamp(0, _totalPages);
final futures = <Future<void>>[];
for (int i = start; i < end; i++) {
final idx = i;
futures.add(() async {
if (_pageImages[idx] == null) return; // render failed, skip
final hasAnnotations = _annotations[idx]?.isNotEmpty ?? false;
if (!hasAnnotations) {
// ✅ FIX 1: Pass PNG directly — pw.MemoryImage handles PNG natively.
// No redundant JPEG re-encode for clean pages.
slots[idx] = _pageImages[idx]!;
} else if (_compositedCache.containsKey(idx) &&
!_dirtyPages.contains(idx)) {
// ✅ Pre-baked — instant, no work to do.
slots[idx] = _compositedCache[idx]!;
} else {
// Fallback: composite now (should be rare since _onPanEnd
// pre-composites in background after every stroke).
slots[idx] = await _renderPageInMemory(idx);
}
}());
}
await Future.wait(futures);
}
final renderedPages = slots.whereType<Uint8List>().toList();
// PDF assembly in background isolate.
// final pdfBytes = await compute(_buildPdfFromPngs, renderedPages);
final pdfBytes = kIsWeb
? await _buildPdfFromPngs(
renderedPages,
) // web: no real isolate anyway
: await compute(
_buildPdfFromPngs,
renderedPages,
); // native: real isolate
final base = widget.fileName.contains('.')
? widget.fileName.substring(0, widget.fileName.lastIndexOf('.'))
: widget.fileName;
final outName = 'EDITED_$base.pdf';
if (mounted && loadingDialogShown) {
Navigator.of(context, rootNavigator: true).pop();
loadingDialogShown = false;
}
if (mounted) setState(() => _saving = false);
await _showSaveShareSheet(context, pdfBytes, outName);
} catch (e) {
debugPrint('PDF save error: $e');
if (mounted && loadingDialogShown) {
Navigator.of(context, rootNavigator: true).pop();
}
if (mounted) {
setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Save failed: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_saving = false;
_isSaving = false;
});
}
}
}
Future _ensureAllPagesRendered() async {
const batchSize = 4;
for (int start = 0; start < _totalPages; start += batchSize) {
final end = (start + batchSize).clamp(0, _totalPages);
final futures = <Future>[];
for (int i = start; i < end; i++) {
if (_pageImages[i] == null) {
futures.add(_renderPage(i));
}
}
if (futures.isNotEmpty) await Future.wait(futures);
}
}
Future _renderPageInMemory(int pageIdx) async {
if (_pageImages[pageIdx] == null) {
await _renderPage(pageIdx);
}
if (_pageImages[pageIdx] == null) {
throw Exception('Page $pageIdx render failed');
}
final pageBytes = _pageImages[pageIdx]!;
final items = _annotations[pageIdx] ?? [];
final codec = await ui.instantiateImageCodec(pageBytes);
final frame = await codec.getNextFrame();
final baseImage = frame.image;
final w = baseImage.width.toDouble();
final h = baseImage.height.toDouble();
final displaySize = _displaySizes[pageIdx] ?? Size(w, h);
final scaleX = w / displaySize.width;
final scaleY = h / displaySize.height;
final scaledItems = items.map((item) {
return item.copyWith(
pts: item.pts.map((p) => Offset(p.dx * scaleX, p.dy * scaleY)).toList(),
s: item.s != null
? Offset(item.s!.dx * scaleX, item.s!.dy * scaleY)
: null,
e: item.e != null
? Offset(item.e!.dx * scaleX, item.e!.dy * scaleY)
: null,
txtPos: item.txtPos != null
? Offset(item.txtPos!.dx * scaleX, item.txtPos!.dy * scaleY)
: null,
sw: item.sw * ((scaleX + scaleY) / 2),
fontSize: item.fontSize * ((scaleX + scaleY) / 2),
textMaxWidth: item.textMaxWidth * scaleX,
);
}).toList();
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder, Rect.fromLTWH(0, 0, w, h));
canvas.drawImage(baseImage, Offset.zero, Paint());
_PagePainter(items: scaledItems).paint(canvas, Size(w, h));
final picture = recorder.endRecording();
final rendered = await picture.toImage(w.toInt(), h.toInt());
// rawRgba capture — near-instant, no PNG encoder involved
// final bd = await rendered.toByteData(format: ui.ImageByteFormat.rawRgba);
// baseImage.dispose();
// rendered.dispose();
// // JPEG encode in a background isolate
// return compute(
// _encodeRgbaToJpg,
// _RgbaEncodeArgs(
// bytes: bd!.buffer.asUint8List(),
// width: w.toInt(),
// height: h.toInt(),
// ),
// );
if (kIsWeb) {
// Flutter web's PNG encoder is WAY faster than image-package JPEG on web
final bd = await rendered.toByteData(format: ui.ImageByteFormat.png);
baseImage.dispose();
rendered.dispose();
return bd!.buffer.asUint8List();
} else {
final bd = await rendered.toByteData(format: ui.ImageByteFormat.rawRgba);
baseImage.dispose();
rendered.dispose();
return compute(
_encodeRgbaToJpg,
_RgbaEncodeArgs(
bytes: bd!.buffer.asUint8List(),
width: w.toInt(),
height: h.toInt(),
),
);
}
}
Future _buildPdfFromPngs(List pages) async {
final doc = pw.Document();
for (final pageBytes in pages) {
final image = pw.MemoryImage(pageBytes);
doc.addPage(
pw.Page(
pageFormat: pw_color.PdfPageFormat(
image.width!.toDouble(),
image.height!.toDouble(),
),
margin: pw.EdgeInsets.zero,
build: (ctx) => pw.Image(image, fit: pw.BoxFit.fill),
),
);
}
final bytes = await doc.save();
return Uint8List.fromList(bytes);
}
Package versions
When a PDF is edited and saved, reopening the saved PDF and editing it again and save causes the application to crash on some Android devices. In some cases, previously saved annotations/highlights are also missing after reopening the generated PDF.
pdf: 3.11.3
pdfx: 2.9.2
image: 4.8.0
Platform
Android only
Tested on:
The issue does NOT occur on iOS.
Steps to reproduce
Expected behavior
The edited PDF should save successfully every time.
Actual behavior
The application crashes while generating/saving the PDF.
This issue occurs only on some Android devices.
Additionally, in some cases previously saved annotations disappear after reopening the generated PDF.
Notes
Sample code
Future _save() async {
if (_isSaving) return;
setState(() => _isSaving = true);
_commitText();
await SchedulerBinding.instance.endOfFrame;
setState(() => _saving = true);
}
Future _ensureAllPagesRendered() async {
const batchSize = 4;
for (int start = 0; start < _totalPages; start += batchSize) {
final end = (start + batchSize).clamp(0, _totalPages);
final futures = <Future>[];
for (int i = start; i < end; i++) {
if (_pageImages[i] == null) {
futures.add(_renderPage(i));
}
}
if (futures.isNotEmpty) await Future.wait(futures);
}
}
Future _renderPageInMemory(int pageIdx) async {
if (_pageImages[pageIdx] == null) {
await _renderPage(pageIdx);
}
if (_pageImages[pageIdx] == null) {
throw Exception('Page $pageIdx render failed');
}
}
Future _buildPdfFromPngs(List pages) async {
final doc = pw.Document();
for (final pageBytes in pages) {
final image = pw.MemoryImage(pageBytes);
doc.addPage(
pw.Page(
pageFormat: pw_color.PdfPageFormat(
image.width!.toDouble(),
image.height!.toDouble(),
),
margin: pw.EdgeInsets.zero,
build: (ctx) => pw.Image(image, fit: pw.BoxFit.fill),
),
);
}
final bytes = await doc.save();
return Uint8List.fromList(bytes);
}