Compare commits

..

2 Commits

15 changed files with 295 additions and 323 deletions

View File

@ -7,6 +7,8 @@ import 'package:pdf/pdf.dart' as pdf;
import 'package:printing/printing.dart' as printing; import 'package:printing/printing.dart' as printing;
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import '../../domain/models/model.dart'; import '../../domain/models/model.dart';
// math moved to utils in rot
import '../../utils/rotation_utils.dart' as rot;
// 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.
@ -15,50 +17,6 @@ import '../../domain/models/model.dart';
// cannot import/modify existing PDF pages. If/when a suitable FOSS library exists, wire it here. // cannot import/modify existing PDF pages. If/when a suitable FOSS library exists, wire it here.
class ExportService { class ExportService {
/// Compose a new PDF by rasterizing the original PDF pages (via pdfrx engine)
/// and optionally stamping a signature image on the specified page.
///
/// Inputs:
/// - [inputPath]: Path to the original PDF to read
/// - [outputPath]: Path to write the composed PDF
/// - [uiPageSize]: The logical page size used by the UI layout (SignatureCardStateNotifier.pageSize)
/// - [signatureImageBytes]: PNG/JPEG bytes of the signature image to overlay
/// - [targetDpi]: Rasterization DPI for background pages
Future<bool> exportSignedPdfFromFile({
required String inputPath,
required String outputPath,
required Size uiPageSize,
required Uint8List? signatureImageBytes,
Map<int, List<SignaturePlacement>>? placementsByPage,
Map<String, Uint8List>? libraryBytes,
double targetDpi = 144.0,
}) async {
// Read source bytes and delegate to bytes-based exporter
Uint8List? srcBytes;
try {
srcBytes = await File(inputPath).readAsBytes();
} catch (_) {
srcBytes = null;
}
if (srcBytes == null) return false;
final bytes = await exportSignedPdfFromBytes(
srcBytes: srcBytes,
uiPageSize: uiPageSize,
signatureImageBytes: signatureImageBytes,
placementsByPage: placementsByPage,
libraryBytes: libraryBytes,
targetDpi: targetDpi,
);
if (bytes == null) return false;
try {
final file = File(outputPath);
await file.writeAsBytes(bytes, flush: true);
return true;
} catch (_) {
return false;
}
}
/// Compose a new PDF from source PDF bytes; returns the resulting PDF bytes. /// Compose a new PDF from source PDF bytes; returns the resulting PDF bytes.
Future<Uint8List?> exportSignedPdfFromBytes({ Future<Uint8List?> exportSignedPdfFromBytes({
required Uint8List srcBytes, required Uint8List srcBytes,
@ -68,6 +26,131 @@ class ExportService {
Map<String, Uint8List>? libraryBytes, Map<String, Uint8List>? libraryBytes,
double targetDpi = 144.0, double targetDpi = 144.0,
}) async { }) async {
// Per-call caches to avoid redundant decode/encode and image embedding work
final Map<String, Uint8List> _processedBytesCache = <String, Uint8List>{};
final Map<String, pw.MemoryImage> _memoryImageCache =
<String, pw.MemoryImage>{};
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)
String _baseKeyForBytes(Uint8List b) =>
'${identityHashCode(b)}:${b.length}';
// Fast PNG signature check (no string allocation)
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.
Uint8List _getBaseBytes(SignaturePlacement placement) {
Uint8List baseBytes = placement.asset.bytes;
final libKey = placement.asset.name;
if (libKey != null && libraryBytes != null) {
final libBytes = libraryBytes[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 no graphic changes requested, return bytes as-is (conversion to PNG is deferred to MemoryImage step)
final bool needsAdjust =
(adj.contrast != 1.0 || adj.brightness != 1.0 || adj.bgRemoval);
if (!needsAdjust) {
_processedBytesCache[cacheKey] = baseBytes;
return baseBytes;
}
try {
final decoded = img.decodeImage(baseBytes);
if (decoded == null) {
_processedBytesCache[cacheKey] = baseBytes;
return baseBytes;
}
img.Image processed = decoded;
if (adj.contrast != 1.0 || adj.brightness != 1.0) {
processed = img.adjustColor(
processed,
contrast: adj.contrast,
brightness: adj.brightness,
);
}
if (adj.bgRemoval) {
processed = _removeBackground(processed);
}
final outBytes = Uint8List.fromList(img.encodePng(processed));
_processedBytesCache[cacheKey] = outBytes;
return outBytes;
} catch (_) {
// If processing fails, fall back to original
_processedBytesCache[cacheKey] = baseBytes;
return baseBytes;
}
}
// Wrap bytes in a pw.MemoryImage with caching, converting to PNG only when necessary.
pw.MemoryImage? _getMemoryImage(Uint8List bytes) {
final key = _baseKeyForBytes(bytes);
final cached = _memoryImageCache[key];
if (cached != null) return cached;
try {
if (_isPng(bytes)) {
final imgObj = pw.MemoryImage(bytes);
_memoryImageCache[key] = imgObj;
return imgObj;
}
// Convert to PNG to preserve transparency if not already PNG
final decoded = img.decodeImage(bytes);
if (decoded == null) return null;
final png = Uint8List.fromList(img.encodePng(decoded, level: 6));
final imgObj = pw.MemoryImage(png);
_memoryImageCache[key] = imgObj;
return imgObj;
} catch (_) {
return null;
}
}
// Compute and cache aspect ratio (width/height) for given bytes
double? _getAspectRatioFromBytes(Uint8List bytes) {
final key = _baseKeyForBytes(bytes);
final c = _aspectRatioCache[key];
if (c != null) return c;
try {
final decoded = img.decodeImage(bytes);
if (decoded == null || decoded.width <= 0 || decoded.height <= 0) {
return null;
}
final ar = decoded.width / decoded.height;
_aspectRatioCache[key] = ar;
return ar;
} catch (_) {
return null;
}
}
final out = pw.Document(version: pdf.PdfVersion.pdf_1_4, compress: false); final out = pw.Document(version: pdf.PdfVersion.pdf_1_4, compress: false);
int pageIndex = 0; int pageIndex = 0;
bool anyPage = false; bool anyPage = false;
@ -123,51 +206,22 @@ class ExportService {
final w = r.width * widthPts; final w = r.width * widthPts;
final h = r.height * heightPts; final h = r.height * heightPts;
// Process the signature asset with its graphic adjustments // Get processed bytes (cached) and then embed as MemoryImage (cached)
Uint8List bytes = placement.asset.bytes; Uint8List bytes = _getProcessedBytes(placement);
if (bytes.isNotEmpty) {
try {
// Decode the image
final decoded = img.decodeImage(bytes);
if (decoded != null) {
img.Image processed = decoded;
// Apply contrast and brightness first
if (placement.graphicAdjust.contrast != 1.0 ||
placement.graphicAdjust.brightness != 0.0) {
processed = img.adjustColor(
processed,
contrast: placement.graphicAdjust.contrast,
brightness: placement.graphicAdjust.brightness,
);
}
// Apply background removal after color adjustments
if (placement.graphicAdjust.bgRemoval) {
processed = _removeBackground(processed);
}
// Encode back to PNG to preserve transparency
bytes = Uint8List.fromList(img.encodePng(processed));
}
} catch (e) {
// If processing fails, use original bytes
}
}
// Use fallback if no bytes available
if (bytes.isEmpty && signatureImageBytes != null) { if (bytes.isEmpty && signatureImageBytes != null) {
bytes = signatureImageBytes; bytes = signatureImageBytes;
} }
if (bytes.isNotEmpty) { if (bytes.isNotEmpty) {
pw.MemoryImage? imgObj; final imgObj = _getMemoryImage(bytes);
try {
imgObj = pw.MemoryImage(bytes);
} catch (_) {
imgObj = null;
}
if (imgObj != null) { if (imgObj != null) {
// Align with RotatedSignatureImage: counterclockwise positive
final angle = rot.radians(placement.rotationDeg);
// Prefer AR from base bytes to avoid extra decode of processed
final baseBytes = _getBaseBytes(placement);
final ar = _getAspectRatioFromBytes(baseBytes);
final scaleToFit = rot.scaleToFitForAngle(angle, ar: ar);
children.add( children.add(
pw.Positioned( pw.Positioned(
left: left, left: left,
@ -177,16 +231,16 @@ class ExportService {
height: h, height: h,
child: pw.FittedBox( child: pw.FittedBox(
fit: pw.BoxFit.contain, fit: pw.BoxFit.contain,
child: pw.Transform.scale(
scale: scaleToFit,
child: pw.Transform.rotate( child: pw.Transform.rotate(
angle: angle: angle,
placement.rotationDeg *
3.1415926535 /
180.0,
child: pw.Image(imgObj), child: pw.Image(imgObj),
), ),
), ),
), ),
), ),
),
); );
} }
} }
@ -227,7 +281,7 @@ class ExportService {
color: pdf.PdfColors.white, color: pdf.PdfColors.white,
), ),
]; ];
// Multi-placement stamping on fallback page
if (hasMulti && pagePlacements.isNotEmpty) { if (hasMulti && pagePlacements.isNotEmpty) {
for (var i = 0; i < pagePlacements.length; i++) { for (var i = 0; i < pagePlacements.length; i++) {
final placement = pagePlacements[i]; final placement = pagePlacements[i];
@ -238,65 +292,19 @@ class ExportService {
final w = r.width * widthPts; final w = r.width * widthPts;
final h = r.height * heightPts; final h = r.height * heightPts;
// Process the signature asset with its graphic adjustments Uint8List bytes = _getProcessedBytes(placement);
Uint8List bytes = placement.asset.bytes;
if (bytes.isNotEmpty) {
try {
// Decode the image
final decoded = img.decodeImage(bytes);
if (decoded != null) {
img.Image processed = decoded;
// Apply contrast and brightness first
if (placement.graphicAdjust.contrast != 1.0 ||
placement.graphicAdjust.brightness != 0.0) {
processed = img.adjustColor(
processed,
contrast: placement.graphicAdjust.contrast,
brightness: placement.graphicAdjust.brightness,
);
}
// Apply background removal after color adjustments
if (placement.graphicAdjust.bgRemoval) {
processed = _removeBackground(processed);
}
// Encode back to PNG to preserve transparency
bytes = Uint8List.fromList(img.encodePng(processed));
}
} catch (e) {
// If processing fails, use original bytes
}
}
// Use fallback if no bytes available
if (bytes.isEmpty && signatureImageBytes != null) { if (bytes.isEmpty && signatureImageBytes != null) {
bytes = signatureImageBytes; bytes = signatureImageBytes;
} }
if (bytes.isNotEmpty) { if (bytes.isNotEmpty) {
pw.MemoryImage? imgObj; final imgObj = _getMemoryImage(bytes);
try {
// Ensure PNG for transparency if not already
final asStr = String.fromCharCodes(bytes.take(8));
final isPng =
bytes.length > 8 &&
bytes[0] == 0x89 &&
asStr.startsWith('\u0089PNG');
if (isPng) {
imgObj = pw.MemoryImage(bytes);
} else {
final decoded = img.decodeImage(bytes);
if (decoded != null) {
final png = img.encodePng(decoded, level: 6);
imgObj = pw.MemoryImage(Uint8List.fromList(png));
}
}
} catch (_) {
imgObj = null;
}
if (imgObj != null) { if (imgObj != null) {
final angle = rot.radians(placement.rotationDeg);
final baseBytes = _getBaseBytes(placement);
final ar = _getAspectRatioFromBytes(baseBytes);
final scaleToFit = rot.scaleToFitForAngle(angle, ar: ar);
children.add( children.add(
pw.Positioned( pw.Positioned(
left: left, left: left,
@ -306,14 +314,16 @@ class ExportService {
height: h, height: h,
child: pw.FittedBox( child: pw.FittedBox(
fit: pw.BoxFit.contain, fit: pw.BoxFit.contain,
child: pw.Transform.scale(
scale: scaleToFit,
child: pw.Transform.rotate( child: pw.Transform.rotate(
angle: angle: angle,
placement.rotationDeg * 3.1415926535 / 180.0,
child: pw.Image(imgObj), child: pw.Image(imgObj),
), ),
), ),
), ),
), ),
),
); );
} }
} }

View File

@ -18,7 +18,7 @@ class SignatureCard {
}); });
SignatureCard copyWith({ SignatureCard copyWith({
double? rotationDeg, double? rotationDeg, //z axis is out of the screen, positive is CCW
SignatureAsset? asset, SignatureAsset? asset,
GraphicAdjust? graphicAdjust, GraphicAdjust? graphicAdjust,
}) => SignatureCard( }) => SignatureCard(

View File

@ -54,8 +54,8 @@ class _DrawCanvasState extends State<DrawCanvas> {
onPressed: () async { onPressed: () async {
// Export signature to PNG bytes first // Export signature to PNG bytes first
final byteData = await _control.toImage( final byteData = await _control.toImage(
width: 1024, width: 512,
height: 512, height: 256,
fit: true, fit: true,
color: Colors.black, color: Colors.black,
background: Colors.transparent, background: Colors.transparent,

View File

@ -11,21 +11,10 @@ class PdfPageArea extends ConsumerStatefulWidget {
const PdfPageArea({ const PdfPageArea({
super.key, super.key,
required this.pageSize, required this.pageSize,
required this.onDragSignature,
required this.onResizeSignature,
required this.onConfirmSignature,
required this.onClearActiveOverlay,
required this.onSelectPlaced,
required this.controller, required this.controller,
}); });
final Size pageSize; final Size pageSize;
// viewerController removed in migration
final ValueChanged<Offset> onDragSignature;
final ValueChanged<Offset> onResizeSignature;
final VoidCallback onConfirmSignature;
final VoidCallback onClearActiveOverlay;
final ValueChanged<int?> onSelectPlaced;
final PdfViewerController controller; final PdfViewerController controller;
@override @override
ConsumerState<PdfPageArea> createState() => _PdfPageAreaState(); ConsumerState<PdfPageArea> createState() => _PdfPageAreaState();
@ -156,11 +145,6 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
if (isContinuous) { if (isContinuous) {
return PdfViewerWidget( return PdfViewerWidget(
pageSize: widget.pageSize, pageSize: widget.pageSize,
onDragSignature: widget.onDragSignature,
onResizeSignature: widget.onResizeSignature,
onConfirmSignature: widget.onConfirmSignature,
onClearActiveOverlay: widget.onClearActiveOverlay,
onSelectPlaced: widget.onSelectPlaced,
pageKeyBuilder: _pageKey, pageKeyBuilder: _pageKey,
scrollToPage: _scrollToPage, scrollToPage: _scrollToPage,
controller: widget.controller, controller: widget.controller,

View File

@ -109,22 +109,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
return bytes; return bytes;
} }
void _confirmSignature() {
// In simplified UI, confirmation is a no-op
}
void _onDragSignature(Offset delta) {
// In simplified UI, interactive overlay disabled
}
void _onResizeSignature(Offset delta) {
// In simplified UI, interactive overlay disabled
}
void _onSelectPlaced(int? index) {
// In simplified UI, selection is a no-op for tests
}
Future<Uint8List?> _openDrawCanvas() async { Future<Uint8List?> _openDrawCanvas() async {
final result = await showModalBottomSheet<Uint8List>( final result = await showModalBottomSheet<Uint8List>(
context: context, context: context,
@ -323,11 +307,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
controller: _viewModel.controller, controller: _viewModel.controller,
key: const ValueKey('pdf_page_area'), key: const ValueKey('pdf_page_area'),
pageSize: _pageSize, pageSize: _pageSize,
onDragSignature: _onDragSignature,
onResizeSignature: _onResizeSignature,
onConfirmSignature: _confirmSignature,
onClearActiveOverlay: () {},
onSelectPlaced: _onSelectPlaced,
), ),
), ),
), ),

View File

@ -10,22 +10,12 @@ class PdfViewerWidget extends ConsumerStatefulWidget {
const PdfViewerWidget({ const PdfViewerWidget({
super.key, super.key,
required this.pageSize, required this.pageSize,
required this.onDragSignature,
required this.onResizeSignature,
required this.onConfirmSignature,
required this.onClearActiveOverlay,
required this.onSelectPlaced,
this.pageKeyBuilder, this.pageKeyBuilder,
this.scrollToPage, this.scrollToPage,
required this.controller, required this.controller,
}); });
final Size pageSize; final Size pageSize;
final ValueChanged<Offset> onDragSignature;
final ValueChanged<Offset> onResizeSignature;
final VoidCallback onConfirmSignature;
final VoidCallback onClearActiveOverlay;
final ValueChanged<int?> onSelectPlaced;
final GlobalKey Function(int page)? pageKeyBuilder; final GlobalKey Function(int page)? pageKeyBuilder;
final void Function(int page)? scrollToPage; final void Function(int page)? scrollToPage;
final PdfViewerController controller; final PdfViewerController controller;
@ -88,11 +78,6 @@ class _PdfViewerWidgetState extends ConsumerState<PdfViewerWidget> {
widget.pageKeyBuilder ?? widget.pageKeyBuilder ??
(page) => GlobalKey(debugLabel: 'page_$page'), (page) => GlobalKey(debugLabel: 'page_$page'),
scrollToPage: widget.scrollToPage ?? (page) {}, scrollToPage: widget.scrollToPage ?? (page) {},
onDragSignature: widget.onDragSignature,
onResizeSignature: widget.onResizeSignature,
onConfirmSignature: widget.onConfirmSignature,
onClearActiveOverlay: widget.onClearActiveOverlay,
onSelectPlaced: widget.onSelectPlaced,
); );
} }
@ -163,11 +148,6 @@ class _PdfViewerWidgetState extends ConsumerState<PdfViewerWidget> {
PdfPageOverlays( PdfPageOverlays(
pageSize: Size(pageRect.width, pageRect.height), pageSize: Size(pageRect.width, pageRect.height),
pageNumber: page.pageNumber, pageNumber: page.pageNumber,
onDragSignature: widget.onDragSignature,
onResizeSignature: widget.onResizeSignature,
onConfirmSignature: widget.onConfirmSignature,
onClearActiveOverlay: widget.onClearActiveOverlay,
onSelectPlaced: widget.onSelectPlaced,
), ),
]; ];
}, },

View File

@ -41,7 +41,7 @@ class _ImageEditorDialogState extends State<ImageEditorDialog> {
late bool _bgRemoval; late bool _bgRemoval;
late double _contrast; late double _contrast;
late double _brightness; late double _brightness;
late double _rotation; late final ValueNotifier<double> _rotation;
// Cached image data // Cached image data
late Uint8List _originalBytes; // Original asset bytes (never mutated) late Uint8List _originalBytes; // Original asset bytes (never mutated)
@ -59,7 +59,7 @@ class _ImageEditorDialogState extends State<ImageEditorDialog> {
_bgRemoval = widget.initialGraphicAdjust.bgRemoval; _bgRemoval = widget.initialGraphicAdjust.bgRemoval;
_contrast = widget.initialGraphicAdjust.contrast; _contrast = widget.initialGraphicAdjust.contrast;
_brightness = widget.initialGraphicAdjust.brightness; _brightness = widget.initialGraphicAdjust.brightness;
_rotation = widget.initialRotation; _rotation = ValueNotifier<double>(widget.initialRotation);
_originalBytes = widget.asset.bytes; _originalBytes = widget.asset.bytes;
// Decode lazily only if/when background removal is needed // Decode lazily only if/when background removal is needed
if (_bgRemoval) { if (_bgRemoval) {
@ -172,6 +172,7 @@ class _ImageEditorDialogState extends State<ImageEditorDialog> {
@override @override
void dispose() { void dispose() {
_rotation.dispose();
_bgRemovalDebounce?.cancel(); _bgRemovalDebounce?.cancel();
super.dispose(); super.dispose();
} }
@ -206,18 +207,19 @@ class _ImageEditorDialogState extends State<ImageEditorDialog> {
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: child: ValueListenableBuilder<double>(
_bgRemoval valueListenable: _rotation,
? RotatedSignatureImage( builder: (context, rot, child) {
final image = RotatedSignatureImage(
bytes: _displayBytes, bytes: _displayBytes,
rotationDeg: _rotation, rotationDeg: rot,
) );
: ColorFiltered( if (_bgRemoval) return image;
return ColorFiltered(
colorFilter: _currentColorFilter(), colorFilter: _currentColorFilter(),
child: RotatedSignatureImage( child: image,
bytes: _displayBytes, );
rotationDeg: _rotation, },
),
), ),
), ),
), ),
@ -248,16 +250,26 @@ class _ImageEditorDialogState extends State<ImageEditorDialog> {
children: [ children: [
Text(l10n.rotate), Text(l10n.rotate),
Expanded( Expanded(
child: Slider( child: ValueListenableBuilder<double>(
valueListenable: _rotation,
builder: (context, rot, _) {
return Slider(
key: const Key('sld_rotation'), key: const Key('sld_rotation'),
min: -180, min: -180,
max: 180, max: 180,
divisions: 72, divisions: 72,
value: _rotation, value: rot,
onChanged: (v) => setState(() => _rotation = v), onChanged: (v) => _rotation.value = v,
);
},
), ),
), ),
Text('${_rotation.toStringAsFixed(0)}°'), ValueListenableBuilder<double>(
valueListenable: _rotation,
builder:
(context, rot, _) =>
Text('${rot.toStringAsFixed(0)}°'),
),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -269,7 +281,7 @@ class _ImageEditorDialogState extends State<ImageEditorDialog> {
onPressed: onPressed:
() => Navigator.of(context).pop( () => Navigator.of(context).pop(
ImageEditorResult( ImageEditorResult(
rotation: _rotation, rotation: _rotation.value,
graphicAdjust: domain.GraphicAdjust( graphicAdjust: domain.GraphicAdjust(
contrast: _contrast, contrast: _contrast,
brightness: _brightness, brightness: _brightness,

View File

@ -1,17 +1,19 @@
import 'dart:math' as math;
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;
/// 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.
class RotatedSignatureImage extends StatefulWidget { class RotatedSignatureImage extends StatefulWidget {
const RotatedSignatureImage({ const RotatedSignatureImage({
super.key, super.key,
required this.bytes, required this.bytes,
this.rotationDeg = 0.0, this.rotationDeg = 0.0, // counterclockwise as positive
this.filterQuality = FilterQuality.low, this.filterQuality = FilterQuality.low,
this.semanticLabel, this.semanticLabel,
this.cacheWidth,
this.cacheHeight,
}); });
final Uint8List bytes; final Uint8List bytes;
@ -22,6 +24,10 @@ class RotatedSignatureImage extends StatefulWidget {
final Alignment alignment = Alignment.center; final Alignment alignment = Alignment.center;
final bool wrapInRepaintBoundary = true; 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.
final int? cacheWidth;
final int? cacheHeight;
@override @override
State<RotatedSignatureImage> createState() => _RotatedSignatureImageState(); State<RotatedSignatureImage> createState() => _RotatedSignatureImageState();
@ -45,8 +51,9 @@ class _RotatedSignatureImageState extends State<RotatedSignatureImage> {
@override @override
void didUpdateWidget(covariant RotatedSignatureImage oldWidget) { void didUpdateWidget(covariant RotatedSignatureImage oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.bytes, widget.bytes) || // Only re-resolve when the bytes change. Rotation does not affect
oldWidget.rotationDeg != widget.rotationDeg) { // intrinsic aspect ratio, so avoid expensive decode/resolve on slider drags.
if (!identical(oldWidget.bytes, widget.bytes)) {
_derivedAspectRatio = null; _derivedAspectRatio = null;
_resolveImage(); _resolveImage();
} }
@ -60,25 +67,12 @@ class _RotatedSignatureImageState extends State<RotatedSignatureImage> {
void _resolveImage() { void _resolveImage() {
_unlisten(); _unlisten();
// Decode synchronously to get aspect ratio // Resolve via ImageProvider; when first frame arrives, capture intrinsic size.
// Guard against empty / invalid bytes that some simplified tests may inject. // Avoid synchronous decode on UI thread to keep rotation smooth.
if (widget.bytes.isEmpty) { if (widget.bytes.isEmpty) {
_setAspectRatio(1.0); // assume square to avoid layout exceptions _setAspectRatio(1.0); // safe fallback
return; return;
} }
try {
final decoded = img.decodePng(widget.bytes);
if (decoded != null) {
final w = decoded.width;
final h = decoded.height;
if (w > 0 && h > 0) {
_setAspectRatio(w / h);
}
}
} catch (_) {
// Swallow decode errors for test-provided dummy data; assume square.
_setAspectRatio(1.0);
}
final stream = _provider.resolve(createLocalImageConfiguration(context)); final stream = _provider.resolve(createLocalImageConfiguration(context));
_stream = stream; _stream = stream;
_listener = ImageStreamListener((ImageInfo info, bool sync) { _listener = ImageStreamListener((ImageInfo info, bool sync) {
@ -107,7 +101,7 @@ class _RotatedSignatureImageState extends State<RotatedSignatureImage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final angle = widget.rotationDeg * math.pi / 180.0; final angle = rot.ccwRadians(widget.rotationDeg);
Widget img = Image.memory( Widget img = Image.memory(
widget.bytes, widget.bytes,
fit: widget.fit, fit: widget.fit,
@ -115,6 +109,10 @@ class _RotatedSignatureImageState extends State<RotatedSignatureImage> {
filterQuality: widget.filterQuality, filterQuality: widget.filterQuality,
alignment: widget.alignment, alignment: widget.alignment,
semanticLabel: widget.semanticLabel, 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) { errorBuilder: (context, error, stackTrace) {
// Return a placeholder for invalid images // Return a placeholder for invalid images
return Container( return Container(
@ -125,16 +123,7 @@ class _RotatedSignatureImageState extends State<RotatedSignatureImage> {
); );
if (angle != 0.0) { if (angle != 0.0) {
final double c = math.cos(angle).abs(); final scaleToFit = rot.scaleToFitForAngle(angle, ar: _derivedAspectRatio);
final double s = math.sin(angle).abs();
final ar = _derivedAspectRatio;
double scaleToFit;
if (ar != null && ar > 0) {
scaleToFit = math.min(ar / (ar * c + s), 1.0 / (ar * s + c));
} else {
// Fallback: square approximation
scaleToFit = 1.0 / (c + s).clamp(1.0, double.infinity);
}
img = Transform.scale( img = Transform.scale(
scale: scaleToFit, scale: scaleToFit,
child: Transform.rotate(angle: angle, child: img), child: Transform.rotate(angle: angle, child: img),

View File

@ -1,3 +1,4 @@
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;
@ -7,15 +8,14 @@ import 'package:pdf_signature/l10n/app_localizations.dart';
import '../view_model/signature_view_model.dart'; import '../view_model/signature_view_model.dart';
import '../view_model/dragging_signature_view_model.dart'; import '../view_model/dragging_signature_view_model.dart';
class SignatureCard extends ConsumerWidget { class SignatureCardView extends ConsumerStatefulWidget {
const SignatureCard({ const SignatureCardView({
super.key, super.key,
required this.asset, required this.asset,
required this.disabled, required this.disabled,
required this.onDelete, required this.onDelete,
this.onTap, this.onTap,
this.onAdjust, this.onAdjust,
this.useCurrentBytesForDrag = false,
this.rotationDeg = 0.0, this.rotationDeg = 0.0,
this.graphicAdjust = const domain.GraphicAdjust(), this.graphicAdjust = const domain.GraphicAdjust(),
}); });
@ -24,9 +24,14 @@ class SignatureCard extends ConsumerWidget {
final VoidCallback onDelete; final VoidCallback onDelete;
final VoidCallback? onTap; final VoidCallback? onTap;
final VoidCallback? onAdjust; final VoidCallback? onAdjust;
final bool useCurrentBytesForDrag;
final double rotationDeg; final double rotationDeg;
final domain.GraphicAdjust graphicAdjust; final domain.GraphicAdjust graphicAdjust;
@override
ConsumerState<SignatureCardView> createState() => _SignatureCardViewState();
}
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,
@ -50,22 +55,40 @@ class SignatureCard extends ConsumerWidget {
], ],
); );
if (selected == 'adjust') { if (selected == 'adjust') {
onAdjust?.call(); widget.onAdjust?.call();
} else if (selected == 'delete') { } else if (selected == 'delete') {
onDelete(); widget.onDelete();
} }
} }
void _maybePrecache(Uint8List bytes) {
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, WidgetRef ref) { Widget build(BuildContext context) {
final displayData = ref final displayData = ref
.watch(signatureViewModelProvider) .watch(signatureViewModelProvider)
.getDisplaySignatureData(asset, graphicAdjust); .getDisplaySignatureData(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.
// The card shows inside 96x64 with 6px padding; request ~128px max.
Widget coreImage = RotatedSignatureImage( Widget coreImage = RotatedSignatureImage(
bytes: displayData.bytes, bytes: displayData.bytes,
rotationDeg: rotationDeg, rotationDeg: widget.rotationDeg,
// Only set one dimension to keep aspect ratio
cacheHeight: 128,
); );
Widget img = Widget img =
(displayData.colorMatrix != null) (displayData.colorMatrix != null)
@ -102,7 +125,7 @@ class SignatureCard extends ConsumerWidget {
top: 0, top: 0,
child: IconButton( child: IconButton(
icon: const Icon(Icons.close, size: 16), icon: const Icon(Icons.close, size: 16),
onPressed: disabled ? null : onDelete, onPressed: widget.disabled ? null : widget.onDelete,
tooltip: 'Remove', tooltip: 'Remove',
padding: const EdgeInsets.all(2), padding: const EdgeInsets.all(2),
), ),
@ -111,31 +134,29 @@ class SignatureCard extends ConsumerWidget {
), ),
), ),
); );
Widget child = onTap != null ? InkWell(onTap: onTap, child: base) : base; Widget child =
widget.onTap != null ? InkWell(onTap: widget.onTap, child: base) : base;
// Add context menu for adjust/delete on right-click or long-press // Add context menu for adjust/delete on right-click or long-press
child = GestureDetector( child = GestureDetector(
key: const Key('gd_signature_card_area'), key: const Key('gd_signature_card_area'),
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onSecondaryTapDown: onSecondaryTapDown:
disabled widget.disabled
? null ? null
: (details) => _showContextMenu(context, details.globalPosition), : (details) => _showContextMenu(context, details.globalPosition),
onLongPressStart: onLongPressStart:
disabled widget.disabled
? null ? null
: (details) => _showContextMenu(context, details.globalPosition), : (details) => _showContextMenu(context, details.globalPosition),
child: child, child: child,
); );
if (disabled) return child; if (widget.disabled) return child;
return Draggable<SignatureDragData>( return Draggable<SignatureDragData>(
data: data: SignatureDragData(
useCurrentBytesForDrag
? const SignatureDragData()
: SignatureDragData(
card: domain.SignatureCard( card: domain.SignatureCard(
asset: asset, asset: widget.asset,
rotationDeg: rotationDeg, rotationDeg: widget.rotationDeg,
graphicAdjust: graphicAdjust, graphicAdjust: widget.graphicAdjust,
), ),
), ),
onDragStarted: () { onDragStarted: () {
@ -166,12 +187,14 @@ class SignatureCard extends ConsumerWidget {
), ),
child: RotatedSignatureImage( child: RotatedSignatureImage(
bytes: displayData.bytes, bytes: displayData.bytes,
rotationDeg: rotationDeg, rotationDeg: widget.rotationDeg,
cacheHeight: 256,
), ),
) )
: RotatedSignatureImage( : RotatedSignatureImage(
bytes: displayData.bytes, bytes: displayData.bytes,
rotationDeg: rotationDeg, rotationDeg: widget.rotationDeg,
cacheHeight: 256,
), ),
), ),
), ),

View File

@ -1,6 +1,6 @@
import 'package:pdf_signature/domain/models/model.dart'; import 'package:pdf_signature/domain/models/model.dart';
class SignatureDragData { class SignatureDragData {
final SignatureCard? card; // null means use current processed signature final SignatureCard card; // null means use current processed signature
const SignatureDragData({this.card}); const SignatureDragData({required this.card});
} }

View File

@ -6,9 +6,9 @@ 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/model.dart' hide SignatureCard; import 'package:pdf_signature/domain/models/signature_asset.dart';
import 'image_editor_dialog.dart'; import 'image_editor_dialog.dart';
import 'signature_card.dart'; import 'signature_card_view.dart';
import '../../pdf/view_model/pdf_view_model.dart'; import '../../pdf/view_model/pdf_view_model.dart';
/// Data for drag-and-drop is in signature_drag_data.dart /// Data for drag-and-drop is in signature_drag_data.dart
@ -49,7 +49,7 @@ class _SignatureDrawerState extends ConsumerState<SignatureDrawer> {
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
child: Padding( child: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: SignatureCard( child: SignatureCardView(
key: ValueKey('sig_card_${library.indexOf(card)}'), key: ValueKey('sig_card_${library.indexOf(card)}'),
asset: card.asset, asset: card.asset,
rotationDeg: card.rotationDeg, rotationDeg: card.rotationDeg,

View File

@ -0,0 +1,21 @@
import 'dart:math' as math;
/// Convert degrees to radians with counterclockwise as positive in screen space
/// by inverting the sign (because screen Y axis points downwards).
double ccwRadians(double degrees) => -degrees * math.pi / 180.0;
/// Classic math convention: positive degrees rotate counterclockwise.
/// No screen-space Y-inversion applied.
double radians(double degrees) => degrees * math.pi / 180.0;
/// Compute scale factor to keep a rotated rectangle of aspect ratio [ar]
/// within a unit 1x1 box. If [ar] is null or <= 0, fall back to a square.
/// Returns the scale to apply before rotation.
double scaleToFitForAngle(double angleRad, {double? ar}) {
final double c = angleRad == 0.0 ? 1.0 : math.cos(angleRad).abs();
final double s = angleRad == 0.0 ? 0.0 : math.sin(angleRad).abs();
if (ar != null && ar > 0) {
return math.min(ar / (ar * c + s), 1.0 / (ar * s + c));
}
return 1.0 / (c + s).clamp(1.0, double.infinity);
}

View File

@ -42,11 +42,6 @@ void main() {
height: 520, height: 520,
child: PdfPageArea( child: PdfPageArea(
pageSize: Size(676, 400), pageSize: Size(676, 400),
onDragSignature: _noopOffset,
onResizeSignature: _noopOffset,
onConfirmSignature: _noop,
onClearActiveOverlay: _noop,
onSelectPlaced: _noopInt,
controller: PdfViewerController(), controller: PdfViewerController(),
), ),
), ),
@ -75,6 +70,4 @@ void main() {
}); });
} }
void _noop() {} // No extra callbacks required in the new API
void _noopInt(int? _) {}
void _noopOffset(Offset _) {}

View File

@ -43,11 +43,6 @@ void main() {
height: 520, height: 520,
child: PdfPageArea( child: PdfPageArea(
pageSize: Size(676, 400), pageSize: Size(676, 400),
onDragSignature: _noopOffset,
onResizeSignature: _noopOffset,
onConfirmSignature: _noop,
onClearActiveOverlay: _noop,
onSelectPlaced: _noopInt,
controller: PdfViewerController(), controller: PdfViewerController(),
), ),
), ),
@ -106,6 +101,4 @@ void main() {
); );
} }
void _noop() {} // No extra callbacks required in the new API
void _noopInt(int? _) {}
void _noopOffset(Offset _) {}

View File

@ -43,11 +43,6 @@ void main() {
height: 520, height: 520,
child: PdfPageArea( child: PdfPageArea(
pageSize: const Size(676, 400), pageSize: const Size(676, 400),
onDragSignature: _noopOffset,
onResizeSignature: _noopOffset,
onConfirmSignature: _noop,
onClearActiveOverlay: _noop,
onSelectPlaced: _noopInt,
controller: PdfViewerController(), controller: PdfViewerController(),
), ),
), ),
@ -91,11 +86,6 @@ void main() {
// Keep aspect ratio consistent with uiPageSize // Keep aspect ratio consistent with uiPageSize
child: PdfPageArea( child: PdfPageArea(
pageSize: uiPageSize, pageSize: uiPageSize,
onDragSignature: _noopOffset,
onResizeSignature: _noopOffset,
onConfirmSignature: _noop,
onClearActiveOverlay: _noop,
onSelectPlaced: _noopInt,
controller: PdfViewerController(), controller: PdfViewerController(),
), ),
), ),
@ -170,6 +160,4 @@ void main() {
}); });
} }
void _noop() {} // No extra callbacks required in the new API
void _noopInt(int? _) {}
void _noopOffset(Offset _) {}