import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:pdf/widgets.dart' as pw; 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. // - It does NOT perform vector-accurate stamping into the source PDF. // - Vector stamping remains unimplemented with FOSS-only constraints because the `pdf` package // cannot import/modify existing PDF pages. If/when a suitable FOSS library exists, wire it here. class ExportService { /// Compose a new PDF from source PDF bytes; returns the resulting PDF bytes. Future exportSignedPdfFromBytes({ required Uint8List srcBytes, required Size uiPageSize, required Uint8List? signatureImageBytes, Map>? placementsByPage, 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; try { await for (final raster in printing.Printing.raster( srcBytes, dpi: targetDpi, )) { anyPage = true; pageIndex++; final widthPx = raster.width; final heightPx = raster.height; final widthPts = widthPx * 72.0 / targetDpi; final heightPts = heightPx * 72.0 / targetDpi; final bgPng = await raster.toPng(); final bgImg = pw.MemoryImage(bgPng); final hasMulti = (placementsByPage != null && placementsByPage.isNotEmpty); final pagePlacements = hasMulti ? (placementsByPage[pageIndex] ?? const []) : const []; out.addPage( pw.Page( pageTheme: pw.PageTheme( margin: pw.EdgeInsets.zero, pageFormat: pdf.PdfPageFormat(widthPts, heightPts), ), build: (ctx) { final children = [ pw.Positioned( left: 0, top: 0, child: pw.Image( bgImg, width: widthPts, height: heightPts, fit: pw.BoxFit.fill, ), ), ]; // Multi-placement stamping: per-placement image from libraryBytes if (hasMulti && pagePlacements.isNotEmpty) { for (var i = 0; i < pagePlacements.length; i++) { final placement = pagePlacements[i]; final r = placement.rect; // rect is stored in normalized units (0..1) relative to page final left = r.left * widthPts; final top = r.top * heightPts; final w = r.width * widthPts; final h = r.height * heightPts; // Get processed bytes (cached) and then embed as MemoryImage (cached) Uint8List bytes = _getProcessedBytes(placement); if (bytes.isEmpty && signatureImageBytes != null) { bytes = signatureImageBytes; } if (bytes.isNotEmpty) { 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, top: top, child: pw.SizedBox( width: w, height: h, child: pw.FittedBox( fit: pw.BoxFit.contain, child: pw.Transform.scale( scale: scaleToFit, child: pw.Transform.rotate( angle: angle, child: pw.Image(imgObj), ), ), ), ), ), ); } } } } return pw.Stack(children: children); }, ), ); } } catch (e) { anyPage = false; } if (!anyPage) { // Fallback as A4 blank page with optional signature final widthPts = pdf.PdfPageFormat.a4.width; final heightPts = pdf.PdfPageFormat.a4.height; final hasMulti = (placementsByPage != null && placementsByPage.isNotEmpty); final pagePlacements = hasMulti ? (placementsByPage[1] ?? const []) : const []; out.addPage( pw.Page( pageTheme: pw.PageTheme( margin: pw.EdgeInsets.zero, pageFormat: pdf.PdfPageFormat(widthPts, heightPts), ), build: (ctx) { final children = [ pw.Container( width: widthPts, height: heightPts, color: pdf.PdfColors.white, ), ]; if (hasMulti && pagePlacements.isNotEmpty) { for (var i = 0; i < pagePlacements.length; i++) { final placement = pagePlacements[i]; final r = placement.rect; // rect is stored in normalized units (0..1) relative to page final left = r.left * widthPts; final top = r.top * heightPts; final w = r.width * widthPts; final h = r.height * heightPts; Uint8List bytes = _getProcessedBytes(placement); if (bytes.isEmpty && signatureImageBytes != null) { bytes = signatureImageBytes; } if (bytes.isNotEmpty) { 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, top: top, child: pw.SizedBox( width: w, height: h, child: pw.FittedBox( fit: pw.BoxFit.contain, child: pw.Transform.scale( scale: scaleToFit, child: pw.Transform.rotate( angle: angle, child: pw.Image(imgObj), ), ), ), ), ), ); } } } } return pw.Stack(children: children); }, ), ); } try { return await out.save(); } catch (_) { return null; } } /// Helper: write bytes returned from [exportSignedPdfFromBytes] to a file path. Future saveBytesToFile({ required Uint8List bytes, required String outputPath, }) async { try { final file = File(outputPath); await file.writeAsBytes(bytes, flush: true); return true; } catch (_) { return false; } } /// Remove near-white background by making pixels with high brightness transparent 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; } }