From 58380e4ae0065a18f0313199dffc90bc882491c9 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:19:23 +0900 Subject: [PATCH] =?UTF-8?q?WIP=2081=20=EB=B3=B4=EC=B6=A9=20(15=20=EC=A7=80?= =?UTF-8?q?=EC=A0=81=20=C2=B7=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=8C=90?= =?UTF-8?q?=EB=8B=A8):=20=EB=B9=A0=EB=A5=B8=20=EB=B3=B4=EA=B8=B0=EC=9D=98?= =?UTF-8?q?=20=EA=B8=80=20=EB=AF=B8=EB=A6=AC=20=EB=B3=B4=EA=B8=B0=EA=B0=80?= =?UTF-8?q?=20CP949=20=C2=B7=20UTF-16=20=C2=B7=20UTF-8=20BOM=20=EB=8F=84?= =?UTF-8?q?=20=EC=9D=BD=EA=B2=8C=20(=EA=B2=80=ED=86=A0=20=EB=8C=80?= =?UTF-8?q?=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 예전: UTF-8 만 보고 깨진 글자가 있으면 "미리 볼 수 없음" - 한국어 .smi (대부분 CP949) · 메모장의 UTF-16 글이 안 보였다. - 자막과 같은 글자 코드 판별 (detectCharset · decodeText) 을 그대로 써서 MKV 화면과 같게 판단한다 (UTF-8 (BOM) · UTF-16 (BOM) · CP949 · Shift-JIS · GBK). - 그래도 깨진 글자가 많거나 (2 + 글자 수의 1% 넘음) 글이 아니면 (NUL) "미리 볼 수 없음 (글자 코드를 알 수 없습니다)". 64KB 끝에서 잘린 글자는 빼고 본다. - 시험: CP949 .smi · UTF-16 (BOM) .srt · UTF-8 BOM .txt · 글이 아닌 파일. Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 1 + assets/l10n/ja.json | 1 + assets/l10n/zh-Hans.json | 1 + lib/ui/quick_look.dart | 21 +++++++++++++++------ test/quick_look_test.dart | 18 +++++++++++++++--- 5 files changed, 33 insertions(+), 9 deletions(-) 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);