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>
This commit is contained in:
20
lib/services/camera_service.dart
Normal file
20
lib/services/camera_service.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
/// Thin wrapper around image_picker for camera capture and gallery selection.
|
||||
class CameraService {
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
|
||||
/// Capture a photo using the device camera.
|
||||
/// Returns the file path, or null if the user cancelled.
|
||||
Future<String?> capturePhoto() async {
|
||||
final XFile? image = await _picker.pickImage(source: ImageSource.camera);
|
||||
return image?.path;
|
||||
}
|
||||
|
||||
/// Pick an image from the device gallery.
|
||||
/// Returns the file path, or null if the user cancelled.
|
||||
Future<String?> pickFromGallery() async {
|
||||
final XFile? image = await _picker.pickImage(source: ImageSource.gallery);
|
||||
return image?.path;
|
||||
}
|
||||
}
|
||||
841
lib/services/database_service.dart
Normal file
841
lib/services/database_service.dart
Normal file
@@ -0,0 +1,841 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/bookmark.dart';
|
||||
import '../models/document.dart' as doc;
|
||||
import '../models/ink_point.dart';
|
||||
import '../models/ink_stroke.dart';
|
||||
import '../models/note.dart';
|
||||
import '../models/pen_tool.dart';
|
||||
import '../models/pointer_device_kind.dart';
|
||||
|
||||
class DatabaseService {
|
||||
static DatabaseService? _instance;
|
||||
late Database _database;
|
||||
|
||||
DatabaseService._();
|
||||
|
||||
static Future<DatabaseService> getInstance() async {
|
||||
if (_instance != null) return _instance!;
|
||||
final service = DatabaseService._();
|
||||
await service._initialize();
|
||||
_instance = service;
|
||||
return service;
|
||||
}
|
||||
|
||||
Database get database => _database;
|
||||
|
||||
Future<void> _initialize() async {
|
||||
if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
}
|
||||
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final dbPath = p.join(dir.path, 'badnote.db');
|
||||
|
||||
_database = await openDatabase(
|
||||
dbPath,
|
||||
version: 5,
|
||||
onCreate: _onCreate,
|
||||
onUpgrade: _onUpgrade,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onCreate(Database db, int version) async {
|
||||
// Core tables (original v1)
|
||||
await db.execute('''
|
||||
CREATE TABLE notes (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
tags TEXT NOT NULL DEFAULT '[]'
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE strokes (
|
||||
id TEXT PRIMARY KEY,
|
||||
note_id TEXT NOT NULL,
|
||||
tool TEXT NOT NULL,
|
||||
color INTEGER NOT NULL,
|
||||
stroke_width REAL NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
points TEXT NOT NULL,
|
||||
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute('CREATE INDEX idx_strokes_note_id ON strokes(note_id)');
|
||||
|
||||
await _createFtsTable(db);
|
||||
|
||||
// Documents & annotations (originally v2, now part of fresh install)
|
||||
await db.execute('''
|
||||
CREATE TABLE documents (
|
||||
id TEXT PRIMARY KEY,
|
||||
filename TEXT NOT NULL,
|
||||
doc_type TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
page_count INTEGER NOT NULL DEFAULT 0,
|
||||
rotation INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE annotations (
|
||||
id TEXT PRIMARY KEY,
|
||||
uuid TEXT NOT NULL,
|
||||
document_id TEXT NOT NULL,
|
||||
page_number INTEGER NOT NULL,
|
||||
annotation_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_annotations_doc_page ON annotations(document_id, page_number)',
|
||||
);
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE bookmarks (
|
||||
id TEXT PRIMARY KEY,
|
||||
document_id TEXT NOT NULL,
|
||||
page_number INTEGER NOT NULL,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
color INTEGER NOT NULL DEFAULT 4283215696,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_bookmarks_doc ON bookmarks(document_id)',
|
||||
);
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE ocr_results (
|
||||
id TEXT PRIMARY KEY,
|
||||
document_id TEXT NOT NULL,
|
||||
page_number INTEGER NOT NULL,
|
||||
ocr_text TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
|
||||
// Document FTS (v3)
|
||||
await db.execute('''
|
||||
CREATE VIRTUAL TABLE document_fts USING fts5(
|
||||
document_id, page_number, content, tokenize='porter unicode61'
|
||||
)
|
||||
''');
|
||||
|
||||
// Scratchpads (v5)
|
||||
await db.execute('''
|
||||
CREATE TABLE scratchpads (
|
||||
id TEXT PRIMARY KEY,
|
||||
document_id TEXT UNIQUE NOT NULL,
|
||||
strokes_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_scratchpads_doc ON scratchpads(document_id)',
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
Future<void> _migrateV2toV3(Database db) async {
|
||||
// Wrap the whole migration in a transaction: a failure mid-migration
|
||||
// (after DROP TABLE annotations) would otherwise destroy data.
|
||||
await db.transaction((txn) async {
|
||||
// Add uuid column to annotations
|
||||
await txn.execute('ALTER TABLE annotations ADD COLUMN uuid TEXT');
|
||||
|
||||
// Generate UUIDs for existing rows
|
||||
await txn.rawUpdate(
|
||||
"UPDATE annotations SET uuid = hex(randomblob(16)) WHERE uuid IS NULL",
|
||||
);
|
||||
|
||||
// Recreate annotations table with UUID primary key
|
||||
await txn.execute('''
|
||||
CREATE TABLE annotations_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
uuid TEXT NOT NULL,
|
||||
document_id TEXT NOT NULL,
|
||||
page_number INTEGER NOT NULL,
|
||||
annotation_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
|
||||
await txn.rawInsert('''
|
||||
INSERT INTO annotations_new (id, uuid, document_id, page_number, annotation_json, created_at, updated_at)
|
||||
SELECT id, uuid, document_id, page_number, annotation_json, created_at, updated_at FROM annotations
|
||||
''');
|
||||
|
||||
await txn.execute('DROP TABLE annotations');
|
||||
await txn.execute('ALTER TABLE annotations_new RENAME TO annotations');
|
||||
await txn.execute(
|
||||
'CREATE INDEX idx_annotations_doc_page ON annotations(document_id, page_number)',
|
||||
);
|
||||
|
||||
// Create document FTS table
|
||||
await txn.execute('''
|
||||
CREATE VIRTUAL TABLE document_fts USING fts5(
|
||||
document_id, page_number, content, tokenize='porter unicode61'
|
||||
)
|
||||
''');
|
||||
|
||||
// Add rotation column to documents
|
||||
await txn.execute(
|
||||
'ALTER TABLE documents ADD COLUMN rotation INTEGER NOT NULL DEFAULT 0',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _migrateV4toV5(Database db) async {
|
||||
// Wrap in a transaction so a partial failure does not leave the schema
|
||||
// in an inconsistent state.
|
||||
await db.transaction((txn) async {
|
||||
// Create scratchpads table
|
||||
await txn.execute('''
|
||||
CREATE TABLE scratchpads (
|
||||
id TEXT PRIMARY KEY,
|
||||
document_id TEXT UNIQUE NOT NULL,
|
||||
strokes_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
|
||||
await txn.execute(
|
||||
'CREATE INDEX idx_scratchpads_doc ON scratchpads(document_id)',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Notes CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
Future<List<Note>> getAllNotes() async {
|
||||
final noteRows = await _database.query('notes', orderBy: 'updated_at DESC');
|
||||
final notes = <Note>[];
|
||||
for (final row in noteRows) {
|
||||
notes.add(await _noteFromRow(row));
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
Future<Note?> getNoteById(String id) async {
|
||||
final rows = await _database.query(
|
||||
'notes',
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return _noteFromRow(rows.first);
|
||||
}
|
||||
|
||||
Future<void> insertNote(Note note) async {
|
||||
// Atomic: the note row, its strokes, and the FTS index must all commit
|
||||
// together or not at all.
|
||||
await _database.transaction((txn) async {
|
||||
await txn.insert('notes', {
|
||||
'id': note.id,
|
||||
'title': note.title,
|
||||
'created_at': note.createdAt.toIso8601String(),
|
||||
'updated_at': note.updatedAt.toIso8601String(),
|
||||
'tags': jsonEncode(note.tags),
|
||||
});
|
||||
|
||||
for (final stroke in note.strokes) {
|
||||
await _insertStroke(txn, note.id, stroke);
|
||||
}
|
||||
|
||||
await _extractAndIndexNoteContent(txn, note);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> updateNote(Note note) async {
|
||||
// Atomic: this deletes all strokes then re-inserts them and rebuilds the
|
||||
// FTS entry. An interruption mid-way would permanently lose strokes, so
|
||||
// the whole sequence must run inside one transaction.
|
||||
await _database.transaction((txn) async {
|
||||
await txn.update(
|
||||
'notes',
|
||||
{
|
||||
'title': note.title,
|
||||
'updated_at': note.updatedAt.toIso8601String(),
|
||||
'tags': jsonEncode(note.tags),
|
||||
},
|
||||
where: 'id = ?',
|
||||
whereArgs: [note.id],
|
||||
);
|
||||
|
||||
// Replace all strokes for this note
|
||||
await txn.delete('strokes', where: 'note_id = ?', whereArgs: [note.id]);
|
||||
for (final stroke in note.strokes) {
|
||||
await _insertStroke(txn, note.id, stroke);
|
||||
}
|
||||
|
||||
await removeFromFts(txn, note.id);
|
||||
await _extractAndIndexNoteContent(txn, note);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> deleteNote(String id) async {
|
||||
await _database.transaction((txn) async {
|
||||
await txn.delete('strokes', where: 'note_id = ?', whereArgs: [id]);
|
||||
await txn.delete('notes', where: 'id = ?', whereArgs: [id]);
|
||||
await removeFromFts(txn, id);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Strokes ─────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _insertStroke(
|
||||
DatabaseExecutor db,
|
||||
String noteId,
|
||||
InkStroke stroke,
|
||||
) async {
|
||||
await db.insert('strokes', {
|
||||
'id': stroke.id,
|
||||
'note_id': noteId,
|
||||
'tool': stroke.tool.name,
|
||||
'color': stroke.color,
|
||||
'stroke_width': stroke.strokeWidth,
|
||||
'created_at': stroke.createdAt.toIso8601String(),
|
||||
'points': jsonEncode(stroke.points.map(_pointToJson).toList()),
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<InkStroke>> _getStrokesForNote(String noteId) async {
|
||||
final rows = await _database.query(
|
||||
'strokes',
|
||||
where: 'note_id = ?',
|
||||
whereArgs: [noteId],
|
||||
orderBy: 'created_at ASC',
|
||||
);
|
||||
return rows.map(_strokeFromRow).toList();
|
||||
}
|
||||
|
||||
// ── Serialization helpers ───────────────────────────────────────────
|
||||
|
||||
Map<String, dynamic> _pointToJson(InkPoint p) => {
|
||||
'x': p.x,
|
||||
'y': p.y,
|
||||
'pressure': p.pressure,
|
||||
'tilt': p.tilt,
|
||||
'timestamp': p.timestamp,
|
||||
'pointerDeviceKind': p.pointerDeviceKind.name,
|
||||
};
|
||||
|
||||
InkPoint _pointFromJson(Map<String, dynamic> json) => InkPoint(
|
||||
x: (json['x'] as num).toDouble(),
|
||||
y: (json['y'] as num).toDouble(),
|
||||
pressure: (json['pressure'] as num?)?.toDouble() ?? 0.5,
|
||||
tilt: (json['tilt'] as num?)?.toDouble() ?? 0.0,
|
||||
timestamp: json['timestamp'] as int,
|
||||
pointerDeviceKind: _parseDeviceKind(json['pointerDeviceKind'] as String?),
|
||||
);
|
||||
|
||||
InputDeviceKind _parseDeviceKind(String? value) {
|
||||
if (value == null) return InputDeviceKind.unknown;
|
||||
return InputDeviceKind.values.asNameMap()[value] ?? InputDeviceKind.unknown;
|
||||
}
|
||||
|
||||
InkStroke _strokeFromRow(Map<String, dynamic> row) {
|
||||
final pointsJson = jsonDecode(row['points'] as String) as List;
|
||||
return InkStroke(
|
||||
id: row['id'] as String,
|
||||
points: pointsJson
|
||||
.map((p) => _pointFromJson(p as Map<String, dynamic>))
|
||||
.toList(),
|
||||
tool: _parsePenTool(row['tool'] as String),
|
||||
color: row['color'] as int,
|
||||
strokeWidth: (row['stroke_width'] as num).toDouble(),
|
||||
createdAt: DateTime.parse(row['created_at'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
PenTool _parsePenTool(String value) {
|
||||
return PenTool.values.asNameMap()[value] ?? PenTool.pen;
|
||||
}
|
||||
|
||||
Future<Note> _noteFromRow(Map<String, dynamic> row) async {
|
||||
final tagsJson = jsonDecode(row['tags'] as String) as List;
|
||||
final strokes = await _getStrokesForNote(row['id'] as String);
|
||||
return Note(
|
||||
id: row['id'] as String,
|
||||
title: row['title'] as String,
|
||||
strokes: strokes,
|
||||
createdAt: DateTime.parse(row['created_at'] as String),
|
||||
updatedAt: DateTime.parse(row['updated_at'] as String),
|
||||
tags: tagsJson.cast<String>(),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Full-Text Search (FTS5) ────────────────────────────────────────
|
||||
|
||||
Future<void> _createFtsTable(Database db) async {
|
||||
await db.execute('''
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
|
||||
note_id, title, content, tokenize='porter unicode61'
|
||||
)
|
||||
''');
|
||||
}
|
||||
|
||||
/// Index a note's text content for full-text search.
|
||||
/// [content] should include any typed text, OCR text, etc.
|
||||
Future<void> indexNoteContent(
|
||||
DatabaseExecutor db,
|
||||
String noteId,
|
||||
String title,
|
||||
String content,
|
||||
) async {
|
||||
await db.insert('notes_fts', {
|
||||
'note_id': noteId,
|
||||
'title': title,
|
||||
'content': content,
|
||||
});
|
||||
}
|
||||
|
||||
/// Extract text content from a note's strokes and index it for FTS.
|
||||
/// Concatenates the title with any textContent from strokes.
|
||||
Future<void> _extractAndIndexNoteContent(
|
||||
DatabaseExecutor db,
|
||||
Note note,
|
||||
) async {
|
||||
final textParts = <String>[note.title];
|
||||
for (final stroke in note.strokes) {
|
||||
if (stroke.textContent != null && stroke.textContent!.isNotEmpty) {
|
||||
textParts.add(stroke.textContent!);
|
||||
}
|
||||
}
|
||||
final content = textParts.join(' ');
|
||||
await indexNoteContent(db, note.id, note.title, content);
|
||||
}
|
||||
|
||||
/// Append OCR text to an existing note's FTS entry.
|
||||
/// Reads current content, merges with new OCR text, and re-indexes.
|
||||
Future<void> appendOcrToFts(String noteId, String ocrText) async {
|
||||
if (ocrText.trim().isEmpty) return;
|
||||
|
||||
// Read-modify-write must be atomic: querying the current content, removing
|
||||
// the old entry, and re-inserting the merged content all run inside one
|
||||
// transaction so a concurrent writer cannot cause a lost update.
|
||||
await _database.transaction((txn) async {
|
||||
// Read current FTS content
|
||||
final rows = await txn.query(
|
||||
'notes_fts',
|
||||
where: 'note_id = ?',
|
||||
whereArgs: [noteId],
|
||||
);
|
||||
|
||||
String existingContent = '';
|
||||
String existingTitle = '';
|
||||
if (rows.isNotEmpty) {
|
||||
existingTitle = rows.first['title'] as String? ?? '';
|
||||
existingContent = rows.first['content'] as String? ?? '';
|
||||
}
|
||||
|
||||
// Merge: append OCR text to existing content
|
||||
final mergedContent = existingContent.isEmpty
|
||||
? ocrText
|
||||
: '$existingContent $ocrText';
|
||||
|
||||
// Remove old entry and re-insert with merged content
|
||||
await removeFromFts(txn, noteId);
|
||||
await indexNoteContent(txn, noteId, existingTitle, mergedContent);
|
||||
});
|
||||
}
|
||||
|
||||
/// Full-text search across indexed notes.
|
||||
Future<List<Note>> searchNotes(String query) async {
|
||||
if (query.trim().isEmpty) return [];
|
||||
|
||||
// Sanitize query for FTS5: escape special chars and add prefix matching
|
||||
final sanitized = query.replaceAll('"', '').replaceAll("'", '').trim();
|
||||
if (sanitized.isEmpty) return [];
|
||||
|
||||
final ftsQuery = sanitized
|
||||
.split(RegExp(r'\s+'))
|
||||
.map((w) => '"$w"*')
|
||||
.join(' ');
|
||||
|
||||
final rows = await _database.rawQuery(
|
||||
'SELECT note_id FROM notes_fts WHERE notes_fts MATCH ? ORDER BY rank',
|
||||
[ftsQuery],
|
||||
);
|
||||
|
||||
final notes = <Note>[];
|
||||
for (final row in rows) {
|
||||
final noteId = row['note_id'] as String;
|
||||
final note = await getNoteById(noteId);
|
||||
if (note != null) {
|
||||
notes.add(note);
|
||||
}
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
/// Remove a note from the FTS index.
|
||||
Future<void> removeFromFts(DatabaseExecutor db, String noteId) async {
|
||||
await db.delete('notes_fts', where: 'note_id = ?', whereArgs: [noteId]);
|
||||
}
|
||||
|
||||
// ── Document FTS ────────────────────────────────────────────────────
|
||||
|
||||
/// Index a page's text content for document full-text search.
|
||||
Future<void> indexDocumentContent(
|
||||
String documentId,
|
||||
int pageNumber,
|
||||
String content,
|
||||
) async {
|
||||
// Remove existing entry for this page first
|
||||
await _database.delete(
|
||||
'document_fts',
|
||||
where: 'document_id = ? AND page_number = ?',
|
||||
whereArgs: [documentId, pageNumber],
|
||||
);
|
||||
await _database.insert('document_fts', {
|
||||
'document_id': documentId,
|
||||
'page_number': pageNumber.toString(),
|
||||
'content': content,
|
||||
});
|
||||
}
|
||||
|
||||
/// Remove a page from the document FTS index.
|
||||
Future<void> removeDocumentFromFts(
|
||||
DatabaseExecutor db,
|
||||
String documentId,
|
||||
int pageNumber,
|
||||
) async {
|
||||
await db.delete(
|
||||
'document_fts',
|
||||
where: 'document_id = ? AND page_number = ?',
|
||||
whereArgs: [documentId, pageNumber],
|
||||
);
|
||||
}
|
||||
|
||||
/// Full-text search across indexed document pages.
|
||||
Future<List<Map<String, dynamic>>> searchDocuments(String query) async {
|
||||
if (query.trim().isEmpty) return [];
|
||||
|
||||
final sanitized = query.replaceAll('"', '').replaceAll("'", '').trim();
|
||||
if (sanitized.isEmpty) return [];
|
||||
|
||||
final ftsQuery = sanitized
|
||||
.split(RegExp(r'\s+'))
|
||||
.map((w) => '"$w"*')
|
||||
.join(' ');
|
||||
|
||||
final rows = await _database.rawQuery(
|
||||
'SELECT document_id, page_number, content FROM document_fts WHERE document_fts MATCH ? ORDER BY rank',
|
||||
[ftsQuery],
|
||||
);
|
||||
|
||||
return rows
|
||||
.map(
|
||||
(row) => {
|
||||
'document_id': row['document_id'] as String,
|
||||
'page_number': int.parse(row['page_number'] as String),
|
||||
'content': row['content'] as String,
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// ── Documents CRUD ─────────────────────────────────────────────────
|
||||
|
||||
Future<void> insertDocument(doc.Document document) async {
|
||||
await _database.insert('documents', {
|
||||
'id': document.id,
|
||||
'filename': document.filename,
|
||||
'doc_type': document.docType,
|
||||
'file_path': document.filePath,
|
||||
'page_count': document.pageCount,
|
||||
'rotation': document.rotation,
|
||||
'created_at': document.createdAt.toIso8601String(),
|
||||
'updated_at': document.updatedAt.toIso8601String(),
|
||||
});
|
||||
}
|
||||
|
||||
Future<doc.Document?> getDocument(String id) async {
|
||||
final rows = await _database.query(
|
||||
'documents',
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return _documentFromRow(rows.first);
|
||||
}
|
||||
|
||||
Future<doc.Document?> getDocumentByPath(String filePath) async {
|
||||
final rows = await _database.query(
|
||||
'documents',
|
||||
where: 'file_path = ?',
|
||||
whereArgs: [filePath],
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return _documentFromRow(rows.first);
|
||||
}
|
||||
|
||||
Future<List<doc.Document>> getAllDocuments() async {
|
||||
final rows = await _database.query('documents', orderBy: 'updated_at DESC');
|
||||
return rows.map(_documentFromRow).toList();
|
||||
}
|
||||
|
||||
Future<void> deleteDocument(String id) async {
|
||||
await _database.transaction((txn) async {
|
||||
await txn.delete(
|
||||
'annotations',
|
||||
where: 'document_id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
await txn.delete('bookmarks', where: 'document_id = ?', whereArgs: [id]);
|
||||
await txn.delete(
|
||||
'ocr_results',
|
||||
where: 'document_id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
await txn.delete(
|
||||
'scratchpads',
|
||||
where: 'document_id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
await txn.delete('documents', where: 'id = ?', whereArgs: [id]);
|
||||
});
|
||||
}
|
||||
|
||||
doc.Document _documentFromRow(Map<String, dynamic> row) {
|
||||
return doc.Document(
|
||||
id: row['id'] as String,
|
||||
filename: row['filename'] as String,
|
||||
docType: row['doc_type'] as String,
|
||||
filePath: row['file_path'] as String,
|
||||
pageCount: row['page_count'] as int,
|
||||
rotation: (row['rotation'] as int?) ?? 0,
|
||||
createdAt: DateTime.parse(row['created_at'] as String),
|
||||
updatedAt: DateTime.parse(row['updated_at'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Annotations CRUD ───────────────────────────────────────────────
|
||||
|
||||
Future<void> saveAnnotations(
|
||||
String documentId,
|
||||
int pageNumber,
|
||||
String annotationJson,
|
||||
) async {
|
||||
await _database.delete(
|
||||
'annotations',
|
||||
where: 'document_id = ? AND page_number = ?',
|
||||
whereArgs: [documentId, pageNumber],
|
||||
);
|
||||
await _database.insert('annotations', {
|
||||
'id': const Uuid().v4(),
|
||||
'uuid': const Uuid().v4(),
|
||||
'document_id': documentId,
|
||||
'page_number': pageNumber,
|
||||
'annotation_json': annotationJson,
|
||||
'created_at': DateTime.now().toIso8601String(),
|
||||
'updated_at': DateTime.now().toIso8601String(),
|
||||
});
|
||||
}
|
||||
|
||||
Future<String?> getAnnotations(String documentId, int pageNumber) async {
|
||||
final rows = await _database.query(
|
||||
'annotations',
|
||||
where: 'document_id = ? AND page_number = ?',
|
||||
whereArgs: [documentId, pageNumber],
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return rows.first['annotation_json'] as String;
|
||||
}
|
||||
|
||||
Future<void> deleteDocumentAnnotations(String documentId) async {
|
||||
await _database.delete(
|
||||
'annotations',
|
||||
where: 'document_id = ?',
|
||||
whereArgs: [documentId],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Annotation/Bookmark Remapping ──────────────────────────────────
|
||||
|
||||
/// After deleting a page at [deletedIndex], shift all annotations
|
||||
/// with page_number > deletedIndex down by 1.
|
||||
Future<void> remapAnnotationsAfterDelete(
|
||||
String documentId,
|
||||
int deletedIndex,
|
||||
) async {
|
||||
await _database.rawUpdate(
|
||||
'UPDATE annotations SET page_number = page_number - 1 WHERE document_id = ? AND page_number > ?',
|
||||
[documentId, deletedIndex],
|
||||
);
|
||||
}
|
||||
|
||||
/// After inserting a page at [insertedIndex], shift all annotations
|
||||
/// with page_number >= insertedIndex up by 1.
|
||||
Future<void> remapAnnotationsAfterInsert(
|
||||
String documentId,
|
||||
int insertedIndex,
|
||||
) async {
|
||||
await _database.rawUpdate(
|
||||
'UPDATE annotations SET page_number = page_number + 1 WHERE document_id = ? AND page_number >= ?',
|
||||
[documentId, insertedIndex],
|
||||
);
|
||||
}
|
||||
|
||||
/// After deleting a page at [deletedIndex], shift all bookmarks
|
||||
/// with page_number > deletedIndex down by 1.
|
||||
Future<void> remapBookmarksAfterDelete(
|
||||
String documentId,
|
||||
int deletedIndex,
|
||||
) async {
|
||||
await _database.rawUpdate(
|
||||
'UPDATE bookmarks SET page_number = page_number - 1 WHERE document_id = ? AND page_number > ?',
|
||||
[documentId, deletedIndex],
|
||||
);
|
||||
}
|
||||
|
||||
/// After inserting a page at [insertedIndex], shift all bookmarks
|
||||
/// with page_number >= insertedIndex up by 1.
|
||||
Future<void> remapBookmarksAfterInsert(
|
||||
String documentId,
|
||||
int insertedIndex,
|
||||
) async {
|
||||
await _database.rawUpdate(
|
||||
'UPDATE bookmarks SET page_number = page_number + 1 WHERE document_id = ? AND page_number >= ?',
|
||||
[documentId, insertedIndex],
|
||||
);
|
||||
}
|
||||
|
||||
/// Delete all annotations, bookmarks, and OCR data for a specific page.
|
||||
Future<void> deletePageData(String documentId, int pageNumber) async {
|
||||
await _database.transaction((txn) async {
|
||||
await txn.delete(
|
||||
'annotations',
|
||||
where: 'document_id = ? AND page_number = ?',
|
||||
whereArgs: [documentId, pageNumber],
|
||||
);
|
||||
await txn.delete(
|
||||
'bookmarks',
|
||||
where: 'document_id = ? AND page_number = ?',
|
||||
whereArgs: [documentId, pageNumber],
|
||||
);
|
||||
await txn.delete(
|
||||
'ocr_results',
|
||||
where: 'document_id = ? AND page_number = ?',
|
||||
whereArgs: [documentId, pageNumber],
|
||||
);
|
||||
await removeDocumentFromFts(txn, documentId, pageNumber);
|
||||
});
|
||||
}
|
||||
|
||||
/// Update the stored page count for a document.
|
||||
Future<void> updateDocumentPageCount(
|
||||
String documentId,
|
||||
int newPageCount,
|
||||
) async {
|
||||
await _database.update(
|
||||
'documents',
|
||||
{
|
||||
'page_count': newPageCount,
|
||||
'updated_at': DateTime.now().toIso8601String(),
|
||||
},
|
||||
where: 'id = ?',
|
||||
whereArgs: [documentId],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bookmarks CRUD ─────────────────────────────────────────────────
|
||||
|
||||
Future<void> insertBookmark(Bookmark bookmark) async {
|
||||
await _database.insert('bookmarks', {
|
||||
'id': bookmark.id,
|
||||
'document_id': bookmark.documentId,
|
||||
'page_number': bookmark.pageNumber,
|
||||
'label': bookmark.label,
|
||||
'color': bookmark.color,
|
||||
'created_at': bookmark.createdAt.toIso8601String(),
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<Bookmark>> getBookmarks(String documentId) async {
|
||||
final rows = await _database.query(
|
||||
'bookmarks',
|
||||
where: 'document_id = ?',
|
||||
whereArgs: [documentId],
|
||||
orderBy: 'page_number ASC',
|
||||
);
|
||||
return rows.map(_bookmarkFromRow).toList();
|
||||
}
|
||||
|
||||
Future<void> deleteBookmark(String id) async {
|
||||
await _database.delete('bookmarks', where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
Bookmark _bookmarkFromRow(Map<String, dynamic> row) {
|
||||
return Bookmark(
|
||||
id: row['id'] as String,
|
||||
documentId: row['document_id'] as String,
|
||||
pageNumber: row['page_number'] as int,
|
||||
label: row['label'] as String,
|
||||
color: row['color'] as int,
|
||||
createdAt: DateTime.parse(row['created_at'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scratchpad CRUD ────────────────────────────────────────────────
|
||||
|
||||
/// Save scratchpad strokes for a document (upsert).
|
||||
Future<void> saveScratchpad(String documentId, String strokesJson) async {
|
||||
final now = DateTime.now().toIso8601String();
|
||||
await _database.rawInsert(
|
||||
'''INSERT INTO scratchpads (id, document_id, strokes_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(document_id) DO UPDATE SET strokes_json = excluded.strokes_json, updated_at = excluded.updated_at''',
|
||||
[const Uuid().v4(), documentId, strokesJson, now, now],
|
||||
);
|
||||
}
|
||||
|
||||
/// Load scratchpad strokes for a document.
|
||||
Future<List<InkStroke>> loadScratchpad(String documentId) async {
|
||||
final rows = await _database.query(
|
||||
'scratchpads',
|
||||
where: 'document_id = ?',
|
||||
whereArgs: [documentId],
|
||||
);
|
||||
if (rows.isEmpty) return [];
|
||||
final json = rows.first['strokes_json'] as String;
|
||||
if (json.isEmpty || json == '[]') return [];
|
||||
final List<dynamic> list = jsonDecode(json) as List<dynamic>;
|
||||
return list
|
||||
.map((s) => InkStroke.fromJson(s as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
21
lib/services/ocr_engine.dart
Normal file
21
lib/services/ocr_engine.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Platform OCR backend. Uses Windows built-in OCR on desktop Windows.
|
||||
class OcrEngine {
|
||||
static const _channel = MethodChannel('badnote/ocr');
|
||||
|
||||
/// Recognize text from a PNG image. Returns null when unavailable or empty.
|
||||
static Future<String?> recognizeImage(Uint8List pngBytes) async {
|
||||
if (!Platform.isWindows) return null;
|
||||
try {
|
||||
final result = await _channel.invokeMethod<String>('recognize', pngBytes);
|
||||
final text = result?.trim();
|
||||
if (text == null || text.isEmpty) return null;
|
||||
return text;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
46
lib/services/ocr_service.dart
Normal file
46
lib/services/ocr_service.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
import '../models/note.dart';
|
||||
import '../models/pen_tool.dart';
|
||||
import 'database_service.dart';
|
||||
import 'ocr_engine.dart';
|
||||
import 'stroke_rasterizer.dart';
|
||||
|
||||
/// Runs OCR locally: typed text from strokes + handwriting via platform OCR.
|
||||
class OcrService {
|
||||
/// Extract searchable text from [note] and merge into the local FTS index.
|
||||
Future<void> processNote(Note note) async {
|
||||
final parts = <String>[];
|
||||
|
||||
for (final stroke in note.strokes) {
|
||||
if (stroke.tool == PenTool.text &&
|
||||
stroke.textContent != null &&
|
||||
stroke.textContent!.trim().isNotEmpty) {
|
||||
parts.add(stroke.textContent!.trim());
|
||||
}
|
||||
}
|
||||
|
||||
final handwritingStrokes = note.strokes
|
||||
.where(
|
||||
(s) =>
|
||||
s.tool != PenTool.eraser &&
|
||||
s.tool != PenTool.text &&
|
||||
s.points.isNotEmpty,
|
||||
)
|
||||
.toList();
|
||||
|
||||
if (handwritingStrokes.isNotEmpty) {
|
||||
final png = await StrokeRasterizer.render(handwritingStrokes);
|
||||
if (png != null) {
|
||||
final recognized = await OcrEngine.recognizeImage(png);
|
||||
if (recognized != null && recognized.isNotEmpty) {
|
||||
parts.add(recognized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final combined = parts.join(' ').trim();
|
||||
if (combined.isEmpty) return;
|
||||
|
||||
final db = await DatabaseService.getInstance();
|
||||
await db.appendOcrToFts(note.id, combined);
|
||||
}
|
||||
}
|
||||
245
lib/services/pdf_service.dart
Normal file
245
lib/services/pdf_service.dart
Normal file
@@ -0,0 +1,245 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
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:syncfusion_flutter_pdf/pdf.dart';
|
||||
|
||||
import '../models/ink_stroke.dart';
|
||||
|
||||
/// Service for PDF operations: file picking, info extraction, and annotation export.
|
||||
class PdfService {
|
||||
/// Pick a PDF file path using the cross-platform file_picker.
|
||||
Future<String?> pickPdfFile() async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['pdf'],
|
||||
);
|
||||
final files = result?.files;
|
||||
if (files == null || files.isEmpty) return null;
|
||||
return files.first.path;
|
||||
}
|
||||
|
||||
/// Get the page count of the PDF at [filePath].
|
||||
Future<int> getPageCount(String filePath) async {
|
||||
final bytes = await File(filePath).readAsBytes();
|
||||
final document = PdfDocument(inputBytes: bytes);
|
||||
try {
|
||||
return document.pages.count;
|
||||
} finally {
|
||||
document.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get basic info about a PDF file: fileName and fileSize.
|
||||
Future<Map<String, dynamic>> getPdfInfo(String filePath) async {
|
||||
final file = File(filePath);
|
||||
final fileSize = await file.length();
|
||||
return {'fileName': p.basename(filePath), 'fileSize': fileSize};
|
||||
}
|
||||
|
||||
/// Export an annotated PDF by drawing ink strokes onto each page.
|
||||
///
|
||||
/// [annotations] maps page index (0-based) to lists of [InkStroke].
|
||||
/// Stroke coordinates are normalized to [0, 1] relative to the annotation
|
||||
/// overlay size used during capture, and are scaled to actual PDF page
|
||||
/// dimensions during export.
|
||||
///
|
||||
/// Returns the path to the exported annotated PDF.
|
||||
Future<String> exportAnnotatedPdf(
|
||||
String filePath,
|
||||
Map<int, List<InkStroke>> annotations,
|
||||
) async {
|
||||
final bytes = await File(filePath).readAsBytes();
|
||||
final document = PdfDocument(inputBytes: bytes);
|
||||
try {
|
||||
for (final entry in annotations.entries) {
|
||||
final pageIndex = entry.key;
|
||||
final strokes = entry.value;
|
||||
if (strokes.isEmpty) continue;
|
||||
if (pageIndex >= document.pages.count) continue;
|
||||
|
||||
final page = document.pages[pageIndex];
|
||||
_renderStrokes(page, strokes);
|
||||
}
|
||||
|
||||
final outputDir = await getTemporaryDirectory();
|
||||
final baseName = p.basenameWithoutExtension(filePath);
|
||||
final outputPath = p.join(outputDir.path, '${baseName}_annotated.pdf');
|
||||
final savedBytes = await document.save();
|
||||
await File(outputPath).writeAsBytes(savedBytes, flush: true);
|
||||
|
||||
return outputPath;
|
||||
} finally {
|
||||
document.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a page at [pageIndex]. Returns true on success.
|
||||
Future<bool> deletePage(String filePath, int pageIndex) async {
|
||||
try {
|
||||
final bytes = await File(filePath).readAsBytes();
|
||||
final document = PdfDocument(inputBytes: bytes);
|
||||
try {
|
||||
if (pageIndex < 0 || pageIndex >= document.pages.count) {
|
||||
return false;
|
||||
}
|
||||
document.pages.removeAt(pageIndex);
|
||||
final outputBytes = await document.save();
|
||||
await File(filePath).writeAsBytes(outputBytes, flush: true);
|
||||
return true;
|
||||
} finally {
|
||||
document.dispose();
|
||||
}
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a blank A4 page (595 x 842 pt) after [afterIndex].
|
||||
/// Returns true on success.
|
||||
Future<bool> insertBlankPage(String filePath, int afterIndex) async {
|
||||
try {
|
||||
final bytes = await File(filePath).readAsBytes();
|
||||
final document = PdfDocument(inputBytes: bytes);
|
||||
final insertAt = (afterIndex + 1).clamp(0, document.pages.count);
|
||||
document.pages.insert(insertAt);
|
||||
final outputBytes = await document.save();
|
||||
document.dispose();
|
||||
await File(filePath).writeAsBytes(outputBytes, flush: true);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotate page at [pageIndex] 90 degrees clockwise.
|
||||
/// Returns true on success.
|
||||
Future<bool> rotatePage(String filePath, int pageIndex) async {
|
||||
try {
|
||||
final bytes = await File(filePath).readAsBytes();
|
||||
final document = PdfDocument(inputBytes: bytes);
|
||||
try {
|
||||
if (pageIndex < 0 || pageIndex >= document.pages.count) {
|
||||
return false;
|
||||
}
|
||||
final page = document.pages[pageIndex];
|
||||
final current = page.rotation;
|
||||
// Cycle through: 0 -> 90 -> 180 -> 270 -> 0
|
||||
switch (current) {
|
||||
case PdfPageRotateAngle.rotateAngle0:
|
||||
page.rotation = PdfPageRotateAngle.rotateAngle90;
|
||||
case PdfPageRotateAngle.rotateAngle90:
|
||||
page.rotation = PdfPageRotateAngle.rotateAngle180;
|
||||
case PdfPageRotateAngle.rotateAngle180:
|
||||
page.rotation = PdfPageRotateAngle.rotateAngle270;
|
||||
case PdfPageRotateAngle.rotateAngle270:
|
||||
page.rotation = PdfPageRotateAngle.rotateAngle0;
|
||||
}
|
||||
final outputBytes = await document.save();
|
||||
await File(filePath).writeAsBytes(outputBytes, flush: true);
|
||||
return true;
|
||||
} finally {
|
||||
document.dispose();
|
||||
}
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw an image from [imagePath] onto the page at [pageIndex],
|
||||
/// fitted to the page dimensions while preserving aspect ratio.
|
||||
/// Returns the [pdfPath] on success, null on failure.
|
||||
Future<String?> insertImageOnPage(
|
||||
String pdfPath,
|
||||
int pageIndex,
|
||||
String imagePath,
|
||||
) async {
|
||||
try {
|
||||
final pdfBytes = await File(pdfPath).readAsBytes();
|
||||
final document = PdfDocument(inputBytes: pdfBytes);
|
||||
try {
|
||||
if (pageIndex < 0 || pageIndex >= document.pages.count) {
|
||||
return null;
|
||||
}
|
||||
final page = document.pages[pageIndex];
|
||||
final imageBytes = await File(imagePath).readAsBytes();
|
||||
final pdfImage = PdfBitmap(imageBytes);
|
||||
final pageSize = page.getClientSize();
|
||||
|
||||
// Fit the image to the page while preserving its aspect ratio
|
||||
// (letterboxed and centered), rather than stretching it to fill.
|
||||
final imageWidth = pdfImage.width.toDouble();
|
||||
final imageHeight = pdfImage.height.toDouble();
|
||||
final scale = (imageWidth <= 0 || imageHeight <= 0)
|
||||
? 1.0
|
||||
: math.min(
|
||||
pageSize.width / imageWidth,
|
||||
pageSize.height / imageHeight,
|
||||
);
|
||||
final drawWidth = imageWidth * scale;
|
||||
final drawHeight = imageHeight * scale;
|
||||
final left = (pageSize.width - drawWidth) / 2;
|
||||
final top = (pageSize.height - drawHeight) / 2;
|
||||
|
||||
page.graphics.drawImage(
|
||||
pdfImage,
|
||||
Rect.fromLTWH(left, top, drawWidth, drawHeight),
|
||||
);
|
||||
final outputBytes = await document.save();
|
||||
await File(pdfPath).writeAsBytes(outputBytes, flush: true);
|
||||
return pdfPath;
|
||||
} finally {
|
||||
document.dispose();
|
||||
}
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders [strokes] onto a PDF [page] using normalized [0, 1] coordinates
|
||||
/// scaled to the actual page dimensions.
|
||||
void _renderStrokes(PdfPage page, List<InkStroke> strokes) {
|
||||
final graphics = page.graphics;
|
||||
final pageSize = page.getClientSize();
|
||||
|
||||
for (final stroke in strokes) {
|
||||
if (stroke.points.isEmpty) continue;
|
||||
|
||||
final color = stroke.color;
|
||||
final r = (color >> 16) & 0xFF;
|
||||
final g = (color >> 8) & 0xFF;
|
||||
final b = color & 0xFF;
|
||||
final a = (color >> 24) & 0xFF;
|
||||
|
||||
final pen = PdfPen(PdfColor(r, g, b, a));
|
||||
pen.width = stroke.strokeWidth.clamp(1.0, 8.0);
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
62
lib/services/pen_input_service.dart
Normal file
62
lib/services/pen_input_service.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/ink_point.dart';
|
||||
import '../models/pen_tool.dart';
|
||||
import '../models/pointer_device_kind.dart';
|
||||
|
||||
class PenInputService {
|
||||
final StreamController<InkPoint> _pointController =
|
||||
StreamController<InkPoint>.broadcast();
|
||||
|
||||
Stream<InkPoint> get pointStream => _pointController.stream;
|
||||
|
||||
PenTool currentTool = PenTool.pen;
|
||||
Color currentColor = Colors.black;
|
||||
double currentStrokeWidth = 2.0;
|
||||
|
||||
void addPoint(InkPoint point) {
|
||||
_pointController.add(point);
|
||||
}
|
||||
|
||||
InputDeviceKind mapFlutterKind(PointerDeviceKind kind) {
|
||||
switch (kind) {
|
||||
case PointerDeviceKind.touch:
|
||||
return InputDeviceKind.touch;
|
||||
case PointerDeviceKind.mouse:
|
||||
return InputDeviceKind.mouse;
|
||||
case PointerDeviceKind.stylus:
|
||||
return InputDeviceKind.stylus;
|
||||
case PointerDeviceKind.invertedStylus:
|
||||
return InputDeviceKind.invertedStylus;
|
||||
case PointerDeviceKind.trackpad:
|
||||
return InputDeviceKind.trackpad;
|
||||
case PointerDeviceKind.unknown:
|
||||
return InputDeviceKind.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
InkPoint fromPointerEvent(PointerEvent event) {
|
||||
// Devices without real pressure support (mouse, basic touch) report a
|
||||
// degenerate range where pressureMin == pressureMax, which can yield a
|
||||
// pressure of 0.0 and produce zero-width strokes. In that case fall back
|
||||
// to a neutral mid-pressure value so strokes remain visible.
|
||||
final pressure = event.pressureMin == event.pressureMax
|
||||
? 0.5
|
||||
: event.pressure;
|
||||
return InkPoint(
|
||||
x: event.localPosition.dx,
|
||||
y: event.localPosition.dy,
|
||||
pressure: pressure,
|
||||
tilt: event is PointerMoveEvent ? event.tilt : 0.0,
|
||||
timestamp: event.timeStamp.inMicroseconds,
|
||||
pointerDeviceKind: mapFlutterKind(event.kind),
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_pointController.close();
|
||||
}
|
||||
}
|
||||
293
lib/services/pptx_service.dart
Normal file
293
lib/services/pptx_service.dart
Normal file
@@ -0,0 +1,293 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// Service for processing PPTX files: text extraction, image conversion, file picking.
|
||||
///
|
||||
/// PPTX files are ZIP archives containing XML. We extract text from
|
||||
/// `ppt/slides/slide*.xml` `<a:t>` elements and convert slides to images
|
||||
/// using LibreOffice (headless) or generate placeholder images as fallback.
|
||||
class PptxService {
|
||||
static const _uuid = Uuid();
|
||||
|
||||
/// Extract all text content from a PPTX file.
|
||||
///
|
||||
/// PPTX is a ZIP archive. Slide text lives in `ppt/slides/slide*.xml`
|
||||
/// inside `<a:t>` (ASCII text) elements within `<a:r>` (run) or
|
||||
/// `<a:p>` (paragraph) nodes.
|
||||
Future<String> extractText(String pptxPath) async {
|
||||
final tmpDir = await _makeTmpDir('pptx_text');
|
||||
|
||||
try {
|
||||
// Unzip the PPTX
|
||||
final unzipResult = await Process.run('unzip', [
|
||||
'-o',
|
||||
'-q',
|
||||
pptxPath,
|
||||
'-d',
|
||||
tmpDir.path,
|
||||
]);
|
||||
|
||||
if (unzipResult.exitCode != 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Find all slide XML files
|
||||
final slidesDir = Directory(p.join(tmpDir.path, 'ppt', 'slides'));
|
||||
if (!await slidesDir.exists()) return '';
|
||||
|
||||
final slideFiles = await slidesDir
|
||||
.list()
|
||||
.where((f) => f.path.contains(RegExp(r'slide\d+\.xml$')))
|
||||
.toList();
|
||||
|
||||
// Sort by slide number
|
||||
slideFiles.sort((a, b) {
|
||||
final aNum = _extractSlideNumber(a.path);
|
||||
final bNum = _extractSlideNumber(b.path);
|
||||
return aNum.compareTo(bNum);
|
||||
});
|
||||
|
||||
final buffer = StringBuffer();
|
||||
for (final slideFile in slideFiles) {
|
||||
final xml = await File(slideFile.path).readAsString();
|
||||
final slideText = _extractTextFromXml(xml);
|
||||
if (slideText.isNotEmpty) {
|
||||
final num = _extractSlideNumber(slideFile.path);
|
||||
buffer.writeln('--- Slide $num ---');
|
||||
buffer.writeln(slideText);
|
||||
buffer.writeln();
|
||||
}
|
||||
}
|
||||
|
||||
return buffer.toString().trim();
|
||||
} catch (_) {
|
||||
return '';
|
||||
} finally {
|
||||
// Cleanup
|
||||
try {
|
||||
await tmpDir.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert PPTX slides to a list of image file paths.
|
||||
///
|
||||
/// Attempts LibreOffice headless conversion first. Falls back to
|
||||
/// generating placeholder slide images (colored rectangles with slide numbers).
|
||||
Future<List<String>> convertToImages(String pptxPath) async {
|
||||
// Try LibreOffice first
|
||||
final loImages = await _convertViaLibreOffice(pptxPath);
|
||||
if (loImages.isNotEmpty) return loImages;
|
||||
|
||||
// Fallback: generate placeholder images
|
||||
return _generatePlaceholderImages(pptxPath);
|
||||
}
|
||||
|
||||
/// Open a file picker dialog and return the selected PPTX path, or null.
|
||||
///
|
||||
/// Uses the cross-platform file_picker package.
|
||||
Future<String?> openPptxFile() async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['pptx', 'ppt'],
|
||||
);
|
||||
final files = result?.files;
|
||||
if (files == null || files.isEmpty) return null;
|
||||
return files.first.path;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implementation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Extract text from PPTX slide XML by finding `<a:t>` content.
|
||||
String _extractTextFromXml(String xml) {
|
||||
final lines = <String>[];
|
||||
// Match <a:t>...</a:t> — handles both <a:t>text</a:t> and <a:t xml:space="preserve">text</a:t>
|
||||
final regex = RegExp(r'<a:t[^>]*>(.*?)</a:t>', dotAll: true);
|
||||
for (final match in regex.allMatches(xml)) {
|
||||
final text = match.group(1) ?? '';
|
||||
if (text.trim().isNotEmpty) {
|
||||
lines.add(text.trim());
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
int _extractSlideNumber(String path) {
|
||||
final match = RegExp(r'slide(\d+)\.xml$').firstMatch(path);
|
||||
if (match != null) return int.parse(match.group(1)!);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Try converting via LibreOffice headless.
|
||||
Future<List<String>> _convertViaLibreOffice(String pptxPath) async {
|
||||
try {
|
||||
// Check if LibreOffice is available
|
||||
final which = await Process.run('which', ['libreoffice']);
|
||||
if (which.exitCode != 0) return [];
|
||||
|
||||
final outDir = await _makeTmpDir('pptx_images');
|
||||
|
||||
final result = await Process.run('libreoffice', [
|
||||
'--headless',
|
||||
'--convert-to',
|
||||
'png',
|
||||
'--outdir',
|
||||
outDir.path,
|
||||
pptxPath,
|
||||
]);
|
||||
|
||||
if (result.exitCode != 0) return [];
|
||||
|
||||
// Collect generated PNGs, sorted by name
|
||||
final pngs = await outDir
|
||||
.list()
|
||||
.where((f) => f.path.endsWith('.png'))
|
||||
.map((f) => f.path)
|
||||
.toList();
|
||||
|
||||
pngs.sort();
|
||||
|
||||
// Move to a persistent temp location so outDir can be cleaned up
|
||||
final persistDir = await _makeTmpDir('pptx_slides');
|
||||
final persistentPaths = <String>[];
|
||||
for (var i = 0; i < pngs.length; i++) {
|
||||
final src = File(pngs[i]);
|
||||
final dst = p.join(persistDir.path, 'slide_${i + 1}.png');
|
||||
await src.copy(dst);
|
||||
persistentPaths.add(dst);
|
||||
}
|
||||
|
||||
// Clean up the LibreOffice output dir
|
||||
try {
|
||||
await outDir.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
|
||||
return persistentPaths;
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate placeholder slide images when LibreOffice is not available.
|
||||
///
|
||||
/// Uses ImageMagick `convert` to create PNG files with slide numbers.
|
||||
/// If ImageMagick is not available, writes minimal 1x1 white PNGs as
|
||||
/// last-resort placeholders.
|
||||
Future<List<String>> _generatePlaceholderImages(String pptxPath) async {
|
||||
// Count slides by unzipping and counting slide XML files
|
||||
final slideCount = await _countSlides(pptxPath);
|
||||
if (slideCount == 0) return [];
|
||||
|
||||
final outDir = await _makeTmpDir('pptx_placeholders');
|
||||
final paths = <String>[];
|
||||
|
||||
// Try ImageMagick
|
||||
final hasConvert = await _hasCommand('convert');
|
||||
|
||||
for (var i = 1; i <= slideCount; i++) {
|
||||
final path = p.join(outDir.path, 'slide_$i.png');
|
||||
if (hasConvert) {
|
||||
await _generateWithImageMagick(path, i, slideCount);
|
||||
} else {
|
||||
await _writeMinimalPng(path);
|
||||
}
|
||||
paths.add(path);
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
Future<int> _countSlides(String pptxPath) async {
|
||||
final tmpDir = await _makeTmpDir('pptx_count');
|
||||
try {
|
||||
await Process.run('unzip', ['-o', '-q', pptxPath, '-d', tmpDir.path]);
|
||||
final slidesDir = Directory(p.join(tmpDir.path, 'ppt', 'slides'));
|
||||
if (!await slidesDir.exists()) return 0;
|
||||
final count = await slidesDir
|
||||
.list()
|
||||
.where((f) => f.path.contains(RegExp(r'slide\d+\.xml$')))
|
||||
.length;
|
||||
return count;
|
||||
} catch (_) {
|
||||
return 0;
|
||||
} finally {
|
||||
try {
|
||||
await tmpDir.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _hasCommand(String cmd) async {
|
||||
try {
|
||||
final result = await Process.run('which', [cmd]);
|
||||
return result.exitCode == 0;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _generateWithImageMagick(
|
||||
String outPath,
|
||||
int slideNum,
|
||||
int total,
|
||||
) async {
|
||||
// Light pastel background with slide number
|
||||
final hue = ((slideNum - 1) * 137) % 360; // golden-angle spacing
|
||||
await Process.run('convert', [
|
||||
'-size',
|
||||
'1920x1080',
|
||||
'xc:hsl($hue, 60%, 92%)',
|
||||
'-gravity',
|
||||
'center',
|
||||
'-pointsize',
|
||||
'120',
|
||||
'-fill',
|
||||
'hsl($hue, 30%, 40%)',
|
||||
'-annotate',
|
||||
'+0+0',
|
||||
'Slide $slideNum / $total',
|
||||
outPath,
|
||||
]);
|
||||
}
|
||||
|
||||
/// Write a minimal valid 1x1 white PNG as an absolute last resort.
|
||||
/// This is a hand-crafted PNG (IHDR + single white pixel IDAT + IEND).
|
||||
Future<void> _writeMinimalPng(String path) async {
|
||||
// Minimal valid 1x1 white PNG
|
||||
const pngBytes = <int>[
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
|
||||
// IHDR chunk
|
||||
0x00, 0x00, 0x00, 0x0D, // length = 13
|
||||
0x49, 0x48, 0x44, 0x52, // "IHDR"
|
||||
0x00, 0x00, 0x00, 0x01, // width = 1
|
||||
0x00, 0x00, 0x00, 0x01, // height = 1
|
||||
0x08, 0x02, // bit depth = 8, color type = 2 (RGB)
|
||||
0x00, 0x00, 0x00, // compression, filter, interlace
|
||||
0x90, 0x77, 0x53, 0xDE, // CRC
|
||||
// IDAT chunk
|
||||
0x00, 0x00, 0x00, 0x0C, // length = 12
|
||||
0x49, 0x44, 0x41, 0x54, // "IDAT"
|
||||
0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00,
|
||||
0x01, 0x01, 0x01, 0x00, // compressed data
|
||||
0x18, 0xDD, 0x8D, 0xB4, // CRC
|
||||
// IEND chunk
|
||||
0x00, 0x00, 0x00, 0x00, // length = 0
|
||||
0x49, 0x45, 0x4E, 0x44, // "IEND"
|
||||
0xAE, 0x42, 0x60, 0x82, // CRC
|
||||
];
|
||||
await File(path).writeAsBytes(pngBytes);
|
||||
}
|
||||
|
||||
Future<Directory> _makeTmpDir(String prefix) async {
|
||||
final base = await getTemporaryDirectory();
|
||||
final dir = Directory(p.join(base.path, '${prefix}_${_uuid.v4()}'));
|
||||
await dir.create(recursive: true);
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
302
lib/services/stroke_rasterizer.dart
Normal file
302
lib/services/stroke_rasterizer.dart
Normal file
@@ -0,0 +1,302 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
130
lib/services/thumbnail_service.dart
Normal file
130
lib/services/thumbnail_service.dart
Normal file
@@ -0,0 +1,130 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:syncfusion_pdfviewer_platform_interface/pdfviewer_platform_interface.dart';
|
||||
|
||||
/// Service for generating, caching, and retrieving page thumbnails.
|
||||
class ThumbnailService {
|
||||
static Future<File> _thumbnailFile(String documentId, int pageIndex) async {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final dir = Directory('${appDir.path}/thumbnails/$documentId');
|
||||
if (!await dir.exists()) await dir.create(recursive: true);
|
||||
return File('${dir.path}/$pageIndex.png');
|
||||
}
|
||||
|
||||
static Future<Uint8List?> _rgbaToPng(
|
||||
Uint8List rgba,
|
||||
int width,
|
||||
int height,
|
||||
) async {
|
||||
final completer = Completer<ui.Image>();
|
||||
ui.decodeImageFromPixels(
|
||||
rgba,
|
||||
width,
|
||||
height,
|
||||
ui.PixelFormat.bgra8888,
|
||||
completer.complete,
|
||||
rowBytes: width * 4,
|
||||
);
|
||||
final image = await completer.future;
|
||||
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
return byteData?.buffer.asUint8List();
|
||||
}
|
||||
|
||||
/// Render a single PDF page to PNG bytes at [maxWidth] pixel width.
|
||||
/// Returns null on failure.
|
||||
static Future<Uint8List?> generate(
|
||||
String filePath,
|
||||
int pageIndex, {
|
||||
int maxWidth = 160,
|
||||
}) async {
|
||||
// Stable, low-collision renderer handle key for this file. Plain
|
||||
// `filePath.hashCode` can collide between different paths; combining it
|
||||
// with the path length and basename (no extra deps beyond `path`)
|
||||
// drastically reduces the chance two distinct files share a handle.
|
||||
final documentId =
|
||||
'thumb-${filePath.hashCode}-${filePath.length}-${p.basename(filePath)}';
|
||||
try {
|
||||
final bytes = await File(filePath).readAsBytes();
|
||||
final pageCountStr = await PdfViewerPlatform.instance
|
||||
.initializePdfRenderer(bytes, documentId);
|
||||
if (pageCountStr == null) return null;
|
||||
|
||||
final pageCount = int.tryParse(pageCountStr);
|
||||
if (pageCount == null || pageIndex < 0 || pageIndex >= pageCount) {
|
||||
await PdfViewerPlatform.instance.closeDocument(documentId);
|
||||
return null;
|
||||
}
|
||||
|
||||
final pagesHeight = await PdfViewerPlatform.instance.getPagesHeight(
|
||||
documentId,
|
||||
);
|
||||
final pagesWidth = await PdfViewerPlatform.instance.getPagesWidth(
|
||||
documentId,
|
||||
);
|
||||
if (pagesHeight == null || pagesWidth == null) {
|
||||
await PdfViewerPlatform.instance.closeDocument(documentId);
|
||||
return null;
|
||||
}
|
||||
|
||||
final pageHeight = pagesHeight[pageIndex] as double;
|
||||
final pageWidth = pagesWidth[pageIndex] as double;
|
||||
final thumbnailHeight = (maxWidth * pageHeight / pageWidth).round();
|
||||
|
||||
final rgba = await PdfViewerPlatform.instance.getPage(
|
||||
pageIndex + 1,
|
||||
maxWidth,
|
||||
thumbnailHeight,
|
||||
documentId,
|
||||
);
|
||||
await PdfViewerPlatform.instance.closeDocument(documentId);
|
||||
|
||||
if (rgba == null) return null;
|
||||
return _rgbaToPng(rgba, maxWidth, thumbnailHeight);
|
||||
} catch (_) {
|
||||
try {
|
||||
await PdfViewerPlatform.instance.closeDocument(documentId);
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist thumbnail bytes to disk and return the file.
|
||||
static Future<File?> cacheThumbnail(
|
||||
String documentId,
|
||||
int pageIndex,
|
||||
Uint8List data,
|
||||
) async {
|
||||
final file = await _thumbnailFile(documentId, pageIndex);
|
||||
await file.writeAsBytes(data);
|
||||
return file;
|
||||
}
|
||||
|
||||
/// Whether a cached thumbnail exists on disk.
|
||||
static Future<bool> hasCached(String documentId, int pageIndex) async {
|
||||
return (await _thumbnailFile(documentId, pageIndex)).exists();
|
||||
}
|
||||
|
||||
/// Return the cached file if it exists, otherwise null.
|
||||
static Future<File?> getCached(String documentId, int pageIndex) async {
|
||||
final file = await _thumbnailFile(documentId, pageIndex);
|
||||
return (await file.exists()) ? file : null;
|
||||
}
|
||||
|
||||
/// Delete all cached thumbnails for [documentId].
|
||||
static Future<void> invalidateAll(String documentId) async {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final dir = Directory('${appDir.path}/thumbnails/$documentId');
|
||||
if (await dir.exists()) await dir.delete(recursive: true);
|
||||
}
|
||||
|
||||
/// Invalidate a single page thumbnail.
|
||||
static Future<void> invalidatePage(String documentId, int pageIndex) async {
|
||||
final file = await _thumbnailFile(documentId, pageIndex);
|
||||
if (await file.exists()) await file.delete();
|
||||
}
|
||||
}
|
||||
102
lib/services/undo_manager.dart
Normal file
102
lib/services/undo_manager.dart
Normal file
@@ -0,0 +1,102 @@
|
||||
import '../models/ink_stroke.dart';
|
||||
|
||||
/// Manages undo/redo state for ink strokes.
|
||||
///
|
||||
/// Each action records a stroke that was added or removed.
|
||||
/// [undo] returns the inverse of the last action (remove if add, add if remove).
|
||||
/// [redo] re-applies the undone action.
|
||||
class UndoManager {
|
||||
final List<_UndoAction> _undoStack = [];
|
||||
final List<_UndoAction> _redoStack = [];
|
||||
final List<InkStroke> _strokes = [];
|
||||
|
||||
/// The current list of strokes (read-only view).
|
||||
List<InkStroke> get currentStrokes => List.unmodifiable(_strokes);
|
||||
|
||||
bool get canUndo => _undoStack.isNotEmpty;
|
||||
bool get canRedo => _redoStack.isNotEmpty;
|
||||
|
||||
/// Records that a new stroke was added to the canvas.
|
||||
void addStroke(InkStroke stroke) {
|
||||
_strokes.add(stroke);
|
||||
_undoStack.add(_UndoAction(type: _ActionType.add, stroke: stroke));
|
||||
_redoStack.clear();
|
||||
}
|
||||
|
||||
/// Records that a stroke was removed from the canvas.
|
||||
/// Also handles partial-eraser replacements: removes [stroke] and adds
|
||||
/// [replacements] (which may be empty if fully erased, or 1-2 sub-strokes).
|
||||
void removeStroke(
|
||||
InkStroke stroke, {
|
||||
List<InkStroke> replacements = const [],
|
||||
}) {
|
||||
_strokes.removeWhere((s) => s.id == stroke.id);
|
||||
_strokes.addAll(replacements);
|
||||
_undoStack.add(
|
||||
_UndoAction(
|
||||
type: _ActionType.remove,
|
||||
stroke: stroke,
|
||||
replacements: replacements,
|
||||
),
|
||||
);
|
||||
_redoStack.clear();
|
||||
}
|
||||
|
||||
/// Undoes the last action. Returns the stroke that was affected and needs
|
||||
/// to be reversed on the canvas, or `null` if nothing to undo.
|
||||
///
|
||||
/// For add actions: the stroke should be removed from the canvas.
|
||||
/// For remove actions: the stroke (and its replacements) should be restored.
|
||||
InkStroke? undo() {
|
||||
if (_undoStack.isEmpty) return null;
|
||||
|
||||
final action = _undoStack.removeLast();
|
||||
_redoStack.add(action);
|
||||
|
||||
switch (action.type) {
|
||||
case _ActionType.add:
|
||||
_strokes.removeWhere((s) => s.id == action.stroke.id);
|
||||
return action.stroke;
|
||||
case _ActionType.remove:
|
||||
// Remove the replacements that were added during the original remove
|
||||
for (final r in action.replacements) {
|
||||
_strokes.removeWhere((s) => s.id == r.id);
|
||||
}
|
||||
_strokes.add(action.stroke);
|
||||
return action.stroke;
|
||||
}
|
||||
}
|
||||
|
||||
/// Redoes the last undone action. Returns the stroke that was affected, or
|
||||
/// `null` if nothing to redo.
|
||||
InkStroke? redo() {
|
||||
if (_redoStack.isEmpty) return null;
|
||||
|
||||
final action = _redoStack.removeLast();
|
||||
_undoStack.add(action);
|
||||
|
||||
switch (action.type) {
|
||||
case _ActionType.add:
|
||||
_strokes.add(action.stroke);
|
||||
return action.stroke;
|
||||
case _ActionType.remove:
|
||||
_strokes.removeWhere((s) => s.id == action.stroke.id);
|
||||
_strokes.addAll(action.replacements);
|
||||
return action.stroke;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum _ActionType { add, remove }
|
||||
|
||||
class _UndoAction {
|
||||
final _ActionType type;
|
||||
final InkStroke stroke;
|
||||
final List<InkStroke> replacements;
|
||||
|
||||
_UndoAction({
|
||||
required this.type,
|
||||
required this.stroke,
|
||||
this.replacements = const [],
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user