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 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 = {}; 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) continue; final outPath = p.join(out.path, p.basename(mediaPath)); await File(outPath).writeAsBytes(file!.content as List); media[id] = outPath; } } catch (_) {} } final blocks = []; 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 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); } }