Files
Aq-Accounting-Flutter/lib/pages/watermark/watermark_painter.dart
T
2026-09-28 09:59:43 +08:00

371 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String> 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 = <String>[
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<String> _wrapByCharCount(String text, int maxChars) {
if (text.length <= maxChars) return [text];
final result = <String>[];
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<Uint8List> 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);
}