From 8daf5ea3ca07d1c647cb2bbc0b6165493e8ba0ac Mon Sep 17 00:00:00 2001 From: insleker Date: Fri, 19 Sep 2025 16:53:49 +0800 Subject: [PATCH] fix: exported document doesn't scale, rotate signature correctly --- lib/data/services/export_service.dart | 304 +++++++++--------- .../widgets/image_editor_dialog.dart | 60 ++-- .../widgets/rotated_signature_image.dart | 44 +-- lib/utils/rotation_utils.dart | 21 ++ 4 files changed, 231 insertions(+), 198 deletions(-) create mode 100644 lib/utils/rotation_utils.dart diff --git a/lib/data/services/export_service.dart b/lib/data/services/export_service.dart index 2016505..8469707 100644 --- a/lib/data/services/export_service.dart +++ b/lib/data/services/export_service.dart @@ -7,6 +7,8 @@ import 'package:pdf/pdf.dart' as pdf; import 'package:printing/printing.dart' as printing; import 'package:image/image.dart' as img; import '../../domain/models/model.dart'; +// math moved to utils in rot +import '../../utils/rotation_utils.dart' as rot; // NOTE: // - 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. 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 exportSignedPdfFromFile({ - required String inputPath, - required String outputPath, - required Size uiPageSize, - required Uint8List? signatureImageBytes, - Map>? placementsByPage, - Map? 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. Future exportSignedPdfFromBytes({ required Uint8List srcBytes, @@ -68,6 +26,131 @@ class ExportService { Map? libraryBytes, double targetDpi = 144.0, }) async { + // Per-call caches to avoid redundant decode/encode and image embedding work + final Map _processedBytesCache = {}; + final Map _memoryImageCache = + {}; + final Map _aspectRatioCache = {}; + + // 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); int pageIndex = 0; bool anyPage = false; @@ -123,51 +206,22 @@ class ExportService { final w = r.width * widthPts; final h = r.height * heightPts; - // Process the signature asset with its graphic adjustments - 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 + // Get processed bytes (cached) and then embed as MemoryImage (cached) + Uint8List bytes = _getProcessedBytes(placement); if (bytes.isEmpty && signatureImageBytes != null) { bytes = signatureImageBytes; } if (bytes.isNotEmpty) { - pw.MemoryImage? imgObj; - try { - imgObj = pw.MemoryImage(bytes); - } catch (_) { - imgObj = null; - } + final imgObj = _getMemoryImage(bytes); 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( pw.Positioned( left: left, @@ -177,12 +231,12 @@ class ExportService { height: h, child: pw.FittedBox( fit: pw.BoxFit.contain, - child: pw.Transform.rotate( - angle: - placement.rotationDeg * - 3.1415926535 / - 180.0, - child: pw.Image(imgObj), + child: pw.Transform.scale( + scale: scaleToFit, + child: pw.Transform.rotate( + angle: angle, + child: pw.Image(imgObj), + ), ), ), ), @@ -227,7 +281,7 @@ class ExportService { color: pdf.PdfColors.white, ), ]; - // Multi-placement stamping on fallback page + if (hasMulti && pagePlacements.isNotEmpty) { for (var i = 0; i < pagePlacements.length; i++) { final placement = pagePlacements[i]; @@ -238,65 +292,19 @@ class ExportService { final w = r.width * widthPts; final h = r.height * heightPts; - // Process the signature asset with its graphic adjustments - 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 + Uint8List bytes = _getProcessedBytes(placement); if (bytes.isEmpty && signatureImageBytes != null) { bytes = signatureImageBytes; } if (bytes.isNotEmpty) { - pw.MemoryImage? imgObj; - 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; - } + final imgObj = _getMemoryImage(bytes); 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( pw.Positioned( left: left, @@ -306,10 +314,12 @@ class ExportService { height: h, child: pw.FittedBox( fit: pw.BoxFit.contain, - child: pw.Transform.rotate( - angle: - placement.rotationDeg * 3.1415926535 / 180.0, - child: pw.Image(imgObj), + child: pw.Transform.scale( + scale: scaleToFit, + child: pw.Transform.rotate( + angle: angle, + child: pw.Image(imgObj), + ), ), ), ), diff --git a/lib/ui/features/signature/widgets/image_editor_dialog.dart b/lib/ui/features/signature/widgets/image_editor_dialog.dart index 83969b4..f8cca3d 100644 --- a/lib/ui/features/signature/widgets/image_editor_dialog.dart +++ b/lib/ui/features/signature/widgets/image_editor_dialog.dart @@ -41,7 +41,7 @@ class _ImageEditorDialogState extends State { late bool _bgRemoval; late double _contrast; late double _brightness; - late double _rotation; + late final ValueNotifier _rotation; // Cached image data late Uint8List _originalBytes; // Original asset bytes (never mutated) @@ -59,7 +59,7 @@ class _ImageEditorDialogState extends State { _bgRemoval = widget.initialGraphicAdjust.bgRemoval; _contrast = widget.initialGraphicAdjust.contrast; _brightness = widget.initialGraphicAdjust.brightness; - _rotation = widget.initialRotation; + _rotation = ValueNotifier(widget.initialRotation); _originalBytes = widget.asset.bytes; // Decode lazily only if/when background removal is needed if (_bgRemoval) { @@ -172,6 +172,7 @@ class _ImageEditorDialogState extends State { @override void dispose() { + _rotation.dispose(); _bgRemovalDebounce?.cancel(); super.dispose(); } @@ -206,19 +207,20 @@ class _ImageEditorDialogState extends State { ), child: ClipRRect( borderRadius: BorderRadius.circular(8), - child: - _bgRemoval - ? RotatedSignatureImage( - bytes: _displayBytes, - rotationDeg: _rotation, - ) - : ColorFiltered( - colorFilter: _currentColorFilter(), - child: RotatedSignatureImage( - bytes: _displayBytes, - rotationDeg: _rotation, - ), - ), + child: ValueListenableBuilder( + valueListenable: _rotation, + builder: (context, rot, child) { + final image = RotatedSignatureImage( + bytes: _displayBytes, + rotationDeg: rot, + ); + if (_bgRemoval) return image; + return ColorFiltered( + colorFilter: _currentColorFilter(), + child: image, + ); + }, + ), ), ), ), @@ -248,16 +250,26 @@ class _ImageEditorDialogState extends State { children: [ Text(l10n.rotate), Expanded( - child: Slider( - key: const Key('sld_rotation'), - min: -180, - max: 180, - divisions: 72, - value: _rotation, - onChanged: (v) => setState(() => _rotation = v), + child: ValueListenableBuilder( + valueListenable: _rotation, + builder: (context, rot, _) { + return Slider( + key: const Key('sld_rotation'), + min: -180, + max: 180, + divisions: 72, + value: rot, + onChanged: (v) => _rotation.value = v, + ); + }, ), ), - Text('${_rotation.toStringAsFixed(0)}°'), + ValueListenableBuilder( + valueListenable: _rotation, + builder: + (context, rot, _) => + Text('${rot.toStringAsFixed(0)}°'), + ), ], ), const SizedBox(height: 12), @@ -269,7 +281,7 @@ class _ImageEditorDialogState extends State { onPressed: () => Navigator.of(context).pop( ImageEditorResult( - rotation: _rotation, + rotation: _rotation.value, graphicAdjust: domain.GraphicAdjust( contrast: _contrast, brightness: _brightness, diff --git a/lib/ui/features/signature/widgets/rotated_signature_image.dart b/lib/ui/features/signature/widgets/rotated_signature_image.dart index 69ee1e4..75ca9cd 100644 --- a/lib/ui/features/signature/widgets/rotated_signature_image.dart +++ b/lib/ui/features/signature/widgets/rotated_signature_image.dart @@ -1,7 +1,7 @@ -import 'dart:math' as math; import 'dart:typed_data'; 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 /// angle-aware scale-to-fit so the rotated image stays within its bounds. @@ -9,7 +9,7 @@ class RotatedSignatureImage extends StatefulWidget { const RotatedSignatureImage({ super.key, required this.bytes, - this.rotationDeg = 0.0, + this.rotationDeg = 0.0, // counterclockwise as positive this.filterQuality = FilterQuality.low, this.semanticLabel, }); @@ -45,8 +45,9 @@ class _RotatedSignatureImageState extends State { @override void didUpdateWidget(covariant RotatedSignatureImage oldWidget) { super.didUpdateWidget(oldWidget); - if (!identical(oldWidget.bytes, widget.bytes) || - oldWidget.rotationDeg != widget.rotationDeg) { + // Only re-resolve when the bytes change. Rotation does not affect + // intrinsic aspect ratio, so avoid expensive decode/resolve on slider drags. + if (!identical(oldWidget.bytes, widget.bytes)) { _derivedAspectRatio = null; _resolveImage(); } @@ -60,24 +61,21 @@ class _RotatedSignatureImageState extends State { void _resolveImage() { _unlisten(); - // Decode synchronously to get aspect ratio - // Guard against empty / invalid bytes that some simplified tests may inject. + // 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); // assume square to avoid layout exceptions + _setAspectRatio(1.0); // safe fallback return; } + // One-time synchronous header decode to establish aspect ratio quickly. + // This only runs when bytes change (not on rotation), so it's acceptable. 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); - } + final decoded = img.decodeImage(widget.bytes); + if (decoded != null && decoded.width > 0 && decoded.height > 0) { + _setAspectRatio(decoded.width / decoded.height); } } catch (_) { - // Swallow decode errors for test-provided dummy data; assume square. - _setAspectRatio(1.0); + // ignore decode errors and rely on image stream listener } final stream = _provider.resolve(createLocalImageConfiguration(context)); _stream = stream; @@ -107,7 +105,7 @@ class _RotatedSignatureImageState extends State { @override Widget build(BuildContext context) { - final angle = widget.rotationDeg * math.pi / 180.0; + final angle = rot.ccwRadians(widget.rotationDeg); Widget img = Image.memory( widget.bytes, fit: widget.fit, @@ -115,6 +113,7 @@ class _RotatedSignatureImageState extends State { filterQuality: widget.filterQuality, alignment: widget.alignment, semanticLabel: widget.semanticLabel, + isAntiAlias: false, errorBuilder: (context, error, stackTrace) { // Return a placeholder for invalid images return Container( @@ -125,16 +124,7 @@ class _RotatedSignatureImageState extends State { ); if (angle != 0.0) { - final double c = math.cos(angle).abs(); - 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); - } + final scaleToFit = rot.scaleToFitForAngle(angle, ar: _derivedAspectRatio); img = Transform.scale( scale: scaleToFit, child: Transform.rotate(angle: angle, child: img), diff --git a/lib/utils/rotation_utils.dart b/lib/utils/rotation_utils.dart new file mode 100644 index 0000000..9fdabbd --- /dev/null +++ b/lib/utils/rotation_utils.dart @@ -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); +}