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:
@@ -1,5 +1,6 @@
|
||||
import 'dart:io';
|
||||
|
||||
import '../diagnostics/badnote_log.dart';
|
||||
import '../editor/persistence/sidecar_repository.dart';
|
||||
import '../models/note.dart';
|
||||
import '../models/pen_tool.dart';
|
||||
@@ -16,6 +17,10 @@ class OcrService {
|
||||
/// working. [note.id] is the synthetic note path, which is exactly the
|
||||
/// `sourceFilePath` the editor opened its [SidecarRepository] with.
|
||||
Future<void> processNote(Note note) async {
|
||||
BadNoteLog.instance.info(LogSubsystem.diag, 'ocr_start', fields: {
|
||||
'note': note.id,
|
||||
'strokes': note.strokes.length,
|
||||
});
|
||||
final parts = <String>[];
|
||||
|
||||
for (final stroke in note.strokes) {
|
||||
@@ -41,12 +46,18 @@ class OcrService {
|
||||
final recognized = await OcrEngine.recognizeImage(png);
|
||||
if (recognized != null && recognized.isNotEmpty) {
|
||||
parts.add(recognized);
|
||||
BadNoteLog.instance.info(LogSubsystem.diag, 'ocr_handwriting', fields: {
|
||||
'chars': recognized.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final combined = parts.join(' ').trim();
|
||||
if (combined.isEmpty) return;
|
||||
if (combined.isEmpty) {
|
||||
BadNoteLog.instance.debug(LogSubsystem.diag, 'ocr_empty');
|
||||
return;
|
||||
}
|
||||
|
||||
// Persist into the note's sidecar so the vault-scan search index finds it.
|
||||
// Prefer the editor's already-open repo (same in-memory sidecar — no race);
|
||||
|
||||
120
lib/services/office/docx_parser.dart
Normal file
120
lib/services/office/docx_parser.dart
Normal file
@@ -0,0 +1,120 @@
|
||||
import 'dart:io';
|
||||
|
||||
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 DOCX parser — block-level structure for BadNote annotation pages.
|
||||
class DocxParser {
|
||||
Future<ParsedDocx> parse(String docxPath, {Directory? cacheDir}) async {
|
||||
BadNoteLog.instance.info(LogSubsystem.office, 'docx_parse_start', fields: {
|
||||
'path': docxPath,
|
||||
});
|
||||
final bytes = await File(docxPath).readAsBytes();
|
||||
final archive = ZipDecoder().decodeBytes(bytes);
|
||||
Directory out = cacheDir ??
|
||||
Directory(p.join(Directory.systemTemp.path, 'badnote_docx_${DateTime.now().millisecondsSinceEpoch}'));
|
||||
if (!await out.exists()) await out.create(recursive: true);
|
||||
|
||||
final documentXml = _decode(_find(archive, 'word/document.xml'));
|
||||
if (documentXml == null) {
|
||||
return ParsedDocx(sourcePath: docxPath, blocks: const []);
|
||||
}
|
||||
|
||||
// Media map from relationships.
|
||||
final media = <String, String>{};
|
||||
final rels = _decode(_find(archive, 'word/_rels/document.xml.rels'));
|
||||
if (rels != null) {
|
||||
try {
|
||||
final relDoc = XmlDocument.parse(rels);
|
||||
for (final rel in relDoc.findAllElements('Relationship')) {
|
||||
final id = rel.getAttribute('Id');
|
||||
final type = rel.getAttribute('Type') ?? '';
|
||||
final target = rel.getAttribute('Target') ?? '';
|
||||
if (id == null || !type.contains('image') || target.isEmpty) continue;
|
||||
final mediaPath = p.normalize(p.join('word', target));
|
||||
final file = _find(archive, mediaPath);
|
||||
if (file?.content is! List<int>) continue;
|
||||
final outPath = p.join(out.path, p.basename(mediaPath));
|
||||
await File(outPath).writeAsBytes(file!.content as List<int>);
|
||||
media[id] = outPath;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
final blocks = <DocBlock>[];
|
||||
try {
|
||||
final doc = XmlDocument.parse(documentXml);
|
||||
for (final pEl in doc.findAllElements('w:p')) {
|
||||
final style = pEl
|
||||
.findElements('w:pPr')
|
||||
.expand((e) => e.findElements('w:pStyle'))
|
||||
.map((e) => e.getAttribute('w:val') ?? '')
|
||||
.firstWhere((s) => s.isNotEmpty, orElse: () => '');
|
||||
final texts = pEl.findAllElements('w:t').map((t) => t.innerText).join();
|
||||
final blips = pEl.findAllElements('a:blip');
|
||||
for (final blip in blips) {
|
||||
final embed = blip.getAttribute('r:embed') ?? blip.getAttribute('embed');
|
||||
if (embed != null && media[embed] != null) {
|
||||
blocks.add(DocBlock(
|
||||
type: DocBlockType.image,
|
||||
text: '',
|
||||
imagePath: media[embed],
|
||||
));
|
||||
}
|
||||
}
|
||||
if (texts.trim().isEmpty && blips.isEmpty) continue;
|
||||
if (texts.trim().isEmpty) continue;
|
||||
final isHeading = style.toLowerCase().startsWith('heading') ||
|
||||
RegExp(r'^Heading\s*\d', caseSensitive: false).hasMatch(style);
|
||||
final level = int.tryParse(RegExp(r'(\d+)').firstMatch(style)?.group(1) ?? '') ??
|
||||
(isHeading ? 1 : 0);
|
||||
blocks.add(DocBlock(
|
||||
type: isHeading ? DocBlockType.heading : DocBlockType.paragraph,
|
||||
text: texts,
|
||||
level: level,
|
||||
));
|
||||
}
|
||||
// Tables
|
||||
for (final row in doc.findAllElements('w:tr')) {
|
||||
final cells = row
|
||||
.findElements('w:tc')
|
||||
.map((tc) => tc.findAllElements('w:t').map((t) => t.innerText).join())
|
||||
.where((s) => s.trim().isNotEmpty)
|
||||
.join(' | ');
|
||||
if (cells.isEmpty) continue;
|
||||
blocks.add(DocBlock(type: DocBlockType.tableRow, text: cells));
|
||||
}
|
||||
} catch (e) {
|
||||
BadNoteLog.instance.warn(LogSubsystem.office, 'docx_parse_error', fields: {
|
||||
'error': '$e',
|
||||
});
|
||||
}
|
||||
|
||||
BadNoteLog.instance.info(LogSubsystem.office, 'docx_parse_done', fields: {
|
||||
'blocks': blocks.length,
|
||||
});
|
||||
return ParsedDocx(sourcePath: docxPath, blocks: blocks);
|
||||
}
|
||||
|
||||
Future<String> extractText(String docxPath) async {
|
||||
final parsed = await parse(docxPath);
|
||||
return parsed.plainText;
|
||||
}
|
||||
|
||||
static ArchiveFile? _find(Archive archive, String name) {
|
||||
final n = name.replaceAll('\\', '/');
|
||||
for (final f in archive.files) {
|
||||
if (f.name.replaceAll('\\', '/') == n) return f;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? _decode(ArchiveFile? file) {
|
||||
if (file == null) return null;
|
||||
return String.fromCharCodes(file.content);
|
||||
}
|
||||
}
|
||||
98
lib/services/office/office_document.dart
Normal file
98
lib/services/office/office_document.dart
Normal file
@@ -0,0 +1,98 @@
|
||||
/// Shared OOXML document models for native Word/PPT parsing.
|
||||
library;
|
||||
|
||||
class OfficeTextRun {
|
||||
const OfficeTextRun({
|
||||
required this.text,
|
||||
this.x = 0,
|
||||
this.y = 0,
|
||||
this.width = 0,
|
||||
this.height = 0,
|
||||
this.fontSize = 18,
|
||||
});
|
||||
|
||||
final String text;
|
||||
final double x;
|
||||
final double y;
|
||||
final double width;
|
||||
final double height;
|
||||
final double fontSize;
|
||||
}
|
||||
|
||||
class OfficeImage {
|
||||
const OfficeImage({
|
||||
required this.bytesPath,
|
||||
this.x = 0,
|
||||
this.y = 0,
|
||||
this.width = 0,
|
||||
this.height = 0,
|
||||
});
|
||||
|
||||
final String bytesPath;
|
||||
final double x;
|
||||
final double y;
|
||||
final double width;
|
||||
final double height;
|
||||
}
|
||||
|
||||
class OfficeSlide {
|
||||
const OfficeSlide({
|
||||
required this.index,
|
||||
required this.width,
|
||||
required this.height,
|
||||
this.runs = const [],
|
||||
this.images = const [],
|
||||
this.plainText = '',
|
||||
});
|
||||
|
||||
final int index;
|
||||
final double width;
|
||||
final double height;
|
||||
final List<OfficeTextRun> runs;
|
||||
final List<OfficeImage> images;
|
||||
final String plainText;
|
||||
}
|
||||
|
||||
class ParsedPptx {
|
||||
const ParsedPptx({
|
||||
required this.sourcePath,
|
||||
required this.slides,
|
||||
});
|
||||
|
||||
final String sourcePath;
|
||||
final List<OfficeSlide> slides;
|
||||
|
||||
String get allText =>
|
||||
slides.map((s) => '--- Slide ${s.index + 1} ---\n${s.plainText}').join('\n\n');
|
||||
|
||||
/// Alias used by [PptxService.extractText].
|
||||
String get plainText => allText;
|
||||
}
|
||||
|
||||
enum DocBlockType { heading, paragraph, tableRow, image }
|
||||
|
||||
class DocBlock {
|
||||
const DocBlock({
|
||||
required this.type,
|
||||
required this.text,
|
||||
this.level = 0,
|
||||
this.imagePath,
|
||||
});
|
||||
|
||||
final DocBlockType type;
|
||||
final String text;
|
||||
final int level;
|
||||
final String? imagePath;
|
||||
}
|
||||
|
||||
class ParsedDocx {
|
||||
const ParsedDocx({
|
||||
required this.sourcePath,
|
||||
required this.blocks,
|
||||
});
|
||||
|
||||
final String sourcePath;
|
||||
final List<DocBlock> blocks;
|
||||
|
||||
String get plainText => blocks.map((b) => b.text).where((t) => t.isNotEmpty).join('\n');
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -5,24 +5,83 @@ 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.
|
||||
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.
|
||||
///
|
||||
/// 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.
|
||||
/// **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.
|
||||
///
|
||||
/// 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.
|
||||
/// 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 {
|
||||
// Unzip the PPTX
|
||||
final unzipResult = await Process.run('unzip', [
|
||||
'-o',
|
||||
'-q',
|
||||
@@ -35,7 +94,6 @@ class PptxService {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Find all slide XML files
|
||||
final slidesDir = Directory(p.join(tmpDir.path, 'ppt', 'slides'));
|
||||
if (!await slidesDir.exists()) return '';
|
||||
|
||||
@@ -44,7 +102,6 @@ class PptxService {
|
||||
.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);
|
||||
@@ -67,47 +124,14 @@ class PptxService {
|
||||
} 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) ?? '';
|
||||
@@ -124,12 +148,10 @@ class PptxService {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Try converting via LibreOffice headless.
|
||||
/// LibreOffice fallback — only when native fails or caller wants PNGs and
|
||||
/// soffice is installed.
|
||||
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 [];
|
||||
|
||||
@@ -146,7 +168,6 @@ class PptxService {
|
||||
|
||||
if (result.exitCode != 0) return [];
|
||||
|
||||
// Collect generated PNGs, sorted by name
|
||||
final pngs = await outDir
|
||||
.list()
|
||||
.where((f) => f.path.endsWith('.png'))
|
||||
@@ -155,7 +176,6 @@ class PptxService {
|
||||
|
||||
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++) {
|
||||
@@ -165,7 +185,6 @@ class PptxService {
|
||||
persistentPaths.add(dst);
|
||||
}
|
||||
|
||||
// Clean up the LibreOffice output dir
|
||||
try {
|
||||
await outDir.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
@@ -176,15 +195,7 @@ class PptxService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Resolve the LibreOffice CLI binary, or null when unavailable.
|
||||
static Future<String?> resolveSoffice() async {
|
||||
if (Platform.isWindows) {
|
||||
const candidates = [
|
||||
@@ -194,7 +205,6 @@ class PptxService {
|
||||
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;
|
||||
}
|
||||
@@ -212,11 +222,8 @@ class PptxService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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;
|
||||
@@ -243,20 +250,13 @@ class PptxService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
final slideCount = await _countSlidesNative(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++) {
|
||||
@@ -272,7 +272,16 @@ class PptxService {
|
||||
return paths;
|
||||
}
|
||||
|
||||
Future<int> _countSlides(String pptxPath) async {
|
||||
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]);
|
||||
@@ -306,8 +315,7 @@ class PptxService {
|
||||
int slideNum,
|
||||
int total,
|
||||
) async {
|
||||
// Light pastel background with slide number
|
||||
final hue = ((slideNum - 1) * 137) % 360; // golden-angle spacing
|
||||
final hue = ((slideNum - 1) * 137) % 360;
|
||||
await Process.run('convert', [
|
||||
'-size',
|
||||
'1920x1080',
|
||||
@@ -325,30 +333,24 @@ class PptxService {
|
||||
]);
|
||||
}
|
||||
|
||||
/// 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"
|
||||
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, // 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
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user