diff --git a/assets/l10n/en.json b/assets/l10n/en.json index f9c0f57..cb081e0 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -1085,6 +1085,7 @@ "미리 보기": "Quick look", "미리 볼 수 없습니다: {0}": "Cannot preview: {0}", "미리 볼 수 없음 (글자 코드가 UTF-8 이 아닙니다)": "Can't preview (the text isn't UTF-8)", + "미리 볼 수 없음 (글자 코드를 알 수 없습니다)": "Can't preview (unknown text encoding)", "미리보기: 목록에서 동영상을 고르세요": "Preview: choose a video in the list", "미얀마어": "Burmese", "미지정": "Unspecified", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 826c568..6ece6ac 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -1085,6 +1085,7 @@ "미리 보기": "クイックプレビュー", "미리 볼 수 없습니다: {0}": "プレビューできません: {0}", "미리 볼 수 없음 (글자 코드가 UTF-8 이 아닙니다)": "プレビューできません (文字コードが UTF-8 ではありません)", + "미리 볼 수 없음 (글자 코드를 알 수 없습니다)": "プレビューできません (文字コードが分かりません)", "미리보기: 목록에서 동영상을 고르세요": "プレビュー: リストで動画を選んでください", "미얀마어": "ミャンマー語", "미지정": "未指定", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index d334d94..f835b57 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -1085,6 +1085,7 @@ "미리 보기": "快速预览", "미리 볼 수 없습니다: {0}": "无法预览: {0}", "미리 볼 수 없음 (글자 코드가 UTF-8 이 아닙니다)": "无法预览 (文本不是 UTF-8)", + "미리 볼 수 없음 (글자 코드를 알 수 없습니다)": "无法预览 (未知的文本编码)", "미리보기: 목록에서 동영상을 고르세요": "预览: 请在列表中选择视频", "미얀마어": "缅甸语", "미지정": "未指定", diff --git a/lib/ui/quick_look.dart b/lib/ui/quick_look.dart index a9cc883..ea00442 100644 --- a/lib/ui/quick_look.dart +++ b/lib/ui/quick_look.dart @@ -1,11 +1,12 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../core/charset_detector.dart'; import '../core/file_ops.dart'; +import '../core/text_codec.dart'; import '../core/models.dart'; import '../core/playlist.dart' show isAudioFile, isVideoFile; import '../core/vfs.dart'; @@ -15,16 +16,24 @@ import 'theme.dart'; /// 81: 빠른 보기에서 글로 보는 확장자 const textExtensions = ['txt', 'srt', 'ass', 'ssa', 'vtt', 'smi', 'log', 'md', 'json', 'xml', 'csv', 'ini', 'nfo']; -/// 81: 글 파일의 앞 64KB (UTF-8). 글자 코드가 깨졌으면 null - 끝에서 잘린 한 글자는 깨진 것으로 치지 않는다 +/// 81: 글 파일의 앞 64KB. 81 보충 (15 지적 · 관리자 판단): 자막과 같은 글자 코드 판별 (detectCharset · decodeText) 로 +/// UTF-8 (BOM) · UTF-16 (BOM) · CP949 · Shift-JIS · GBK 를 읽는다 (MKV 화면과 같은 판단 - 한국어 .smi 는 대부분 CP949). +/// 그래도 깨진 글자가 많거나 글이 아니면 (NUL) null - 끝에서 잘린 한 글자는 깨진 것으로 치지 않는다 Future readTextHead(String path, {int limit = 64 * 1024}) async { final bytes = []; await for (final chunk in File(path).openRead(0, limit)) { bytes.addAll(chunk); } - final text = const Utf8Decoder(allowMalformed: true).convert(bytes); + final cs = detectCharset(Uint8List.fromList(bytes)); + var text = decodeText(bytes, cs); + if (bytes.length >= limit) { + while (text.endsWith('�')) { + text = text.substring(0, text.length - 1); + } + } final bad = '�'.allMatches(text).length; - final cut = bytes.length >= limit && text.endsWith('�') ? 1 : 0; - return bad - cut > 0 ? null : (cut > 0 ? text.substring(0, text.length - 1) : text); + if (text.contains('\u0000') || bad > 2 + text.length ~/ 100) return null; + return text; } /// 81: 빠른 보기 (Space · TC 는 F3 · 길게 누르기 [미리 보기]) - 동영상은 큰 썸네일 · 길이 · 해상도 · 코덱, 그림은 그림, @@ -102,7 +111,7 @@ class _QuickLookState extends State { builder: (_, s) => s.connectionState != ConnectionState.done ? const Center(child: CircularProgressIndicator()) : s.data == null - ? _info([tr('미리 볼 수 없음 (글자 코드가 UTF-8 이 아닙니다)')]) + ? _info([tr('미리 볼 수 없음 (글자 코드를 알 수 없습니다)')]) : SingleChildScrollView( child: SelectableText(s.data!, style: const TextStyle(fontFamily: 'Consolas', fontSize: 12)), ), diff --git a/test/quick_look_test.dart b/test/quick_look_test.dart index b00c157..545c617 100644 --- a/test/quick_look_test.dart +++ b/test/quick_look_test.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -14,11 +15,22 @@ void main() { setUp(() => tmp = Directory.systemTemp.createTempSync('jj_ql81_')); tearDown(() => tmp.deleteSync(recursive: true)); - test('글 앞부분: UTF-8 은 그대로 · 다른 글자 코드 (CP949) 는 null · 64KB 끝에서 잘린 한 글자는 괜찮음', () async { + test('글 앞부분: UTF-8 · CP949 .smi · UTF-16 (BOM) .srt · UTF-8 BOM .txt 를 읽고, 글이 아니면 null · 64KB 끝에서 잘린 한 글자는 괜찮음', () async { final a = File(p.join(tmp.path, 'a.txt'))..writeAsStringSync('안녕 hello'); expect(await readTextHead(a.path), '안녕 hello'); - final b = File(p.join(tmp.path, 'b.smi'))..writeAsBytesSync([0xBE, 0xC8, 0xB3, 0xE7]); // CP949 "안녕" - expect(await readTextHead(b.path), isNull); + // 81 보충 (15 지적): 한국어 자막에 흔한 CP949 · 메모장의 UTF-16 · UTF-8 BOM + final b = File(p.join(tmp.path, 'b.smi'))..writeAsBytesSync([0xBE, 0xC8, 0xB3, 0xE7, 0x20, 0xC0, 0xDA, 0xB8, 0xB7]); // CP949 "안녕 자막" + expect(await readTextHead(b.path), '안녕 자막'); + final u16 = [0xFF, 0xFE]; + for (final r in '자막 1'.codeUnits) { + u16.addAll([r & 0xFF, r >> 8]); + } + final s = File(p.join(tmp.path, 's.srt'))..writeAsBytesSync(u16); + expect(await readTextHead(s.path), '자막 1'); + final bom = File(p.join(tmp.path, 'bom.txt'))..writeAsBytesSync([0xEF, 0xBB, 0xBF, ...utf8.encode('hi 안녕')]); + expect(await readTextHead(bom.path), 'hi 안녕'); + final bin = File(p.join(tmp.path, 'bin.txt'))..writeAsBytesSync([0x00, 0x01, 0x02, 0x00, 0xFF]); + expect(await readTextHead(bin.path), isNull, reason: '글이 아님'); // 64KB 경계에 3바이트 글자가 걸리게 final c = File(p.join(tmp.path, 'c.txt'))..writeAsStringSync('${'x' * (64 * 1024 - 1)}가나다'); final head = await readTextHead(c.path);