refactor: use image object to replace bytes

This commit is contained in:
insleker 2025-09-20 15:38:34 +08:00
parent 81a352a513
commit bc524e958f
50 changed files with 688 additions and 838 deletions

View File

@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart'; import 'package:integration_test/integration_test.dart';
import 'package:image/image.dart' as img;
import 'dart:io'; import 'dart:io';
import 'package:file_selector/file_selector.dart' as fs; import 'package:file_selector/file_selector.dart' as fs;
@ -18,6 +17,7 @@ import 'package:pdf_signature/ui/features/pdf/view_model/pdf_export_view_model.d
import 'package:pdf_signature/ui/features/pdf/widgets/pages_sidebar.dart'; import 'package:pdf_signature/ui/features/pdf/widgets/pages_sidebar.dart';
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart'; import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:image/image.dart' as img;
import 'package:pdf_signature/data/repositories/preferences_repository.dart'; import 'package:pdf_signature/data/repositories/preferences_repository.dart';
import 'package:pdf_signature/l10n/app_localizations.dart'; import 'package:pdf_signature/l10n/app_localizations.dart';
@ -38,7 +38,7 @@ class LightweightExporter extends ExportService {
required Size uiPageSize, required Size uiPageSize,
required Uint8List? signatureImageBytes, required Uint8List? signatureImageBytes,
Map<int, List<SignaturePlacement>>? placementsByPage, Map<int, List<SignaturePlacement>>? placementsByPage,
Map<String, Uint8List>? libraryBytes, Map<String, img.Image>? libraryImages,
double targetDpi = 144.0, double targetDpi = 144.0,
}) async { }) async {
// Return minimal non-empty bytes; content isn't used further in tests // Return minimal non-empty bytes; content isn't used further in tests
@ -147,12 +147,15 @@ void main() {
), ),
signatureAssetRepositoryProvider.overrideWith((ref) { signatureAssetRepositoryProvider.overrideWith((ref) {
final c = SignatureAssetRepository(); final c = SignatureAssetRepository();
c.add(sigBytes, name: 'image'); c.addImage(img.decodeImage(sigBytes)!, name: 'image');
return c; return c;
}), }),
signatureCardRepositoryProvider.overrideWith((ref) { signatureCardRepositoryProvider.overrideWith((ref) {
final cardRepo = SignatureCardStateNotifier(); final cardRepo = SignatureCardStateNotifier();
final asset = SignatureAsset(bytes: sigBytes, name: 'image'); final asset = SignatureAsset(
sigImage: img.decodeImage(sigBytes)!,
name: 'image',
);
cardRepo.addWithAsset(asset, 0.0); cardRepo.addWithAsset(asset, 0.0);
return cardRepo; return cardRepo;
}), }),

View File

@ -3,7 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart'; import 'package:integration_test/integration_test.dart';
import 'dart:io'; import 'dart:io';
import 'package:flutter/services.dart';
import 'package:file_selector/file_selector.dart' as fs; import 'package:file_selector/file_selector.dart' as fs;
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart'; import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart';

View File

