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` `` 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 `` (ASCII text) elements within `` (run) or /// `` (paragraph) nodes. Future 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> 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 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 `` content. String _extractTextFromXml(String xml) { final lines = []; // Match ... — handles both text and text final regex = RegExp(r']*>(.*?)', 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> _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 []; 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 []; // 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 = []; 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 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 _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 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; } } /// 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> _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 = []; // 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 _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 _hasCommand(String cmd) async { try { final result = await Process.run('which', [cmd]); return result.exitCode == 0; } catch (_) { return false; } } Future _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 _writeMinimalPng(String path) async { // Minimal valid 1x1 white PNG const pngBytes = [ 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 _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; } }