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:
2026-06-21 23:41:01 +08:00
parent 1e2a83b0b9
commit 914951afb7
16 changed files with 2267 additions and 23 deletions

View File

@@ -41,7 +41,7 @@ class DatabaseService {
_database = await openDatabase(
dbPath,
version: 5,
version: 6,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
);
@@ -156,12 +156,71 @@ class DatabaseService {
await db.execute(
'CREATE INDEX idx_scratchpads_doc ON scratchpads(document_id)',
);
// Editor ink strokes (v6)
await db.execute('''
CREATE TABLE ink (
id TEXT PRIMARY KEY,
host_kind TEXT NOT NULL,
host_id TEXT NOT NULL,
stroke_json TEXT NOT NULL,
ordinal INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
''');
await db.execute(
'CREATE INDEX idx_ink_host ON ink(host_kind, host_id)',
);
// Notebook pages (v6)
await db.execute('''
CREATE TABLE notebook_pages (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL,
ordinal INTEGER NOT NULL,
source_page_index INTEGER NOT NULL,
kind TEXT NOT NULL,
created_at INTEGER NOT NULL
)
''');
}
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
if (oldVersion < 3) await _migrateV2toV3(db);
if (oldVersion < 4) {} // v3->v4: version boundary (no-op schema)
if (oldVersion < 5) await _migrateV4toV5(db);
if (oldVersion < 6) await _migrateV5toV6(db);
}
Future<void> _migrateV5toV6(Database db) async {
await db.transaction((txn) async {
await txn.execute('''
CREATE TABLE ink (
id TEXT PRIMARY KEY,
host_kind TEXT NOT NULL,
host_id TEXT NOT NULL,
stroke_json TEXT NOT NULL,
ordinal INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
''');
await txn.execute(
'CREATE INDEX idx_ink_host ON ink(host_kind, host_id)',
);
await txn.execute('''
CREATE TABLE notebook_pages (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL,
ordinal INTEGER NOT NULL,
source_page_index INTEGER NOT NULL,
kind TEXT NOT NULL,
created_at INTEGER NOT NULL
)
''');
});
}
Future<void> _migrateV2toV3(Database db) async {

View File

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