@ -1,4 +1,5 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:image/image.dart' as img;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/data/services/export_service.dart'; import 'package:pdf_signature/data/services/export_service.dart';
@ -58,7 +59,7 @@ class DocumentStateNotifier extends StateNotifier<Document> {
list.add( list.add(
SignaturePlacement( SignaturePlacement(
rect: rect, rect: rect,
asset: asset ?? SignatureAsset(bytes: _singleTransparentPng), asset: asset ?? SignatureAsset(sigImage: _singleTransparentPng),
rotationDeg: rotationDeg, rotationDeg: rotationDeg,
graphicAdjust: graphicAdjust ?? const GraphicAdjust(), graphicAdjust: graphicAdjust ?? const GraphicAdjust(),
), ),
@ -69,75 +70,7 @@ class DocumentStateNotifier extends StateNotifier<Document> {
// Tiny 1x1 transparent PNG to avoid decode crashes in tests when no real // Tiny 1x1 transparent PNG to avoid decode crashes in tests when no real
// signature bytes were provided. // signature bytes were provided.
static final Uint8List _singleTransparentPng = Uint8List.fromList([ static final img.Image _singleTransparentPng = img.Image(width: 1, height: 1);
0x89,
0x50,
0x4E,
0x47,
0x0D,
0x0A,
0x1A,
0x0A,
0x00,
0x00,
0x00,
0x0D,
0x49,
0x48,
0x44,
0x52,
0x00,
0x00,
0x00,
0x01,
0x00,
0x00,
0x00,
0x01,
0x08,
0x06,
0x00,
0x00,
0x00,
0x1F,
0x15,
0xC4,
0x89,
0x00,
0x00,
0x00,
0x0A,
0x49,
0x44,
0x41,
0x54,
0x78,
0x9C,
0x63,
0x60,
0x00,
0x00,
0x00,
0x02,
0x00,
0x01,
0xE5,
0x27,
0xD4,
0xA6,
0x00,
0x00,
0x00,
0x00,
0x49,
0x45,
0x4E,
0x44,
0xAE,
0x42,
0x60,
0x82,
]);
void updatePlacementRotation({ void updatePlacementRotation({
required int page, required int page,

View File

@ -1,4 +1,4 @@
import 'dart:typed_data'; import 'package:image/image.dart' as img;
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/domain/models/model.dart'; import 'package:pdf_signature/domain/models/model.dart';
@ -6,11 +6,9 @@ import 'package:pdf_signature/domain/models/model.dart';
class SignatureAssetRepository extends StateNotifier<List<SignatureAsset>> { class SignatureAssetRepository extends StateNotifier<List<SignatureAsset>> {
SignatureAssetRepository() : super(const []); SignatureAssetRepository() : super(const []);
void add(Uint8List bytes, {String? name}) { /// Preferred API: add from an already decoded image to avoid re-decodes.
// Always add a new asset (allow duplicates). This lets users create multiple cards void addImage(img.Image image, {String? name}) {
// even when loading the same image repeatedly for different adjustments/usages. state = List.of(state)..add(SignatureAsset(sigImage: image, name: name));
if (bytes.isEmpty) return;
state = List.of(state)..add(SignatureAsset(bytes: bytes, name: name));
} }
void remove(SignatureAsset asset) { void remove(SignatureAsset asset) {

View File

@ -1,43 +1,55 @@
import 'dart:typed_data'; import 'package:image/image.dart' as img;
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/models/model.dart'; import '../../domain/models/model.dart';
import '../../data/services/signature_image_processing_service.dart'; import '../../data/services/signature_image_processing_service.dart';
class DisplaySignatureData { class DisplaySignatureData {
final Uint8List bytes; // bytes to render final img.Image image; // image to render (image-first path)
final List<double>? colorMatrix; // optional GPU color matrix final List<double>? colorMatrix; // optional GPU color matrix
const DisplaySignatureData({required this.bytes, this.colorMatrix}); const DisplaySignatureData({required this.image, this.colorMatrix});
} }
/// CachedSignatureCard extends SignatureCard with an internal processed cache /// CachedSignatureCard extends SignatureCard with an internal processed cache
class CachedSignatureCard extends SignatureCard { class CachedSignatureCard extends SignatureCard {
Uint8List? _cachedProcessed; img.Image? _cachedProcessedImage;
CachedSignatureCard({ CachedSignatureCard({
required super.asset, required super.asset,
required super.rotationDeg, required super.rotationDeg,
super.graphicAdjust, super.graphicAdjust,
Uint8List? initialProcessed, img.Image? initialProcessedImage,
}); }) {
// Seed cache if provided
if (initialProcessedImage != null) {
_cachedProcessedImage = initialProcessedImage;
}
}
/// Returns cached processed bytes for the current [graphicAdjust], computing /// Returns cached processed image for the current [graphicAdjust], computing
/// via [service] if not cached yet. /// via [service] if not cached yet.
Uint8List getOrComputeProcessed(SignatureImageProcessingService service) { img.Image getOrComputeProcessedImage(
final existing = _cachedProcessed; SignatureImageProcessingService service,
if (existing != null) return existing; ) {
final computed = service.processImage(asset.bytes, graphicAdjust); final existing = _cachedProcessedImage;
_cachedProcessed = computed; if (existing != null) {
return computed; return existing;
}
final computedImage = service.processImageToImage(
asset.sigImage,
graphicAdjust,
);
_cachedProcessedImage = computedImage;
return computedImage;
} }
/// Invalidate the cached processed bytes, forcing recompute next time. /// Invalidate the cached processed image, forcing recompute next time.
void invalidateCache() { void invalidateCache() {
_cachedProcessed = null; _cachedProcessedImage = null;
} }
/// Sets/updates the processed bytes explicitly (used after adjustments update) /// Sets/updates the processed image explicitly (used after adjustments update)
void setProcessed(Uint8List bytes) { void setProcessedImage(img.Image image) {
_cachedProcessed = bytes; _cachedProcessedImage = image;
} }
factory CachedSignatureCard.initial() => CachedSignatureCard( factory CachedSignatureCard.initial() => CachedSignatureCard(
@ -90,8 +102,8 @@ class SignatureCardStateNotifier
graphicAdjust: graphicAdjust ?? c.graphicAdjust, graphicAdjust: graphicAdjust ?? c.graphicAdjust,
); );
// Compute and set the single processed bytes for the updated adjust // Compute and set the single processed bytes for the updated adjust
final processed = _processingService.processImage( final processedImage = _processingService.processImageToImage(
updated.asset.bytes, updated.asset.sigImage,
updated.graphicAdjust, updated.graphicAdjust,
); );
final next = CachedSignatureCard( final next = CachedSignatureCard(
@ -99,7 +111,7 @@ class SignatureCardStateNotifier
rotationDeg: updated.rotationDeg, rotationDeg: updated.rotationDeg,
graphicAdjust: updated.graphicAdjust, graphicAdjust: updated.graphicAdjust,
); );
next.setProcessed(processed); next.setProcessedImage(processedImage);
list[i] = next; list[i] = next;
state = List<CachedSignatureCard>.unmodifiable(list); state = List<CachedSignatureCard>.unmodifiable(list);
return; return;
@ -117,47 +129,58 @@ class SignatureCardStateNotifier
state = const <CachedSignatureCard>[]; state = const <CachedSignatureCard>[];
} }
/// Returns processed image bytes for the given asset + adjustments. /// New: Returns processed decoded image for the given asset + adjustments.
/// Uses an internal cache to avoid re-processing. img.Image getProcessedImage(SignatureAsset asset, GraphicAdjust adjust) {
Uint8List getProcessedBytes(SignatureAsset asset, GraphicAdjust adjust) {
// Try to find a matching card by asset // Try to find a matching card by asset
for (final c in state) { for (final c in state) {
if (c.asset == asset) { if (c.asset == asset) {
// If requested adjust equals the card's current adjust, use per-card cache
if (c.graphicAdjust == adjust) { if (c.graphicAdjust == adjust) {
return c.getOrComputeProcessed(_processingService); // If cached bytes exist, decode once; otherwise compute from image
if (c._cachedProcessedImage != null) {
return c._cachedProcessedImage!;
}
return _processingService.processImageToImage(
c.asset.sigImage,
c.graphicAdjust,
);
} }
// Previewing unsaved adjustments: compute without caching // Previewing unsaved adjustments: compute from image without caching
return _processingService.processImage(asset.bytes, adjust); return _processingService.processImageToImage(asset.sigImage, adjust);
} }
} }
// Asset not found among cards (e.g., preview in dialog): compute on-the-fly // Asset not found among cards (e.g., preview in dialog): compute on-the-fly
return _processingService.processImage(asset.bytes, adjust); return _processingService.processImageToImage(asset.sigImage, adjust);
} }
/// Provide display data optimized: if bgRemoval false, returns original bytes + matrix; /// Provide display data optimized: if bgRemoval false, returns original image + matrix;
/// if bgRemoval true, returns processed bytes with baked adjustments and null matrix. /// if bgRemoval true, returns processed image with baked adjustments and null matrix.
DisplaySignatureData getDisplayData( DisplaySignatureData getDisplayData(
SignatureAsset asset, SignatureAsset asset,
GraphicAdjust adjust, GraphicAdjust adjust,
) { ) {
if (!adjust.bgRemoval) { if (!adjust.bgRemoval) {
// Find card for potential original bytes (identical object) - no CPU processing. // No CPU processing. Return original image + matrix for consumers.
for (final c in state) {
if (c.asset == asset) {
final matrix = _processingService.buildColorMatrix(adjust);
return DisplaySignatureData(
bytes: c.asset.bytes,
colorMatrix: matrix,
);
}
}
final matrix = _processingService.buildColorMatrix(adjust); final matrix = _processingService.buildColorMatrix(adjust);
return DisplaySignatureData(bytes: asset.bytes, colorMatrix: matrix); return DisplaySignatureData(image: asset.sigImage, colorMatrix: matrix);
} }
// bgRemoval path: need CPU processed bytes (includes brightness/contrast first) // bgRemoval path: provide processed image with baked adjustments.
final processed = getProcessedBytes(asset, adjust); final processed = getProcessedImage(asset, adjust);
return DisplaySignatureData(bytes: processed, colorMatrix: null); return DisplaySignatureData(image: processed, colorMatrix: null);
}
/// New: Provide display image optimized for UI widgets that can accept img.Image.
/// If bgRemoval is false, returns original image and a GPU color matrix.
/// If bgRemoval is true, returns processed image with baked adjustments and null matrix.
(img.Image image, List<double>? colorMatrix) getDisplayImage(
SignatureAsset asset,
GraphicAdjust adjust,
) {
if (!adjust.bgRemoval) {
final matrix = _processingService.buildColorMatrix(adjust);
return (asset.sigImage, matrix);
}
final processed = getProcessedImage(asset, adjust);
return (processed, null);
} }
/// Clears all cached processed images. /// Clears all cached processed images.

View File

@ -9,6 +9,7 @@ import 'package:image/image.dart' as img;
import '../../domain/models/model.dart'; import '../../domain/models/model.dart';
// math moved to utils in rot // math moved to utils in rot
import '../../utils/rotation_utils.dart' as rot; import '../../utils/rotation_utils.dart' as rot;
import '../../utils/background_removal.dart' as br;
// 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.
@ -23,109 +24,82 @@ class ExportService {
required Size uiPageSize, required Size uiPageSize,
required Uint8List? signatureImageBytes, required Uint8List? signatureImageBytes,
Map<int, List<SignaturePlacement>>? placementsByPage, Map<int, List<SignaturePlacement>>? placementsByPage,
Map<String, Uint8List>? libraryBytes, Map<String, img.Image>? libraryImages,
double targetDpi = 144.0, double targetDpi = 144.0,
}) async { }) async {
// Per-call caches to avoid redundant decode/encode and image embedding work // Per-call caches to avoid redundant decode/encode and image embedding work
final Map<String, Uint8List> _processedBytesCache = <String, Uint8List>{}; final Map<String, img.Image> _baseImageCache = <String, img.Image>{};
final Map<String, img.Image> _processedImageCache = <String, img.Image>{};
final Map<String, Uint8List> _encodedPngCache = <String, Uint8List>{};
final Map<String, pw.MemoryImage> _memoryImageCache = final Map<String, pw.MemoryImage> _memoryImageCache =
<String, pw.MemoryImage>{}; <String, pw.MemoryImage>{};
final Map<String, double> _aspectRatioCache = <String, double>{}; final Map<String, double> _aspectRatioCache = <String, double>{};
// Returns a stable-ish cache key for bytes within this process (not content-hash, but good enough per-call) // Returns a stable-ish cache key for bytes within this process (not content-hash, but good enough per-call)
String _baseKeyForBytes(Uint8List b) => String _baseKeyForImage(img.Image im) =>
'${identityHashCode(b)}:${b.length}'; 'im:${identityHashCode(im)}:${im.width}x${im.height}';
String _adjustKey(GraphicAdjust adj) =>
'c=${adj.contrast}|b=${adj.brightness}|bg=${adj.bgRemoval}';
// Fast PNG signature check (no string allocation) // Removed: PNG signature helper is no longer needed; we always encode to PNG explicitly.
bool _isPng(Uint8List bytes) {
if (bytes.length < 8) return false;
return bytes[0] == 0x89 &&
bytes[1] == 0x50 && // P
bytes[2] == 0x4E && // N
bytes[3] == 0x47 && // G
bytes[4] == 0x0D &&
bytes[5] == 0x0A &&
bytes[6] == 0x1A &&
bytes[7] == 0x0A;
}
// Resolve base (unprocessed) bytes for a placement, considering library override. // Resolve base (unprocessed) image for a placement, considering library override.
Uint8List _getBaseBytes(SignaturePlacement placement) { img.Image _getBaseImage(SignaturePlacement placement) {
Uint8List baseBytes = placement.asset.bytes;
final libKey = placement.asset.name; final libKey = placement.asset.name;
if (libKey != null && libraryBytes != null) { if (libKey != null && libraryImages != null) {
final libBytes = libraryBytes[libKey]; final cached = _baseImageCache[libKey];
if (libBytes != null && libBytes.isNotEmpty) { if (cached != null) return cached;
baseBytes = libBytes; final provided = libraryImages[libKey];
if (provided != null) {
_baseImageCache[libKey] = provided;
return provided;
} }
} }
return baseBytes; return placement.asset.sigImage;
} }
// Get processed bytes for a placement, with caching. // Get processed image for a placement, with caching.
Uint8List _getProcessedBytes(SignaturePlacement placement) { img.Image _getProcessedImage(SignaturePlacement placement) {
final Uint8List baseBytes = _getBaseBytes(placement); final base = _getBaseImage(placement);
final key =
final adj = placement.graphicAdjust; '${_baseKeyForImage(base)}|${_adjustKey(placement.graphicAdjust)}';
final cacheKey = final cached = _processedImageCache[key];
'${_baseKeyForBytes(baseBytes)}|c=${adj.contrast}|b=${adj.brightness}|bg=${adj.bgRemoval}';
final cached = _processedBytesCache[cacheKey];
if (cached != null) return cached; if (cached != null) return cached;
final adj = placement.graphicAdjust;
// If no graphic changes requested, return bytes as-is (conversion to PNG is deferred to MemoryImage step) img.Image processed = base;
final bool needsAdjust = if (adj.contrast != 1.0 || adj.brightness != 1.0) {
(adj.contrast != 1.0 || adj.brightness != 1.0 || adj.bgRemoval); processed = img.adjustColor(
if (!needsAdjust) { processed,
_processedBytesCache[cacheKey] = baseBytes; contrast: adj.contrast,
return baseBytes; brightness: adj.brightness,
);
} }
if (adj.bgRemoval) {
try { processed = br.removeNearWhiteBackground(processed, threshold: 240);
final decoded = img.decodeImage(baseBytes);
if (decoded == null) {
_processedBytesCache[cacheKey] = baseBytes;
return baseBytes;
}
img.Image processed = decoded;
if (adj.contrast != 1.0 || adj.brightness != 1.0) {
processed = img.adjustColor(
processed,
contrast: adj.contrast,
brightness: adj.brightness,
);
}
if (adj.bgRemoval) {
processed = _removeBackground(processed);
}
final outBytes = Uint8List.fromList(img.encodePng(processed));
_processedBytesCache[cacheKey] = outBytes;
return outBytes;
} catch (_) {
// If processing fails, fall back to original
_processedBytesCache[cacheKey] = baseBytes;
return baseBytes;
} }
_processedImageCache[key] = processed;
return processed;
} }
// Wrap bytes in a pw.MemoryImage with caching, converting to PNG only when necessary. // Get PNG bytes for the processed image, caching the encoding.
pw.MemoryImage? _getMemoryImage(Uint8List bytes) { Uint8List _getProcessedPng(SignaturePlacement placement) {
final key = _baseKeyForBytes(bytes); final base = _getBaseImage(placement);
final key =
'${_baseKeyForImage(base)}|${_adjustKey(placement.graphicAdjust)}';
final cached = _encodedPngCache[key];
if (cached != null) return cached;
final processed = _getProcessedImage(placement);
final png = Uint8List.fromList(img.encodePng(processed, level: 6));
_encodedPngCache[key] = png;
return png;
}
// Wrap bytes in a pw.MemoryImage with caching.
pw.MemoryImage? _getMemoryImage(Uint8List bytes, String key) {
final cached = _memoryImageCache[key]; final cached = _memoryImageCache[key];
if (cached != null) return cached; if (cached != null) return cached;
try { try {
if (_isPng(bytes)) { final imgObj = pw.MemoryImage(bytes);
final imgObj = pw.MemoryImage(bytes);
_memoryImageCache[key] = imgObj;
return imgObj;
}
// Convert to PNG to preserve transparency if not already PNG
final decoded = img.decodeImage(bytes);
if (decoded == null) return null;
final png = Uint8List.fromList(img.encodePng(decoded, level: 6));
final imgObj = pw.MemoryImage(png);
_memoryImageCache[key] = imgObj; _memoryImageCache[key] = imgObj;
return imgObj; return imgObj;
} catch (_) { } catch (_) {
@ -133,22 +107,15 @@ class ExportService {
} }
} }
// Compute and cache aspect ratio (width/height) for given bytes // Compute and cache aspect ratio (width/height) for given image
double? _getAspectRatioFromBytes(Uint8List bytes) { double? _getAspectRatioFromImage(img.Image image) {
final key = _baseKeyForBytes(bytes); final key = _baseKeyForImage(image);
final c = _aspectRatioCache[key]; final c = _aspectRatioCache[key];
if (c != null) return c; if (c != null) return c;
try { if (image.width <= 0 || image.height <= 0) return null;
final decoded = img.decodeImage(bytes); final ar = image.width / image.height;
if (decoded == null || decoded.width <= 0 || decoded.height <= 0) { _aspectRatioCache[key] = ar;
return null; return ar;
}
final ar = decoded.width / decoded.height;
_aspectRatioCache[key] = ar;
return ar;
} catch (_) {
return null;
}
} }
final out = pw.Document(version: pdf.PdfVersion.pdf_1_4, compress: false); final out = pw.Document(version: pdf.PdfVersion.pdf_1_4, compress: false);
@ -206,20 +173,18 @@ class ExportService {
final w = r.width * widthPts; final w = r.width * widthPts;
final h = r.height * heightPts; final h = r.height * heightPts;
// Get processed bytes (cached) and then embed as MemoryImage (cached) // Get processed image and embed as MemoryImage (cached)
Uint8List bytes = _getProcessedBytes(placement); final processedPng = _getProcessedPng(placement);
if (bytes.isEmpty && signatureImageBytes != null) { final baseImage = _getBaseImage(placement);
bytes = signatureImageBytes; final memKey =
} '${_baseKeyForImage(baseImage)}|${_adjustKey(placement.graphicAdjust)}';
if (processedPng.isNotEmpty) {
if (bytes.isNotEmpty) { final imgObj = _getMemoryImage(processedPng, memKey);
final imgObj = _getMemoryImage(bytes);
if (imgObj != null) { if (imgObj != null) {
// Align with RotatedSignatureImage: counterclockwise positive // Align with RotatedSignatureImage: counterclockwise positive
final angle = rot.radians(placement.rotationDeg); final angle = rot.radians(placement.rotationDeg);
// Prefer AR from base bytes to avoid extra decode of processed // Use AR from base image
final baseBytes = _getBaseBytes(placement); final ar = _getAspectRatioFromImage(baseImage);
final ar = _getAspectRatioFromBytes(baseBytes);
final scaleToFit = rot.scaleToFitForAngle(angle, ar: ar); final scaleToFit = rot.scaleToFitForAngle(angle, ar: ar);
children.add( children.add(
@ -292,17 +257,15 @@ class ExportService {
final w = r.width * widthPts; final w = r.width * widthPts;
final h = r.height * heightPts; final h = r.height * heightPts;
Uint8List bytes = _getProcessedBytes(placement); final processedPng = _getProcessedPng(placement);
if (bytes.isEmpty && signatureImageBytes != null) { final baseImage = _getBaseImage(placement);
bytes = signatureImageBytes; final memKey =
} '${_baseKeyForImage(baseImage)}|${_adjustKey(placement.graphicAdjust)}';
if (processedPng.isNotEmpty) {
if (bytes.isNotEmpty) { final imgObj = _getMemoryImage(processedPng, memKey);
final imgObj = _getMemoryImage(bytes);
if (imgObj != null) { if (imgObj != null) {
final angle = rot.radians(placement.rotationDeg); final angle = rot.radians(placement.rotationDeg);
final baseBytes = _getBaseBytes(placement); final ar = _getAspectRatioFromImage(baseImage);
final ar = _getAspectRatioFromBytes(baseBytes);
final scaleToFit = rot.scaleToFitForAngle(angle, ar: ar); final scaleToFit = rot.scaleToFitForAngle(angle, ar: ar);
children.add( children.add(
@ -356,30 +319,5 @@ class ExportService {
} }
} }
/// Remove near-white background by making pixels with high brightness transparent // Background removal implemented in utils/background_removal.dart
img.Image _removeBackground(img.Image image) {
final result =
image.hasAlpha ? img.Image.from(image) : image.convert(numChannels: 4);
const int threshold = 245; // Near-white threshold (0-255)
for (int y = 0; y < result.height; y++) {
for (int x = 0; x < result.width; x++) {
final pixel = result.getPixel(x, y);
// Get RGB values
final r = pixel.r;
final g = pixel.g;
final b = pixel.b;
// Check if pixel is near-white (all channels above threshold)
if (r >= threshold && g >= threshold && b >= threshold) {
// Make transparent
result.setPixelRgba(x, y, r, g, b, 0);
}
}
}
return result;
}
} }

View File

@ -1,8 +1,8 @@
import 'dart:typed_data';
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import 'package:colorfilter_generator/colorfilter_generator.dart'; import 'package:colorfilter_generator/colorfilter_generator.dart';
import 'package:colorfilter_generator/addons.dart'; import 'package:colorfilter_generator/addons.dart';
import '../../domain/models/model.dart' as domain; import '../../domain/models/model.dart' as domain;
import '../../utils/background_removal.dart' as br;
/// Service for processing signature images with graphic adjustments /// Service for processing signature images with graphic adjustments
class SignatureImageProcessingService { class SignatureImageProcessingService {
@ -22,138 +22,27 @@ class SignatureImageProcessingService {
return gen.matrix; return gen.matrix;
} }
/// For display: if bgRemoval not requested, return original bytes + matrix. /// Process an already decoded image and return a new decoded image.
/// If bgRemoval requested, perform full CPU pipeline (brightness/contrast then bg removal) img.Image processImageToImage(img.Image image, domain.GraphicAdjust adjust) {
/// and return processed bytes with null matrix (already baked in). img.Image processed = img.Image.from(image);
Uint8List processForDisplay(Uint8List bytes, domain.GraphicAdjust adjust) {
if (!adjust.bgRemoval) {
// No CPU processing unless any color adjust combined with bg removal.
if (adjust.contrast == 1.0 && adjust.brightness == 1.0) {
return bytes; // identity
}
// We let GPU handle; return original bytes.
return bytes;
}
return processImage(bytes, adjust);
}
/// Decode image bytes once and reuse the decoded image for preview processing. // Apply contrast and brightness first (domain neutral is 1.0)
img.Image? decode(Uint8List bytes) { if (adjust.contrast != 1.0 || adjust.brightness != 1.0) {
try { // performance actually bad due to dual forloops internally
return img.decodeImage(bytes); processed = img.adjustColor(
} catch (_) { processed,
return null; contrast: adjust.contrast,
} brightness: adjust.brightness,
}
/// Process image bytes with the given graphic adjustments
Uint8List processImage(Uint8List bytes, domain.GraphicAdjust adjust) {
if (adjust.contrast == 1.0 &&
adjust.brightness == 0.0 &&
!adjust.bgRemoval) {
return bytes; // No processing needed
}
try {
final decoded = img.decodeImage(bytes);
if (decoded != null) {
img.Image processed = decoded;
// Apply contrast and brightness first
if (adjust.contrast != 1.0 || adjust.brightness != 0.0) {
processed = img.adjustColor(
processed,
contrast: adjust.contrast,
brightness: adjust.brightness,
);
}
// Apply background removal after color adjustments
if (adjust.bgRemoval) {
processed = _removeBackground(processed);
}
// Encode back to PNG to preserve transparency
return Uint8List.fromList(img.encodePng(processed));
} else {
return bytes;
}
} catch (e) {
// If processing fails, return original bytes
return bytes;
}
}
/// Fast preview processing:
/// - Reuses a decoded image
/// - Downscales to a small size for UI preview
/// - Uses low-compression PNG to reduce CPU cost
Uint8List processPreviewFromDecoded(
img.Image decoded,
domain.GraphicAdjust adjust, {
int maxDimension = 256,
}) {
try {
// Create a small working copy for quick adjustments
final int w = decoded.width;
final int h = decoded.height;
final double scale = (w > h ? maxDimension / w : maxDimension / h).clamp(
0.0,
1.0,
);
img.Image work =
(scale < 1.0)
? img.copyResize(decoded, width: (w * scale).round())
: img.Image.from(decoded);
// Apply contrast and brightness
if (adjust.contrast != 1.0 || adjust.brightness != 0.0) {
work = img.adjustColor(
work,
contrast: adjust.contrast,
brightness: adjust.brightness,
);
}
// Background removal on downscaled image for speed
if (adjust.bgRemoval) {
work = _removeBackground(work);
}
// Encode with low compression (level 0) for speed
return Uint8List.fromList(img.encodePng(work, level: 0));
} catch (_) {
// Fall back to original size path if something goes wrong
return processImage(
Uint8List.fromList(img.encodePng(decoded, level: 0)),
adjust,
); );
} }
}
/// Remove near-white background using simple threshold approach for maximum speed // Apply background removal after color adjustments
img.Image _removeBackground(img.Image image) { if (adjust.bgRemoval) {
final result = processed = br.removeNearWhiteBackground(processed, threshold: 240);
image.hasAlpha ? img.Image.from(image) : image.convert(numChannels: 4);
// Simple and fast: single pass through all pixels
for (int y = 0; y < result.height; y++) {
for (int x = 0; x < result.width; x++) {
final pixel = result.getPixel(x, y);
final r = pixel.r;
final g = pixel.g;
final b = pixel.b;
// Simple threshold: if pixel is close to white, make it transparent
const int threshold = 240; // Very close to white
if (r >= threshold && g >= threshold && b >= threshold) {
result.setPixel(
x,
y,
img.ColorRgba8(r.toInt(), g.toInt(), b.toInt(), 0),
);
}
}
} }
return result;
return processed;
} }
// Background removal implemented in utils/background_removal.dart
} }

View File

@ -1,27 +1,25 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:image/image.dart' as img;
/// SignatureAsset store image file of a signature, stored in the device or cloud storage /// SignatureAsset store image file of a signature, stored in the device or cloud storage
class SignatureAsset { class SignatureAsset {
final Uint8List bytes; final img.Image sigImage;
// List<List<Offset>>? strokes; // List<List<Offset>>? strokes;
final String? name; // optional display name (e.g., filename) final String? name; // optional display name (e.g., filename)
const SignatureAsset({required this.bytes, this.name}); const SignatureAsset({required this.sigImage, this.name});
/// Encode this image to PNG bytes. Use a small compression level for speed by default.
Uint8List toPngBytes({int level = 3}) =>
Uint8List.fromList(img.encodePng(sigImage, level: level));
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
identical(this, other) || identical(this, other) ||
other is SignatureAsset && other is SignatureAsset &&
name == other.name && name == other.name &&
_bytesEqual(bytes, other.bytes); sigImage == other.sigImage;
@override @override
int get hashCode => name.hashCode ^ bytes.length.hashCode; int get hashCode =>
name.hashCode ^ sigImage.width.hashCode ^ sigImage.height.hashCode;
static bool _bytesEqual(Uint8List a, Uint8List b) {
if (a.length != b.length) return false;
for (int i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
} }

View File

@ -1,5 +1,5 @@
import 'dart:typed_data';
import 'signature_asset.dart'; import 'signature_asset.dart';
import 'package:image/image.dart' as img;
import 'graphic_adjust.dart'; import 'graphic_adjust.dart';
/** /**
@ -28,7 +28,7 @@ class SignatureCard {
); );
factory SignatureCard.initial() => SignatureCard( factory SignatureCard.initial() => SignatureCard(
asset: SignatureAsset(bytes: Uint8List(0)), asset: SignatureAsset(sigImage: img.Image(width: 1, height: 1)),
rotationDeg: 0.0, rotationDeg: 0.0,
graphicAdjust: const GraphicAdjust(), graphicAdjust: const GraphicAdjust(),
); );

View File

@ -113,9 +113,9 @@ class _PdfMockContinuousListState extends ConsumerState<PdfMockContinuousList> {
.addPlacement( .addPlacement(
page: pageNum, page: pageNum,
rect: rect, rect: rect,
asset: dragData.card?.asset, asset: dragData.card.asset,
rotationDeg: dragData.card?.rotationDeg ?? 0.0, rotationDeg: dragData.card.rotationDeg,
graphicAdjust: dragData.card?.graphicAdjust, graphicAdjust: dragData.card.graphicAdjust,
); );
} }
}, },

View File

@ -70,9 +70,9 @@ class PdfPageOverlays extends ConsumerWidget {
.addPlacement( .addPlacement(
page: pageNumber, page: pageNumber,
rect: rect, rect: rect,
asset: d.card?.asset, asset: d.card.asset,
rotationDeg: d.card?.rotationDeg ?? 0.0, rotationDeg: d.card.rotationDeg,
graphicAdjust: d.card?.graphicAdjust, graphicAdjust: d.card.graphicAdjust,
); );
}, },
builder: (context, candidateData, rejectedData) { builder: (context, candidateData, rejectedData) {

View File

@ -15,6 +15,7 @@ import 'signatures_sidebar.dart';
import '../view_model/pdf_export_view_model.dart'; import '../view_model/pdf_export_view_model.dart';
import 'package:pdf_signature/utils/download.dart'; import 'package:pdf_signature/utils/download.dart';
import '../view_model/pdf_view_model.dart'; import '../view_model/pdf_view_model.dart';
import 'package:image/image.dart' as img;
class PdfSignatureHomePage extends ConsumerStatefulWidget { class PdfSignatureHomePage extends ConsumerStatefulWidget {
final Future<void> Function() onPickPdf; final Future<void> Function() onPickPdf;
@ -97,7 +98,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
if (controller.isReady) controller.goToPage(pageNumber: target); if (controller.isReady) controller.goToPage(pageNumber: target);
} }
Future<Uint8List?> _loadSignatureFromFile() async { Future<img.Image?> _loadSignatureFromFile() async {
final typeGroup = fs.XTypeGroup( final typeGroup = fs.XTypeGroup(
label: label:
Localizations.of<AppLocalizations>(context, AppLocalizations)?.image, Localizations.of<AppLocalizations>(context, AppLocalizations)?.image,
@ -106,20 +107,31 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
final file = await fs.openFile(acceptedTypeGroups: [typeGroup]); final file = await fs.openFile(acceptedTypeGroups: [typeGroup]);
if (file == null) return null; if (file == null) return null;
final bytes = await file.readAsBytes(); final bytes = await file.readAsBytes();
return bytes; try {
var sigImage = img.decodeImage(bytes);
sigImage?.convert(numChannels: 4);
return sigImage;
} catch (_) {
return null;
}
} }
Future<Uint8List?> _openDrawCanvas() async { Future<img.Image?> _openDrawCanvas() async {
final result = await showModalBottomSheet<Uint8List>( final result = await showModalBottomSheet<Uint8List>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
enableDrag: false, enableDrag: false,
builder: (_) => const DrawCanvas(closeOnConfirmImmediately: false), builder: (_) => const DrawCanvas(closeOnConfirmImmediately: false),
); );
if (result != null && result.isNotEmpty) { if (result == null || result.isEmpty) return null;
// In simplified UI, adding to library isn't implemented // In simplified UI, adding to library isn't implemented
try {
var sigImage = img.decodeImage(result);
sigImage?.convert(numChannels: 4);
return sigImage;
} catch (_) {
return null;
} }
return result;
} }
Future<void> _saveSignedPdf() async { Future<void> _saveSignedPdf() async {

View File

@ -26,9 +26,9 @@ class SignatureOverlay extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final processedBytes = ref final processedImage = ref
.watch(signatureViewModelProvider) .watch(signatureViewModelProvider)
.getProcessedBytes(placement.asset, placement.graphicAdjust); .getProcessedImage(placement.asset, placement.graphicAdjust);
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final pageW = constraints.maxWidth; final pageW = constraints.maxWidth;
@ -133,7 +133,7 @@ class SignatureOverlay extends ConsumerWidget {
child: FittedBox( child: FittedBox(
fit: BoxFit.contain, fit: BoxFit.contain,
child: RotatedSignatureImage( child: RotatedSignatureImage(
bytes: processedBytes, image: processedImage,
rotationDeg: placement.rotationDeg, rotationDeg: placement.rotationDeg,
), ),
), ),

View File

@ -1,7 +1,8 @@
import 'dart:typed_data'; // no bytes here; use decoded images
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart'; import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:image/image.dart' as img;
import '../../signature/widgets/signature_drawer.dart'; import '../../signature/widgets/signature_drawer.dart';
import '../view_model/pdf_export_view_model.dart'; import '../view_model/pdf_export_view_model.dart';
@ -14,8 +15,8 @@ class SignaturesSidebar extends ConsumerWidget {
required this.onSave, required this.onSave,
}); });
final Future<Uint8List?> Function() onLoadSignatureFromFile; final Future<img.Image?> Function() onLoadSignatureFromFile;
final Future<Uint8List?> Function() onOpenDrawCanvas; final Future<img.Image?> Function() onOpenDrawCanvas;
final VoidCallback onSave; final VoidCallback onSave;
@override @override

View File

@ -1,22 +1,14 @@
import 'dart:typed_data';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/domain/models/model.dart' as domain; import 'package:pdf_signature/domain/models/model.dart' as domain;
import 'package:pdf_signature/data/repositories/signature_card_repository.dart' import 'package:pdf_signature/data/repositories/signature_card_repository.dart'
as repo; as repo;
import 'package:image/image.dart' as img;
class SignatureViewModel { class SignatureViewModel {
final Ref ref; final Ref ref;
SignatureViewModel(this.ref); SignatureViewModel(this.ref);
Uint8List getProcessedBytes(
domain.SignatureAsset asset,
domain.GraphicAdjust adjust,
) {
final notifier = ref.read(repo.signatureCardRepositoryProvider.notifier);
return notifier.getProcessedBytes(asset, adjust);
}
repo.DisplaySignatureData getDisplaySignatureData( repo.DisplaySignatureData getDisplaySignatureData(
domain.SignatureAsset asset, domain.SignatureAsset asset,
domain.GraphicAdjust adjust, domain.GraphicAdjust adjust,
@ -25,6 +17,23 @@ class SignatureViewModel {
return notifier.getDisplayData(asset, adjust); return notifier.getDisplayData(asset, adjust);
} }
// New image-based accessors
img.Image getProcessedImage(
domain.SignatureAsset asset,
domain.GraphicAdjust adjust,
) {
final notifier = ref.read(repo.signatureCardRepositoryProvider.notifier);
return notifier.getProcessedImage(asset, adjust);
}
(img.Image image, List<double>? colorMatrix) getDisplayImage(
domain.SignatureAsset asset,
domain.GraphicAdjust adjust,
) {
final notifier = ref.read(repo.signatureCardRepositoryProvider.notifier);
return notifier.getDisplayImage(asset, adjust);
}
void clearCache() { void clearCache() {
final notifier = ref.read(repo.signatureCardRepositoryProvider.notifier); final notifier = ref.read(repo.signatureCardRepositoryProvider.notifier);
notifier.clearProcessedCache(); notifier.clearProcessedCache();

View File

@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import 'package:colorfilter_generator/colorfilter_generator.dart'; import 'package:colorfilter_generator/colorfilter_generator.dart';
@ -8,6 +7,7 @@ import 'package:pdf_signature/l10n/app_localizations.dart';
import '../../pdf/widgets/adjustments_panel.dart'; import '../../pdf/widgets/adjustments_panel.dart';
import '../../../../domain/models/model.dart' as domain; import '../../../../domain/models/model.dart' as domain;
import 'rotated_signature_image.dart'; import 'rotated_signature_image.dart';
import '../../../../utils/background_removal.dart' as br;
class ImageEditorResult { class ImageEditorResult {
final double rotation; final double rotation;
@ -44,10 +44,9 @@ class _ImageEditorDialogState extends State<ImageEditorDialog> {
late final ValueNotifier<double> _rotation; late final ValueNotifier<double> _rotation;
// Cached image data // Cached image data
late Uint8List _originalBytes; // Original asset bytes (never mutated) late img.Image _originalImage; // Original asset image
Uint8List? img.Image?
_processedBgRemovedBytes; // Cached brightness/contrast adjusted then bg-removed bytes _processedBgRemovedImage; // Cached brightness/contrast adjusted then bg-removed image
img.Image? _decodedBase; // Decoded original for processing
// Debounce for background removal (in case we later tie it to brightness/contrast) // Debounce for background removal (in case we later tie it to brightness/contrast)
Timer? _bgRemovalDebounce; Timer? _bgRemovalDebounce;
@ -60,17 +59,14 @@ class _ImageEditorDialogState extends State<ImageEditorDialog> {
_contrast = widget.initialGraphicAdjust.contrast; _contrast = widget.initialGraphicAdjust.contrast;
_brightness = widget.initialGraphicAdjust.brightness; _brightness = widget.initialGraphicAdjust.brightness;
_rotation = ValueNotifier<double>(widget.initialRotation); _rotation = ValueNotifier<double>(widget.initialRotation);
_originalBytes = widget.asset.bytes; _originalImage = widget.asset.sigImage;
// Decode lazily only if/when background removal is needed // If background removal initially enabled, precompute immediately
if (_bgRemoval) { if (_bgRemoval) {
_scheduleBgRemovalReprocess(immediate: true); _scheduleBgRemovalReprocess(immediate: true);
} }
} }
Uint8List get _displayBytes => // No _displayBytes cache: the preview now uses img.Image directly.
_bgRemoval
? (_processedBgRemovedBytes ?? _originalBytes)
: _originalBytes;
void _onBgRemovalChanged(bool value) { void _onBgRemovalChanged(bool value) {
setState(() { setState(() {
@ -95,9 +91,7 @@ class _ImageEditorDialogState extends State<ImageEditorDialog> {
} }
void _recomputeBgRemoval() { void _recomputeBgRemoval() {
_decodedBase ??= img.decodeImage(_originalBytes); final base = _originalImage;
final base = _decodedBase;
if (base == null) return;
// Apply brightness & contrast first (domain uses 1.0 neutral) // Apply brightness & contrast first (domain uses 1.0 neutral)
img.Image working = img.Image.from(base); img.Image working = img.Image.from(base);
final needAdjust = _brightness != 1.0 || _contrast != 1.0; final needAdjust = _brightness != 1.0 || _contrast != 1.0;
@ -109,22 +103,11 @@ class _ImageEditorDialogState extends State<ImageEditorDialog> {
); );
} }
// Then remove background on adjusted pixels // Then remove background on adjusted pixels
const int threshold = 240; working = br.removeNearWhiteBackground(working, threshold: 240);
if (!working.hasAlpha) {
working = working.convert(numChannels: 4);
}
for (int y = 0; y < working.height; y++) {
for (int x = 0; x < working.width; x++) {
final p = working.getPixel(x, y);
final r = p.r, g = p.g, b = p.b;
if (r >= threshold && g >= threshold && b >= threshold) {
working.setPixelRgba(x, y, r, g, b, 0);
}
}
}
final bytes = Uint8List.fromList(img.encodePng(working));
if (!mounted) return; if (!mounted) return;
setState(() => _processedBgRemovedBytes = bytes); setState(() {
_processedBgRemovedImage = working;
});
} }
ColorFilter _currentColorFilter() { ColorFilter _currentColorFilter() {
@ -211,7 +194,11 @@ class _ImageEditorDialogState extends State<ImageEditorDialog> {
valueListenable: _rotation, valueListenable: _rotation,
builder: (context, rot, child) { builder: (context, rot, child) {
final image = RotatedSignatureImage( final image = RotatedSignatureImage(
bytes: _displayBytes, image:
_bgRemoval
? (_processedBgRemovedImage ??
_originalImage)
: _originalImage,
rotationDeg: rot, rotationDeg: rot,
); );
if (_bgRemoval) return image; if (_bgRemoval) return image;

View File

@ -1,14 +1,16 @@
import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image/image.dart' as img;
import '../../../../utils/rotation_utils.dart' as rot; import '../../../../utils/rotation_utils.dart' as rot;
/// A lightweight widget to render signature bytes with rotation and an /// A lightweight widget to render signature bytes with rotation and an
/// angle-aware scale-to-fit so the rotated image stays within its bounds. /// angle-aware scale-to-fit so the rotated image stays within its bounds.
/// Aware that `decodeImage` large images can be crazily slow, especially on web. /// Don't use `decodeImage`, large images can be crazily slow, especially on web.
class RotatedSignatureImage extends StatefulWidget { class RotatedSignatureImage extends StatefulWidget {
const RotatedSignatureImage({ const RotatedSignatureImage({
super.key, super.key,
required this.bytes, required this.image,
this.rotationDeg = 0.0, // counterclockwise as positive this.rotationDeg = 0.0, // counterclockwise as positive
this.filterQuality = FilterQuality.low, this.filterQuality = FilterQuality.low,
this.semanticLabel, this.semanticLabel,
@ -16,16 +18,19 @@ class RotatedSignatureImage extends StatefulWidget {
this.cacheHeight, this.cacheHeight,
}); });
final Uint8List bytes; /// Decoded CPU image (from `package:image`).
final img.Image image;
/// Rotation in degrees. Positive values rotate counterclockwise in math sense.
/// Screen-space is handled via [rot.ccwRadians].
final double rotationDeg; final double rotationDeg;
final FilterQuality filterQuality; final FilterQuality filterQuality;
final BoxFit fit = BoxFit.contain;
final bool gaplessPlayback = true;
final Alignment alignment = Alignment.center;
final bool wrapInRepaintBoundary = true;
final String? semanticLabel; final String? semanticLabel;
// Hint the decoder to decode at a smaller size to reduce memory/latency.
// On some platforms these may be ignored, but they are safe no-ops. /// Optional target size hints to reduce decode cost.
/// If only one is provided, the other is computed to preserve aspect.
final int? cacheWidth; final int? cacheWidth;
final int? cacheHeight; final int? cacheHeight;
@ -34,103 +39,126 @@ class RotatedSignatureImage extends StatefulWidget {
} }
class _RotatedSignatureImageState extends State<RotatedSignatureImage> { class _RotatedSignatureImageState extends State<RotatedSignatureImage> {
ImageStream? _stream; Uint8List? _encodedBytes; // PNG-encoded bytes for Image.memory
ImageStreamListener? _listener; img.Image? _lastSrc; // To detect changes cheaply
double? _derivedAspectRatio; // width / height int? _lastW;
int? _lastH;
MemoryImage get _provider {
return MemoryImage(widget.bytes);
}
@override @override
void didChangeDependencies() { void initState() {
super.didChangeDependencies(); super.initState();
_resolveImage(); _prepare();
} }
@override @override
void didUpdateWidget(covariant RotatedSignatureImage oldWidget) { void didUpdateWidget(covariant RotatedSignatureImage oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
// Only re-resolve when the bytes change. Rotation does not affect final srcChanged =
// intrinsic aspect ratio, so avoid expensive decode/resolve on slider drags. !identical(widget.image, _lastSrc) ||
if (!identical(oldWidget.bytes, widget.bytes)) { widget.image.width != (oldWidget.image.width) ||
_derivedAspectRatio = null; widget.image.height != (oldWidget.image.height);
_resolveImage(); final sizeHintChanged =
widget.cacheWidth != oldWidget.cacheWidth ||
widget.cacheHeight != oldWidget.cacheHeight;
if (srcChanged || sizeHintChanged) {
_prepare();
} }
} }
void _setAspectRatio(double ar) {
if (mounted && _derivedAspectRatio != ar) {
setState(() => _derivedAspectRatio = ar);
}
}
void _resolveImage() {
_unlisten();
// Resolve via ImageProvider; when first frame arrives, capture intrinsic size.
// Avoid synchronous decode on UI thread to keep rotation smooth.
if (widget.bytes.isEmpty) {
_setAspectRatio(1.0); // safe fallback
return;
}
final stream = _provider.resolve(createLocalImageConfiguration(context));
_stream = stream;
_listener = ImageStreamListener((ImageInfo info, bool sync) {
final w = info.image.width;
final h = info.image.height;
if (w > 0 && h > 0) {
_setAspectRatio(w / h);
}
});
stream.addListener(_listener!);
}
void _unlisten() {
if (_stream != null && _listener != null) {
_stream!.removeListener(_listener!);
}
_stream = null;
_listener = null;
}
@override @override
void dispose() { void dispose() {
_unlisten();
super.dispose(); super.dispose();
} }
Future<void> _prepare() async {
final src = widget.image;
_lastSrc = src;
// Compute target decode size preserving aspect if hints provided.
int targetW = src.width;
int targetH = src.height;
if (widget.cacheWidth != null || widget.cacheHeight != null) {
if (widget.cacheWidth != null && widget.cacheHeight != null) {
targetW = widget.cacheWidth!.clamp(1, src.width);
targetH = widget.cacheHeight!.clamp(1, src.height);
} else if (widget.cacheWidth != null) {
targetW = widget.cacheWidth!.clamp(1, src.width);
targetH = (targetW * src.height / src.width).round().clamp(
1,
src.height,
);
} else if (widget.cacheHeight != null) {
targetH = widget.cacheHeight!.clamp(1, src.height);
targetW = (targetH * src.width / src.height).round().clamp(
1,
src.width,
);
}
}
img.Image working = src;
if (working.width != targetW || working.height != targetH) {
// High-quality resize; image package chooses a reasonable default.
working = img.copyResize(working, width: targetW, height: targetH);
}
// Ensure RGBA (4 channels) so alpha is preserved when encoding.
working = working.convert(numChannels: 4);
_lastW = working.width;
_lastH = working.height;
// Encode to PNG with low compression level for faster encode.
// This avoids manual decode in the widget; Flutter will decode the PNG.
final pngEncoder = img.PngEncoder(level: 1);
final bytes = Uint8List.fromList(pngEncoder.encode(working));
if (!mounted) return;
setState(() => _encodedBytes = bytes);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final angle = rot.ccwRadians(widget.rotationDeg); // Compute angle-aware scale so rotated image stays within bounds.
Widget img = Image.memory( final double angleRad = rot.ccwRadians(widget.rotationDeg);
widget.bytes, final double ar =
fit: widget.fit, widget.image.width == 0
gaplessPlayback: widget.gaplessPlayback, ? 1.0
filterQuality: widget.filterQuality, : widget.image.width / widget.image.height;
alignment: widget.alignment, final double k = rot.scaleToFitForAngle(angleRad, ar: ar);
semanticLabel: widget.semanticLabel,
// Provide at most one dimension to preserve aspect ratio if only one is set
cacheWidth: widget.cacheWidth,
cacheHeight: widget.cacheHeight,
isAntiAlias: false,
errorBuilder: (context, error, stackTrace) {
// Return a placeholder for invalid images
return Container(
color: Colors.grey[300],
child: const Icon(Icons.broken_image, color: Colors.grey),
);
},
);
if (angle != 0.0) { Widget core =
final scaleToFit = rot.scaleToFitForAngle(angle, ar: _derivedAspectRatio); _encodedBytes == null
img = Transform.scale( ? const SizedBox.shrink()
scale: scaleToFit, : Image.memory(
child: Transform.rotate(angle: angle, child: img), _encodedBytes!,
); fit: BoxFit.contain,
filterQuality: widget.filterQuality,
gaplessPlayback: true,
);
if (widget.semanticLabel != null) {
core = Semantics(label: widget.semanticLabel, child: core);
} }
if (!widget.wrapInRepaintBoundary) return img; // Order: scale first, then rotate. Scale ensures rotated bounds fit.
return RepaintBoundary(child: img); Widget transformed = Transform.scale(
scale: k,
alignment: Alignment.center,
child: Transform.rotate(
angle: angleRad,
alignment: Alignment.center,
child: core,
),
);
// Allow parent to size; we simply contain within available space.
return FittedBox(
fit: BoxFit.contain,
alignment: Alignment.center,
child: SizedBox(
width: _lastW?.toDouble() ?? widget.image.width.toDouble(),
height: _lastH?.toDouble() ?? widget.image.height.toDouble(),
child: transformed,
),
);
} }
} }

View File

@ -1,4 +1,3 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/domain/models/model.dart' as domain; import 'package:pdf_signature/domain/models/model.dart' as domain;
@ -31,7 +30,6 @@ class SignatureCardView extends ConsumerStatefulWidget {
} }
class _SignatureCardViewState extends ConsumerState<SignatureCardView> { class _SignatureCardViewState extends ConsumerState<SignatureCardView> {
Uint8List? _lastBytesRef;
Future<void> _showContextMenu(BuildContext context, Offset position) async { Future<void> _showContextMenu(BuildContext context, Offset position) async {
final selected = await showMenu<String>( final selected = await showMenu<String>(
context: context, context: context,
@ -61,39 +59,27 @@ class _SignatureCardViewState extends ConsumerState<SignatureCardView> {
} }
} }
void _maybePrecache(Uint8List bytes) { // No precache needed when using decoded images directly.
if (identical(_lastBytesRef, bytes)) return;
_lastBytesRef = bytes;
// Schedule after frame to avoid doing work during build.
WidgetsBinding.instance.addPostFrameCallback((_) {
// Use single-dimension hints to preserve aspect ratio.
final img128 = ResizeImage(MemoryImage(bytes), height: 128);
final img256 = ResizeImage(MemoryImage(bytes), height: 256);
precacheImage(img128, context);
precacheImage(img256, context);
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final displayData = ref final (displayImage, colorMatrix) = ref
.watch(signatureViewModelProvider) .watch(signatureViewModelProvider)
.getDisplaySignatureData(widget.asset, widget.graphicAdjust); .getDisplayImage(widget.asset, widget.graphicAdjust);
_maybePrecache(displayData.bytes);
// Fit inside 96x64 with 6px padding using the shared rotated image widget // Fit inside 96x64 with 6px padding using the shared rotated image widget
const boxW = 96.0, boxH = 64.0, pad = 6.0; const boxW = 96.0, boxH = 64.0, pad = 6.0;
// Hint decoder with small target size to reduce decode cost. // Hint decoder with small target size to reduce decode cost.
// The card shows inside 96x64 with 6px padding; request ~128px max. // The card shows inside 96x64 with 6px padding; request ~128px max.
Widget coreImage = RotatedSignatureImage( Widget coreImage = RotatedSignatureImage(
bytes: displayData.bytes, image: displayImage,
rotationDeg: widget.rotationDeg, rotationDeg: widget.rotationDeg,
// Only set one dimension to keep aspect ratio // Only set one dimension to keep aspect ratio
cacheHeight: 128, cacheHeight: 128,
); );
Widget img = Widget img =
(displayData.colorMatrix != null) (colorMatrix != null)
? ColorFiltered( ? ColorFiltered(
colorFilter: ColorFilter.matrix(displayData.colorMatrix!), colorFilter: ColorFilter.matrix(colorMatrix),
child: coreImage, child: coreImage,
) )
: coreImage; : coreImage;
@ -180,19 +166,17 @@ class _SignatureCardViewState extends ConsumerState<SignatureCardView> {
child: Padding( child: Padding(
padding: const EdgeInsets.all(6.0), padding: const EdgeInsets.all(6.0),
child: child:
(displayData.colorMatrix != null) (colorMatrix != null)
? ColorFiltered( ? ColorFiltered(
colorFilter: ColorFilter.matrix( colorFilter: ColorFilter.matrix(colorMatrix),
displayData.colorMatrix!,
),
child: RotatedSignatureImage( child: RotatedSignatureImage(
bytes: displayData.bytes, image: displayImage,
rotationDeg: widget.rotationDeg, rotationDeg: widget.rotationDeg,
cacheHeight: 256, cacheHeight: 256,
), ),
) )
: RotatedSignatureImage( : RotatedSignatureImage(
bytes: displayData.bytes, image: displayImage,
rotationDeg: widget.rotationDeg, rotationDeg: widget.rotationDeg,
cacheHeight: 256, cacheHeight: 256,
), ),

View File

@ -1,4 +1,4 @@
import 'dart:typed_data'; // no bytes here; image-first
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart'; import 'package:pdf_signature/l10n/app_localizations.dart';
@ -7,6 +7,7 @@ import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:pdf_signature/data/repositories/signature_asset_repository.dart'; import 'package:pdf_signature/data/repositories/signature_asset_repository.dart';
import 'package:pdf_signature/data/repositories/signature_card_repository.dart'; import 'package:pdf_signature/data/repositories/signature_card_repository.dart';
import 'package:pdf_signature/domain/models/signature_asset.dart'; import 'package:pdf_signature/domain/models/signature_asset.dart';
import 'package:image/image.dart' as img;
import 'image_editor_dialog.dart'; import 'image_editor_dialog.dart';
import 'signature_card_view.dart'; import 'signature_card_view.dart';
import '../../pdf/view_model/pdf_view_model.dart'; import '../../pdf/view_model/pdf_view_model.dart';
@ -22,10 +23,10 @@ class SignatureDrawer extends ConsumerStatefulWidget {
}); });
final bool disabled; final bool disabled;
// Return the loaded bytes (if any) so we can add the exact image to the library immediately. // Return decoded image so inner layers don't decode.
final Future<Uint8List?> Function() onLoadSignatureFromFile; final Future<img.Image?> Function() onLoadSignatureFromFile;
// Return the drawn bytes (if any) so we can add it to the library immediately. // Return decoded image so inner layers don't decode.
final Future<Uint8List?> Function() onOpenDrawCanvas; final Future<img.Image?> Function() onOpenDrawCanvas;
@override @override
ConsumerState<SignatureDrawer> createState() => _SignatureDrawerState(); ConsumerState<SignatureDrawer> createState() => _SignatureDrawerState();
@ -120,12 +121,11 @@ class _SignatureDrawerState extends ConsumerState<SignatureDrawer> {
disabled disabled
? null ? null
: () async { : () async {
final loaded = final image =
await widget.onLoadSignatureFromFile(); await widget.onLoadSignatureFromFile();
final b = loaded; if (image != null) {
if (b != null) {
final asset = SignatureAsset( final asset = SignatureAsset(
bytes: b, sigImage: image,
name: 'image', name: 'image',
); );
ref ref
@ -133,7 +133,7 @@ class _SignatureDrawerState extends ConsumerState<SignatureDrawer> {
signatureAssetRepositoryProvider signatureAssetRepositoryProvider
.notifier, .notifier,
) )
.add(b, name: 'image'); .addImage(image, name: 'image');
ref ref
.read( .read(
signatureCardRepositoryProvider signatureCardRepositoryProvider
@ -151,11 +151,10 @@ class _SignatureDrawerState extends ConsumerState<SignatureDrawer> {
disabled disabled
? null ? null
: () async { : () async {
final drawn = await widget.onOpenDrawCanvas(); final image = await widget.onOpenDrawCanvas();
final b = drawn; if (image != null) {
if (b != null) {
final asset = SignatureAsset( final asset = SignatureAsset(
bytes: b, sigImage: image,
name: 'drawing', name: 'drawing',
); );
ref ref
@ -163,7 +162,7 @@ class _SignatureDrawerState extends ConsumerState<SignatureDrawer> {
signatureAssetRepositoryProvider signatureAssetRepositoryProvider
.notifier, .notifier,
) )
.add(b, name: 'drawing'); .addImage(image, name: 'drawing');
ref ref
.read( .read(
signatureCardRepositoryProvider signatureCardRepositoryProvider

View File

@ -0,0 +1,34 @@
import 'package:image/image.dart' as img;
/// Removes near-white background by making pixels with high RGB values transparent.
///
/// - Ensures the image has an alpha channel (RGBA) before modification.
/// - Returns a new img.Image instance; does not mutate the input reference.
/// - threshold: 0..255; pixels with r,g,b >= threshold become fully transparent.
img.Image removeNearWhiteBackground(img.Image image, {int threshold = 240}) {
// Ensure truecolor RGBA; paletted images won't apply per-pixel alpha properly.
final hadAlpha = image.hasAlpha;
img.Image out =
(image.hasPalette || !image.hasAlpha)
? image.convert(numChannels: 4)
: img.Image.from(image);
for (int y = 0; y < out.height; y++) {
for (int x = 0; x < out.width; x++) {
final p = out.getPixel(x, y);
final r = p.r;
final g = p.g;
final b = p.b;
if (r >= threshold && g >= threshold && b >= threshold) {
out.setPixelRgba(x, y, r, g, b, 0);
} else {
// Keep original alpha if input had alpha; otherwise force fully opaque.
final a = hadAlpha ? p.a : 255;
if (p.a != a) {
out.setPixelRgba(x, y, r, g, b, a);
}
}
}
}
return out;
}

View File

@ -1,3 +1,4 @@
// ignore_for_file: deprecated_member_use
// ignore: avoid_web_libraries_in_flutter // ignore: avoid_web_libraries_in_flutter
import 'dart:html' as html; import 'dart:html' as html;
import 'dart:typed_data'; import 'dart:typed_data';

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

View File

@ -10,12 +10,13 @@ import 'package:pdf_signature/data/repositories/document_repository.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_export_view_model.dart'; import 'package:pdf_signature/ui/features/pdf/view_model/pdf_export_view_model.dart';
import 'package:pdf_signature/data/services/export_service.dart'; import 'package:pdf_signature/data/services/export_service.dart';
import 'package:pdf_signature/domain/models/model.dart'; import 'package:pdf_signature/domain/models/model.dart';
import 'package:image/image.dart' as img;
class FakeExportService extends ExportService { class FakeExportService extends ExportService {
bool exported = false; bool exported = false;
@override @override
Future<Uint8List?> exportSignedPdfFromBytes({ Future<Uint8List?> exportSignedPdfFromBytes({
Map<String, Uint8List>? libraryBytes, Map<String, img.Image>? libraryImages,
required Uint8List srcBytes, required Uint8List srcBytes,
required Size uiPageSize, required Size uiPageSize,
required Uint8List? signatureImageBytes, required Uint8List? signatureImageBytes,

View File

@ -1,4 +1,4 @@
import 'dart:typed_data'; import 'package:image/image.dart' as img;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/data/repositories/signature_asset_repository.dart'; import 'package:pdf_signature/data/repositories/signature_asset_repository.dart';
@ -10,8 +10,11 @@ Future<void> aCreatedSignatureCard(WidgetTester tester) async {
final container = TestWorld.container ?? ProviderContainer(); final container = TestWorld.container ?? ProviderContainer();
TestWorld.container = container; TestWorld.container = container;
// Create a dummy signature asset // Create a dummy signature asset
final asset = SignatureAsset(bytes: Uint8List(100), name: 'Test Card'); final asset = SignatureAsset(
sigImage: img.Image(width: 1, height: 1),
name: 'Test Card',
);
container container
.read(signatureAssetRepositoryProvider.notifier) .read(signatureAssetRepositoryProvider.notifier)
.add(asset.bytes, name: asset.name); .addImage(asset.sigImage, name: asset.name);
} }

View File

@ -1,4 +1,4 @@
import 'dart:typed_data'; import 'package:image/image.dart' as img;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -12,14 +12,15 @@ Future<void> aDocumentIsOpenAndContainsAtLeastOneSignaturePlacement(
) async { ) async {
final container = TestWorld.container ?? ProviderContainer(); final container = TestWorld.container ?? ProviderContainer();
TestWorld.container = container; TestWorld.container = container;
container container.read(documentRepositoryProvider.notifier).openPicked(pageCount: 5);
.read(documentRepositoryProvider.notifier)
.openPicked(pageCount: 5);
container container
.read(documentRepositoryProvider.notifier) .read(documentRepositoryProvider.notifier)
.addPlacement( .addPlacement(
page: 1, page: 1,
rect: Rect.fromLTWH(10, 10, 100, 50), rect: Rect.fromLTWH(10, 10, 100, 50),
asset: SignatureAsset(bytes: Uint8List(0), name: 'sig.png'), asset: SignatureAsset(
sigImage: img.Image(width: 1, height: 1),
name: 'sig.png',
),
); );
} }

View File

@ -1,4 +1,4 @@
import 'dart:typed_data'; import 'package:image/image.dart' as img;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -19,7 +19,10 @@ aDocumentIsOpenAndContainsMultiplePlacedSignaturePlacementsAcrossPages(
.addPlacement( .addPlacement(
page: 1, page: 1,
rect: Rect.fromLTWH(0.1, 0.1, 0.2, 0.1), rect: Rect.fromLTWH(0.1, 0.1, 0.2, 0.1),
asset: SignatureAsset(bytes: Uint8List(0), name: 'sig1.png'), asset: SignatureAsset(
sigImage: img.Image(width: 1, height: 1),
name: 'sig1.png',
),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
container container
@ -27,7 +30,10 @@ aDocumentIsOpenAndContainsMultiplePlacedSignaturePlacementsAcrossPages(
.addPlacement( .addPlacement(
page: 2, page: 2,
rect: Rect.fromLTWH(0.2, 0.2, 0.2, 0.1), rect: Rect.fromLTWH(0.2, 0.2, 0.2, 0.1),
asset: SignatureAsset(bytes: Uint8List(0), name: 'sig2.png'), asset: SignatureAsset(
sigImage: img.Image(width: 1, height: 1),
name: 'sig2.png',
),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
container container
@ -35,7 +41,10 @@ aDocumentIsOpenAndContainsMultiplePlacedSignaturePlacementsAcrossPages(
.addPlacement( .addPlacement(
page: 3, page: 3,
rect: Rect.fromLTWH(0.3, 0.3, 0.2, 0.1), rect: Rect.fromLTWH(0.3, 0.3, 0.2, 0.1),
asset: SignatureAsset(bytes: Uint8List(0), name: 'sig3.png'), asset: SignatureAsset(
sigImage: img.Image(width: 1, height: 1),
name: 'sig3.png',
),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
} }

View File

@ -1,4 +1,4 @@
import 'dart:typed_data'; import 'package:image/image.dart' as img;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/data/repositories/signature_asset_repository.dart'; import 'package:pdf_signature/data/repositories/signature_asset_repository.dart';
@ -17,78 +17,9 @@ Future<void> aSignatureAssetIsLoadedOrDrawn(WidgetTester tester) async {
container.read(signatureCardRepositoryProvider.notifier).state = [ container.read(signatureCardRepositoryProvider.notifier).state = [
CachedSignatureCard.initial(), CachedSignatureCard.initial(),
]; ];
// Use a tiny valid PNG so any later image decoding succeeds. final image = img.Image(width: 1, height: 1);
final bytes = Uint8List.fromList([
0x89,
0x50,
0x4E,
0x47,
0x0D,
0x0A,
0x1A,
0x0A,
0x00,
0x00,
0x00,
0x0D,
0x49,
0x48,
0x44,
0x52,
0x00,
0x00,
0x00,
0x01,
0x00,
0x00,
0x00,
0x01,
0x08,
0x06,
0x00,
0x00,
0x00,
0x1F,
0x15,
0xC4,
0x89,
0x00,
0x00,
0x00,
0x0A,
0x49,
0x44,
0x41,
0x54,
0x78,
0x9C,
0x63,
0x60,
0x00,
0x00,
0x00,
0x02,
0x00,
0x01,
0xE5,
0x27,
0xD4,
0xA6,
0x00,
0x00,
0x00,
0x00,
0x49,
0x45,
0x4E,
0x44,
0xAE,
0x42,
0x60,
0x82,
]);
container container
.read(signatureAssetRepositoryProvider.notifier) .read(signatureAssetRepositoryProvider.notifier)
.add(bytes, name: 'test.png'); .addImage(image, name: 'test.png');
await tester.pump(); await tester.pump();
} }

View File

@ -1,4 +1,4 @@
import 'dart:typed_data'; import 'package:image/image.dart' as img;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -26,10 +26,10 @@ Future<void> aSignatureAssetIsPlacedOnThePage(WidgetTester tester) async {
if (library.isNotEmpty) { if (library.isNotEmpty) {
asset = library.first; asset = library.first;
} else { } else {
final bytes = Uint8List.fromList([1, 2, 3, 4, 5]); final image = img.Image(width: 1, height: 1);
container container
.read(signatureAssetRepositoryProvider.notifier) .read(signatureAssetRepositoryProvider.notifier)
.add(bytes, name: 'test.png'); .addImage(image, name: 'test.png');
asset = container asset = container
.read(signatureAssetRepositoryProvider) .read(signatureAssetRepositoryProvider)
.firstWhere((a) => a.name == 'test.png'); .firstWhere((a) => a.name == 'test.png');

View File

@ -1,4 +1,4 @@
import 'dart:typed_data'; import 'package:image/image.dart' as img;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/data/repositories/signature_asset_repository.dart'; import 'package:pdf_signature/data/repositories/signature_asset_repository.dart';
@ -14,7 +14,7 @@ Future<void> aSignatureAssetIsSelected(WidgetTester tester) async {
if (library.isEmpty) { if (library.isEmpty) {
container container
.read(signatureAssetRepositoryProvider.notifier) .read(signatureAssetRepositoryProvider.notifier)
.add(Uint8List(100), name: 'Selected Asset'); .addImage(img.Image(width: 1, height: 1), name: 'Selected Asset');
// Re-read the library // Re-read the library
library = container.read(signatureAssetRepositoryProvider); library = container.read(signatureAssetRepositoryProvider);
} }

View File

@ -1,4 +1,4 @@
import 'dart:typed_data'; import 'package:image/image.dart' as img;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/data/repositories/signature_asset_repository.dart'; import 'package:pdf_signature/data/repositories/signature_asset_repository.dart';
@ -19,10 +19,9 @@ Future<void> aSignatureAssetLoadedOrDrawnIsWrappedInASignatureCard(
container.read(signatureCardRepositoryProvider.notifier).state = [ container.read(signatureCardRepositoryProvider.notifier).state = [
CachedSignatureCard.initial(), CachedSignatureCard.initial(),
]; ];
final bytes = Uint8List.fromList([1, 2, 3, 4, 5]);
container container
.read(signatureAssetRepositoryProvider.notifier) .read(signatureAssetRepositoryProvider.notifier)
.add(bytes, name: 'test.png'); .addImage(img.Image(width: 1, height: 1), name: 'test.png');
// Allow provider scheduler to flush any pending timers // Allow provider scheduler to flush any pending timers
await tester.pump(); await tester.pump();
} }

View File

@ -1,4 +1,4 @@
import 'dart:typed_data'; import 'package:image/image.dart' as img;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -25,7 +25,10 @@ Future<void> aSignaturePlacementIsPlacedOnPage(
.addPlacement( .addPlacement(
page: page, page: page,
rect: Rect.fromLTWH(20, 20, 100, 50), rect: Rect.fromLTWH(20, 20, 100, 50),
asset: SignatureAsset(bytes: Uint8List(0), name: 'test.png'), asset: SignatureAsset(
sigImage: img.Image(width: 1, height: 1),
name: 'test.png',
),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
} }

View File

@ -1,6 +1,6 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/data/repositories/document_repository.dart'; import 'package:pdf_signature/data/repositories/document_repository.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart'; import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
@ -25,7 +25,10 @@ Future<void> aSignaturePlacementIsPlacedWithAPositionAndSizeRelativeToThePage(
page: currentPage, page: currentPage,
// Use normalized 0..1 fractions relative to page size as required // Use normalized 0..1 fractions relative to page size as required
rect: const Rect.fromLTWH(0.2, 0.3, 0.4, 0.2), rect: const Rect.fromLTWH(0.2, 0.3, 0.4, 0.2),
asset: SignatureAsset(bytes: Uint8List(0), name: 'test.png'), asset: SignatureAsset(
sigImage: img.Image(width: 1, height: 1),
name: 'test.png',
),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
} }

View File

@ -1,4 +1,3 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -23,10 +22,8 @@ Future<void> nearwhiteBackgroundBecomesTransparentInThePreview(
src.setPixelRgba(0, 0, 250, 250, 250, 255); src.setPixelRgba(0, 0, 250, 250, 250, 255);
// Solid black stays opaque // Solid black stays opaque
src.setPixelRgba(1, 0, 0, 0, 0, 255); src.setPixelRgba(1, 0, 0, 0, 0, 255);
final png = Uint8List.fromList(img.encodePng(src, level: 6)); // Create a widget with the decoded image
final widget = RotatedSignatureImage(image: src);
// Create a widget with the image
final widget = RotatedSignatureImage(bytes: png);
// Pump the widget // Pump the widget
await tester.pumpWidget(MaterialApp(home: Scaffold(body: widget))); await tester.pumpWidget(MaterialApp(home: Scaffold(body: widget)));
@ -40,14 +37,11 @@ Future<void> nearwhiteBackgroundBecomesTransparentInThePreview(
expect(find.byType(RotatedSignatureImage), findsOneWidget); expect(find.byType(RotatedSignatureImage), findsOneWidget);
// Test the processing logic directly // Test the processing logic directly
final decoded = img.decodeImage(png); final processedImg = _removeBackground(src);
expect(decoded, isNotNull); final resultImg =
final processedImg = _removeBackground(decoded!); processedImg.hasAlpha
final processed = Uint8List.fromList(img.encodePng(processedImg)); ? img.Image.from(processedImg)
expect(processed, isNotNull); : processedImg.convert(numChannels: 4);
final outImg = img.decodeImage(processed);
expect(outImg, isNotNull);
final resultImg = outImg!.hasAlpha ? outImg : outImg.convert(numChannels: 4);
final p0 = resultImg.getPixel(0, 0); final p0 = resultImg.getPixel(0, 0);
final p1 = resultImg.getPixel(1, 0); final p1 = resultImg.getPixel(1, 0);

View File

@ -1,4 +1,4 @@
import 'dart:typed_data'; import 'package:image/image.dart' as img;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/data/repositories/signature_asset_repository.dart'; import 'package:pdf_signature/data/repositories/signature_asset_repository.dart';
@ -10,8 +10,8 @@ Future<void> theUserChoosesAImageFileAsASignatureAsset(
) async { ) async {
final container = TestWorld.container ?? ProviderContainer(); final container = TestWorld.container ?? ProviderContainer();
TestWorld.container = container; TestWorld.container = container;
final bytes = Uint8List.fromList([1, 2, 3, 4, 5]); final image = img.Image(width: 1, height: 1);
container container
.read(signatureAssetRepositoryProvider.notifier) .read(signatureAssetRepositoryProvider.notifier)
.add(bytes, name: 'chosen.png'); .addImage(image, name: 'chosen.png');
} }

View File

@ -1,4 +1,4 @@
import 'dart:typed_data'; import 'package:image/image.dart' as img;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/data/repositories/signature_asset_repository.dart'; import 'package:pdf_signature/data/repositories/signature_asset_repository.dart';
@ -10,8 +10,7 @@ Future<void> theUserChoosesASignatureAssetToCreatedASignatureCard(
) async { ) async {
final container = TestWorld.container ?? ProviderContainer(); final container = TestWorld.container ?? ProviderContainer();
TestWorld.container = container; TestWorld.container = container;
final bytes = Uint8List.fromList([1, 2, 3, 4, 5]);
container container
.read(signatureAssetRepositoryProvider.notifier) .read(signatureAssetRepositoryProvider.notifier)
.add(bytes, name: 'card.png'); .addImage(img.Image(width: 1, height: 1), name: 'card.png');
} }

View File

@ -1,4 +1,4 @@
import 'dart:typed_data'; import 'package:image/image.dart' as img;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/data/repositories/document_repository.dart'; import 'package:pdf_signature/data/repositories/document_repository.dart';
@ -16,7 +16,10 @@ theUserDragsItOnThePageOfTheDocumentToPlaceSignaturePlacementsInMultipleLocation
final asset = final asset =
lib.isNotEmpty lib.isNotEmpty
? lib.first ? lib.first
: SignatureAsset(bytes: Uint8List(0), name: 'shared.png'); : SignatureAsset(
sigImage: img.Image(width: 1, height: 1),
name: 'shared.png',
);
// Ensure PDF is open // Ensure PDF is open
if (!container.read(documentRepositoryProvider).loaded) { if (!container.read(documentRepositoryProvider).loaded) {

View File

@ -1,4 +1,4 @@
import 'dart:typed_data'; import 'package:image/image.dart' as img;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -30,10 +30,9 @@ theUserDragsThisSignatureCardOnThePageOfTheDocumentToPlaceASignaturePlacement(
if (library.isNotEmpty) { if (library.isNotEmpty) {
asset = library.first; asset = library.first;
} else { } else {
final bytes = Uint8List.fromList([1, 2, 3, 4, 5]);
container container
.read(signatureAssetRepositoryProvider.notifier) .read(signatureAssetRepositoryProvider.notifier)
.add(bytes, name: 'placement.png'); .addImage(img.Image(width: 1, height: 1), name: 'placement.png');
asset = container asset = container
.read(signatureAssetRepositoryProvider) .read(signatureAssetRepositoryProvider)
.firstWhere((a) => a.name == 'placement.png'); .firstWhere((a) => a.name == 'placement.png');

View File

@ -1,4 +1,4 @@
import 'dart:typed_data'; import 'package:image/image.dart' as img;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/data/repositories/signature_asset_repository.dart'; import 'package:pdf_signature/data/repositories/signature_asset_repository.dart';
@ -44,78 +44,6 @@ Future<void> theUserDrawsStrokesAndConfirms(WidgetTester tester) async {
if (container != null) { if (container != null) {
container container
.read(signatureAssetRepositoryProvider.notifier) .read(signatureAssetRepositoryProvider.notifier)
.add( .addImage(img.Image(width: 1, height: 1), name: 'drawing');
// Tiny 1x1 transparent PNG (duplicated constant for test clarity)
Uint8List.fromList([
0x89,
0x50,
0x4E,
0x47,
0x0D,
0x0A,
0x1A,
0x0A,
0x00,
0x00,
0x00,
0x0D,
0x49,
0x48,
0x44,
0x52,
0x00,
0x00,
0x00,
0x01,
0x00,
0x00,
0x00,
0x01,
0x08,
0x06,
0x00,
0x00,
0x00,
0x1F,
0x15,
0xC4,
0x89,
0x00,
0x00,
0x00,
0x0A,
0x49,
0x44,
0x41,
0x54,
0x78,
0x9C,
0x63,
0x60,
0x00,
0x00,
0x00,
0x02,
0x00,
0x01,
0xE5,
0x27,
0xD4,
0xA6,
0x00,
0x00,
0x00,
0x00,
0x49,
0x45,
0x4E,
0x44,
0xAE,
0x42,
0x60,
0x82,
]),
name: 'drawing',
);
} }
} }

View File

@ -1,4 +1,4 @@
import 'dart:typed_data'; import 'package:image/image.dart' as img;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -25,7 +25,10 @@ Future<void> theUserNavigatesToPageAndPlacesAnotherSignaturePlacement(
.addPlacement( .addPlacement(
page: page, page: page,
rect: Rect.fromLTWH(40, 40, 100, 50), rect: Rect.fromLTWH(40, 40, 100, 50),
asset: SignatureAsset(bytes: Uint8List(0), name: 'another.png'), asset: SignatureAsset(
sigImage: img.Image(width: 1, height: 1),
name: 'another.png',
),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
} }

View File

@ -1,4 +1,4 @@
import 'dart:typed_data'; import 'package:image/image.dart' as img;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -20,7 +20,7 @@ Future<void> theUserPlacesASignaturePlacementFromAssetOnPage(
// add dummy asset // add dummy asset
container container
.read(signatureAssetRepositoryProvider.notifier) .read(signatureAssetRepositoryProvider.notifier)
.add(Uint8List(100), name: assetName); .addImage(img.Image(width: 1, height: 1), name: assetName);
final updatedLibrary = container.read(signatureAssetRepositoryProvider); final updatedLibrary = container.read(signatureAssetRepositoryProvider);
asset = updatedLibrary.firstWhere((a) => a.name == assetName); asset = updatedLibrary.firstWhere((a) => a.name == assetName);
} }

View File

@ -1,4 +1,4 @@
import 'dart:typed_data'; import 'package:image/image.dart' as img;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -19,7 +19,10 @@ Future<void> theUserPlacesASignaturePlacementOnPage(
.addPlacement( .addPlacement(
page: page, page: page,
rect: Rect.fromLTWH(20, 20, 100, 50), rect: Rect.fromLTWH(20, 20, 100, 50),
asset: SignatureAsset(bytes: Uint8List(0), name: 'test.png'), asset: SignatureAsset(
sigImage: img.Image(width: 1, height: 1),
name: 'test.png',
),
); );
// Allow Riverpod's scheduler to flush any pending microtasks/timers // Allow Riverpod's scheduler to flush any pending microtasks/timers
await tester.pumpAndSettle(); await tester.pumpAndSettle();

View File

@ -1,4 +1,4 @@
import 'dart:typed_data'; import 'package:image/image.dart' as img;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -21,16 +21,7 @@ Future<void> theUserPlacesTwoSignaturePlacementsOnTheSamePage(
page: page, page: page,
rect: Rect.fromLTWH(10, 10, 100, 50), rect: Rect.fromLTWH(10, 10, 100, 50),
asset: SignatureAsset( asset: SignatureAsset(
bytes: Uint8List.fromList([ sigImage: img.Image(width: 1, height: 1),
0x89,
0x50,
0x4E,
0x47,
0x0D,
0x0A,
0x1A,
0x0A,
]),
name: 'sig1.png', name: 'sig1.png',
), ),
); );
@ -41,17 +32,7 @@ Future<void> theUserPlacesTwoSignaturePlacementsOnTheSamePage(
page: page, page: page,
rect: Rect.fromLTWH(120, 10, 100, 50), rect: Rect.fromLTWH(120, 10, 100, 50),
asset: SignatureAsset( asset: SignatureAsset(
bytes: Uint8List.fromList([ sigImage: img.Image(width: 1, height: 1),
0x89,
0x50,
0x4E,
0x47,
0x0D,
0x0A,
0x1A,
0x0A,
0x00,
]),
name: 'sig2.png', name: 'sig2.png',
), ),
); );

View File

@ -1,4 +1,4 @@
import 'dart:typed_data'; import 'package:image/image.dart' as img;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -28,19 +28,28 @@ Future<void> threeSignaturePlacementsArePlacedOnTheCurrentPage(
pdfN.addPlacement( pdfN.addPlacement(
page: page, page: page,
rect: Rect.fromLTWH(10, 10, 50, 50), rect: Rect.fromLTWH(10, 10, 50, 50),
asset: SignatureAsset(bytes: Uint8List(0), name: 'test1'), asset: SignatureAsset(
sigImage: img.Image(width: 1, height: 1),
name: 'test1',
),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
pdfN.addPlacement( pdfN.addPlacement(
page: page, page: page,
rect: Rect.fromLTWH(70, 10, 50, 50), rect: Rect.fromLTWH(70, 10, 50, 50),
asset: SignatureAsset(bytes: Uint8List(0), name: 'test2'), asset: SignatureAsset(
sigImage: img.Image(width: 1, height: 1),
name: 'test2',
),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
pdfN.addPlacement( pdfN.addPlacement(
page: page, page: page,
rect: Rect.fromLTWH(130, 10, 50, 50), rect: Rect.fromLTWH(130, 10, 50, 50),
asset: SignatureAsset(bytes: Uint8List(0), name: 'test3'), asset: SignatureAsset(
sigImage: img.Image(width: 1, height: 1),
name: 'test3',
),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
} }

View File

@ -0,0 +1,138 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:pdf_signature/utils/background_removal.dart';
void main() {
group('removeNearWhiteBackground', () {
test('makes pure white transparent and keeps black opaque', () {
final im = img.Image(width: 2, height: 1);
// Left pixel white, right pixel black
im.setPixel(0, 0, img.ColorRgb8(255, 255, 255));
im.setPixel(1, 0, img.ColorRgb8(0, 0, 0));
final out = removeNearWhiteBackground(im, threshold: 240);
final pWhite = out.getPixel(0, 0);
final pBlack = out.getPixel(1, 0);
expect(pWhite.a, 0, reason: 'white should become transparent');
expect(pBlack.a, 255, reason: 'black should remain opaque');
});
test(
'near-white above threshold becomes transparent, below stays opaque',
() {
final im = img.Image(width: 3, height: 1);
im.setPixel(0, 0, img.ColorRgb8(239, 239, 239)); // below 240
im.setPixel(1, 0, img.ColorRgb8(240, 240, 240)); // at threshold
im.setPixel(2, 0, img.ColorRgb8(250, 250, 250)); // above threshold
final out = removeNearWhiteBackground(im, threshold: 240);
expect(out.getPixel(0, 0).a, 255, reason: '239 should stay opaque');
expect(out.getPixel(1, 0).a, 0, reason: '240 should be transparent');
expect(out.getPixel(2, 0).a, 0, reason: '250 should be transparent');
},
);
test('preserves color channels while zeroing alpha for near-white', () {
final im = img.Image(width: 1, height: 1);
im.setPixel(0, 0, img.ColorRgb8(245, 246, 247));
final out = removeNearWhiteBackground(im, threshold: 240);
final p = out.getPixel(0, 0);
expect(p.r, 245);
expect(p.g, 246);
expect(p.b, 247);
expect(p.a, 0);
});
test('works when input already has alpha channel', () {
final im = img.Image(width: 1, height: 2, numChannels: 4);
im.setPixel(0, 0, img.ColorRgba8(255, 255, 255, 200));
im.setPixel(0, 1, img.ColorRgba8(10, 10, 10, 123));
final out = removeNearWhiteBackground(im, threshold: 240);
expect(out.getPixel(0, 0).a, 0, reason: 'white alpha -> 0');
expect(out.getPixel(0, 1).a, 123, reason: 'non-white alpha preserved');
});
test(
'real image: test/data/test_signature_image.png background becomes transparent',
() {
final path = 'test/data/test_signature_image.png';
final file = File(path);
if (!file.existsSync()) {
// Fallback: create a simple signature-like PNG if missing
Directory('test/data').createSync(recursive: true);
final w = 200, h = 100;
final canvas = img.Image(width: w, height: h);
img.fill(canvas, color: img.ColorRgb8(255, 255, 255));
for (int dy = -1; dy <= 1; dy++) {
img.drawLine(
canvas,
x1: 20,
y1: h ~/ 2 + dy,
x2: w - 20,
y2: h ~/ 2 + dy,
color: img.ColorRgb8(0, 0, 0),
);
}
img.drawLine(
canvas,
x1: w - 50,
y1: h ~/ 2 - 10,
x2: w - 10,
y2: h ~/ 2 - 20,
color: img.ColorRgb8(0, 0, 0),
);
file.writeAsBytesSync(img.encodePng(canvas));
}
final bytes = file.readAsBytesSync();
final decoded = img.decodeImage(bytes);
expect(decoded, isNotNull, reason: 'should decode test image');
final processed = removeNearWhiteBackground(decoded!, threshold: 240);
// Corners are often paper margin: expect transparency where near-white
final c00 = processed.getPixel(0, 0);
final c10 = processed.getPixel(processed.width - 1, 0);
final c01 = processed.getPixel(0, processed.height - 1);
final c11 = processed.getPixel(
processed.width - 1,
processed.height - 1,
);
// If any corner is near-white, it should be transparent
bool anyCornerTransparent = false;
for (final p in [c00, c10, c01, c11]) {
if (p.r >= 240 && p.g >= 240 && p.b >= 240) {
expect(p.a, 0, reason: 'near-white corner should be transparent');
anyCornerTransparent = true;
}
}
expect(
anyCornerTransparent,
isTrue,
reason: 'expected at least one near-white corner in the test image',
);
// Find a dark pixel and assert it remains opaque
bool foundDarkOpaque = false;
for (int y = 0; y < processed.height && !foundDarkOpaque; y++) {
for (int x = 0; x < processed.width && !foundDarkOpaque; x++) {
final p = processed.getPixel(x, y);
if (p.r < 50 && p.g < 50 && p.b < 50) {
expect(p.a, 255, reason: 'dark stroke pixel should stay opaque');
foundDarkOpaque = true;
}
}
}
expect(
foundDarkOpaque,
isTrue,
reason: 'expected at least one dark stroke pixel in the test image',
);
},
);
});
}

View File

@ -1,4 +1,4 @@
import 'dart:typed_data'; import 'package:image/image.dart' as img;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/signature/widgets/image_editor_dialog.dart'; import 'package:pdf_signature/ui/features/signature/widgets/image_editor_dialog.dart';
import 'package:pdf_signature/domain/models/model.dart' as domain; import 'package:pdf_signature/domain/models/model.dart' as domain;
@ -8,7 +8,7 @@ void main() {
test('should create ImageEditorDialog with background removal enabled', () { test('should create ImageEditorDialog with background removal enabled', () {
// Create test data // Create test data
final testAsset = domain.SignatureAsset( final testAsset = domain.SignatureAsset(
bytes: Uint8List(0), sigImage: img.Image(width: 1, height: 1),
name: 'test', name: 'test',
); );
final testGraphicAdjust = domain.GraphicAdjust(bgRemoval: true); final testGraphicAdjust = domain.GraphicAdjust(bgRemoval: true);
@ -35,7 +35,7 @@ void main() {
() { () {
// Create test data // Create test data
final testAsset = domain.SignatureAsset( final testAsset = domain.SignatureAsset(
bytes: Uint8List(0), sigImage: img.Image(width: 1, height: 1),
name: 'test', name: 'test',
); );
final testGraphicAdjust = domain.GraphicAdjust(bgRemoval: false); final testGraphicAdjust = domain.GraphicAdjust(bgRemoval: false);

View File

@ -14,6 +14,8 @@ import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
import 'package:pdf_signature/data/repositories/document_repository.dart'; import 'package:pdf_signature/data/repositories/document_repository.dart';
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart'; import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart';
import 'package:pdf_signature/l10n/app_localizations.dart'; import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:image/image.dart' as img;
import 'package:pdf_signature/domain/models/model.dart';
class RecordingExporter extends ExportService { class RecordingExporter extends ExportService {
bool called = false; bool called = false;
@ -22,8 +24,8 @@ class RecordingExporter extends ExportService {
required Uint8List srcBytes, required Uint8List srcBytes,
required Size uiPageSize, required Size uiPageSize,
required Uint8List? signatureImageBytes, required Uint8List? signatureImageBytes,
Map<int, List<dynamic>>? placementsByPage, Map<int, List<SignaturePlacement>>? placementsByPage,
Map<String, Uint8List>? libraryBytes, Map<String, img.Image>? libraryImages,
double targetDpi = 144.0, double targetDpi = 144.0,
}) async { }) async {
// Return tiny dummy PDF bytes // Return tiny dummy PDF bytes

View File

@ -378,19 +378,22 @@ Future<void> pumpWithOpenPdfAndSig(WidgetTester tester) async {
notifier.addPlacement( notifier.addPlacement(
page: 1, page: 1,
rect: const Rect.fromLTWH(0.1, 0.1, 0.3, 0.2), rect: const Rect.fromLTWH(0.1, 0.1, 0.3, 0.2),
asset: SignatureAsset(bytes: Uint8List.fromList(bytes)), asset: SignatureAsset(
sigImage: img.decodeImage(Uint8List.fromList(bytes))!,
),
); );
return notifier; return notifier;
}), }),
signatureAssetRepositoryProvider.overrideWith((ref) { signatureAssetRepositoryProvider.overrideWith((ref) {
final repo = SignatureAssetRepository(); final repo = SignatureAssetRepository();
repo.add(Uint8List.fromList(bytes), name: 'test'); final image = img.decodeImage(Uint8List.fromList(bytes))!;
repo.addImage(image, name: 'test');
return repo; return repo;
}), }),
signatureCardRepositoryProvider.overrideWith((ref) { signatureCardRepositoryProvider.overrideWith((ref) {
final cardRepo = SignatureCardStateNotifier(); final cardRepo = SignatureCardStateNotifier();
final asset = SignatureAsset( final asset = SignatureAsset(
bytes: Uint8List.fromList(bytes), sigImage: img.decodeImage(Uint8List.fromList(bytes))!,
name: 'test', name: 'test',
); );
cardRepo.addWithAsset(asset, 0.0); cardRepo.addWithAsset(asset, 0.0);

View File

@ -1,7 +1,7 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:image/image.dart' as img;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_page_area.dart'; import 'package:pdf_signature/ui/features/pdf/widgets/pdf_page_area.dart';
@ -108,7 +108,7 @@ void main() {
.addPlacement( .addPlacement(
page: 1, page: 1,
rect: const Rect.fromLTWH(0.25, 0.50, 0.10, 0.10), rect: const Rect.fromLTWH(0.25, 0.50, 0.10, 0.10),
asset: SignatureAsset(bytes: bytes), asset: SignatureAsset(sigImage: img.decodeImage(bytes)!),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();

View File

@ -1,22 +1,21 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import 'package:pdf_signature/ui/features/signature/widgets/rotated_signature_image.dart'; import 'package:pdf_signature/ui/features/signature/widgets/rotated_signature_image.dart';
/// Generates a simple solid-color PNG with given width/height. /// Generates a simple solid-color image with given width/height.
Uint8List makePng({required int w, required int h}) { img.Image makeImage({required int w, required int h}) {
final im = img.Image(width: w, height: h); final im = img.Image(width: w, height: h);
// Fill with opaque white // Fill with opaque white
img.fill(im, color: img.ColorRgba8(255, 255, 255, 255)); img.fill(im, color: img.ColorRgba8(255, 255, 255, 255));
return Uint8List.fromList(img.encodePng(im)); return im;
} }
void main() { void main() {
testWidgets('4:3 image rotated -90 deg scales to 3/4', (tester) async { testWidgets('4:3 image rotated -90 deg scales to 3/4', (tester) async {
// 4:3 aspect image -> width/height = 4/3 // 4:3 aspect image -> width/height = 4/3
final bytes = makePng(w: 400, h: 300); final image = makeImage(w: 400, h: 300);
// Pump widget under a fixed-size parent so Transform.scale is applied // Pump widget under a fixed-size parent so Transform.scale is applied
await tester.pumpWidget( await tester.pumpWidget(
@ -26,7 +25,7 @@ void main() {
child: SizedBox( child: SizedBox(
width: 200, width: 200,
height: 150, // same aspect as image bounds (4:3) height: 150, // same aspect as image bounds (4:3)
child: RotatedSignatureImage(bytes: bytes, rotationDeg: -90), child: RotatedSignatureImage(image: image, rotationDeg: -90),
), ),
), ),
), ),

View File

@ -29,7 +29,10 @@ void main() {
color: img.ColorUint8.rgb(0, 0, 0), color: img.ColorUint8.rgb(0, 0, 0),
); );
final bytes = img.encodePng(canvas); final bytes = img.encodePng(canvas);
testAsset = SignatureAsset(bytes: bytes, name: 'test_signature.png'); testAsset = SignatureAsset(
sigImage: img.decodeImage(bytes)!,
name: 'test_signature.png',
);
container = ProviderContainer( container = ProviderContainer(
overrides: [ overrides: [