All checks were successful
CI / Windows build (push) Successful in 14m22s
Make Surface remote debugging and classroom workflows viable: always-on structured logs with one-click zip export, a single AppShell chrome, OOXML PPTX/DOCX annotation without LibreOffice, and a first-class sticky board. Also drop spike/legacy ink widgets and tighten pen feel (predictor, PenInfoHistory, page-tile layer). Co-authored-by: Cursor <cursoragent@cursor.com>
365 lines
11 KiB
Dart
365 lines
11 KiB
Dart
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';
|
|
|
|
import 'office/office_document.dart';
|
|
import 'office/pptx_parser.dart';
|
|
|
|
/// Service for processing PPTX files: text extraction, structured slide parse,
|
|
/// optional LibreOffice image conversion, and file picking.
|
|
///
|
|
/// **Native OOXML parsing is primary** ([PptxParser] via `package:archive` +
|
|
/// `package:xml`). LibreOffice (`soffice`) is an optional fallback ONLY when
|
|
/// the native path fails AND the binary is present on the machine.
|
|
class PptxService {
|
|
static const _uuid = Uuid();
|
|
|
|
final PptxParser _parser;
|
|
|
|
PptxService({PptxParser? parser}) : _parser = parser ?? PptxParser();
|
|
|
|
/// Extract all text content from a PPTX file.
|
|
///
|
|
/// Prefers the native [PptxParser]. Falls back to a legacy unzip+regex path
|
|
/// only if native parsing throws.
|
|
Future<String> extractText(String pptxPath) async {
|
|
try {
|
|
final parsed = await _parser.parse(pptxPath);
|
|
return parsed.plainText;
|
|
} catch (_) {
|
|
return _extractTextLegacy(pptxPath);
|
|
}
|
|
}
|
|
|
|
/// Parse PPTX into structured slides (text runs with approximate positions,
|
|
/// embedded images extracted to a cache dir). Native-only — no LibreOffice.
|
|
Future<ParsedPptx> parseSlides(String pptxPath, {String? cacheDir}) {
|
|
return _parser.parse(pptxPath, cacheDirPath: cacheDir);
|
|
}
|
|
|
|
/// Convert PPTX slides to a list of image file paths (legacy PenSlideScreen).
|
|
///
|
|
/// Prefer [parseSlides] for native text+image rendering. This method only
|
|
/// invokes LibreOffice when native parse fails AND `soffice` exists;
|
|
/// otherwise it emits placeholder PNGs from the native slide count.
|
|
Future<List<String>> convertToImages(String pptxPath) async {
|
|
try {
|
|
final parsed = await _parser.parse(pptxPath);
|
|
// Native succeeded — do NOT call LibreOffice; placeholders for callers
|
|
// that still expect image paths. OfficeDocumentScreen uses [parseSlides].
|
|
if (parsed.slides.isEmpty) return [];
|
|
return _generatePlaceholderImages(pptxPath);
|
|
} catch (_) {
|
|
// Native failed — LibreOffice fallback ONLY if soffice exists.
|
|
final soffice = await resolveSoffice();
|
|
if (soffice != null) {
|
|
final loImages = await _convertViaLibreOffice(pptxPath);
|
|
if (loImages.isNotEmpty) return loImages;
|
|
}
|
|
return _generatePlaceholderImages(pptxPath);
|
|
}
|
|
}
|
|
|
|
/// Open a file picker dialog and return the selected PPTX path, or null.
|
|
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;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Legacy / LibreOffice helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
Future<String> _extractTextLegacy(String pptxPath) async {
|
|
final tmpDir = await _makeTmpDir('pptx_text');
|
|
|
|
try {
|
|
final unzipResult = await Process.run('unzip', [
|
|
'-o',
|
|
'-q',
|
|
pptxPath,
|
|
'-d',
|
|
tmpDir.path,
|
|
]);
|
|
|
|
if (unzipResult.exitCode != 0) {
|
|
return '';
|
|
}
|
|
|
|
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();
|
|
|
|
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 {
|
|
try {
|
|
await tmpDir.delete(recursive: true);
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
String _extractTextFromXml(String xml) {
|
|
final lines = <String>[];
|
|
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;
|
|
}
|
|
|
|
/// LibreOffice fallback — only when native fails or caller wants PNGs and
|
|
/// soffice is installed.
|
|
Future<List<String>> _convertViaLibreOffice(String pptxPath) async {
|
|
try {
|
|
final soffice = await resolveSoffice();
|
|
if (soffice == null) return [];
|
|
|
|
final outDir = await _makeTmpDir('pptx_images');
|
|
|
|
final result = await Process.run(soffice, [
|
|
'--headless',
|
|
'--convert-to',
|
|
'png',
|
|
'--outdir',
|
|
outDir.path,
|
|
pptxPath,
|
|
]);
|
|
|
|
if (result.exitCode != 0) return [];
|
|
|
|
final pngs = await outDir
|
|
.list()
|
|
.where((f) => f.path.endsWith('.png'))
|
|
.map((f) => f.path)
|
|
.toList();
|
|
|
|
pngs.sort();
|
|
|
|
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);
|
|
}
|
|
|
|
try {
|
|
await outDir.delete(recursive: true);
|
|
} catch (_) {}
|
|
|
|
return persistentPaths;
|
|
} catch (_) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/// Resolve the LibreOffice CLI binary, or null when unavailable.
|
|
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;
|
|
}
|
|
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.
|
|
/// Optional — native [DocxParser] is preferred for opening in BadNote.
|
|
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;
|
|
}
|
|
}
|
|
|
|
Future<List<String>> _generatePlaceholderImages(String pptxPath) async {
|
|
final slideCount = await _countSlidesNative(pptxPath);
|
|
if (slideCount == 0) return [];
|
|
|
|
final outDir = await _makeTmpDir('pptx_placeholders');
|
|
final paths = <String>[];
|
|
|
|
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> _countSlidesNative(String pptxPath) async {
|
|
try {
|
|
final parsed = await _parser.parse(pptxPath);
|
|
return parsed.slides.length;
|
|
} catch (_) {
|
|
return _countSlidesUnzip(pptxPath);
|
|
}
|
|
}
|
|
|
|
Future<int> _countSlidesUnzip(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 {
|
|
final hue = ((slideNum - 1) * 137) % 360;
|
|
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,
|
|
]);
|
|
}
|
|
|
|
Future<void> _writeMinimalPng(String path) async {
|
|
const pngBytes = <int>[
|
|
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
|
|
0x00, 0x00, 0x00, 0x0D,
|
|
0x49, 0x48, 0x44, 0x52,
|
|
0x00, 0x00, 0x00, 0x01,
|
|
0x00, 0x00, 0x00, 0x01,
|
|
0x08, 0x02,
|
|
0x00, 0x00, 0x00,
|
|
0x90, 0x77, 0x53, 0xDE,
|
|
0x00, 0x00, 0x00, 0x0C,
|
|
0x49, 0x44, 0x41, 0x54,
|
|
0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00,
|
|
0x01, 0x01, 0x01, 0x00,
|
|
0x18, 0xDD, 0x8D, 0xB4,
|
|
0x00, 0x00, 0x00, 0x00,
|
|
0x49, 0x45, 0x4E, 0x44,
|
|
0xAE, 0x42, 0x60, 0x82,
|
|
];
|
|
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;
|
|
}
|
|
}
|