feat: unified shell, diagnostics pack, native Office, sticky board
All checks were successful
CI / Windows build (push) Successful in 14m22s
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>
This commit is contained in:
164
lib/services/office/pptx_parser.dart
Normal file
164
lib/services/office/pptx_parser.dart
Normal file
@@ -0,0 +1,164 @@
|
||||
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<ParsedPptx> 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 = <OfficeSlide>[];
|
||||
for (var i = 0; i < slideFiles.length; i++) {
|
||||
final file = slideFiles[i];
|
||||
final xml = _decode(file);
|
||||
if (xml == null) continue;
|
||||
final runs = <OfficeTextRun>[];
|
||||
final images = <OfficeImage>[];
|
||||
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<String> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user