Files
BadNote/lib/services/stroke_rasterizer.dart
Akiba So 72428dc075
Some checks failed
CI / Test (Server, optional) (push) Failing after 2m10s
Windows Build / Build Windows (x64) (push) Failing after 29s
CI / Test (Flutter, Linux) (push) Has been cancelled
CI / Analyze (Flutter) (push) Has been cancelled
Fix bugs across app + server, optimize UI/UX, add Gitea CI
Bug fixes (Flutter):
- Wrap multi-statement DB writes (insert/update/delete note, deleteDocument,
  deletePageData, OCR FTS merge, migrations) in transactions to prevent data
  loss on interruption and a read-modify-write FTS race.
- Fix PdfDocument leaks on exception (try/finally dispose) and preserve image
  aspect ratio when stamping images onto PDF pages.
- Guard file-picker against empty selection (was .single -> crash).
- Fix eraser ConcurrentModificationError and unmodifiable-list crash on PDF
  pages; capture page synchronously on save to stop wrong-page data loss.
- Fix Riverpod DB-not-ready races, broken pull-to-refresh, settings load race,
  and search N+1; transform stored annotations on PDF page rotation.
- Normalize pen pressure for devices without a pressure range.
- PPT: single source of truth for slide strokes so ink displays and exports.

UI/UX:
- Material 3 typography, theme-aware colors (dark-mode fixes), hover cursors
  and right-click/visible actions on desktop, keyboard shortcuts (undo/redo/
  save/find), toolbar overflow handling, friendlier empty states, semantic OCR
  status badges, relative timestamps, 1-based page indicators, large-deck PPT
  navigation, and a scratchpad-scope label in split view.

Server (optional backend):
- Persist JWT secret (was per-process random), block path traversal in storage,
  fix CORS '*'+credentials, add OCR job ownership checks, last-writer-wins sync
  guard, constant-time login, and split out heavy OCR deps so the API/tests run
  without them.

CI: Gitea workflows for format+analyze+test (Linux, system sqlite) and a
Windows release build; pristine `flutter analyze`, all Flutter and server tests
green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 03:18:00 +08:00

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));
}
}