Files
pyramid/lib/features/chat/document/document_viewer_archive.dart
T
Bernd Steckmeister f9979e4f32 fix: beim Gott-Datei-Split verlorene Erklärkommentare wiederhergestellt
Qualitäts-Review-Befund (a): Alle 6 part/part-of-Aufteilungen hatten Code
vollständig übernommen (Zeilen-Multiset-Vergleich Original vs. heutiger
Stand: keine Code-Zeile verloren, keine hinzugefügt), ABER erklärende
Kommentare über den Klassen gingen verloren (24 Blöcke, u. a. die
HTML-Subset-Doku über _MatrixHtmlText, die Selbst-Aussperr-Warnung über
updatePowerLevelsSafely, TURN-Verbrauchskarte, PDF-Cache/Viewer-Doku).
Alle an den richtigen Klassen wiederhergestellt; reine Trennlinien und
ALL-CAPS-Abschnittslabels bewusst nicht (Gott-Datei-Navigation, durch
die neuen Dateinamen ersetzt).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:37:48 +02:00

323 lines
11 KiB
Dart

part of '../document_viewer.dart';
class _ArchiveViewer extends StatefulWidget {
final Uint8List bytes;
final String filename;
final PyramidTheme pt;
const _ArchiveViewer({required this.bytes, required this.filename, required this.pt});
@override
State<_ArchiveViewer> createState() => _ArchiveViewerState();
}
class _ArchiveViewerState extends State<_ArchiveViewer> {
List<_ArchiveEntry>? _entries;
String? _error;
@override
void initState() {
super.initState();
_decode();
}
void _decode() {
try {
final ext = widget.filename.contains('.') ? widget.filename.split('.').last.toLowerCase() : '';
Archive archive;
if (ext == 'tar') {
archive = TarDecoder().decodeBytes(widget.bytes);
} else if (ext == 'gz' || ext == 'tgz') {
final inner = GZipDecoder().decodeBytes(widget.bytes);
archive = TarDecoder().decodeBytes(inner);
} else {
archive = ZipDecoder().decodeBytes(widget.bytes);
}
final entries = archive.files.map((f) => _ArchiveEntry(name: f.name, size: f.size, isDir: !f.isFile)).toList()
..sort((a, b) {
if (a.isDir != b.isDir) return a.isDir ? -1 : 1;
return a.name.compareTo(b.name);
});
setState(() => _entries = entries);
} catch (e) {
setState(() => _error = e.toString());
}
}
String _fmtSize(int b) {
if (b < 1024) return '$b B';
if (b < 1024 * 1024) return '${(b / 1024).toStringAsFixed(1)} KB';
return '${(b / (1024 * 1024)).toStringAsFixed(1)} MB';
}
@override
Widget build(BuildContext context) {
if (_error != null) return _ErrorView(message: _error!, pt: widget.pt);
if (_entries == null) return Center(child: CircularProgressIndicator(strokeWidth: 2, color: widget.pt.accent));
final pt = widget.pt;
final entries = _entries!;
final totalBytes = entries.fold<int>(0, (s, e) => s + (e.isDir ? 0 : e.size));
return Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: pt.bg1,
child: Row(
children: [
Icon(Icons.folder_zip_outlined, size: 15, color: pt.fgDim),
const SizedBox(width: 8),
Text('${entries.length} Einträge · ${_fmtSize(totalBytes)} unkomprimiert', style: TextStyle(color: pt.fgDim, fontSize: 12)),
],
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: entries.length,
itemBuilder: (ctx, i) {
final e = entries[i];
return ListTile(
dense: true,
visualDensity: VisualDensity.compact,
leading: Icon(e.isDir ? Icons.folder_outlined : _fileIcon(e.name), size: 18, color: e.isDir ? pt.accent : pt.fgDim),
title: Text(e.name, style: TextStyle(color: pt.fg, fontSize: 13), overflow: TextOverflow.ellipsis),
trailing: e.isDir ? null : Text(_fmtSize(e.size), style: TextStyle(color: pt.fgDim, fontSize: 11)),
);
},
),
),
],
);
}
IconData _fileIcon(String name) {
final ext = name.contains('.') ? name.split('.').last.toLowerCase() : '';
return switch (ext) {
'dart' || 'py' || 'js' || 'ts' || 'json' || 'xml' || 'yaml' || 'toml' => Icons.code_rounded,
'png' || 'jpg' || 'jpeg' || 'gif' || 'webp' || 'svg' => Icons.image_outlined,
'mp4' || 'mov' || 'avi' || 'mkv' => Icons.videocam_outlined,
'mp3' || 'ogg' || 'flac' || 'wav' => Icons.music_note_outlined,
'pdf' => Icons.picture_as_pdf_outlined,
'zip' || 'tar' || 'gz' || '7z' || 'rar' => Icons.folder_zip_outlined,
'md' || 'markdown' => Icons.article_outlined,
_ => Icons.insert_drive_file_outlined,
};
}
}
class _ArchiveEntry {
final String name;
final int size;
final bool isDir;
const _ArchiveEntry({required this.name, required this.size, required this.isDir});
}
// Extracts the JPEG thumbnail stored in PSD Image Resource 1036.
Uint8List? _extractPsdThumbnail(Uint8List bytes) {
try {
if (bytes.length < 34) return null;
final data = ByteData.sublistView(bytes);
// Verify '8BPS' signature
if (bytes[0] != 0x38 || bytes[1] != 0x42 || bytes[2] != 0x50 || bytes[3] != 0x53) return null;
// PSD is big-endian throughout.
// Color Mode Data section length at offset 26.
final cmdLen = data.getUint32(26, Endian.big);
// Image Resources section starts after 26-byte header + 4-byte length field + cmdLen bytes.
final irPos = 30 + cmdLen;
if (irPos + 4 > bytes.length) return null;
final irLen = data.getUint32(irPos, Endian.big);
int pos = irPos + 4;
final irEnd = pos + irLen;
while (pos + 10 <= irEnd && pos + 10 <= bytes.length) {
// Signature: '8BIM'
if (bytes[pos] != 0x38 || bytes[pos+1] != 0x42 || bytes[pos+2] != 0x49 || bytes[pos+3] != 0x4D) break;
pos += 4;
final resourceId = data.getUint16(pos, Endian.big);
pos += 2;
// Pascal string: 1-byte length + string, total padded to even.
final nameLen = bytes[pos];
pos += (nameLen + 2) & ~1;
if (pos + 4 > bytes.length) break;
final dataSize = data.getUint32(pos, Endian.big);
pos += 4;
// Resource 1036 (Photoshop 5+) or 1033 (older) = Thumbnail
if ((resourceId == 0x040C || resourceId == 0x0409) && dataSize >= 28) {
final format = data.getUint32(pos, Endian.big);
if (format == 1) { // kJpegRGB
// Use dataSize-28 for the JPEG extent (more reliable than the
// compressedSize field at pos+20, which varies across PS versions).
final jpegStart = pos + 28;
final jpegEnd = (pos + dataSize).clamp(jpegStart, bytes.length);
if (jpegEnd > jpegStart) {
final thumb = Uint8List.sublistView(bytes, jpegStart, jpegEnd);
// Validate JPEG magic bytes (FF D8)
if (thumb.length >= 2 && thumb[0] == 0xFF && thumb[1] == 0xD8) {
return thumb;
}
// Some files need a forward scan for the actual JPEG start
for (var i = 0; i < thumb.length - 1; i++) {
if (thumb[i] == 0xFF && thumb[i + 1] == 0xD8) {
return Uint8List.sublistView(thumb, i);
}
}
}
}
return null;
}
// Advance past this resource's data (padded to even).
final advance = (dataSize + 1) & ~1;
if (advance == 0 && dataSize == 0) { pos += 0; } else { pos += advance; }
}
return null;
} catch (_) {
return null;
}
}
class _PsdThumbViewer extends StatelessWidget {
final Uint8List bytes;
final String filename;
final PyramidTheme pt;
const _PsdThumbViewer({required this.bytes, required this.filename, required this.pt});
@override
Widget build(BuildContext context) {
final thumb = _extractPsdThumbnail(bytes);
if (thumb == null) {
return _ProprietaryViewer(filename: filename, bytes: bytes, pt: pt);
}
return Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
color: pt.bg2,
child: Row(
children: [
Icon(Icons.info_outline_rounded, size: 14, color: pt.fgDim),
const SizedBox(width: 8),
Text('Eingebettete Vorschau (Vollbild nicht verfügbar)', style: TextStyle(color: pt.fgDim, fontSize: 12)),
],
),
),
Expanded(
child: InteractiveViewer(
minScale: 0.5,
maxScale: 10,
child: Padding(
padding: const EdgeInsets.all(24),
child: Image.memory(thumb, fit: BoxFit.contain),
),
),
),
],
);
}
}
// ZIP-preview viewer (Sketch, XD).
// These formats are ZIP archives that embed a preview PNG.
class _ZipPreviewViewer extends StatefulWidget {
final Uint8List bytes;
final String filename;
final PyramidTheme pt;
const _ZipPreviewViewer({required this.bytes, required this.filename, required this.pt});
@override
State<_ZipPreviewViewer> createState() => _ZipPreviewViewerState();
}
class _ZipPreviewViewerState extends State<_ZipPreviewViewer> {
Uint8List? _preview;
bool _loaded = false;
bool _isImage = false;
@override
void initState() {
super.initState();
_extract();
}
void _extract() {
try {
final archive = ZipDecoder().decodeBytes(widget.bytes);
// Priority search list
const candidates = [
'previews/preview.png', 'preview.png', 'thumbnail.png',
'thumbnails/thumbnail.png', 'preview.jpg', 'thumbnail.jpg',
];
ArchiveFile? found;
for (final path in candidates) {
found = archive.findFile(path);
if (found != null && found.isFile) break;
}
// Fallback: any file with preview/thumbnail in the name
if (found == null) {
for (final f in archive.files) {
if (!f.isFile) continue;
final n = f.name.toLowerCase();
if ((n.contains('preview') || n.contains('thumb')) &&
(n.endsWith('.png') || n.endsWith('.jpg') || n.endsWith('.jpeg'))) {
found = f;
break;
}
}
}
if (found != null) {
setState(() {
_preview = Uint8List.fromList(found!.content as List<int>);
_isImage = true;
_loaded = true;
});
} else {
setState(() => _loaded = true);
}
} catch (_) {
setState(() => _loaded = true);
}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
if (!_loaded) {
return Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent));
}
if (_isImage && _preview != null) {
return Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
color: pt.bg2,
child: Row(
children: [
Icon(Icons.info_outline_rounded, size: 14, color: pt.fgDim),
const SizedBox(width: 8),
Text('Eingebettete Vorschau', style: TextStyle(color: pt.fgDim, fontSize: 12)),
],
),
),
Expanded(
child: InteractiveViewer(
minScale: 0.5,
maxScale: 10,
child: Padding(
padding: const EdgeInsets.all(24),
child: Image.memory(_preview!, fit: BoxFit.contain),
),
),
),
],
);
}
// No preview image found — fall back to archive listing
return _ArchiveViewer(bytes: widget.bytes, filename: widget.filename, pt: pt);
}
}