From 828eee49e2620e408d71abfe173b238a93b0653a Mon Sep 17 00:00:00 2001 From: insleker Date: Fri, 29 Aug 2025 16:53:06 +0800 Subject: [PATCH] fix: image background remove function --- docs/FRs.md | 8 + lib/data/services/export_service.dart | 18 +- lib/data/services/providers.dart | 3 + .../features/pdf/view_model/view_model.dart | 72 ++++ lib/ui/features/pdf/widgets/pdf_screen.dart | 311 +++++++++++------- .../step/a_signature_image_is_selected.dart | 2 + ...nd_becomes_transparent_in_the_preview.dart | 32 ++ ...nges_contrast_and_brightness_controls.dart | 2 + .../the_user_enables_background_removal.dart | 2 + 9 files changed, 320 insertions(+), 130 deletions(-) diff --git a/docs/FRs.md b/docs/FRs.md index 063f48a..387f856 100644 --- a/docs/FRs.md +++ b/docs/FRs.md @@ -26,3 +26,11 @@ * role: user * functionality: save/export the signed PDF document * benefit: easily keep a copy of the signed document for records. +* name: preferences for app + * role: user + * functionality: configure app preferences such as `theme`, `language`. + * benefit: customize the app experience to better fit user needs +* name: remember preferences + * role: user + * functionality: remember user preferences for future sessions + * benefit: provide a consistent and personalized experience diff --git a/lib/data/services/export_service.dart b/lib/data/services/export_service.dart index 41cad57..105502d 100644 --- a/lib/data/services/export_service.dart +++ b/lib/data/services/export_service.dart @@ -160,10 +160,20 @@ class ExportService { signatureImageBytes.isNotEmpty; if (shouldStamp) { try { - final decoded = img.decodeImage(signatureImageBytes); - if (decoded != null) { - final jpg = img.encodeJpg(decoded, quality: 90); - sigImgObj = pw.MemoryImage(Uint8List.fromList(jpg)); + // If it's already PNG, keep as-is to preserve alpha; otherwise decode/encode PNG + final asStr = String.fromCharCodes(signatureImageBytes.take(8)); + final isPng = + signatureImageBytes.length > 8 && + signatureImageBytes[0] == 0x89 && + asStr.startsWith('\u0089PNG'); + if (isPng) { + sigImgObj = pw.MemoryImage(signatureImageBytes); + } else { + final decoded = img.decodeImage(signatureImageBytes); + if (decoded != null) { + final png = img.encodePng(decoded, level: 6); + sigImgObj = pw.MemoryImage(Uint8List.fromList(png)); + } } } catch (_) {} } diff --git a/lib/data/services/providers.dart b/lib/data/services/providers.dart index 4ac4835..8fc4cbc 100644 --- a/lib/data/services/providers.dart +++ b/lib/data/services/providers.dart @@ -17,6 +17,9 @@ final exportDpiProvider = StateProvider((_) => 144.0); // Controls whether signature overlay is visible (used to hide on non-stamped pages during export) final signatureVisibilityProvider = StateProvider((_) => true); +// Global exporting state to show loading UI and block interactions while saving/exporting +final exportingProvider = StateProvider((_) => false); + // Save path picker (injected for tests) final savePathPickerProvider = Provider Function()>((ref) { return () async { diff --git a/lib/ui/features/pdf/view_model/view_model.dart b/lib/ui/features/pdf/view_model/view_model.dart index 14d57b8..6ff2c19 100644 --- a/lib/ui/features/pdf/view_model/view_model.dart +++ b/lib/ui/features/pdf/view_model/view_model.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:image/image.dart' as img; import '../../../../data/model/model.dart'; @@ -192,3 +193,74 @@ final signatureProvider = StateNotifierProvider( (ref) => SignatureController(), ); + +/// Derived provider that returns processed signature image bytes according to +/// current adjustment settings (contrast/brightness) and background removal. +/// Returns null if no image is loaded. The output is a PNG to preserve alpha. +final processedSignatureImageProvider = Provider((ref) { + final s = ref.watch(signatureProvider); + final bytes = s.imageBytes; + if (bytes == null || bytes.isEmpty) return null; + + // Decode (supports PNG/JPEG, etc.) + final decoded = img.decodeImage(bytes); + if (decoded == null) return bytes; + + // Work on a copy and ensure an alpha channel is present (RGBA) + var out = decoded.clone(); + if (out.hasPalette || !out.hasAlpha) { + // Force truecolor RGBA image so per-pixel alpha writes take effect + out = out.convert(numChannels: 4); + } + + // Parameters + final double contrast = s.contrast; // [0..2], 1 = neutral + final double brightness = s.brightness; // [-1..1], 0 = neutral + const int thrLow = 220; // begin soft transparency from this avg luminance + const int thrHigh = 245; // fully transparent from this avg luminance + + // Helper to clamp int + int _clamp255(num v) => v.clamp(0, 255).toInt(); + + // Iterate pixels + for (int y = 0; y < out.height; y++) { + for (int x = 0; x < out.width; x++) { + final p = out.getPixel(x, y); + int a = _clamp255(p.aNormalized * 255.0); + int r = _clamp255(p.rNormalized * 255.0); + int g = _clamp255(p.gNormalized * 255.0); + int b = _clamp255(p.bNormalized * 255.0); + + // Apply contrast/brightness in sRGB space + // new = (old-128)*contrast + 128 + brightness*255 + final double brOffset = brightness * 255.0; + r = _clamp255((r - 128) * contrast + 128 + brOffset); + g = _clamp255((g - 128) * contrast + 128 + brOffset); + b = _clamp255((b - 128) * contrast + 128 + brOffset); + + // Near-white background removal (compute average luminance) + final int avg = ((r + g + b) / 3).round(); + int remAlpha = 255; // 255 = fully opaque, 0 = transparent + if (s.bgRemoval) { + if (avg >= thrHigh) { + remAlpha = 0; + } else if (avg >= thrLow) { + // Soft fade between thrLow..thrHigh + final double t = (avg - thrLow) / (thrHigh - thrLow); + remAlpha = _clamp255(255 * (1.0 - t)); + } else { + remAlpha = 255; + } + } + + // Combine with existing alpha (preserve existing transparency) + final newA = math.min(a, remAlpha); + + out.setPixelRgba(x, y, r, g, b, newA); + } + } + + // Encode as PNG to preserve transparency + final png = img.encodePng(out, level: 6); + return Uint8List.fromList(png); +}); diff --git a/lib/ui/features/pdf/widgets/pdf_screen.dart b/lib/ui/features/pdf/widgets/pdf_screen.dart index f3654c6..a1ee584 100644 --- a/lib/ui/features/pdf/widgets/pdf_screen.dart +++ b/lib/ui/features/pdf/widgets/pdf_screen.dart @@ -91,116 +91,129 @@ class _PdfSignatureHomePageState extends ConsumerState { } Future _saveSignedPdf() async { - final pdf = ref.read(pdfProvider); - final sig = ref.read(signatureProvider); - // Cache messenger before any awaits to avoid using BuildContext across async gaps. - final messenger = ScaffoldMessenger.of(context); - if (!pdf.loaded || sig.rect == null) { - messenger.showSnackBar( - const SnackBar( - content: Text('Nothing to save yet'), - ), // guard per use-case - ); - return; - } - final exporter = ref.read(exportServiceProvider); - final targetDpi = ref.read(exportDpiProvider); - final useMock = ref.read(useMockViewerProvider); - bool ok = false; - 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, + // Set exporting state to show loading overlay and block interactions + ref.read(exportingProvider.notifier).state = true; + try { + final pdf = ref.read(pdfProvider); + final sig = ref.read(signatureProvider); + // Cache messenger before any awaits to avoid using BuildContext across async gaps. + final messenger = ScaffoldMessenger.of(context); + if (!pdf.loaded || sig.rect == null) { + messenger.showSnackBar( + const SnackBar( + content: Text('Nothing to save yet'), + ), // guard per use-case ); - if (bytes != null) { - try { - await printing.Printing.sharePdf( - bytes: bytes, - filename: 'signed.pdf', - ); - ok = true; - } catch (_) { + return; + } + final exporter = ref.read(exportServiceProvider); + final targetDpi = ref.read(exportDpiProvider); + final useMock = ref.read(useMockViewerProvider); + bool ok = false; + String? savedPath; + if (kIsWeb) { + // Web: prefer using picked bytes; share via Printing + Uint8List? src = pdf.pickedPdfBytes; + if (src == null) { + ok = false; + } else { + final processed = ref.read(processedSignatureImageProvider); + final bytes = await exporter.exportSignedPdfFromBytes( + srcBytes: src, + signedPage: pdf.signedPage, + signatureRectUi: sig.rect, + uiPageSize: SignatureController.pageSize, + signatureImageBytes: processed ?? 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 { + // 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 processed = ref.read(processedSignatureImageProvider); + final out = await exporter.exportSignedPdfFromBytes( + srcBytes: pdf.pickedPdfBytes!, + signedPage: pdf.signedPage, + signatureRectUi: sig.rect, + uiPageSize: SignatureController.pageSize, + signatureImageBytes: processed ?? 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 { + final processed = ref.read(processedSignatureImageProvider); + ok = await exporter.exportSignedPdfFromFile( + inputPath: pdf.pickedPdfPath!, + outputPath: fullPath, + signedPage: pdf.signedPage, + signatureRectUi: sig.rect, + uiPageSize: SignatureController.pageSize, + signatureImageBytes: processed ?? sig.imageBytes, + targetDpi: targetDpi, + ); + } } else { ok = false; } } - } else { - // 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); + if (!kIsWeb) { + // Desktop/mobile: we had a concrete path + if (ok) { + messenger.showSnackBar( + SnackBar(content: Text('Saved: ${savedPath ?? ''}')), + ); } 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, + messenger.showSnackBar( + const SnackBar(content: Text('Failed to save PDF')), ); } } else { - ok = false; - } - } - if (!kIsWeb) { - // Desktop/mobile: we had a concrete path - if (ok) { - messenger.showSnackBar( - SnackBar(content: Text('Saved: ${savedPath ?? ''}')), - ); - } else { - messenger.showSnackBar( - const SnackBar(content: Text('Failed to save PDF')), - ); - } - } else { - // Web: indicate whether we triggered a download dialog - if (ok) { - messenger.showSnackBar( - const SnackBar(content: Text('Download started')), - ); - } else { - messenger.showSnackBar( - const SnackBar(content: Text('Failed to generate PDF')), - ); + // Web: indicate whether we triggered a download dialog + if (ok) { + messenger.showSnackBar( + const SnackBar(content: Text('Download started')), + ); + } else { + messenger.showSnackBar( + const SnackBar(content: Text('Failed to generate PDF')), + ); + } } + } finally { + // Clear exporting state when finished or on error + ref.read(exportingProvider.notifier).state = false; } } @@ -212,30 +225,62 @@ class _PdfSignatureHomePageState extends ConsumerState { @override Widget build(BuildContext context) { final pdf = ref.watch(pdfProvider); + final isExporting = ref.watch(exportingProvider); return Scaffold( appBar: AppBar(title: const Text('PDF Signature')), body: Padding( padding: const EdgeInsets.all(12), - child: Column( + child: Stack( children: [ - _buildToolbar(pdf), - const SizedBox(height: 8), - Expanded(child: _buildPageArea(pdf)), - Consumer( - builder: (context, ref, _) { - final sig = ref.watch(signatureProvider); - return sig.rect != null - ? _buildAdjustmentsPanel(sig) - : const SizedBox.shrink(); - }, + Column( + children: [ + _buildToolbar(pdf, disabled: isExporting), + const SizedBox(height: 8), + Expanded( + child: AbsorbPointer( + absorbing: isExporting, + child: _buildPageArea(pdf), + ), + ), + Consumer( + builder: (context, ref, _) { + final sig = ref.watch(signatureProvider); + return sig.rect != null + ? AbsorbPointer( + absorbing: isExporting, + child: _buildAdjustmentsPanel(sig), + ) + : const SizedBox.shrink(); + }, + ), + ], ), + if (isExporting) + Positioned.fill( + child: Container( + color: Colors.black45, + child: const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 12), + Text( + 'Exporting... Please wait', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + ), + ), ], ), ), ); } - Widget _buildToolbar(PdfState pdf) { + Widget _buildToolbar(PdfState pdf, {bool disabled = false}) { final dpi = ref.watch(exportDpiProvider); final pageInfo = 'Page ${pdf.currentPage}/${pdf.pageCount}'; return Wrap( @@ -245,7 +290,7 @@ class _PdfSignatureHomePageState extends ConsumerState { children: [ OutlinedButton( key: const Key('btn_open_pdf_picker'), - onPressed: _pickPdf, + onPressed: disabled ? null : _pickPdf, child: const Text('Open PDF...'), ), if (pdf.loaded) ...[ @@ -254,14 +299,16 @@ class _PdfSignatureHomePageState extends ConsumerState { children: [ IconButton( key: const Key('btn_prev'), - onPressed: () => _jumpToPage(pdf.currentPage - 1), + onPressed: + disabled ? null : () => _jumpToPage(pdf.currentPage - 1), icon: const Icon(Icons.chevron_left), tooltip: 'Prev', ), Text(pageInfo, key: const Key('lbl_page_info')), IconButton( key: const Key('btn_next'), - onPressed: () => _jumpToPage(pdf.currentPage + 1), + onPressed: + disabled ? null : () => _jumpToPage(pdf.currentPage + 1), icon: const Icon(Icons.chevron_right), tooltip: 'Next', ), @@ -276,6 +323,7 @@ class _PdfSignatureHomePageState extends ConsumerState { child: TextField( key: const Key('txt_goto'), keyboardType: TextInputType.number, + enabled: !disabled, onSubmitted: (v) { final n = int.tryParse(v); if (n != null) _jumpToPage(n); @@ -301,17 +349,20 @@ class _PdfSignatureHomePageState extends ConsumerState { ), ) .toList(), - onChanged: (v) { - if (v != null) { - ref.read(exportDpiProvider.notifier).state = v; - } - }, + onChanged: + disabled + ? null + : (v) { + if (v != null) { + ref.read(exportDpiProvider.notifier).state = v; + } + }, ), ], ), ElevatedButton( key: const Key('btn_mark_signing'), - onPressed: _toggleMarkForSigning, + onPressed: disabled ? null : _toggleMarkForSigning, child: Text( pdf.markedForSigning ? 'Unmark Signing' : 'Mark for Signing', ), @@ -319,18 +370,18 @@ class _PdfSignatureHomePageState extends ConsumerState { if (pdf.loaded) ElevatedButton( key: const Key('btn_save_pdf'), - onPressed: _saveSignedPdf, + onPressed: disabled ? null : _saveSignedPdf, child: const Text('Save Signed PDF'), ), if (pdf.markedForSigning) ...[ OutlinedButton( key: const Key('btn_load_signature_picker'), - onPressed: _loadSignatureFromFile, + onPressed: disabled ? null : _loadSignatureFromFile, child: const Text('Load Signature from file'), ), ElevatedButton( key: const Key('btn_draw_signature'), - onPressed: _openDrawCanvas, + onPressed: disabled ? null : _openDrawCanvas, child: const Text('Draw Signature'), ), ], @@ -473,10 +524,18 @@ class _PdfSignatureHomePageState extends ConsumerState { ), child: Stack( children: [ - if (sig.imageBytes != null) - Image.memory(sig.imageBytes!, fit: BoxFit.contain) - else - const Center(child: Text('Signature')), + Consumer( + builder: (context, ref, _) { + final processed = ref.watch( + processedSignatureImageProvider, + ); + final bytes = processed ?? sig.imageBytes; + if (bytes == null) { + return const Center(child: Text('Signature')); + } + return Image.memory(bytes, fit: BoxFit.contain); + }, + ), Positioned( right: 0, bottom: 0, diff --git a/test/features/step/a_signature_image_is_selected.dart b/test/features/step/a_signature_image_is_selected.dart index 9135a9f..da88cbb 100644 --- a/test/features/step/a_signature_image_is_selected.dart +++ b/test/features/step/a_signature_image_is_selected.dart @@ -15,4 +15,6 @@ Future aSignatureImageIsSelected(WidgetTester tester) async { container .read(signatureProvider.notifier) .setImageBytes(Uint8List.fromList([1, 2, 3])); + // Allow provider scheduler to process queued updates fully + await tester.pumpAndSettle(); } diff --git a/test/features/step/nearwhite_background_becomes_transparent_in_the_preview.dart b/test/features/step/nearwhite_background_becomes_transparent_in_the_preview.dart index a1f9e9a..6af4d4f 100644 --- a/test/features/step/nearwhite_background_becomes_transparent_in_the_preview.dart +++ b/test/features/step/nearwhite_background_becomes_transparent_in_the_preview.dart @@ -1,5 +1,7 @@ +import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:image/image.dart' as img; import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart'; import '_world.dart'; @@ -8,5 +10,35 @@ Future nearwhiteBackgroundBecomesTransparentInThePreview( WidgetTester tester, ) async { final container = TestWorld.container ?? ProviderContainer(); + TestWorld.container = container; + + // Ensure the flag is on per the previous step expect(container.read(signatureProvider).bgRemoval, isTrue); + + // Build a tiny 2x1 image: left pixel near-white (should become transparent), + // right pixel black (should remain opaque). + final src = img.Image(width: 2, height: 1); + // Near-white >= thrHigh(245) to ensure fully transparent after processing + src.setPixelRgba(0, 0, 250, 250, 250, 255); + // Solid black stays opaque + src.setPixelRgba(1, 0, 0, 0, 0, 255); + final png = Uint8List.fromList(img.encodePng(src, level: 6)); + + // Feed this into signature state + container.read(signatureProvider.notifier).setImageBytes(png); + // Allow provider scheduler to process invalidations + await tester.pumpAndSettle(); + // Get processed bytes + final processed = container.read(processedSignatureImageProvider); + expect(processed, isNotNull); + final decoded = img.decodeImage(processed!); + expect(decoded, isNotNull); + final outImg = decoded!.hasAlpha ? decoded : decoded.convert(numChannels: 4); + + final p0 = outImg.getPixel(0, 0); + final p1 = outImg.getPixel(1, 0); + final a0 = (p0.aNormalized * 255).round(); + final a1 = (p1.aNormalized * 255).round(); + expect(a0, equals(0), reason: 'near-white should be transparent'); + expect(a1, equals(255), reason: 'dark pixel should remain opaque'); } diff --git a/test/features/step/the_user_changes_contrast_and_brightness_controls.dart b/test/features/step/the_user_changes_contrast_and_brightness_controls.dart index c51d60c..a26db4f 100644 --- a/test/features/step/the_user_changes_contrast_and_brightness_controls.dart +++ b/test/features/step/the_user_changes_contrast_and_brightness_controls.dart @@ -11,4 +11,6 @@ Future theUserChangesContrastAndBrightnessControls( container.read(signatureProvider.notifier) ..setContrast(1.3) ..setBrightness(0.2); + // Let provider updates settle + await tester.pumpAndSettle(); } diff --git a/test/features/step/the_user_enables_background_removal.dart b/test/features/step/the_user_enables_background_removal.dart index db42aba..645448b 100644 --- a/test/features/step/the_user_enables_background_removal.dart +++ b/test/features/step/the_user_enables_background_removal.dart @@ -7,4 +7,6 @@ import '_world.dart'; Future theUserEnablesBackgroundRemoval(WidgetTester tester) async { final container = TestWorld.container ?? ProviderContainer(); container.read(signatureProvider.notifier).setBgRemoval(true); + // Let provider updates settle + await tester.pumpAndSettle(); }