121 lines
4.4 KiB
Dart
121 lines
4.4 KiB
Dart
|
|
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);
|
||
|
|
}
|
||
|
|
}
|