refactor: adopt better method to export pdfwq
This commit is contained in:
parent
9a31903d0d
commit
c5ecdf2706
|
@ -7,12 +7,10 @@ import 'package:path_provider/path_provider.dart' as pp;
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import '../share/export_service.dart';
|
import '../share/export_service.dart';
|
||||||
import 'package:hand_signature/signature.dart' as hand;
|
import 'package:hand_signature/signature.dart' as hand;
|
||||||
import 'package:meta/meta.dart';
|
|
||||||
|
|
||||||
part 'viewer_state.dart';
|
part 'viewer_state.dart';
|
||||||
part 'viewer_widgets.dart';
|
part 'viewer_widgets.dart';
|
||||||
|
|
||||||
// Testing hook: allow using a mock viewer instead of pdfrx to avoid async I/O in widget tests
|
|
||||||
final useMockViewerProvider = Provider<bool>((_) => false);
|
final useMockViewerProvider = Provider<bool>((_) => false);
|
||||||
// Export service injection for testability
|
// Export service injection for testability
|
||||||
final exportServiceProvider = Provider<ExportService>((_) => ExportService());
|
final exportServiceProvider = Provider<ExportService>((_) => ExportService());
|
||||||
|
@ -138,30 +136,39 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
final fullPath = _ensurePdfExtension(path.trim());
|
final fullPath = _ensurePdfExtension(path.trim());
|
||||||
final exporter = ref.read(exportServiceProvider);
|
final exporter = ref.read(exportServiceProvider);
|
||||||
final targetDpi = ref.read(exportDpiProvider);
|
final targetDpi = ref.read(exportDpiProvider);
|
||||||
// Multi-page export: iterate pages by navigating the viewer
|
final useMock = ref.read(useMockViewerProvider);
|
||||||
final controller = ref.read(pdfProvider.notifier);
|
bool ok = false;
|
||||||
final current = pdf.currentPage;
|
if (!useMock && pdf.pickedPdfPath != null) {
|
||||||
final targetPage = pdf.signedPage; // may be null if not marked
|
// Preferred path: operate on the original PDF file using engine-rendered backgrounds
|
||||||
final ok = await exporter.exportMultiPageFromBoundary(
|
ok = await exporter.exportSignedPdfFromFile(
|
||||||
boundaryKey: _captureKey,
|
inputPath: pdf.pickedPdfPath!,
|
||||||
outputPath: fullPath,
|
outputPath: fullPath,
|
||||||
pageCount: pdf.pageCount,
|
signedPage: pdf.signedPage,
|
||||||
targetDpi: targetDpi,
|
signatureRectUi: sig.rect,
|
||||||
onGotoPage: (p) async {
|
uiPageSize: SignatureController.pageSize,
|
||||||
controller.jumpTo(p);
|
signatureImageBytes: sig.imageBytes,
|
||||||
// Show overlay only on the signed page (if any)
|
targetDpi: targetDpi,
|
||||||
// If a target page is specified, show overlay only on that page.
|
);
|
||||||
// If not specified, keep overlay visible (backwards compatible single-page case).
|
} else {
|
||||||
final show = targetPage == null ? true : (targetPage == p);
|
// Fallback in mock/tests: snapshot the viewer per page
|
||||||
ref.read(signatureVisibilityProvider.notifier).state = show;
|
final controller = ref.read(pdfProvider.notifier);
|
||||||
// Allow build to occur
|
final current = pdf.currentPage;
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
final targetPage = pdf.signedPage; // may be null if not marked
|
||||||
},
|
ok = await exporter.exportMultiPageFromBoundary(
|
||||||
);
|
boundaryKey: _captureKey,
|
||||||
// Restore page
|
outputPath: fullPath,
|
||||||
controller.jumpTo(current);
|
pageCount: pdf.pageCount,
|
||||||
// Restore visibility
|
targetDpi: targetDpi,
|
||||||
ref.read(signatureVisibilityProvider.notifier).state = true;
|
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;
|
||||||
|
}
|
||||||
if (ok) {
|
if (ok) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
|
|
|
@ -1,9 +1,12 @@
|
||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:pdf/widgets.dart' as pw;
|
import 'package:pdf/widgets.dart' as pw;
|
||||||
import 'package:pdf/pdf.dart' as pdf;
|
import 'package:pdf/pdf.dart' as pdf;
|
||||||
|
import 'package:printing/printing.dart' as printing;
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
|
||||||
// NOTE:
|
// NOTE:
|
||||||
// - This exporter uses a raster snapshot of the UI (RepaintBoundary) and embeds it into a new PDF.
|
// - This exporter uses a raster snapshot of the UI (RepaintBoundary) and embeds it into a new PDF.
|
||||||
|
@ -12,6 +15,260 @@ import 'package:pdf/pdf.dart' as pdf;
|
||||||
// cannot import/modify existing PDF pages. If/when a suitable FOSS library exists, wire it here.
|
// cannot import/modify existing PDF pages. If/when a suitable FOSS library exists, wire it here.
|
||||||
|
|
||||||
class ExportService {
|
class ExportService {
|
||||||
|
/// Compose a new PDF by rasterizing the original PDF pages (via pdfrx engine)
|
||||||
|
/// and optionally stamping a signature image on the specified page.
|
||||||
|
///
|
||||||
|
/// Inputs:
|
||||||
|
/// - [inputPath]: Path to the original PDF to read
|
||||||
|
/// - [outputPath]: Path to write the composed PDF
|
||||||
|
/// - [signedPage]: 1-based page index to place the signature on (null = no overlay)
|
||||||
|
/// - [signatureRectUi]: Rect in the UI's logical page space (e.g. 400x560)
|
||||||
|
/// - [uiPageSize]: The logical page size used by the UI layout (SignatureController.pageSize)
|
||||||
|
/// - [signatureImageBytes]: PNG/JPEG bytes of the signature image to overlay
|
||||||
|
/// - [targetDpi]: Rasterization DPI for background pages
|
||||||
|
Future<bool> exportSignedPdfFromFile({
|
||||||
|
required String inputPath,
|
||||||
|
required String outputPath,
|
||||||
|
required int? signedPage,
|
||||||
|
required Rect? signatureRectUi,
|
||||||
|
required Size uiPageSize,
|
||||||
|
required Uint8List? signatureImageBytes,
|
||||||
|
double targetDpi = 144.0,
|
||||||
|
}) async {
|
||||||
|
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
|
||||||
|
Uint8List? srcBytes;
|
||||||
|
try {
|
||||||
|
srcBytes = await File(inputPath).readAsBytes();
|
||||||
|
} catch (_) {
|
||||||
|
srcBytes = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int pageIndex = 0; // 0-based stream index, 1-based page number for UI
|
||||||
|
bool anyPage = false;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback path for environments where raster is unavailable (e.g., tests)
|
||||||
|
if (!anyPage) {
|
||||||
|
// print('exportSignedPdfFromFile: using fallback A4 page path');
|
||||||
|
final widthPts = pdf.PdfPageFormat.a4.width;
|
||||||
|
final heightPts = pdf.PdfPageFormat.a4.height;
|
||||||
|
// Prepare signature image if needed
|
||||||
|
pw.MemoryImage? sigImgObj;
|
||||||
|
final shouldStampThisPage =
|
||||||
|
signedPage != null &&
|
||||||
|
signedPage == 1 &&
|
||||||
|
signatureRectUi != null &&
|
||||||
|
signatureImageBytes != null &&
|
||||||
|
signatureImageBytes.isNotEmpty;
|
||||||
|
if (shouldStampThisPage) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.addPage(
|
||||||
|
pw.Page(
|
||||||
|
pageTheme: pw.PageTheme(
|
||||||
|
margin: pw.EdgeInsets.zero,
|
||||||
|
pageFormat: pdf.PdfPageFormat(widthPts, heightPts),
|
||||||
|
),
|
||||||
|
build: (ctx) {
|
||||||
|
final children = <pw.Widget>[
|
||||||
|
pw.Container(
|
||||||
|
width: widthPts,
|
||||||
|
height: heightPts,
|
||||||
|
color: pdf.PdfColors.white,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> exportSignedPdfFromBoundary({
|
Future<bool> exportSignedPdfFromBoundary({
|
||||||
required GlobalKey boundaryKey,
|
required GlobalKey boundaryKey,
|
||||||
required String outputPath,
|
required String outputPath,
|
||||||
|
|
|
@ -42,6 +42,8 @@ dependencies:
|
||||||
pdfrx: ^1.3.5
|
pdfrx: ^1.3.5
|
||||||
pdf: ^3.10.8
|
pdf: ^3.10.8
|
||||||
hand_signature: ^3.1.0+2
|
hand_signature: ^3.1.0+2
|
||||||
|
image: ^4.2.0
|
||||||
|
printing: ^5.14.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
@ -0,0 +1,87 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'dart:ui' show Rect, Size;
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
import 'package:pdf/pdf.dart' as pdf;
|
||||||
|
import 'package:pdf/widgets.dart' as pw;
|
||||||
|
|
||||||
|
import 'package:pdf_signature/features/share/export_service.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test(
|
||||||
|
'exportSignedPdfFromFile overlays signature image (structure/size check)',
|
||||||
|
() async {
|
||||||
|
// 1) Create a simple 1-page white PDF as the source
|
||||||
|
final srcDoc = pw.Document();
|
||||||
|
srcDoc.addPage(
|
||||||
|
pw.Page(
|
||||||
|
pageFormat: pdf.PdfPageFormat.a4,
|
||||||
|
build: (_) => pw.Container(color: pdf.PdfColors.white),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final srcBytes = await srcDoc.save();
|
||||||
|
final srcPath =
|
||||||
|
'${Directory.systemTemp.path}/export_src_${DateTime.now().millisecondsSinceEpoch}.pdf';
|
||||||
|
await File(srcPath).writeAsBytes(srcBytes, flush: true);
|
||||||
|
|
||||||
|
// 2) Create a small opaque black PNG as the signature image
|
||||||
|
final sigW = 60, sigH = 30;
|
||||||
|
final sigBitmap = img.Image(width: sigW, height: sigH);
|
||||||
|
img.fill(sigBitmap, color: img.ColorRgb8(0, 0, 0));
|
||||||
|
final sigPng = Uint8List.fromList(img.encodePng(sigBitmap));
|
||||||
|
|
||||||
|
// 3) Define signature rect in UI logical space (400x560), centered
|
||||||
|
const uiSize = Size(400, 560);
|
||||||
|
final r = Rect.fromLTWH(
|
||||||
|
uiSize.width / 2 - sigW / 2,
|
||||||
|
uiSize.height / 2 - sigH / 2,
|
||||||
|
sigW.toDouble(),
|
||||||
|
sigH.toDouble(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4) Baseline export without signature (no overlay)
|
||||||
|
final baselinePath =
|
||||||
|
'${Directory.systemTemp.path}/export_baseline_${DateTime.now().millisecondsSinceEpoch}.pdf';
|
||||||
|
final svc = ExportService();
|
||||||
|
final okBase = await svc.exportSignedPdfFromFile(
|
||||||
|
inputPath: srcPath,
|
||||||
|
outputPath: baselinePath,
|
||||||
|
signedPage: null,
|
||||||
|
signatureRectUi: null,
|
||||||
|
uiPageSize: uiSize,
|
||||||
|
signatureImageBytes: null,
|
||||||
|
targetDpi: 144.0,
|
||||||
|
);
|
||||||
|
expect(okBase, isTrue, reason: 'baseline export should succeed');
|
||||||
|
final baseBytes = await File(baselinePath).readAsBytes();
|
||||||
|
expect(baseBytes.isNotEmpty, isTrue);
|
||||||
|
|
||||||
|
// 5) Export with overlay
|
||||||
|
final outPath =
|
||||||
|
'${Directory.systemTemp.path}/export_out_${DateTime.now().millisecondsSinceEpoch}.pdf';
|
||||||
|
final ok = await svc.exportSignedPdfFromFile(
|
||||||
|
inputPath: srcPath,
|
||||||
|
outputPath: outPath,
|
||||||
|
signedPage: 1,
|
||||||
|
signatureRectUi: r,
|
||||||
|
uiPageSize: uiSize,
|
||||||
|
signatureImageBytes: sigPng,
|
||||||
|
targetDpi: 144.0,
|
||||||
|
);
|
||||||
|
expect(ok, isTrue, reason: 'export should succeed');
|
||||||
|
final outBytes = await File(outPath).readAsBytes();
|
||||||
|
expect(outBytes.isNotEmpty, isTrue);
|
||||||
|
|
||||||
|
// 6) Heuristic validations without rasterization:
|
||||||
|
// - The output with overlay should be larger than the baseline.
|
||||||
|
// - The output should contain at least one image object marker.
|
||||||
|
expect(outBytes.length, greaterThan(baseBytes.length));
|
||||||
|
// Decode as latin1 to preserve byte-to-char mapping, then look for the image marker
|
||||||
|
final outText = String.fromCharCodes(outBytes);
|
||||||
|
final hasImageMarker = RegExp(r"/Subtype\s*/Image").hasMatch(outText);
|
||||||
|
expect(hasImageMarker, isTrue);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
|
@ -10,7 +10,6 @@ import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'dart:ui' show PointerDeviceKind;
|
import 'dart:ui' show PointerDeviceKind;
|
||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:pdf_signature/features/pdf/viewer.dart';
|
import 'package:pdf_signature/features/pdf/viewer.dart';
|
||||||
import 'package:pdf_signature/features/share/export_service.dart';
|
import 'package:pdf_signature/features/share/export_service.dart';
|
||||||
|
|
Loading…
Reference in New Issue