pdf_signature/lib/data/services/export_service.dart

244 lines
8.7 KiB
Dart

import 'dart:io';
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/widgets.dart';
import 'package:image/image.dart' as img;
import 'package:pdf/widgets.dart' as pw;
import 'package:pdf/pdf.dart' as pdf;
import 'package:pdfrx_engine/pdfrx_engine.dart' as engine;
import 'package:pdfrx/pdfrx.dart' show pdfrxFlutterInitialize;
import '../../domain/models/model.dart';
import '../../utils/rotation_utils.dart' as rot;
import '../../utils/background_removal.dart' as br;
// 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 {
ExportService({this.enableRaster = true});
// Deprecated: retained for API compatibility. Raster is no longer used.
final bool enableRaster;
/// Compose a new PDF by rendering source pages to images (FOSS path via pdfrx)
/// and overlaying signature images at normalized rects. Returns resulting bytes.
Future<Uint8List?> exportSignedPdfFromBytes({
required Uint8List srcBytes,
required Size uiPageSize, // not used in this implementation
required Uint8List?
signatureImageBytes, // not used; placements carry images
Map<int, List<SignaturePlacement>>? placementsByPage,
Map<String, img.Image>? libraryImages,
double targetDpi = 144.0,
}) async {
// Caches per call
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, double> _aspectRatioCache = <String, double>{};
String _baseKeyForImage(img.Image im) =>
'im:${identityHashCode(im)}:${im.width}x${im.height}';
String _adjustKey(GraphicAdjust adj) =>
'c=${adj.contrast}|b=${adj.brightness}|bg=${adj.bgRemoval}';
img.Image _getBaseImage(SignaturePlacement placement) {
final libKey = placement.asset.name;
if (libKey != null && libraryImages != null) {
final cached = _baseImageCache[libKey];
if (cached != null) return cached;
final provided = libraryImages[libKey];
if (provided != null) {
_baseImageCache[libKey] = provided;
return provided;
}
}
return placement.asset.sigImage;
}
img.Image _getProcessedImage(SignaturePlacement placement) {
final base = _getBaseImage(placement);
final key =
'${_baseKeyForImage(base)}|${_adjustKey(placement.graphicAdjust)}';
final cached = _processedImageCache[key];
if (cached != null) return cached;
final adj = placement.graphicAdjust;
img.Image processed = base;
if (adj.contrast != 1.0 || adj.brightness != 1.0) {
processed = img.adjustColor(
processed,
contrast: adj.contrast,
brightness: adj.brightness,
);
}
Future<void> _ = Future<void>.delayed(Duration.zero);
if (adj.bgRemoval) {
processed = br.removeNearWhiteBackground(processed, threshold: 240);
}
Future<void> _ = Future<void>.delayed(Duration.zero);
_processedImageCache[key] = processed;
return processed;
}
Uint8List _getProcessedPng(SignaturePlacement placement) {
final base = _getBaseImage(placement);
final key =
'${_baseKeyForImage(base)}|${_adjustKey(placement.graphicAdjust)}';
final cached = _encodedPngCache[key];
if (cached != null) return cached;
final processed = _getProcessedImage(placement);
final png = Uint8List.fromList(img.encodePng(processed, level: 6));
_encodedPngCache[key] = png;
return png;
}
double? _getAspectRatioFromImage(img.Image image) {
final key = _baseKeyForImage(image);
final c = _aspectRatioCache[key];
if (c != null) return c;
if (image.width <= 0 || image.height <= 0) return null;
final ar = image.width / image.height;
_aspectRatioCache[key] = ar;
return ar;
}
// Initialize engine (safe to call multiple times)
pdfrxFlutterInitialize();
// Open source document from memory; if not supported, write temp file
engine.PdfDocument? doc;
try {
doc = await engine.PdfDocument.openData(srcBytes);
} catch (_) {
debugPrint('Warning: pdfrx openData failed');
final tmp = File(
'${Directory.systemTemp.path}/pdfrx_src_${DateTime.now().millisecondsSinceEpoch}.pdf',
);
await tmp.writeAsBytes(srcBytes, flush: true);
doc = await engine.PdfDocument.openFile(tmp.path);
try {
tmp.deleteSync();
} catch (_) {
debugPrint('Warning: temp file delete failed');
}
}
// doc is guaranteed to be assigned by either openData or openFile above
final out = pw.Document(version: pdf.PdfVersion.pdf_1_4, compress: false);
final pages = doc.pages;
final scale = targetDpi / 72.0;
for (int i = 0; i < pages.length; i++) {
// Cooperative yield between pages so the UI can animate the spinner.
await Future<void>.delayed(Duration.zero);
final page = pages[i];
final pageIndex = i + 1;
final widthPts = page.width;
final heightPts = page.height;
// Render background image via engine
final imgPage = await page.render(
fullWidth: widthPts * scale,
fullHeight: heightPts * scale,
);
if (imgPage == null) continue;
final bgImage = imgPage.createImageNF();
imgPage.dispose();
// Lower compression for background snapshot too.
final bgPng = Uint8List.fromList(img.encodePng(bgImage, level: 1));
final _ = Future<void>.delayed(Duration.zero);
final bgMem = pw.MemoryImage(bgPng);
final pagePlacements =
(placementsByPage ??
const <int, List<SignaturePlacement>>{})[pageIndex] ??
const <SignaturePlacement>[];
out.addPage(
pw.Page(
pageTheme: pw.PageTheme(
margin: pw.EdgeInsets.zero,
pageFormat: pdf.PdfPageFormat(widthPts, heightPts),
),
build: (ctx) {
final children = <pw.Widget>[
pw.Positioned(
left: 0,
top: 0,
child: pw.Image(
bgMem,
width: widthPts,
height: heightPts,
fit: pw.BoxFit.fill,
),
),
];
for (final placement in pagePlacements) {
final r = placement.rect;
final left = r.left * widthPts;
final top = r.top * heightPts;
final w = r.width * widthPts;
final h = r.height * heightPts;
final processedPng = _getProcessedPng(placement);
if (processedPng.isEmpty) continue;
final memImg = pw.MemoryImage(processedPng);
final angle = rot.radians(placement.rotationDeg);
final baseImage = _getBaseImage(placement);
final ar = _getAspectRatioFromImage(baseImage);
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(memImg),
),
),
),
),
),
);
// Yield occasionally within large placement lists to keep UI responsive.
// ignore: unused_local_variable
final _ = Future<void>.delayed(Duration.zero);
}
return pw.Stack(children: children);
},
),
);
final _ = Future<void>.delayed(Duration.zero);
}
final bytes = await out.save();
doc.dispose();
debugPrint('exportSignedPdfFromBytes succeeded');
return bytes;
}
Future<bool> saveBytesToFile({
required Uint8List bytes,
required String outputPath,
}) async {
try {
final file = File(outputPath);
await file.writeAsBytes(bytes, flush: true);
return true;
} catch (_) {
debugPrint('Error: saveBytesToFile failed');
return false;
}
}
}