From 6b4743c9e9fee23e0e1c192a885e4700d112df53 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:05:47 +0900 Subject: [PATCH] =?UTF-8?q?WIP=20=EB=B3=B4=EA=B8=B0=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20[=EB=8B=A4=EC=8B=9C=20=EC=8B=9C=EB=8F=84]=20(28=20=EC=A7=80?= =?UTF-8?q?=EC=A0=81,=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=A7=80=EC=8B=9C?= =?UTF-8?q?=2010/20):=20=EA=B7=B8=EB=A6=BC=20=C2=B7=20ZIP=20=EC=9B=90?= =?UTF-8?q?=EB=B3=B8=EB=8F=84=20=EB=8B=A4=EC=8B=9C=20=EC=9D=BD=EC=9D=8C=20?= =?UTF-8?q?-=20=EA=B7=B8=20=EC=9E=A5=EC=9D=98=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=ED=82=A4=EB=A5=BC=20=EC=98=AC=EB=A6=AC=EA=B3=A0=20(=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=ED=82=A4=20=C2=B7=20=EA=B0=99=EC=9D=80=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=BC=EC=9D=B4=EB=A9=B4=20Image=20=EA=B0=80=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EC=9D=BD=EC=A7=80=20=EC=95=8A=EC=95=98=EB=8B=A4)?= =?UTF-8?q?=20=EA=B7=B8=EB=A6=BC=20=EC=BA=90=EC=8B=9C=EC=97=90=EC=84=9C?= =?UTF-8?q?=EB=8F=84=20=EB=B9=84=EC=9B=80=20=C2=B7=20=EA=B7=B8=EB=A6=BC=20?= =?UTF-8?q?=EB=94=94=EC=BD=94=EB=93=9C=20=EC=98=A4=EB=A5=98=EB=8A=94=20"?= =?UTF-8?q?=EA=B7=B8=EB=A6=BC=20=ED=8C=8C=EC=9D=BC=EC=9D=84=20=EC=9D=BD?= =?UTF-8?q?=EC=9D=84=20=EC=88=98=20=EC=97=86=EC=8A=B5=EB=8B=88=EB=8B=A4=20?= =?UTF-8?q?-=20=EA=B9=A8=EC=A1=8C=EA=B1=B0=EB=82=98=20=EC=A7=80=EC=9B=90?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=ED=98=95=EC=8B=9D",?= =?UTF-8?q?=20=EC=9B=90=EB=AC=B8=EC=9D=80=20[=EC=9E=90=EC=84=B8=ED=9E=88]?= =?UTF-8?q?=20=C2=B7=20=EC=8B=9C=ED=97=98:=20=EA=B9=A8=EC=A7=84=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=BC=20=E2=86=92=20=EA=B3=A0=EC=B9=9C=20=EB=92=A4?= =?UTF-8?q?=20[=EB=8B=A4=EC=8B=9C=20=EC=8B=9C=EB=8F=84]=20=ED=95=98?= =?UTF-8?q?=EB=A9=B4=20=EC=97=B4=EB=A6=BC=20(=ED=82=A4=20=EC=98=AC?= =?UTF-8?q?=EB=A6=AC=EA=B8=B0=EB=A5=BC=20=EB=B9=BC=EB=A9=B4=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=ED=95=A8=EC=9D=84=20=ED=99=95=EC=9D=B8)=20(=EA=B2=80?= =?UTF-8?q?=ED=86=A0=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/reader_page.dart | 71 +++++++++++++++++++++++++--------------- test/reader_test.dart | 28 ++++++++++++++++ 5 files changed, 75 insertions(+), 27 deletions(-) diff --git a/assets/l10n/en.json b/assets/l10n/en.json index c192a1d..d2928b8 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -800,6 +800,7 @@ "그림 · 만화 보기, PDF 보기 · 페이지 편집, ZIP 목록 · 만화 보기": "Images · comic reading, PDF view · page editing, ZIP list · comic reading", "그림 · 프롬프트가 기기 밖으로 나갑니다 ({0})": "Images and prompts leave this device ({0})", "그림 보기 대상": "Image viewer file types", + "그림 파일을 읽을 수 없습니다 - 깨졌거나 지원하지 않는 형식": "Can't read the image file - damaged or unsupported format", "그림으로 바꾸는 중": "Turning into an image", "그림을 만들지 못했습니다: {0}": "Couldn't make the image: {0}", "그림을 바꿔 만들기": "Make from this picture", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index b6c6095..be1d869 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -800,6 +800,7 @@ "그림 · 만화 보기, PDF 보기 · 페이지 편집, ZIP 목록 · 만화 보기": "画像・マンガ表示、PDF 表示・ページ編集、ZIP 一覧・マンガ表示", "그림 · 프롬프트가 기기 밖으로 나갑니다 ({0})": "画像・プロンプトが端末の外に送られます ({0})", "그림 보기 대상": "画像表示の対象", + "그림 파일을 읽을 수 없습니다 - 깨졌거나 지원하지 않는 형식": "画像ファイルを読み込めません - 破損しているか未対応の形式", "그림으로 바꾸는 중": "画像に変換中", "그림을 만들지 못했습니다: {0}": "画像を作成できませんでした: {0}", "그림을 바꿔 만들기": "画像を変えて作成", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index b1d77b7..37d1822 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -800,6 +800,7 @@ "그림 · 만화 보기, PDF 보기 · 페이지 편집, ZIP 목록 · 만화 보기": "图片 · 漫画阅读,PDF 查看 · 页面编辑,ZIP 列表 · 漫画阅读", "그림 · 프롬프트가 기기 밖으로 나갑니다 ({0})": "图片和提示词将离开本设备 ({0})", "그림 보기 대상": "图片查看的文件类型", + "그림 파일을 읽을 수 없습니다 - 깨졌거나 지원하지 않는 형식": "无法读取图片文件 - 已损坏或格式不受支持", "그림으로 바꾸는 중": "正在生成图像", "그림을 만들지 못했습니다: {0}": "无法生成图片: {0}", "그림을 바꿔 만들기": "基于图片生成", diff --git a/lib/ui/reader_page.dart b/lib/ui/reader_page.dart index e9601e3..fa450e6 100644 --- a/lib/ui/reader_page.dart +++ b/lib/ui/reader_page.dart @@ -68,6 +68,9 @@ class _ReaderPageState extends State { /// PDF 는 페이지를 지우거나 넣으면 다시 그린다 int _version = 0; + /// [다시 시도] 를 누른 횟수 (장마다) - 그 장의 화면을 새로 만든다 (28 지적: 같은 키면 실패한 그림을 그대로 다시 썼다) + final _retry = {}; + @override void initState() { super.initState(); @@ -442,37 +445,51 @@ class _ReaderPageState extends State { final slot = _v[i]; final half = slot.part >= 0; // 관리자 지시 (10/20, HWP 2단계): 이유와 [다시 시도] - 그 장의 기억을 지우고 다시 부른다 (시간 제한을 넘긴 HWP 묶음 등) - Widget failed(Object? e) => SizedBox( - width: 320, - height: 160, - child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ - Text(trf('열 수 없습니다: {0}', [e]), textAlign: TextAlign.center, style: const TextStyle(color: Colors.white70)), - const SizedBox(height: 8), - // 관리자 지시 (10/20, 28 지적): 변환기 원문은 화면 글 대신 [자세히] 에 - if (e is HwpPdfException && e.detail.isNotEmpty) - TextButton( - onPressed: () => showDialog( - context: context, - builder: (ctx) => AlertDialog( - content: SelectableText(e.detail, style: const TextStyle(fontSize: 12)), - actions: [TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(tr('닫기')))], + // [provider]: 그림 파일 · ZIP 의 그림을 읽다 (디코드) 실패한 것 - 화면 글은 종류에 맞게, 원문은 [자세히] (28 지적) + Widget failed(Object? e, {ImageProvider? provider}) { + final text = e is HwpPdfException + ? trf('열 수 없습니다: {0}', [e.message]) + : provider != null + ? tr('그림 파일을 읽을 수 없습니다 - 깨졌거나 지원하지 않는 형식') + : trf('열 수 없습니다: {0}', [e]); + final detail = e is HwpPdfException ? e.detail : (provider != null ? '$e' : ''); + return SizedBox( + width: 320, + height: 160, + child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Text(text, textAlign: TextAlign.center, style: const TextStyle(color: Colors.white70)), + const SizedBox(height: 8), + // 관리자 지시 (10/20, 28 지적): 변환기 · 그림 디코드 원문은 화면 글 대신 [자세히] 에 + if (detail.isNotEmpty) + TextButton( + onPressed: () => showDialog( + context: context, + builder: (ctx) => AlertDialog( + content: SelectableText(detail, style: const TextStyle(fontSize: 12)), + actions: [TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(tr('닫기')))], + ), ), + child: Text(tr('자세히')), ), - child: Text(tr('자세히')), + OutlinedButton( + onPressed: () { + // 그림은 Flutter 의 그림 캐시에 실패가 남아 같은 키로 다시 쓰인다 - 비우고 (ResizeImage 의 키째) 다시 읽는다 + if (provider != null) unawaited(provider.evict()); + setState(() { + _cache.remove(slot.page); + _cacheHalf.remove(slot.page); + _sharp.remove(i); + _sharpWidth.remove(i); + _retry[slot.page] = (_retry[slot.page] ?? 0) + 1; + }); + }, + child: Text(tr('다시 시도')), ), - OutlinedButton( - onPressed: () => setState(() { - _cache.remove(slot.page); - _cacheHalf.remove(slot.page); - _sharp.remove(i); - _sharpWidth.remove(i); - }), - child: Text(tr('다시 시도')), - ), - ])); + ])); + } // 확대해 다시 그린 것이 있으면 그것 (다 그릴 때까지는 앞의 그림을 그대로 보인다) final page = FutureBuilder( - key: ValueKey('$_version:${slot.page}:${slot.part}'), + key: ValueKey('$_version:${slot.page}:${slot.part}:${_retry[slot.page] ?? 0}'), future: _sharp[i] ?? (half ? _loadHalf(slot.page) : _load(slot.page)), builder: (context, snap) { if (snap.hasError) return Center(child: failed(snap.error)); @@ -481,7 +498,7 @@ class _ReaderPageState extends State { Widget picture = r.image != null ? RawImage(image: r.image, filterQuality: FilterQuality.medium) : Image(image: r.provider!, gaplessPlayback: true, filterQuality: FilterQuality.medium, - errorBuilder: (_, e, _) => failed(e)); + errorBuilder: (_, e, _) => failed(e, provider: r.provider)); // 120: 회전 · 상하 반전 (보기만, 파일은 그대로) if (_turns != 0) picture = RotatedBox(quarterTurns: _turns, child: picture); if (_flipV) picture = Transform.flip(flipY: true, child: picture); diff --git a/test/reader_test.dart b/test/reader_test.dart index 7a43932..d79e976 100644 --- a/test/reader_test.dart +++ b/test/reader_test.dart @@ -188,6 +188,33 @@ void main() { expect(c.settings.readerSystemBars, isFalse); expect(AppSettings.fromJson(c.settings.toJson()).readerSystemBars, isFalse); }); + + testWidgets('그림이 깨졌으면 화면 글 + [자세히], 파일을 고친 뒤 [다시 시도] 하면 열린다 (28 지적: 같은 키 · 같은 그림이라 Image 가 다시 읽지 않았다 - 장의 키를 올리고 그림 캐시도 비움)', (tester) async { + tester.view.physicalSize = const Size(900, 1200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final broken = File(p.join(tmp.path, 'broken.png'))..writeAsBytesSync([1, 2, 3, 4, 5]); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + final src = ImageFilesSource([broken.path], tempDir: tmp.path, title: 'imgs'); + Future settle() async { + for (var k = 0; k < 20; k++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 50))); + await tester.pump(); + } + } + + await tester.pumpWidget(MaterialApp(home: ReaderPage(c: c, source: src))); + await settle(); + expect(find.text('그림 파일을 읽을 수 없습니다 - 깨졌거나 지원하지 않는 형식'), findsOneWidget, reason: '원문 대신 화면 글'); + expect(find.text('자세히'), findsOneWidget, reason: '원문은 [자세히] 에'); + // 파일을 고치고 [다시 시도] + broken.writeAsBytesSync(_png(0, 200, 0)); + await tester.tap(find.text('다시 시도')); + await settle(); + expect(find.text('그림 파일을 읽을 수 없습니다 - 깨졌거나 지원하지 않는 형식'), findsNothing); + expect(find.byType(RawImage), findsWidgets, reason: '고친 그림이 보임'); + await tester.pumpWidget(const SizedBox()); + }); } /// 읽은 너비를 기록 (확대하면 더 크게 다시 읽는지) @@ -209,4 +236,5 @@ class _CountingSource implements ReaderSource { @override Future dispose() => inner.dispose(); + }