WIP 보기 화면 [다시 시도] (28 지적, 관리자 지시 10/20): 그림 · ZIP 원본도 다시 읽음 - 그 장의 화면 키를 올리고 (같은 키 · 같은 그림이면 Image 가 다시 읽지 않았다) 그림 캐시에서도 비움 · 그림 디코드 오류는 "그림 파일을 읽을 수 없습니다 - 깨졌거나 지원하지 않는 형식", 원문은 [자세히] · 시험: 깨진 그림 → 고친 뒤 [다시 시도] 하면 열림 (키 올리기를 빼면 실패함을 확인) (검토 대기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hwp3
RyuJongJin 2026-10-20 21:05:47 +09:00
parent ae4e29620e
commit 6b4743c9e9
5 changed files with 75 additions and 27 deletions

View File

@ -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",

View File

@ -800,6 +800,7 @@
"그림 · 만화 보기, PDF 보기 · 페이지 편집, ZIP 목록 · 만화 보기": "画像・マンガ表示、PDF 表示・ページ編集、ZIP 一覧・マンガ表示",
"그림 · 프롬프트가 기기 밖으로 나갑니다 ({0})": "画像・プロンプトが端末の外に送られます ({0})",
"그림 보기 대상": "画像表示の対象",
"그림 파일을 읽을 수 없습니다 - 깨졌거나 지원하지 않는 형식": "画像ファイルを読み込めません - 破損しているか未対応の形式",
"그림으로 바꾸는 중": "画像に変換中",
"그림을 만들지 못했습니다: {0}": "画像を作成できませんでした: {0}",
"그림을 바꿔 만들기": "画像を変えて作成",

View File

@ -800,6 +800,7 @@
"그림 · 만화 보기, PDF 보기 · 페이지 편집, ZIP 목록 · 만화 보기": "图片 · 漫画阅读,PDF 查看 · 页面编辑,ZIP 列表 · 漫画阅读",
"그림 · 프롬프트가 기기 밖으로 나갑니다 ({0})": "图片和提示词将离开本设备 ({0})",
"그림 보기 대상": "图片查看的文件类型",
"그림 파일을 읽을 수 없습니다 - 깨졌거나 지원하지 않는 형식": "无法读取图片文件 - 已损坏或格式不受支持",
"그림으로 바꾸는 중": "正在生成图像",
"그림을 만들지 못했습니다: {0}": "无法生成图片: {0}",
"그림을 바꿔 만들기": "基于图片生成",

View File

@ -68,6 +68,9 @@ class _ReaderPageState extends State<ReaderPage> {
/// PDF 는 페이지를 지우거나 넣으면 다시 그린다
int _version = 0;
/// [다시 시도] 를 누른 횟수 (장마다) - 그 장의 화면을 새로 만든다 (28 지적: 같은 키면 실패한 그림을 그대로 다시 썼다)
final _retry = <int, int>{};
@override
void initState() {
super.initState();
@ -442,37 +445,51 @@ class _ReaderPageState extends State<ReaderPage> {
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<void>(
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<void>(
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<ReaderImage>(
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<ReaderPage> {
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);

View File

@ -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<void> settle() async {
for (var k = 0; k < 20; k++) {
await tester.runAsync(() => Future<void>.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<void> dispose() => inner.dispose();
}