import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:image/image.dart' as img; import '../../../models/watermark.dart'; /// 水印绘制核心 —— **预览、拖动、导出三处共用同一套几何**。 /// /// 关键约定:所有尺寸都按 `scale = 画布宽度 / [kWatermarkDesignWidth]` 等比缩放。 /// 于是同一个 [paintWatermark],在预览时传屏幕显示尺寸、在导出时传图片像素尺寸, /// 出来的**视觉完全一致**——这就是「所见即所得」的实现方式。 /// /// 改样式(字号/间距/颜色/位置)只改这里,三处自动同步。 /// 设计稿基准宽度(沿用原水印相机项目的 1080) const double kWatermarkDesignWidth = 1080; // ---- 设计稿尺寸(单位:设计稿像素,实际绘制时乘 scale)---- const double _margin = 28; // 水印块到画布边缘的默认距离 const double _lineWidth = 4; // 左侧黄色竖线宽 const double _lineToText = 14; // 竖线到文字的水平距离 const double _cardPaddingY = 16; // 水印块上下内边距 const double _fontSize = 37; const double _rowHeight = 54; const double _rowGap = 8; const int _maxCharsPerLine = 18; // 每行最多几个字,超了折行 // 右下角品牌角标 const double _brandMargin = 8; const double _brandIconSize = 48; const double _brandTextSize = 32; const double _brandGap = 10; const double _brandPaddingX = 16; const double _brandPaddingY = 12; const double _brandOpacity = 0.8; const String _brandText = '水印相机'; /// 黄色竖线的颜色(品牌装饰,不跟随文字颜色变) const Color _accentColor = Color(0xFFFFB800); /// 水印正文块的几何信息。 /// /// 抽出来是为了让「拖动」能用和「绘制」完全一样的公式算位置 —— /// 两边各算一遍的话,迟早会漂移。 class WatermarkLayout { const WatermarkLayout({ required this.scale, required this.lines, required this.textHeight, required this.cardHeight, required this.cardWidth, required this.origin, }); final double scale; /// 折行后的实际文本行(已按 [_maxCharsPerLine] 拆好) final List lines; /// 所有文本行的总高度 final double textHeight; /// 块总高(含上下内边距);没有内容时为 0 final double cardHeight; /// 块总宽(最宽一行 + 竖线 + 间距) final double cardWidth; /// 块左上角在画布上的位置 final Offset origin; bool get isEmpty => lines.isEmpty; } /// 按 [size] 算出水印块的位置和尺寸 WatermarkLayout layoutWatermark( Size size, WatermarkSettings settings, WatermarkData data, ) { final scale = size.width / kWatermarkDesignWidth; final lines = [ for (final line in buildWatermarkLines(settings, data)) // 标了「不折行」的(时间)原样保留,其余按字数折 ...(line.wrap ? _wrapByCharCount(line.text, _maxCharsPerLine) : [line.text]), ]; if (lines.isEmpty) { return WatermarkLayout( scale: scale, lines: const [], textHeight: 0, cardHeight: 0, cardWidth: 0, origin: Offset( settings.posX != null ? settings.posX! * size.width : _margin * scale, settings.posY != null ? settings.posY! * size.height : size.height - _margin * scale, ), ); } final rowH = _rowHeight * scale; final gap = _rowGap * scale; final padding = _cardPaddingY * scale; final textHeight = lines.length * rowH + (lines.length - 1) * gap; final cardHeight = textHeight + padding * 2; // 最宽一行决定块的宽度(拖动时用它做边界夹紧) var maxLineWidth = 0.0; for (final line in lines) { final painter = _layoutText( line, fontSize: _fontSize * scale, color: Colors.white, scale: scale, ); if (painter.width > maxLineWidth) maxLineWidth = painter.width; } final cardWidth = _lineWidth * scale + _lineToText * scale + maxLineWidth; final origin = Offset( settings.posX != null ? settings.posX! * size.width : _margin * scale, settings.posY != null ? settings.posY! * size.height : size.height - _margin * scale - cardHeight, ); return WatermarkLayout( scale: scale, lines: lines, textHeight: textHeight, cardHeight: cardHeight, cardWidth: cardWidth, origin: origin, ); } /// 画一笔水印。 /// /// [size] 既可以是屏幕显示尺寸(预览),也可以是图片像素尺寸(导出)。 void paintWatermark( Canvas canvas, Size size, WatermarkSettings settings, WatermarkData data, { ui.Image? brandIcon, }) { if (size.width <= 0 || size.height <= 0) return; final layout = layoutWatermark(size, settings, data); final scale = layout.scale; final textColor = Color(settings.colorValue); // ---- 正文块 ---- if (!layout.isEmpty) { final origin = layout.origin; final padding = _cardPaddingY * scale; // 左侧黄色竖线 canvas.drawRect( Rect.fromLTWH(origin.dx, origin.dy + padding, _lineWidth * scale, layout.textHeight), Paint()..color = _accentColor, ); // 多行文字 final textX = origin.dx + _lineWidth * scale + _lineToText * scale; var y = origin.dy + padding; for (final line in layout.lines) { final painter = _layoutText( line, fontSize: _fontSize * scale, color: textColor, scale: scale, ); painter.paint(canvas, Offset(textX, y)); y += _rowHeight * scale + _rowGap * scale; } } // ---- 右下角品牌角标 ---- if (settings.showBrand) { _paintBrand(canvas, size, scale, brandIcon); } } /// 右下角「相机图标 + 水印相机」,整体 80% 透明 void _paintBrand(Canvas canvas, Size size, double scale, ui.Image? brandIcon) { final margin = _brandMargin * scale; final iconSize = _brandIconSize * scale; final gap = _brandGap * scale; final padX = _brandPaddingX * scale; final padY = _brandPaddingY * scale; final textPainter = _layoutText( _brandText, fontSize: _brandTextSize * scale, color: Colors.white, bold: true, scale: scale, ); final hasIcon = brandIcon != null; final brandW = (hasIcon ? iconSize + gap : 0) + textPainter.width + padX * 2; final brandH = (hasIcon ? iconSize : textPainter.height) + padY * 2; final brandX = size.width - margin - brandW; final brandY = size.height - margin - brandH; // saveLayer 的 paint alpha 就是整组元素的透明度(Flutter 的 Opacity 内部也这么做) canvas.saveLayer( Rect.fromLTWH(brandX, brandY, brandW, brandH), Paint()..color = const Color(0xFF000000).withValues(alpha: _brandOpacity), ); double contentX = brandX + padX; if (hasIcon) { canvas.drawImageRect( brandIcon, Rect.fromLTWH(0, 0, brandIcon.width.toDouble(), brandIcon.height.toDouble()), Rect.fromLTWH(contentX, brandY + padY, iconSize, iconSize), Paint()..filterQuality = FilterQuality.medium, ); contentX += iconSize + gap; } // 文字在内容区里垂直居中 final contentCenterY = brandY + brandH / 2; textPainter.paint(canvas, Offset(contentX, contentCenterY - textPainter.height / 2)); canvas.restore(); } /// 排一段文字。加了淡阴影 —— 白字直接压在浅色照片上会糊,老项目没做这一步。 TextPainter _layoutText( String text, { required double fontSize, required Color color, required double scale, bool bold = false, }) { final painter = TextPainter( text: TextSpan( text: text, style: TextStyle( fontSize: fontSize, color: color, fontWeight: bold ? FontWeight.bold : FontWeight.normal, height: 1.2, shadows: [ Shadow( color: const Color(0x99000000), blurRadius: 5 * scale, offset: Offset(0, 1 * scale), ), ], ), ), textDirection: TextDirection.ltr, maxLines: 1, ); painter.layout(); return painter; } /// 按字符数折行(中文按字数折最直观,和原项目一致) List _wrapByCharCount(String text, int maxChars) { if (text.length <= maxChars) return [text]; final result = []; for (var i = 0; i < text.length; i += maxChars) { final end = (i + maxChars < text.length) ? i + maxChars : text.length; result.add(text.substring(i, end)); } return result; } /// 预览用:把水印盖在照片上 class WatermarkPainter extends CustomPainter { const WatermarkPainter({ required this.settings, required this.data, this.brandIcon, }); final WatermarkSettings settings; final WatermarkData data; final ui.Image? brandIcon; @override void paint(Canvas canvas, Size size) { paintWatermark(canvas, size, settings, data, brandIcon: brandIcon); } @override bool shouldRepaint(WatermarkPainter old) { return old.settings != settings || old.data != data || old.brandIcon != brandIcon; } } /// 把原图和水印合成为一张 JPEG。 /// /// 走离屏绘制而不是「截图 Widget」:这样能按**图片原始像素**出图, /// 不受屏幕分辨率限制,也不会因为页面滚动/遮挡出问题。 /// /// [maxWidth] 限制最长边(原项目也是 1920)—— 一是省内存,二是纯 Dart 的 /// JPEG 编码在大图上会明显变慢。 Future composeWatermarkedJpeg({ required Uint8List originalBytes, required WatermarkSettings settings, required WatermarkData data, ui.Image? brandIcon, double maxWidth = 1920, int quality = 92, }) async { // 先只读文件头拿原始尺寸,再决定按多大解码。 // 直接全尺寸解码一张 12MP 照片要 48MB 内存,没必要。 final buffer = await ui.ImmutableBuffer.fromUint8List(originalBytes); final descriptor = await ui.ImageDescriptor.encoded(buffer); final ratio = descriptor.width > maxWidth ? maxWidth / descriptor.width : 1.0; final outW = (descriptor.width * ratio).round(); final outH = (descriptor.height * ratio).round(); final codec = await descriptor.instantiateCodec(targetWidth: outW, targetHeight: outH); final frame = await codec.getNextFrame(); final source = frame.image; final recorder = ui.PictureRecorder(); final canvas = Canvas(recorder); canvas.drawImage(source, Offset.zero, Paint()); paintWatermark( canvas, Size(outW.toDouble(), outH.toDouble()), settings, data, brandIcon: brandIcon, ); final picture = recorder.endRecording(); final rendered = await picture.toImage(outW, outH); // 到这里内容已经栅格化,可以把中间对象都放掉 source.dispose(); picture.dispose(); codec.dispose(); descriptor.dispose(); buffer.dispose(); final byteData = await rendered.toByteData(format: ui.ImageByteFormat.rawRgba); rendered.dispose(); if (byteData == null) { throw StateError('水印合成失败:拿不到位图数据'); } // toByteData 只有 PNG / rawRgba,出不了 JPEG,所以自己用 image 包编码。 // 之所以非要 JPEG:1920×1440 的照片 PNG 要 3~6MB,JPEG 只要 ~500KB。 // 画面不透明(底图铺满),预乘与非预乘 RGBA 等价,直接当 RGBA 用。 final raster = img.Image.fromBytes( width: outW, height: outH, bytes: byteData.buffer, bytesOffset: byteData.offsetInBytes, numChannels: 4, ); return img.encodeJpg(raster, quality: quality); }