303 lines
8.0 KiB
Dart
303 lines
8.0 KiB
Dart
|
|
import 'dart:math';
|
||
|
|
import 'dart:typed_data';
|
||
|
|
import 'dart:ui' as ui;
|
||
|
|
|
||
|
|
import 'package:flutter/material.dart';
|
||
|
|
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
|
||
|
|
|
||
|
|
import '../models/ink_point.dart';
|
||
|
|
import '../models/ink_stroke.dart';
|
||
|
|
import '../models/pen_tool.dart';
|
||
|
|
import '../models/pressure_curve.dart';
|
||
|
|
|
||
|
|
/// Renders ink strokes to a PNG byte array for local OCR.
|
||
|
|
class StrokeRasterizer {
|
||
|
|
static const _padding = 24.0;
|
||
|
|
static const _defaultPressureCurve = PressureCurve.linear;
|
||
|
|
|
||
|
|
/// Render [strokes] onto a white canvas and return PNG bytes, or null if empty.
|
||
|
|
static Future<Uint8List?> render(List<InkStroke> strokes) async {
|
||
|
|
final drawable = strokes
|
||
|
|
.where((s) => s.tool != PenTool.eraser && s.points.isNotEmpty)
|
||
|
|
.toList();
|
||
|
|
if (drawable.isEmpty) return null;
|
||
|
|
|
||
|
|
final bounds = _computeBounds(drawable);
|
||
|
|
if (bounds == null) return null;
|
||
|
|
|
||
|
|
final width = (bounds.width + _padding * 2).ceil().clamp(1, 4096);
|
||
|
|
final height = (bounds.height + _padding * 2).ceil().clamp(1, 4096);
|
||
|
|
final offset = Offset(_padding - bounds.left, _padding - bounds.top);
|
||
|
|
|
||
|
|
final recorder = ui.PictureRecorder();
|
||
|
|
final canvas = Canvas(recorder);
|
||
|
|
canvas.drawRect(
|
||
|
|
Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()),
|
||
|
|
Paint()..color = Colors.white,
|
||
|
|
);
|
||
|
|
|
||
|
|
for (final stroke in drawable) {
|
||
|
|
_drawStroke(canvas, stroke, offset);
|
||
|
|
}
|
||
|
|
|
||
|
|
final picture = recorder.endRecording();
|
||
|
|
final image = await picture.toImage(width, height);
|
||
|
|
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
||
|
|
return byteData?.buffer.asUint8List();
|
||
|
|
}
|
||
|
|
|
||
|
|
static Rect? _computeBounds(List<InkStroke> strokes) {
|
||
|
|
double? minX, minY, maxX, maxY;
|
||
|
|
for (final stroke in strokes) {
|
||
|
|
for (final p in stroke.points) {
|
||
|
|
minX = minX == null ? p.x : min(minX, p.x);
|
||
|
|
minY = minY == null ? p.y : min(minY, p.y);
|
||
|
|
maxX = maxX == null ? p.x : max(maxX, p.x);
|
||
|
|
maxY = maxY == null ? p.y : max(maxY, p.y);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (minX == null || minY == null || maxX == null || maxY == null) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return Rect.fromLTRB(minX, minY, maxX, maxY);
|
||
|
|
}
|
||
|
|
|
||
|
|
static List<InkPoint> _offsetPoints(List<InkPoint> points, Offset offset) {
|
||
|
|
return points
|
||
|
|
.map(
|
||
|
|
(p) => InkPoint(
|
||
|
|
x: p.x + offset.dx,
|
||
|
|
y: p.y + offset.dy,
|
||
|
|
pressure: p.pressure,
|
||
|
|
timestamp: p.timestamp,
|
||
|
|
),
|
||
|
|
)
|
||
|
|
.toList();
|
||
|
|
}
|
||
|
|
|
||
|
|
static void _drawStroke(Canvas canvas, InkStroke stroke, Offset offset) {
|
||
|
|
final points = _offsetPoints(stroke.points, offset);
|
||
|
|
final color = Color(stroke.color);
|
||
|
|
final tool = stroke.tool;
|
||
|
|
|
||
|
|
switch (tool) {
|
||
|
|
case PenTool.pen:
|
||
|
|
case PenTool.marker:
|
||
|
|
case PenTool.highlighter:
|
||
|
|
_drawFreehand(canvas, points, tool, color, stroke.strokeWidth);
|
||
|
|
break;
|
||
|
|
case PenTool.rectangle:
|
||
|
|
if (points.length >= 2) {
|
||
|
|
_drawRect(canvas, points, color, stroke.strokeWidth, stroke.filled);
|
||
|
|
} else {
|
||
|
|
_drawFreehand(canvas, points, tool, color, stroke.strokeWidth);
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
case PenTool.ellipse:
|
||
|
|
if (points.length >= 2) {
|
||
|
|
_drawOval(canvas, points, color, stroke.strokeWidth, stroke.filled);
|
||
|
|
} else {
|
||
|
|
_drawFreehand(canvas, points, tool, color, stroke.strokeWidth);
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
case PenTool.line:
|
||
|
|
if (points.length >= 2) {
|
||
|
|
_drawLine(canvas, points, color, stroke.strokeWidth);
|
||
|
|
} else {
|
||
|
|
_drawFreehand(canvas, points, tool, color, stroke.strokeWidth);
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
case PenTool.arrow:
|
||
|
|
if (points.length >= 2) {
|
||
|
|
_drawArrow(canvas, points, color, stroke.strokeWidth);
|
||
|
|
} else {
|
||
|
|
_drawFreehand(canvas, points, tool, color, stroke.strokeWidth);
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
case PenTool.text:
|
||
|
|
if (stroke.textContent != null && stroke.textContent!.isNotEmpty) {
|
||
|
|
_drawText(
|
||
|
|
canvas,
|
||
|
|
points,
|
||
|
|
stroke.textContent!,
|
||
|
|
stroke.fontSize,
|
||
|
|
color,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
case PenTool.eraser:
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
static void _drawFreehand(
|
||
|
|
Canvas canvas,
|
||
|
|
List<InkPoint> points,
|
||
|
|
PenTool tool,
|
||
|
|
Color color,
|
||
|
|
double strokeWidth,
|
||
|
|
) {
|
||
|
|
final pfPoints = points
|
||
|
|
.map(
|
||
|
|
(p) => pf.Point(
|
||
|
|
p.x,
|
||
|
|
p.y,
|
||
|
|
_defaultPressureCurve.apply(p.pressure).clamp(0.0, 1.0),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
.toList();
|
||
|
|
|
||
|
|
final thinning = (tool == PenTool.marker || tool == PenTool.highlighter)
|
||
|
|
? 0.0
|
||
|
|
: 0.7;
|
||
|
|
|
||
|
|
final outline = pf.getStroke(
|
||
|
|
pfPoints,
|
||
|
|
size: strokeWidth,
|
||
|
|
thinning: thinning,
|
||
|
|
smoothing: 0.5,
|
||
|
|
streamline: 0.5,
|
||
|
|
simulatePressure: tool != PenTool.marker && tool != PenTool.highlighter,
|
||
|
|
isComplete: true,
|
||
|
|
);
|
||
|
|
if (outline.isEmpty) return;
|
||
|
|
|
||
|
|
final path = Path()..moveTo(outline[0].x, outline[0].y);
|
||
|
|
for (var i = 1; i < outline.length; i++) {
|
||
|
|
path.lineTo(outline[i].x, outline[i].y);
|
||
|
|
}
|
||
|
|
path.close();
|
||
|
|
|
||
|
|
canvas.drawPath(
|
||
|
|
path,
|
||
|
|
Paint()
|
||
|
|
..color = color
|
||
|
|
..style = PaintingStyle.fill
|
||
|
|
..isAntiAlias = true,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
static void _drawRect(
|
||
|
|
Canvas canvas,
|
||
|
|
List<InkPoint> points,
|
||
|
|
Color color,
|
||
|
|
double strokeWidth,
|
||
|
|
bool filled,
|
||
|
|
) {
|
||
|
|
final rect = Rect.fromPoints(
|
||
|
|
Offset(points[0].x, points[0].y),
|
||
|
|
Offset(points[1].x, points[1].y),
|
||
|
|
);
|
||
|
|
canvas.drawRect(
|
||
|
|
rect,
|
||
|
|
Paint()
|
||
|
|
..color = color
|
||
|
|
..strokeWidth = strokeWidth
|
||
|
|
..style = filled ? PaintingStyle.fill : PaintingStyle.stroke
|
||
|
|
..isAntiAlias = true,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
static void _drawOval(
|
||
|
|
Canvas canvas,
|
||
|
|
List<InkPoint> points,
|
||
|
|
Color color,
|
||
|
|
double strokeWidth,
|
||
|
|
bool filled,
|
||
|
|
) {
|
||
|
|
final rect = Rect.fromPoints(
|
||
|
|
Offset(points[0].x, points[0].y),
|
||
|
|
Offset(points[1].x, points[1].y),
|
||
|
|
);
|
||
|
|
canvas.drawOval(
|
||
|
|
rect,
|
||
|
|
Paint()
|
||
|
|
..color = color
|
||
|
|
..strokeWidth = strokeWidth
|
||
|
|
..style = filled ? PaintingStyle.fill : PaintingStyle.stroke
|
||
|
|
..isAntiAlias = true,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
static void _drawLine(
|
||
|
|
Canvas canvas,
|
||
|
|
List<InkPoint> points,
|
||
|
|
Color color,
|
||
|
|
double strokeWidth,
|
||
|
|
) {
|
||
|
|
canvas.drawLine(
|
||
|
|
Offset(points[0].x, points[0].y),
|
||
|
|
Offset(points[1].x, points[1].y),
|
||
|
|
Paint()
|
||
|
|
..color = color
|
||
|
|
..strokeWidth = strokeWidth
|
||
|
|
..strokeCap = StrokeCap.round
|
||
|
|
..isAntiAlias = true,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
static void _drawArrow(
|
||
|
|
Canvas canvas,
|
||
|
|
List<InkPoint> points,
|
||
|
|
Color color,
|
||
|
|
double strokeWidth,
|
||
|
|
) {
|
||
|
|
final start = Offset(points[0].x, points[0].y);
|
||
|
|
final end = Offset(points[1].x, points[1].y);
|
||
|
|
canvas.drawLine(
|
||
|
|
start,
|
||
|
|
end,
|
||
|
|
Paint()
|
||
|
|
..color = color
|
||
|
|
..strokeWidth = strokeWidth
|
||
|
|
..strokeCap = StrokeCap.round
|
||
|
|
..isAntiAlias = true,
|
||
|
|
);
|
||
|
|
|
||
|
|
final angle = atan2(end.dy - start.dy, end.dx - start.dx);
|
||
|
|
const headLength = 12.0;
|
||
|
|
const headAngle = pi / 6;
|
||
|
|
final p1 =
|
||
|
|
end +
|
||
|
|
Offset(
|
||
|
|
-headLength * cos(angle - headAngle),
|
||
|
|
-headLength * sin(angle - headAngle),
|
||
|
|
);
|
||
|
|
final p2 =
|
||
|
|
end +
|
||
|
|
Offset(
|
||
|
|
-headLength * cos(angle + headAngle),
|
||
|
|
-headLength * sin(angle + headAngle),
|
||
|
|
);
|
||
|
|
final head = Path()
|
||
|
|
..moveTo(end.dx, end.dy)
|
||
|
|
..lineTo(p1.dx, p1.dy)
|
||
|
|
..lineTo(p2.dx, p2.dy)
|
||
|
|
..close();
|
||
|
|
canvas.drawPath(
|
||
|
|
head,
|
||
|
|
Paint()
|
||
|
|
..color = color
|
||
|
|
..style = PaintingStyle.fill,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
static void _drawText(
|
||
|
|
Canvas canvas,
|
||
|
|
List<InkPoint> points,
|
||
|
|
String text,
|
||
|
|
double fontSize,
|
||
|
|
Color color,
|
||
|
|
) {
|
||
|
|
if (points.isEmpty) return;
|
||
|
|
final painter = TextPainter(
|
||
|
|
text: TextSpan(
|
||
|
|
text: text,
|
||
|
|
style: TextStyle(color: color, fontSize: fontSize),
|
||
|
|
),
|
||
|
|
textDirection: TextDirection.ltr,
|
||
|
|
)..layout();
|
||
|
|
painter.paint(canvas, Offset(points[0].x, points[0].y));
|
||
|
|
}
|
||
|
|
}
|