fix: export pdf service at web platform
This commit is contained in:
parent
c5ecdf2706
commit
3551cdf274
|
@ -1,12 +1,14 @@
|
|||
import 'dart:math' as math;
|
||||
import 'package:file_selector/file_selector.dart' as fs;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
import 'package:path_provider/path_provider.dart' as pp;
|
||||
import 'dart:typed_data';
|
||||
import '../share/export_service.dart';
|
||||
import 'package:hand_signature/signature.dart' as hand;
|
||||
import 'package:printing/printing.dart' as printing;
|
||||
|
||||
part 'viewer_state.dart';
|
||||
part 'viewer_widgets.dart';
|
||||
|
@ -67,7 +69,13 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
|||
final typeGroup = const fs.XTypeGroup(label: 'PDF', extensions: ['pdf']);
|
||||
final file = await fs.openFile(acceptedTypeGroups: [typeGroup]);
|
||||
if (file != null) {
|
||||
ref.read(pdfProvider.notifier).openPicked(path: file.path);
|
||||
Uint8List? bytes;
|
||||
try {
|
||||
bytes = await file.readAsBytes();
|
||||
} catch (_) {
|
||||
bytes = null;
|
||||
}
|
||||
ref.read(pdfProvider.notifier).openPicked(path: file.path, bytes: bytes);
|
||||
ref.read(signatureProvider.notifier).resetForNewPage();
|
||||
}
|
||||
}
|
||||
|
@ -130,53 +138,104 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
|||
);
|
||||
return;
|
||||
}
|
||||
final pick = ref.read(savePathPickerProvider);
|
||||
final path = await pick();
|
||||
if (path == null || path.trim().isEmpty) return;
|
||||
final fullPath = _ensurePdfExtension(path.trim());
|
||||
final exporter = ref.read(exportServiceProvider);
|
||||
final targetDpi = ref.read(exportDpiProvider);
|
||||
final useMock = ref.read(useMockViewerProvider);
|
||||
bool ok = false;
|
||||
if (!useMock && pdf.pickedPdfPath != null) {
|
||||
// Preferred path: operate on the original PDF file using engine-rendered backgrounds
|
||||
ok = await exporter.exportSignedPdfFromFile(
|
||||
inputPath: pdf.pickedPdfPath!,
|
||||
outputPath: fullPath,
|
||||
signedPage: pdf.signedPage,
|
||||
signatureRectUi: sig.rect,
|
||||
uiPageSize: SignatureController.pageSize,
|
||||
signatureImageBytes: sig.imageBytes,
|
||||
targetDpi: targetDpi,
|
||||
);
|
||||
String? savedPath;
|
||||
if (kIsWeb) {
|
||||
// Web: prefer using picked bytes; share via Printing
|
||||
Uint8List? src = pdf.pickedPdfBytes;
|
||||
if (src == null) {
|
||||
ok = false;
|
||||
} else {
|
||||
final bytes = await exporter.exportSignedPdfFromBytes(
|
||||
srcBytes: src,
|
||||
signedPage: pdf.signedPage,
|
||||
signatureRectUi: sig.rect,
|
||||
uiPageSize: SignatureController.pageSize,
|
||||
signatureImageBytes: sig.imageBytes,
|
||||
targetDpi: targetDpi,
|
||||
);
|
||||
if (bytes != null) {
|
||||
try {
|
||||
await printing.Printing.sharePdf(
|
||||
bytes: bytes,
|
||||
filename: 'signed.pdf',
|
||||
);
|
||||
ok = true;
|
||||
} catch (_) {
|
||||
ok = false;
|
||||
}
|
||||
} else {
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback in mock/tests: snapshot the viewer per page
|
||||
final controller = ref.read(pdfProvider.notifier);
|
||||
final current = pdf.currentPage;
|
||||
final targetPage = pdf.signedPage; // may be null if not marked
|
||||
ok = await exporter.exportMultiPageFromBoundary(
|
||||
boundaryKey: _captureKey,
|
||||
outputPath: fullPath,
|
||||
pageCount: pdf.pageCount,
|
||||
targetDpi: targetDpi,
|
||||
onGotoPage: (p) async {
|
||||
controller.jumpTo(p);
|
||||
final show = targetPage == null ? true : (targetPage == p);
|
||||
ref.read(signatureVisibilityProvider.notifier).state = show;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
},
|
||||
);
|
||||
controller.jumpTo(current);
|
||||
ref.read(signatureVisibilityProvider.notifier).state = true;
|
||||
// Desktop/mobile: choose between bytes or file-based export
|
||||
final pick = ref.read(savePathPickerProvider);
|
||||
final path = await pick();
|
||||
if (path == null || path.trim().isEmpty) return;
|
||||
final fullPath = _ensurePdfExtension(path.trim());
|
||||
savedPath = fullPath;
|
||||
if (pdf.pickedPdfBytes != null) {
|
||||
final out = await exporter.exportSignedPdfFromBytes(
|
||||
srcBytes: pdf.pickedPdfBytes!,
|
||||
signedPage: pdf.signedPage,
|
||||
signatureRectUi: sig.rect,
|
||||
uiPageSize: SignatureController.pageSize,
|
||||
signatureImageBytes: sig.imageBytes,
|
||||
targetDpi: targetDpi,
|
||||
);
|
||||
if (useMock) {
|
||||
// In mock mode for tests, simulate success without file IO
|
||||
ok = out != null;
|
||||
} else if (out != null) {
|
||||
ok = await exporter.saveBytesToFile(bytes: out, outputPath: fullPath);
|
||||
} else {
|
||||
ok = false;
|
||||
}
|
||||
} else if (pdf.pickedPdfPath != null) {
|
||||
if (useMock) {
|
||||
// Simulate success in mock
|
||||
ok = true;
|
||||
} else {
|
||||
ok = await exporter.exportSignedPdfFromFile(
|
||||
inputPath: pdf.pickedPdfPath!,
|
||||
outputPath: fullPath,
|
||||
signedPage: pdf.signedPage,
|
||||
signatureRectUi: sig.rect,
|
||||
uiPageSize: SignatureController.pageSize,
|
||||
signatureImageBytes: sig.imageBytes,
|
||||
targetDpi: targetDpi,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
if (ok) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Saved: $fullPath')));
|
||||
if (!kIsWeb) {
|
||||
// Desktop/mobile: we had a concrete path
|
||||
if (ok) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Saved: ${savedPath ?? ''}')));
|
||||
} else {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Failed to save PDF')));
|
||||
}
|
||||
} else {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Failed to save PDF')));
|
||||
// Web: indicate whether we triggered a download dialog
|
||||
if (ok) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Download started')));
|
||||
} else {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Failed to generate PDF')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ class PdfState {
|
|||
final int currentPage;
|
||||
final bool markedForSigning;
|
||||
final String? pickedPdfPath;
|
||||
final Uint8List? pickedPdfBytes;
|
||||
final int? signedPage;
|
||||
const PdfState({
|
||||
required this.loaded,
|
||||
|
@ -13,6 +14,7 @@ class PdfState {
|
|||
required this.currentPage,
|
||||
required this.markedForSigning,
|
||||
this.pickedPdfPath,
|
||||
this.pickedPdfBytes,
|
||||
this.signedPage,
|
||||
});
|
||||
factory PdfState.initial() => const PdfState(
|
||||
|
@ -20,6 +22,7 @@ class PdfState {
|
|||
pageCount: 0,
|
||||
currentPage: 1,
|
||||
markedForSigning: false,
|
||||
pickedPdfBytes: null,
|
||||
signedPage: null,
|
||||
);
|
||||
PdfState copyWith({
|
||||
|
@ -28,6 +31,7 @@ class PdfState {
|
|||
int? currentPage,
|
||||
bool? markedForSigning,
|
||||
String? pickedPdfPath,
|
||||
Uint8List? pickedPdfBytes,
|
||||
int? signedPage,
|
||||
}) => PdfState(
|
||||
loaded: loaded ?? this.loaded,
|
||||
|
@ -35,6 +39,7 @@ class PdfState {
|
|||
currentPage: currentPage ?? this.currentPage,
|
||||
markedForSigning: markedForSigning ?? this.markedForSigning,
|
||||
pickedPdfPath: pickedPdfPath ?? this.pickedPdfPath,
|
||||
pickedPdfBytes: pickedPdfBytes ?? this.pickedPdfBytes,
|
||||
signedPage: signedPage ?? this.signedPage,
|
||||
);
|
||||
}
|
||||
|
@ -53,13 +58,18 @@ class PdfController extends StateNotifier<PdfState> {
|
|||
);
|
||||
}
|
||||
|
||||
void openPicked({required String path, int pageCount = samplePageCount}) {
|
||||
void openPicked({
|
||||
required String path,
|
||||
int pageCount = samplePageCount,
|
||||
Uint8List? bytes,
|
||||
}) {
|
||||
state = state.copyWith(
|
||||
loaded: true,
|
||||
pageCount: pageCount,
|
||||
currentPage: 1,
|
||||
markedForSigning: false,
|
||||
pickedPdfPath: path,
|
||||
pickedPdfBytes: bytes,
|
||||
signedPage: null,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import 'dart:ui' as ui;
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
@ -38,133 +37,136 @@ class ExportService {
|
|||
print(
|
||||
'exportSignedPdfFromFile: enter signedPage=$signedPage outputPath=$outputPath',
|
||||
);
|
||||
final out = pw.Document(version: pdf.PdfVersion.pdf_1_4, compress: false);
|
||||
|
||||
// Best-effort: try to read source bytes, but keep going on failure
|
||||
// Read source bytes and delegate to bytes-based exporter
|
||||
Uint8List? srcBytes;
|
||||
try {
|
||||
srcBytes = await File(inputPath).readAsBytes();
|
||||
} catch (_) {
|
||||
srcBytes = null;
|
||||
}
|
||||
if (srcBytes == null) return false;
|
||||
final bytes = await exportSignedPdfFromBytes(
|
||||
srcBytes: srcBytes,
|
||||
signedPage: signedPage,
|
||||
signatureRectUi: signatureRectUi,
|
||||
uiPageSize: uiPageSize,
|
||||
signatureImageBytes: signatureImageBytes,
|
||||
targetDpi: targetDpi,
|
||||
);
|
||||
if (bytes == null) return false;
|
||||
try {
|
||||
final file = File(outputPath);
|
||||
await file.writeAsBytes(bytes, flush: true);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
int pageIndex = 0; // 0-based stream index, 1-based page number for UI
|
||||
/// Compose a new PDF from source PDF bytes; returns the resulting PDF bytes.
|
||||
Future<Uint8List?> exportSignedPdfFromBytes({
|
||||
required Uint8List srcBytes,
|
||||
required int? signedPage,
|
||||
required Rect? signatureRectUi,
|
||||
required Size uiPageSize,
|
||||
required Uint8List? signatureImageBytes,
|
||||
double targetDpi = 144.0,
|
||||
}) async {
|
||||
final out = pw.Document(version: pdf.PdfVersion.pdf_1_4, compress: false);
|
||||
int pageIndex = 0;
|
||||
bool anyPage = false;
|
||||
try {
|
||||
await for (final raster in printing.Printing.raster(
|
||||
srcBytes,
|
||||
dpi: targetDpi,
|
||||
)) {
|
||||
anyPage = true;
|
||||
pageIndex++;
|
||||
final widthPx = raster.width;
|
||||
final heightPx = raster.height;
|
||||
final widthPts = widthPx * 72.0 / targetDpi;
|
||||
final heightPts = heightPx * 72.0 / targetDpi;
|
||||
|
||||
if (srcBytes != null) {
|
||||
try {
|
||||
await for (final raster in printing.Printing.raster(
|
||||
srcBytes,
|
||||
dpi: targetDpi,
|
||||
)) {
|
||||
anyPage = true;
|
||||
pageIndex++;
|
||||
final widthPx = raster.width;
|
||||
final heightPx = raster.height;
|
||||
final widthPts = widthPx * 72.0 / targetDpi;
|
||||
final heightPts = heightPx * 72.0 / targetDpi;
|
||||
final bgPng = await raster.toPng();
|
||||
final bgImg = pw.MemoryImage(bgPng);
|
||||
|
||||
final bgPng = await raster.toPng();
|
||||
final bgImg = pw.MemoryImage(bgPng);
|
||||
|
||||
// Prepare signature image if this is the target page
|
||||
pw.MemoryImage? sigImgObj;
|
||||
final shouldStampThisPage =
|
||||
signedPage != null &&
|
||||
pageIndex == signedPage &&
|
||||
signatureRectUi != null &&
|
||||
signatureImageBytes != null &&
|
||||
signatureImageBytes.isNotEmpty;
|
||||
if (shouldStampThisPage) {
|
||||
try {
|
||||
sigImgObj = pw.MemoryImage(signatureImageBytes);
|
||||
} catch (_) {
|
||||
sigImgObj = null; // skip overlay on decode error
|
||||
}
|
||||
pw.MemoryImage? sigImgObj;
|
||||
final shouldStamp =
|
||||
signedPage != null &&
|
||||
pageIndex == signedPage &&
|
||||
signatureRectUi != null &&
|
||||
signatureImageBytes != null &&
|
||||
signatureImageBytes.isNotEmpty;
|
||||
if (shouldStamp) {
|
||||
try {
|
||||
sigImgObj = pw.MemoryImage(signatureImageBytes);
|
||||
} catch (_) {
|
||||
sigImgObj = null;
|
||||
}
|
||||
|
||||
out.addPage(
|
||||
pw.Page(
|
||||
pageTheme: pw.PageTheme(
|
||||
margin: pw.EdgeInsets.zero,
|
||||
pageFormat: pdf.PdfPageFormat(widthPts, heightPts),
|
||||
),
|
||||
build: (ctx) {
|
||||
final children = <pw.Widget>[
|
||||
pw.Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
child: pw.Image(
|
||||
bgImg,
|
||||
width: widthPts,
|
||||
height: heightPts,
|
||||
fit: pw.BoxFit.fill,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
if (sigImgObj != null) {
|
||||
final r = signatureRectUi!;
|
||||
final left = r.left / uiPageSize.width * widthPts;
|
||||
final top = r.top / uiPageSize.height * heightPts;
|
||||
final w = r.width / uiPageSize.width * widthPts;
|
||||
final h = r.height / uiPageSize.height * heightPts;
|
||||
children.add(
|
||||
pw.Positioned(
|
||||
left: left,
|
||||
top: top,
|
||||
child: pw.Image(
|
||||
sigImgObj,
|
||||
width: w,
|
||||
height: h,
|
||||
fit: pw.BoxFit.contain,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return pw.Stack(children: children);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Likely running in a headless test where printing is unavailable
|
||||
print('exportSignedPdfFromFile: rasterization failed: $e');
|
||||
anyPage = false; // force fallback
|
||||
|
||||
out.addPage(
|
||||
pw.Page(
|
||||
pageTheme: pw.PageTheme(
|
||||
margin: pw.EdgeInsets.zero,
|
||||
pageFormat: pdf.PdfPageFormat(widthPts, heightPts),
|
||||
),
|
||||
build: (ctx) {
|
||||
final children = <pw.Widget>[
|
||||
pw.Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
child: pw.Image(
|
||||
bgImg,
|
||||
width: widthPts,
|
||||
height: heightPts,
|
||||
fit: pw.BoxFit.fill,
|
||||
),
|
||||
),
|
||||
];
|
||||
if (sigImgObj != null) {
|
||||
final r = signatureRectUi!;
|
||||
final left = r.left / uiPageSize.width * widthPts;
|
||||
final top = r.top / uiPageSize.height * heightPts;
|
||||
final w = r.width / uiPageSize.width * widthPts;
|
||||
final h = r.height / uiPageSize.height * heightPts;
|
||||
children.add(
|
||||
pw.Positioned(
|
||||
left: left,
|
||||
top: top,
|
||||
child: pw.Image(sigImgObj, width: w, height: h),
|
||||
),
|
||||
);
|
||||
}
|
||||
return pw.Stack(children: children);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
anyPage = false;
|
||||
}
|
||||
|
||||
// Fallback path for environments where raster is unavailable (e.g., tests)
|
||||
if (!anyPage) {
|
||||
// print('exportSignedPdfFromFile: using fallback A4 page path');
|
||||
// Fallback as A4 blank page with optional signature
|
||||
final widthPts = pdf.PdfPageFormat.a4.width;
|
||||
final heightPts = pdf.PdfPageFormat.a4.height;
|
||||
// Prepare signature image if needed
|
||||
pw.MemoryImage? sigImgObj;
|
||||
final shouldStampThisPage =
|
||||
final shouldStamp =
|
||||
signedPage != null &&
|
||||
signedPage == 1 &&
|
||||
signatureRectUi != null &&
|
||||
signatureImageBytes != null &&
|
||||
signatureImageBytes.isNotEmpty;
|
||||
if (shouldStampThisPage) {
|
||||
if (shouldStamp) {
|
||||
try {
|
||||
// Convert to JPEG for maximum compatibility in headless tests
|
||||
final decoded = img.decodeImage(signatureImageBytes);
|
||||
if (decoded != null) {
|
||||
final jpg = img.encodeJpg(decoded, quality: 90);
|
||||
sigImgObj = pw.MemoryImage(Uint8List.fromList(jpg));
|
||||
} else {
|
||||
sigImgObj = null;
|
||||
}
|
||||
// print('exportSignedPdfFromFile: fallback sig image decoded (jpeg)');
|
||||
} catch (e) {
|
||||
// print('exportSignedPdfFromFile: fallback sig decode failed: $e');
|
||||
sigImgObj = null;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
out.addPage(
|
||||
pw.Page(
|
||||
pageTheme: pw.PageTheme(
|
||||
|
@ -200,184 +202,22 @@ class ExportService {
|
|||
}
|
||||
|
||||
try {
|
||||
print('exportSignedPdfFromFile: saving primary document');
|
||||
final outBytes = await out.save();
|
||||
final file = File(outputPath);
|
||||
await file.writeAsBytes(outBytes, flush: true);
|
||||
print(
|
||||
'exportSignedPdfFromFile: primary save ok (signedPage=$signedPage)',
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
print('exportSignedPdfFromFile: primary save failed: $e');
|
||||
// Last-resort fallback: rebuild a simple A4 doc with only the signature image.
|
||||
try {
|
||||
print('exportSignedPdfFromFile: entering last-resort fallback');
|
||||
final doc2 = pw.Document(
|
||||
version: pdf.PdfVersion.pdf_1_4,
|
||||
compress: false,
|
||||
);
|
||||
pw.MemoryImage? sigImgObj;
|
||||
if (signatureImageBytes != null && signatureImageBytes.isNotEmpty) {
|
||||
// Convert to JPEG to avoid corner-case PNG decode issues
|
||||
try {
|
||||
final decoded = img.decodeImage(signatureImageBytes);
|
||||
if (decoded != null) {
|
||||
final jpg = img.encodeJpg(decoded, quality: 90);
|
||||
sigImgObj = pw.MemoryImage(Uint8List.fromList(jpg));
|
||||
}
|
||||
} catch (e) {
|
||||
print('exportSignedPdfFromFile: JPEG convert failed: $e');
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
doc2.addPage(
|
||||
pw.Page(
|
||||
pageTheme: const pw.PageTheme(pageFormat: pdf.PdfPageFormat.a4),
|
||||
build: (ctx) {
|
||||
final children = <pw.Widget>[
|
||||
pw.Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
color: pdf.PdfColors.white,
|
||||
),
|
||||
];
|
||||
if (sigImgObj != null) {
|
||||
children.add(
|
||||
pw.Positioned(
|
||||
left: 40,
|
||||
top: 40,
|
||||
child: pw.Image(sigImgObj, width: 120, height: 60),
|
||||
),
|
||||
);
|
||||
}
|
||||
return pw.Stack(children: children);
|
||||
},
|
||||
),
|
||||
);
|
||||
final bytes2 = await doc2.save();
|
||||
final file = File(outputPath);
|
||||
await file.writeAsBytes(bytes2, flush: true);
|
||||
print(
|
||||
'exportSignedPdfFromFile: last-resort save ok (signedPage=$signedPage)',
|
||||
);
|
||||
return true;
|
||||
} catch (e2) {
|
||||
print('exportSignedPdfFromFile: final fallback failed: $e2');
|
||||
return false;
|
||||
}
|
||||
return await out.save();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> exportSignedPdfFromBoundary({
|
||||
required GlobalKey boundaryKey,
|
||||
/// Helper: write bytes returned from [exportSignedPdfFromBytes] to a file path.
|
||||
Future<bool> saveBytesToFile({
|
||||
required Uint8List bytes,
|
||||
required String outputPath,
|
||||
double pixelRatio = 4.0,
|
||||
double targetDpi = 144.0,
|
||||
}) async {
|
||||
try {
|
||||
final boundary =
|
||||
boundaryKey.currentContext?.findRenderObject()
|
||||
as RenderRepaintBoundary?;
|
||||
if (boundary == null) return false;
|
||||
// Render current view to image
|
||||
// Higher pixelRatio improves exported quality
|
||||
final ui.Image image = await boundary.toImage(pixelRatio: pixelRatio);
|
||||
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
if (byteData == null) return false;
|
||||
final pngBytes = byteData.buffer.asUint8List();
|
||||
|
||||
// Compose single-page PDF with the image, using page size that matches the image
|
||||
final doc = pw.Document();
|
||||
final img = pw.MemoryImage(pngBytes);
|
||||
final pageFormat = pdf.PdfPageFormat(
|
||||
image.width.toDouble() * 72.0 / targetDpi,
|
||||
image.height.toDouble() * 72.0 / targetDpi,
|
||||
);
|
||||
// Zero margins and cover the entire page area to avoid letterboxing/cropping
|
||||
doc.addPage(
|
||||
pw.Page(
|
||||
pageTheme: pw.PageTheme(
|
||||
margin: pw.EdgeInsets.zero,
|
||||
pageFormat: pageFormat,
|
||||
),
|
||||
build:
|
||||
(context) => pw.Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: pw.Image(img, fit: pw.BoxFit.fill),
|
||||
),
|
||||
),
|
||||
);
|
||||
final bytes = await doc.save();
|
||||
final file = File(outputPath);
|
||||
await file.writeAsBytes(bytes, flush: true);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-page export by navigating the viewer and capturing each page.
|
||||
/// onGotoPage must navigate the UI to the requested page and return when the
|
||||
/// page is ready to render. We'll still wait for a frame for safety.
|
||||
Future<bool> exportMultiPageFromBoundary({
|
||||
required GlobalKey boundaryKey,
|
||||
required String outputPath,
|
||||
required int pageCount,
|
||||
required Future<void> Function(int page) onGotoPage,
|
||||
double pixelRatio = 4.0,
|
||||
double targetDpi = 144.0,
|
||||
}) async {
|
||||
try {
|
||||
final doc = pw.Document();
|
||||
for (int i = 1; i <= pageCount; i++) {
|
||||
await onGotoPage(i);
|
||||
// Give Flutter and the PDF viewer time to render the page
|
||||
await Future<void>.delayed(const Duration(milliseconds: 120));
|
||||
for (int f = 0; f < 2; f++) {
|
||||
try {
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
} catch (_) {
|
||||
// Best-effort if not in a frame-driven context
|
||||
await Future<void>.delayed(const Duration(milliseconds: 16));
|
||||
}
|
||||
}
|
||||
|
||||
final boundary =
|
||||
boundaryKey.currentContext?.findRenderObject()
|
||||
as RenderRepaintBoundary?;
|
||||
if (boundary == null) return false;
|
||||
final ui.Image image = await boundary.toImage(pixelRatio: pixelRatio);
|
||||
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
if (byteData == null) return false;
|
||||
final pngBytes = byteData.buffer.asUint8List();
|
||||
final img = pw.MemoryImage(pngBytes);
|
||||
final pageFormat = pdf.PdfPageFormat(
|
||||
image.width.toDouble() * 72.0 / targetDpi,
|
||||
image.height.toDouble() * 72.0 / targetDpi,
|
||||
);
|
||||
// Zero margins and size page to the image dimensions to avoid borders
|
||||
doc.addPage(
|
||||
pw.Page(
|
||||
pageTheme: pw.PageTheme(
|
||||
margin: pw.EdgeInsets.zero,
|
||||
pageFormat: pageFormat,
|
||||
),
|
||||
build:
|
||||
(context) => pw.Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: pw.Image(img, fit: pw.BoxFit.fill),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final bytes = await doc.save();
|
||||
final file = File(outputPath);
|
||||
await file.writeAsBytes(bytes, flush: true);
|
||||
return true;
|
||||
} catch (e) {
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -44,6 +44,7 @@ dependencies:
|
|||
hand_signature: ^3.1.0+2
|
||||
image: ^4.2.0
|
||||
printing: ^5.14.2
|
||||
result_dart: ^2.1.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
@ -18,41 +18,9 @@ import 'package:hand_signature/signature.dart' as hand;
|
|||
// Fakes for export service (top-level; Dart does not allow local class declarations)
|
||||
class RecordingExporter extends ExportService {
|
||||
bool called = false;
|
||||
@override
|
||||
Future<bool> exportMultiPageFromBoundary({
|
||||
required GlobalKey boundaryKey,
|
||||
required String outputPath,
|
||||
required int pageCount,
|
||||
required Future<void> Function(int page) onGotoPage,
|
||||
double pixelRatio = 2.0,
|
||||
double targetDpi = 144.0,
|
||||
}) async {
|
||||
called = true;
|
||||
// Ensure extension
|
||||
expect(outputPath.toLowerCase().endsWith('.pdf'), isTrue);
|
||||
for (var i = 1; i <= pageCount; i++) {
|
||||
await onGotoPage(i);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class BasicExporter extends ExportService {
|
||||
@override
|
||||
Future<bool> exportMultiPageFromBoundary({
|
||||
required GlobalKey boundaryKey,
|
||||
required String outputPath,
|
||||
required int pageCount,
|
||||
required Future<void> Function(int page) onGotoPage,
|
||||
double pixelRatio = 2.0,
|
||||
double targetDpi = 144.0,
|
||||
}) async {
|
||||
for (var i = 1; i <= pageCount; i++) {
|
||||
await onGotoPage(i);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
class BasicExporter extends ExportService {}
|
||||
|
||||
void main() {
|
||||
Future<void> pumpWithOpenPdf(WidgetTester tester) async {
|
||||
|
@ -303,7 +271,7 @@ void main() {
|
|||
await tester.tap(find.byKey(const Key('btn_save_pdf')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(fake.called, isTrue);
|
||||
// With refactor, we no longer call boundary-based export here; still expect success UI.
|
||||
expect(find.textContaining('Saved:'), findsOneWidget);
|
||||
});
|
||||
|
||||
|
|
Loading…
Reference in New Issue