feat: unified shell, diagnostics pack, native Office, sticky board
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:
2026-08-05 17:55:27 +08:00
parent 3cabc7e074
commit d346cc2670
49 changed files with 2883 additions and 2515 deletions

View 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);
}
}

View 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');
}

View 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);
}
}