import 'dart:io'; import 'dart:math' as math; import 'package:archive/archive.dart'; import 'package:path/path.dart' as p; import 'package:xml/xml.dart'; import '../../diagnostics/badnote_log.dart'; import 'office_document.dart'; /// Native PPTX parser — no LibreOffice. Reads OOXML zip + slide XML. class PptxParser { /// EMUs per English inch (Office drawing unit). static const double _emuPerInch = 914400; static const double _defaultDpi = 96; Future parse(String pptxPath, {Directory? cacheDir, String? cacheDirPath}) async { BadNoteLog.instance.info(LogSubsystem.office, 'pptx_parse_start', fields: { 'path': pptxPath, }); final bytes = await File(pptxPath).readAsBytes(); final archive = ZipDecoder().decodeBytes(bytes); Directory out = cacheDir ?? (cacheDirPath != null ? Directory(cacheDirPath) : Directory(p.join(Directory.systemTemp.path, 'badnote_pptx_${DateTime.now().millisecondsSinceEpoch}'))); if (!await out.exists()) await out.create(recursive: true); // Default slide size (widescreen 13.333" x 7.5") in pixels at 96dpi. double slideW = 13.333 * _defaultDpi; double slideH = 7.5 * _defaultDpi; final sldSz = _file(archive, 'ppt/presentation.xml'); if (sldSz != null) { try { final doc = XmlDocument.parse(sldSz); final candidates = [ ...doc.findAllElements('sldSz', namespace: '*'), ...doc.findAllElements('p:sldSz'), ]; final el = candidates.isEmpty ? null : candidates.first; if (el != null) { final cx = int.tryParse(el.getAttribute('cx') ?? '') ?? 0; final cy = int.tryParse(el.getAttribute('cy') ?? '') ?? 0; if (cx > 0 && cy > 0) { slideW = cx / _emuPerInch * _defaultDpi; slideH = cy / _emuPerInch * _defaultDpi; } } } catch (_) {} } final slideFiles = archive.files .where((f) => f.name.startsWith('ppt/slides/slide') && f.name.endsWith('.xml') && !f.name.contains('_rels')) .toList() ..sort((a, b) => _slideNum(a.name).compareTo(_slideNum(b.name))); final slides = []; for (var i = 0; i < slideFiles.length; i++) { final file = slideFiles[i]; final xml = _decode(file); if (xml == null) continue; final runs = []; final images = []; final textBuf = StringBuffer(); try { final doc = XmlDocument.parse(xml); for (final t in doc.findAllElements('a:t')) { final text = t.innerText; if (text.isEmpty) continue; textBuf.writeln(text); // Approximate: stack text vertically when no transform is parsed. runs.add(OfficeTextRun( text: text, x: 48, y: 48.0 + runs.length * 28, width: math.max(120, slideW - 96), height: 28, )); } // Extract images referenced by this slide's relationships. final relsName = 'ppt/slides/_rels/slide${_slideNum(file.name)}.xml.rels'; final relsXml = _file(archive, relsName); if (relsXml != null) { final relsDoc = XmlDocument.parse(relsXml); for (final rel in relsDoc.findAllElements('Relationship')) { final type = rel.getAttribute('Type') ?? ''; if (!type.contains('image')) continue; var target = rel.getAttribute('Target') ?? ''; if (target.isEmpty) continue; // Targets are relative to ppt/slides/ → often ../media/image1.png final mediaPath = p.normalize(p.join('ppt/slides', target)); final media = _archiveFile(archive, mediaPath) ?? _archiveFile(archive, target.replaceFirst('../', 'ppt/')); if (media == null) continue; final content = media.content; final outPath = p.join(out.path, p.basename(mediaPath)); await File(outPath).writeAsBytes(content); images.add(OfficeImage( bytesPath: outPath, x: 80, y: slideH * 0.35, width: slideW * 0.4, height: slideH * 0.4, )); } } } catch (e) { BadNoteLog.instance.warn(LogSubsystem.office, 'slide_parse_error', fields: { 'slide': file.name, 'error': '$e', }); } slides.add(OfficeSlide( index: i, width: slideW, height: slideH, runs: runs, images: images, plainText: textBuf.toString().trim(), )); } BadNoteLog.instance.info(LogSubsystem.office, 'pptx_parse_done', fields: { 'slides': slides.length, }); return ParsedPptx(sourcePath: pptxPath, slides: slides); } Future extractText(String pptxPath) async { final parsed = await parse(pptxPath); return parsed.allText; } static int _slideNum(String name) { final m = RegExp(r'slide(\d+)\.xml').firstMatch(name); return int.tryParse(m?.group(1) ?? '') ?? 0; } static String? _file(Archive archive, String name) { final f = _archiveFile(archive, name); return _decode(f); } static ArchiveFile? _archiveFile(Archive archive, String name) { final normalized = name.replaceAll('\\', '/'); for (final f in archive.files) { if (f.name.replaceAll('\\', '/') == normalized) return f; } return null; } static String? _decode(ArchiveFile? file) { if (file == null) return null; return String.fromCharCodes(file.content); } }