feat(engine): P0 stroke engine + persistence
Per the full-refactor plan §9 (input-independent half of P0): - engine: canonical EditorStroke (lossless InkStroke round-trip) + stroke_geometry (single getStroke outline) + revision-gated StrokeStore - render: static/live ink painters + ink_picture_cache (revision-keyed) + annotation_layer (RepaintBoundary) - persistence: DB v6 (ink, notebook_pages) + editor_repository diff-write (UPSERT changed / DELETE removed in one txn; id-set after commit) + save_scheduler - pdf_service export now FILLS the getStroke outline (R7 hairline fix) Not yet wired into the live editor (input relocation pending pen-pressure diagnostic). 28 new tests pass.
This commit is contained in:
@@ -5,9 +5,11 @@ import 'dart:ui';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
|
||||
import 'package:syncfusion_flutter_pdf/pdf.dart';
|
||||
|
||||
import '../models/ink_stroke.dart';
|
||||
import '../models/pen_tool.dart';
|
||||
|
||||
/// Service for PDF operations: file picking, info extraction, and annotation export.
|
||||
class PdfService {
|
||||
@@ -201,6 +203,11 @@ class PdfService {
|
||||
|
||||
/// Renders [strokes] onto a PDF [page] using normalized [0, 1] coordinates
|
||||
/// scaled to the actual page dimensions.
|
||||
///
|
||||
/// Freehand tools (pen, marker, highlighter) are rendered as filled outline
|
||||
/// polygons produced by perfect_freehand's [getStroke], matching the
|
||||
/// on-screen filled-nib appearance. Shape tools and text keep their existing
|
||||
/// pen-stroke semantics.
|
||||
void _renderStrokes(PdfPage page, List<InkStroke> strokes) {
|
||||
final graphics = page.graphics;
|
||||
final pageSize = page.getClientSize();
|
||||
@@ -213,33 +220,107 @@ class PdfService {
|
||||
final g = (color >> 8) & 0xFF;
|
||||
final b = color & 0xFF;
|
||||
final a = (color >> 24) & 0xFF;
|
||||
final pdfColor = PdfColor(r, g, b, a);
|
||||
|
||||
final pen = PdfPen(PdfColor(r, g, b, a));
|
||||
pen.width = stroke.strokeWidth.clamp(1.0, 8.0);
|
||||
final isFreehand = stroke.tool == PenTool.pen ||
|
||||
stroke.tool == PenTool.marker ||
|
||||
stroke.tool == PenTool.highlighter;
|
||||
|
||||
if (stroke.points.length == 1) {
|
||||
// Single point — draw a dot
|
||||
final pt = stroke.points.first;
|
||||
graphics.drawEllipse(
|
||||
Rect.fromCenter(
|
||||
center: Offset(pt.x * pageSize.width, pt.y * pageSize.height),
|
||||
width: stroke.strokeWidth,
|
||||
height: stroke.strokeWidth,
|
||||
),
|
||||
pen: pen,
|
||||
);
|
||||
} else {
|
||||
// Draw line segments between consecutive points
|
||||
for (int i = 0; i < stroke.points.length - 1; i++) {
|
||||
final p1 = stroke.points[i];
|
||||
final p2 = stroke.points[i + 1];
|
||||
graphics.drawLine(
|
||||
pen,
|
||||
Offset(p1.x * pageSize.width, p1.y * pageSize.height),
|
||||
Offset(p2.x * pageSize.width, p2.y * pageSize.height),
|
||||
if (isFreehand) {
|
||||
final brush = PdfSolidBrush(pdfColor);
|
||||
|
||||
if (stroke.points.length == 1) {
|
||||
// Single point — filled dot matching stroke width.
|
||||
final pt = stroke.points.first;
|
||||
final radius = stroke.strokeWidth / 2;
|
||||
graphics.drawEllipse(
|
||||
Rect.fromCenter(
|
||||
center: Offset(pt.x * pageSize.width, pt.y * pageSize.height),
|
||||
width: radius * 2,
|
||||
height: radius * 2,
|
||||
),
|
||||
brush: brush,
|
||||
);
|
||||
} else {
|
||||
final pdfPath = _buildFreehandPdfPath(stroke, pageSize);
|
||||
if (pdfPath != null) {
|
||||
graphics.drawPath(pdfPath, brush: brush);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Shape tools and text: keep existing pen-segment semantics.
|
||||
final pen = PdfPen(pdfColor);
|
||||
pen.width = stroke.strokeWidth.clamp(1.0, 8.0);
|
||||
|
||||
if (stroke.points.length == 1) {
|
||||
final pt = stroke.points.first;
|
||||
graphics.drawEllipse(
|
||||
Rect.fromCenter(
|
||||
center: Offset(pt.x * pageSize.width, pt.y * pageSize.height),
|
||||
width: stroke.strokeWidth,
|
||||
height: stroke.strokeWidth,
|
||||
),
|
||||
pen: pen,
|
||||
);
|
||||
} else {
|
||||
for (int i = 0; i < stroke.points.length - 1; i++) {
|
||||
final p1 = stroke.points[i];
|
||||
final p2 = stroke.points[i + 1];
|
||||
graphics.drawLine(
|
||||
pen,
|
||||
Offset(p1.x * pageSize.width, p1.y * pageSize.height),
|
||||
Offset(p2.x * pageSize.width, p2.y * pageSize.height),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a [PdfPath] filled outline polygon for a freehand [stroke] using
|
||||
/// perfect_freehand's [getStroke], matching the on-screen recipe from
|
||||
/// [lib/editor/engine/stroke_geometry.dart].
|
||||
///
|
||||
/// Points are scaled from normalized [0,1] coords into PDF-point space
|
||||
/// defined by [pageSize] before being passed to [getStroke], so the
|
||||
/// resulting outline is already in PDF coordinates.
|
||||
///
|
||||
/// Returns null when [getStroke] produces an empty outline.
|
||||
PdfPath? _buildFreehandPdfPath(InkStroke stroke, Size pageSize) {
|
||||
final isHighlighter = stroke.tool == PenTool.highlighter;
|
||||
final pixelWidth = stroke.strokeWidth * pageSize.width;
|
||||
|
||||
// Detect real stylus pressure: the InkPoint default is 0.5, so any point
|
||||
// that differs from the default indicates actual device pressure data.
|
||||
final hasRealPressure = stroke.points.any((pt) => pt.pressure != 0.5);
|
||||
|
||||
final pfPoints = stroke.points
|
||||
.map(
|
||||
(pt) => pf.Point(
|
||||
pt.x * pageSize.width,
|
||||
pt.y * pageSize.height,
|
||||
pt.pressure,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
final outline = pf.getStroke(
|
||||
pfPoints,
|
||||
size: pixelWidth,
|
||||
// Highlighter keeps constant width; pen/marker taper via thinning=0.7.
|
||||
thinning: isHighlighter ? 0.0 : 0.7,
|
||||
smoothing: 0.5,
|
||||
streamline: 0.5,
|
||||
// Real stylus pressure -> don't simulate; no pressure -> let freehand
|
||||
// fake it based on velocity. Highlighter never simulates.
|
||||
simulatePressure: !hasRealPressure && !isHighlighter,
|
||||
isComplete: true,
|
||||
);
|
||||
|
||||
if (outline.isEmpty) return null;
|
||||
|
||||
final path = PdfPath();
|
||||
path.addPolygon(outline.map((pt) => Offset(pt.x, pt.y)).toList());
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user