Files
BadNote/lib/services/pptx_service.dart

363 lines
11 KiB
Dart
Raw Normal View History

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
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 {
// Resolve the LibreOffice binary across platforms. On Windows the binary
// is `soffice.exe` (not on PATH for `which`, which is POSIX-only), so we
// probe the standard install locations as well — see resolveSoffice().
final soffice = await resolveSoffice();
if (soffice == null) return [];
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
final outDir = await _makeTmpDir('pptx_images');
final result = await Process.run(soffice, [
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
'--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 [];
}
}
/// Resolve the LibreOffice CLI binary for the current platform, or null when
/// it cannot be found.
///
/// Order:
/// 1. Windows: `soffice.exe` at the standard install paths
/// (`C:\Program Files\LibreOffice\program\soffice.exe`, and the 32-bit
/// `Program Files (x86)` variant). The POSIX `which` can't find these.
/// 2. POSIX: `which libreoffice`, then `which soffice` (macOS/some distros).
/// 3. Otherwise null → callers fall back gracefully.
static Future<String?> resolveSoffice() async {
if (Platform.isWindows) {
const candidates = [
r'C:\Program Files\LibreOffice\program\soffice.exe',
r'C:\Program Files (x86)\LibreOffice\program\soffice.exe',
];
for (final c in candidates) {
if (await File(c).exists()) return c;
}
// Last resort: maybe soffice is on PATH (e.g. a portable install).
if (await _whichOk('soffice')) return 'soffice';
return null;
}
if (await _whichOk('libreoffice')) return 'libreoffice';
if (await _whichOk('soffice')) return 'soffice';
return null;
}
static Future<bool> _whichOk(String cmd) async {
try {
final r = await Process.run('which', [cmd]);
return r.exitCode == 0;
} catch (_) {
return false;
}
}
/// Convert an arbitrary office document (e.g. DOCX) to PDF via LibreOffice
/// headless, writing the PDF NEXT TO [sourcePath] (same folder, same
/// basename + `.pdf`). Returns the PDF path on success, or null when
/// LibreOffice is unavailable or the conversion fails — callers MUST handle
/// null and surface a friendly message rather than crash.
Future<String?> convertToPdf(String sourcePath) async {
final soffice = await resolveSoffice();
if (soffice == null) return null;
final outDir = p.dirname(sourcePath);
try {
final result = await Process.run(soffice, [
'--headless',
'--convert-to',
'pdf',
'--outdir',
outDir,
sourcePath,
]);
if (result.exitCode != 0) return null;
final pdfPath = p.join(
outDir,
'${p.basenameWithoutExtension(sourcePath)}.pdf',
);
if (await File(pdfPath).exists()) return pdfPath;
return null;
} catch (_) {
return null;
}
}
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
/// 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;
}
}