refactor: use image object to replace bytes
This commit is contained in:
parent
81a352a513
commit
bc524e958f
|
|
@ -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;
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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!;
|
||||||
}
|
}
|
||||||
// Previewing unsaved adjustments: compute without caching
|
return _processingService.processImageToImage(
|
||||||
return _processingService.processImage(asset.bytes, adjust);
|
c.asset.sigImage,
|
||||||
|
c.graphicAdjust,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Previewing unsaved adjustments: compute from image without caching
|
||||||
|
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);
|
final matrix = _processingService.buildColorMatrix(adjust);
|
||||||
return DisplaySignatureData(
|
return DisplaySignatureData(image: asset.sigImage, colorMatrix: matrix);
|
||||||
bytes: c.asset.bytes,
|
|
||||||
colorMatrix: matrix,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
// bgRemoval path: provide processed image with baked adjustments.
|
||||||
|
final processed = getProcessedImage(asset, adjust);
|
||||||
|
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);
|
final matrix = _processingService.buildColorMatrix(adjust);
|
||||||
return DisplaySignatureData(bytes: asset.bytes, colorMatrix: matrix);
|
return (asset.sigImage, matrix);
|
||||||
}
|
}
|
||||||
// bgRemoval path: need CPU processed bytes (includes brightness/contrast first)
|
final processed = getProcessedImage(asset, adjust);
|
||||||
final processed = getProcessedBytes(asset, adjust);
|
return (processed, null);
|
||||||
return DisplaySignatureData(bytes: processed, colorMatrix: null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clears all cached processed images.
|
/// Clears all cached processed images.
|
||||||
|
|
|
||||||
|
|
@ -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,71 +24,49 @@ 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) {
|
|
||||||
baseBytes = libBytes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return baseBytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get processed bytes for a placement, with caching.
|
|
||||||
Uint8List _getProcessedBytes(SignaturePlacement placement) {
|
|
||||||
final Uint8List baseBytes = _getBaseBytes(placement);
|
|
||||||
|
|
||||||
final adj = placement.graphicAdjust;
|
|
||||||
final cacheKey =
|
|
||||||
'${_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 provided = libraryImages[libKey];
|
||||||
// If no graphic changes requested, return bytes as-is (conversion to PNG is deferred to MemoryImage step)
|
if (provided != null) {
|
||||||
final bool needsAdjust =
|
_baseImageCache[libKey] = provided;
|
||||||
(adj.contrast != 1.0 || adj.brightness != 1.0 || adj.bgRemoval);
|
return provided;
|
||||||
if (!needsAdjust) {
|
}
|
||||||
_processedBytesCache[cacheKey] = baseBytes;
|
}
|
||||||
return baseBytes;
|
return placement.asset.sigImage;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Get processed image for a placement, with caching.
|
||||||
final decoded = img.decodeImage(baseBytes);
|
img.Image _getProcessedImage(SignaturePlacement placement) {
|
||||||
if (decoded == null) {
|
final base = _getBaseImage(placement);
|
||||||
_processedBytesCache[cacheKey] = baseBytes;
|
final key =
|
||||||
return baseBytes;
|
'${_baseKeyForImage(base)}|${_adjustKey(placement.graphicAdjust)}';
|
||||||
}
|
final cached = _processedImageCache[key];
|
||||||
img.Image processed = decoded;
|
if (cached != null) return cached;
|
||||||
|
final adj = placement.graphicAdjust;
|
||||||
|
img.Image processed = base;
|
||||||
if (adj.contrast != 1.0 || adj.brightness != 1.0) {
|
if (adj.contrast != 1.0 || adj.brightness != 1.0) {
|
||||||
processed = img.adjustColor(
|
processed = img.adjustColor(
|
||||||
processed,
|
processed,
|
||||||
|
|
@ -95,60 +74,48 @@ class ExportService {
|
||||||
brightness: adj.brightness,
|
brightness: adj.brightness,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (adj.bgRemoval) {
|
if (adj.bgRemoval) {
|
||||||
processed = _removeBackground(processed);
|
processed = br.removeNearWhiteBackground(processed, threshold: 240);
|
||||||
|
}
|
||||||
|
_processedImageCache[key] = processed;
|
||||||
|
return processed;
|
||||||
}
|
}
|
||||||
|
|
||||||
final outBytes = Uint8List.fromList(img.encodePng(processed));
|
// Get PNG bytes for the processed image, caching the encoding.
|
||||||
_processedBytesCache[cacheKey] = outBytes;
|
Uint8List _getProcessedPng(SignaturePlacement placement) {
|
||||||
return outBytes;
|
final base = _getBaseImage(placement);
|
||||||
} catch (_) {
|
final key =
|
||||||
// If processing fails, fall back to original
|
'${_baseKeyForImage(base)}|${_adjustKey(placement.graphicAdjust)}';
|
||||||
_processedBytesCache[cacheKey] = baseBytes;
|
final cached = _encodedPngCache[key];
|
||||||
return baseBytes;
|
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, converting to PNG only when necessary.
|
// Wrap bytes in a pw.MemoryImage with caching.
|
||||||
pw.MemoryImage? _getMemoryImage(Uint8List bytes) {
|
pw.MemoryImage? _getMemoryImage(Uint8List bytes, String key) {
|
||||||
final key = _baseKeyForBytes(bytes);
|
|
||||||
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;
|
_memoryImageCache[key] = imgObj;
|
||||||
return 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;
|
|
||||||
return imgObj;
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final ar = decoded.width / decoded.height;
|
|
||||||
_aspectRatioCache[key] = ar;
|
_aspectRatioCache[key] = ar;
|
||||||
return 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,44 +22,13 @@ 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);
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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 = img.adjustColor(
|
||||||
processed,
|
processed,
|
||||||
contrast: adjust.contrast,
|
contrast: adjust.contrast,
|
||||||
|
|
@ -69,91 +38,11 @@ class SignatureImageProcessingService {
|
||||||
|
|
||||||
// Apply background removal after color adjustments
|
// Apply background removal after color adjustments
|
||||||
if (adjust.bgRemoval) {
|
if (adjust.bgRemoval) {
|
||||||
processed = _removeBackground(processed);
|
processed = br.removeNearWhiteBackground(processed, threshold: 240);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode back to PNG to preserve transparency
|
return processed;
|
||||||
return Uint8List.fromList(img.encodePng(processed));
|
|
||||||
} else {
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// If processing fails, return original bytes
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fast preview processing:
|
// Background removal implemented in utils/background_removal.dart
|
||||||
/// - 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
|
|
||||||
img.Image _removeBackground(img.Image image) {
|
|
||||||
final result =
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
: widget.image.width / widget.image.height;
|
||||||
|
final double k = rot.scaleToFitForAngle(angleRad, ar: ar);
|
||||||
|
|
||||||
|
Widget core =
|
||||||
|
_encodedBytes == null
|
||||||
|
? const SizedBox.shrink()
|
||||||
|
: Image.memory(
|
||||||
|
_encodedBytes!,
|
||||||
|
fit: BoxFit.contain,
|
||||||
filterQuality: widget.filterQuality,
|
filterQuality: widget.filterQuality,
|
||||||
alignment: widget.alignment,
|
gaplessPlayback: true,
|
||||||
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) {
|
|
||||||
final scaleToFit = rot.scaleToFitForAngle(angle, ar: _derivedAspectRatio);
|
|
||||||
img = Transform.scale(
|
|
||||||
scale: scaleToFit,
|
|
||||||
child: Transform.rotate(angle: angle, child: img),
|
|
||||||
);
|
);
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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 |
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue