From ac8fd8ad9a69644fb090e83a37e81b21404b1ba7 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:24:47 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=C2=B7=20PDF=20?= =?UTF-8?q?=C2=B7=20ZIP=20=EB=B3=B4=EA=B8=B0=20(=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20"viewer")?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reader_page: 그림 · 만화 · PDF 보기. 오른쪽 1/3 누르면 다음, 왼쪽은 이전, 가운데는 막대 보이기 · 숨기기, 좌우로 밀어도 넘김. 한 쪽 맞추기 · 좌우 맞추기, 넘기는 방향 (오른쪽→왼쪽 만화), 어둡게 · 밝게, 쪽 막대. - 그림: 탐색기의 지금 정렬 순서대로 같은 폴더 그림을 넘김. 대상 확장자는 환경 설정 > 파일 탐색기에서 고름. - PDF (pdfrx): 보기, 페이지 지우기, 그림을 페이지로 넣기, 저장 (빈 새 문서에 보이는 순서대로 조립 - 원본 문서 페이지 목록을 바꾸면 pdfrx 가 엉뚱한 페이지를 남겼음). 원본은 사본으로 열어 덮어쓰기 가능하게. - 그림 → PDF: 탐색기 메뉴 "PDF 로 만들기" (표시한 그림들을 한 장씩). - ZIP · CBZ: 목록 · 골라서 / 모두 풀기 (다른 창 · 새 폴더 · 같은 폴더), 만화 보기 (설정에서 바로 보기 켜기 · 끄기). - 탐색기 메뉴 "보기", WebDAV 파일도 (받아서). - 시험: reader_test (ZIP · 넘기기 · 설정), integration_test/pdf_test (실제 pdfium). Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 39 ++ assets/l10n/ja.json | 39 ++ assets/l10n/zh-Hans.json | 39 ++ integration_test/pdf_test.dart | 52 +++ lib/app/settings.dart | 28 ++ lib/core/reader_sources.dart | 279 ++++++++++++++ lib/platform/android/android_storage.dart | 4 + .../windows/desktop_storage_service.dart | 11 + lib/services/storage_service.dart | 3 + lib/ui/component_settings.dart | 42 +++ lib/ui/explorer_page.dart | 90 ++++- lib/ui/reader_page.dart | 354 ++++++++++++++++++ lib/ui/settings_page.dart | 1 + lib/ui/zip_page.dart | 182 +++++++++ pubspec.lock | 177 ++++++++- pubspec.yaml | 4 + test/reader_test.dart | 114 ++++++ test/ui_test.dart | 3 + 18 files changed, 1457 insertions(+), 4 deletions(-) create mode 100644 integration_test/pdf_test.dart create mode 100644 lib/core/reader_sources.dart create mode 100644 lib/ui/reader_page.dart create mode 100644 lib/ui/zip_page.dart create mode 100644 test/reader_test.dart diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 3422d50..53eace8 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -122,6 +122,10 @@ "OpenSubtitles 오류 {0}: {1}": "OpenSubtitles error {0}: {1}", "PC 전체 CPU · 메모리 사용량": "Whole-PC CPU · memory usage", "PC 전체 사용량\nCPU {0}%\n메모리 {1} / {2}GB ({3}%){4}": "Whole-PC usage\nCPU {0}%\nMemory {1} / {2}GB ({3}%){4}", + "PDF 로 만들기": "Make PDF", + "PDF 를 만들지 못했습니다: {0}": "Could not make the PDF: {0}", + "PDF 를 열 수 없습니다: {0}": "Cannot open the PDF: {0}", + "PDF 에 넣을 그림 선택": "Choose images to add to the PDF", "Rsync (지금 여기)": "Rsync (you are here)", "SD 카드": "SD card", "SRT 형식이 아니거나 비어 있습니다.": "Not in SRT format or empty.", @@ -153,6 +157,9 @@ "YouTube 주소 · 마그넷 링크 · .torrent 주소가 아니거나 이미 받는 중입니다.": "Not a YouTube URL · magnet link · .torrent URL, or already downloading.", "YouTube 쿠키 (로봇 확인이 나올 때)": "YouTube cookies (when a robot check appears)", "YouTube 화질": "YouTube quality", + "ZIP · CBZ 를 만화로 보기": "Read ZIP · CBZ as comics", + "ZIP 옆 새 폴더 \"{0}\" 로": "To a new folder \"{0}\" next to the ZIP", + "ZIP 이 있는 폴더로": "To the ZIP's folder", "aria2 RPC 에 연결할 수 없습니다.": "Cannot connect to aria2 RPC.", "aria2 오류: {0}": "aria2 error: {0}", "cookies.txt (Netscape 형식)": "cookies.txt (Netscape format)", @@ -204,6 +211,7 @@ "{0} 전체": "All of {0}", "{0} 추가": "Add {0}", "{0} 파일": "{0} file", + "{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "Delete page {0}? (Saved to the file only when you save)", "{0}. {1} (대기)": "{0}. {1} (waiting)", "{0}: 이 동영상을 {1} 로 옮깁니다": "{0}: moves this video to {1}", "{0}: 이미 이동 폴더에 있습니다": "{0}: already in the move folder", @@ -216,6 +224,7 @@ "{0}개 선택": "{0} selected", "{0}개 폴더를 lsync (실시간 동기화) 로 옮겼습니다.": "Moved {0} folders to lsync (live sync).", "{0}개 표시함": "{0} marked", + "{0}개 풀기": "Extract {0}", "{0}개 항목을 {1}": "{0} items {1}", "{0}개 항목을 다음 폴더로 {1}\n{2}": "{0} items to this folder — {1}\n{2}", "{0}개 항목을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "{0} items held. Press [Paste] in the destination folder ({1}).", @@ -223,6 +232,7 @@ "{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "Imported {0} into \"Other bookmarks\".", "{0}개를 옮겼습니다 → {1}": "Moved {0} → {1}", "{0}개를 옮겼습니다 → {1} ({2})": "Moved {0} → {1} ({2})", + "{0}개를 풀었습니다: {1}": "Extracted {0}: {1}", "{0}분 {1}초": "{0} min {1} s", "{0}시간 {1}분": "{0} h {1} min", "{0}줄": "{0} lines", @@ -269,6 +279,7 @@ "고르기 취소": "Unselect", "고르지 않음": "Not chosen", "고른 것 시작": "Start selected", + "고른 것 풀기": "Extract selected", "고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "Best quality at or below the chosen size (nearest if the video lacks that size)", "고지 문서": "Notices", "고치기": "Edit", @@ -280,7 +291,12 @@ "권한 허용 화면 열기": "Open the permission screen", "그 파일 하나만": "Only that file", "그리스어": "Greek", + "그림 {0}장으로 PDF 를 만들었습니다: {1}": "Made a PDF from {0} images: {1}", + "그림 {0}장을 PDF 로 만들기": "Make PDF from {0} images", + "그림 {0}장을 {1} 페이지 뒤에 넣었습니다 (저장해야 파일에 반영됩니다).": "Added {0} images after page {1} (saved to the file when you save).", "그림 · 만화 보기, PDF 보기 · 페이지 편집, ZIP 목록 · 만화 보기": "Images · comic reading, PDF view · page editing, ZIP list · comic reading", + "그림 보기 대상": "Image viewer file types", + "그림을 페이지로 넣기 (이 페이지 뒤)": "Add images as pages (after this page)", "글을 격자에 반영": "Apply text to grid", "글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "Text · button size. Change it any time with − · + on the top bar; press the number in the middle to return to this size (now {0}%)", "금": "Fri", @@ -344,6 +360,9 @@ "내장 저장소": "Internal storage", "내장 플레이어": "Built-in player", "내장 플레이어로 재생": "Play in built-in player", + "넘기는 방향: 오른쪽 → 왼쪽 (만화)": "Page direction: right → left (manga)", + "넘기는 방향: 왼쪽 → 오른쪽": "Page direction: left → right", + "넣지 못했습니다: {0}": "Could not add: {0}", "네덜란드어": "Dutch", "네팔어": "Nepali", "노랑": "Yellow", @@ -363,6 +382,7 @@ "다른 언어로 번역 (AI · 이 {0}에서)": "Translate to another language (AI · on this {0})", "다른 이름으로 저장": "Save as", "다른 이름으로 저장: {0} ({1})": "Save as: {0} ({1})", + "다른 창 폴더로": "To the other pane's folder", "다른 창에서 열기": "Open in other pane", "다른 창으로 복사": "Copy to other pane", "다른 창으로 이동": "Move to other pane", @@ -457,7 +477,10 @@ "루마니아어": "Romanian", "마지막": "Last", "마지막 인코딩 설정을 기억합니다": "Remembers the last encoding settings", + "마지막 장입니다.": "This is the last page.", + "마지막 한 페이지는 지울 수 없습니다.": "The last remaining page cannot be deleted.", "만들 자막 언어 (파일명_언어코드.srt)": "Subtitle languages to make (filename_languagecode.srt)", + "만화 보기": "Read as comic", "말레이어": "Malay", "맞추기 (검은 여백)": "Fit (black bars)", "맞추기 (비율이 다를 때)": "Conform (when the aspect differs)", @@ -487,6 +510,7 @@ "모두 종료": "Quit all", "모두 지우기": "Clear all", "모두 초기화": "Reset all", + "모두 풀기": "Extract all", "모든 작업 취소": "Cancel all jobs", "목": "Thu", "목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "Hides ad areas in lists · next to · on top of videos", @@ -524,6 +548,7 @@ "받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "The downloaded file is corrupt or a different file (SHA256 mismatch). Installation was stopped.", "받음 {0}": "Received {0}", "받지 못했습니다: {0}": "Could not download: {0}", + "밝게": "Brighter", "밝기": "Brightness", "밤 0~6시": "Night 0-6", "방법: {0}": "Method: {0}", @@ -566,6 +591,7 @@ "복사할": "copy", "복사할까요?": "copy?", "복사했습니다.": "copied.", + "볼 그림이 없습니다.": "No images to show.", "볼 수 없는 영상입니다 (삭제 · 지역 제한).": "This video is unavailable (deleted · region restricted).", "분석 실패: {0} - {1}": "Analysis failed: {0} - {1}", "붙여넣기": "Paste", @@ -575,6 +601,7 @@ "비공개 · 회원 전용 영상입니다.": "This is a private · members-only video.", "비밀번호": "Password", "비밀번호 (선택)": "Password (optional)", + "비어 있는 압축 파일입니다.": "The archive is empty.", "비어 있는 폴더": "Empty folder", "비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "This folder is empty.\nPress [Add bookmark] · [New folder] above, or drag bookmarks onto a folder on the left.", "비어 있음": "Empty", @@ -718,6 +745,7 @@ "앱을 새로 설치해 설정이 없습니다. 저장해 둔 v{0} 의 설정 (동영상 목록 · 즐겨찾기 포함) 을 되살릴까요?\n(자막 API 키 · 비밀번호는 저장하지 않았으니 다시 넣어 주세요)": "The app was newly installed and has no settings. Restore the saved settings of v{0} (including the video list and bookmarks)?\n(Subtitle API keys and passwords were not saved - please enter them again)", "앱이 켜져 있는 동안 늘 동기화합니다.": "Always syncs while the app is running.", "양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "Both ways: newer files on the receiving side are not overwritten (-u).", + "어둡게": "Darker", "어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "Press anywhere to bring up the JJ_MKVMaker window", "언어": "Language", "언어 추가": "Add language", @@ -736,6 +764,7 @@ "연결 확인": "Test connection", "연결됨: 항목 {0}개{1}": "Connected: {0} items{1}", "연도": "Year", + "열 수 없습니다: {0}": "Cannot open: {0}", "열기": "Open", "영상 {0}": "Video {0}", "영상 · 음성 합치는 중": "Merging video · audio", @@ -846,6 +875,7 @@ "이 파일에 맞는 자막 우선 (영상 해시)": "Prefer subtitles that match this file (video hash)", "이 파일용": "For this file", "이 파일을 열 수 있는 앱이 없습니다.": "No app can open this file.", + "이 페이지 지우기": "Delete this page", "이 페이지를 {0}(으)로 번역": "Translate this page to {0}", "이 폴더 선택": "Choose this folder", "이 폴더 전체": "This whole folder", @@ -855,6 +885,7 @@ "이 폴더에 현재 페이지 추가": "Add the current page to this folder", "이 폴더에는 고를 파일이 없습니다": "There are no files to choose in this folder", "이 폴더의 동영상 재생": "Play videos in this folder", + "이 확장자의 파일을 두 번 누르면 그림 보기로 엽니다 (오른쪽을 누르면 다음, 왼쪽을 누르면 이전 그림).": "Double-tapping files with these extensions opens the image viewer (tap right for the next image, left for the previous).", "이동": "Move", "이동 ({0})": "Move ({0})", "이동 (붙여넣기로)": "Move (to paste)", @@ -970,6 +1001,7 @@ "저장 공간 정리": "Storage cleanup", "저장 공간 정리: 파일 {0}개 · {1}": "Storage cleanup: {0} files · {1}", "저장 실패: {0}": "Save failed: {0}", + "저장 안 함": "Don't save", "저장 위치": "Save locations", "저장: {0}": "Save: {0}", "저장하고 검색": "Save and search", @@ -1013,6 +1045,7 @@ "좌 ⇄ 우": "Left ⇄ right", "좌우": "Side by side", "좌우 두 폴더 맞추기 (rsync) · 모니터링 · 실시간 동기화 (lsync)": "Sync two folders (rsync) · monitoring · live sync (lsync)", + "좌우 맞추기 (세로로 밀어 봄)": "Fit width (scroll vertically)", "좌우로 밀어 화면 이동": "Swipe left/right to switch screens", "주말 종일": "Weekends, all day", "주소": "Address", @@ -1083,6 +1116,7 @@ "처음 사용 시 내려받기 {0}": "Downloads {0} on first use", "처음 쓸 때 내려받기 (기본)": "Download on first use (default)", "처음 아이콘 (파랑)": "Original icon (blue)", + "첫 장입니다.": "This is the first page.", "첫 줄 추가": "Add first line", "청각장애인용": "Hearing impaired", "체코어": "Czech", @@ -1107,6 +1141,7 @@ "칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "Syncs only in the painted hours; changes outside are collected and synced at the next start. Tap or drag cells to paint (tap a weekday name · hour number for the whole row · column).", "칠한 시간이 없어 동기화하지 않습니다.": "No hours painted, so nothing will sync.", "컴포넌트": "Components", + "켜면 두 번 눌렀을 때 안의 그림을 바로 넘겨 봅니다. 끄면 목록 (골라서 풀기) 을 엽니다.": "When on, double-tapping shows the images inside right away. When off, opens the file list (extract selected).", "켜져 있는 창에서 (기본)": "In the open window (default)", "코덱": "Codec", "크기": "Size", @@ -1145,6 +1180,8 @@ "패키지 라이선스": "Package licenses", "페르시아어": "Persian", "페이지 열기": "Open page", + "페이지 지우기": "Delete page", + "페이지를 바꾼 것을 저장할까요?": "Save the page changes?", "편집 목록 {0}개": "{0} in the edit list", "편집 목록에 있음": "In the edit list", "편집 목록에 추가": "Add to the edit list", @@ -1177,6 +1214,7 @@ "표시 지우기": "Clear marks", "표준": "Standard", "푸는 중": "Extracting", + "풀지 못했습니다: {0}": "Could not extract: {0}", "프랑스어": "French", "프로그램": "Program", "프로그램 정보": "About", @@ -1198,6 +1236,7 @@ "한 번: 바로 실행": "Single: open right away", "한 번: 선택 · 두 번: 실행 (기본)": "Single: select · Double: open (default)", "한 번에": "All at once", + "한 쪽 맞추기": "Fit page", "한 창": "One pane", "한국어": "Korean", "한글": "Hangul", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index fb58c9b..30cf123 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -122,6 +122,10 @@ "OpenSubtitles 오류 {0}: {1}": "OpenSubtitles エラー {0}: {1}", "PC 전체 CPU · 메모리 사용량": "PC 全体の CPU・メモリ使用量", "PC 전체 사용량\nCPU {0}%\n메모리 {1} / {2}GB ({3}%){4}": "PC 全体の使用量\nCPU {0}%\nメモリ {1} / {2}GB({3}%){4}", + "PDF 로 만들기": "PDF を作成", + "PDF 를 만들지 못했습니다: {0}": "PDF を作成できませんでした: {0}", + "PDF 를 열 수 없습니다: {0}": "PDF を開けません: {0}", + "PDF 에 넣을 그림 선택": "PDF に追加する画像を選択", "Rsync (지금 여기)": "Rsync (現在地)", "SD 카드": "SD カード", "SRT 형식이 아니거나 비어 있습니다.": "SRT 形式ではないか、空です。", @@ -153,6 +157,9 @@ "YouTube 주소 · 마그넷 링크 · .torrent 주소가 아니거나 이미 받는 중입니다.": "YouTube の URL・マグネットリンク・.torrent の URL ではないか、既にダウンロード中です。", "YouTube 쿠키 (로봇 확인이 나올 때)": "YouTube クッキー(ロボット確認が出るとき)", "YouTube 화질": "YouTube の画質", + "ZIP · CBZ 를 만화로 보기": "ZIP・CBZ をマンガとして表示", + "ZIP 옆 새 폴더 \"{0}\" 로": "ZIP の隣の新しいフォルダー「{0}」へ", + "ZIP 이 있는 폴더로": "ZIP のあるフォルダーへ", "aria2 RPC 에 연결할 수 없습니다.": "aria2 RPC に接続できません。", "aria2 오류: {0}": "aria2 エラー: {0}", "cookies.txt (Netscape 형식)": "cookies.txt(Netscape 形式)", @@ -204,6 +211,7 @@ "{0} 전체": "{0} 全体", "{0} 추가": "{0} を追加", "{0} 파일": "{0} ファイル", + "{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "{0} ページを削除しますか? (保存するとファイルに反映されます)", "{0}. {1} (대기)": "{0}. {1}(待機)", "{0}: 이 동영상을 {1} 로 옮깁니다": "{0}: この動画を {1} に移動します", "{0}: 이미 이동 폴더에 있습니다": "{0}: 既に移動フォルダーにあります", @@ -216,6 +224,7 @@ "{0}개 선택": "{0}個選択", "{0}개 폴더를 lsync (실시간 동기화) 로 옮겼습니다.": "{0} 個のフォルダーを lsync (リアルタイム同期) へ移しました。", "{0}개 표시함": "{0} 件をマーク", + "{0}개 풀기": "{0} 個を展開", "{0}개 항목을 {1}": "{0} 件の項目を{1}", "{0}개 항목을 다음 폴더로 {1}\n{2}": "{0} 件の項目を次のフォルダーへ{1}\n{2}", "{0}개 항목을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "{0} 件を保持しました。入れるフォルダーで [貼り付け] を押してください ({1})。", @@ -223,6 +232,7 @@ "{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "{0}個を「その他のブックマーク」に取り込みました。", "{0}개를 옮겼습니다 → {1}": "{0}個を移動しました → {1}", "{0}개를 옮겼습니다 → {1} ({2})": "{0}個を移動しました → {1}({2})", + "{0}개를 풀었습니다: {1}": "{0} 個を展開しました: {1}", "{0}분 {1}초": "{0}分 {1}秒", "{0}시간 {1}분": "{0}時間 {1}分", "{0}줄": "{0}行", @@ -269,6 +279,7 @@ "고르기 취소": "選択を解除", "고르지 않음": "未選択", "고른 것 시작": "選んだものを開始", + "고른 것 풀기": "選択したものを展開", "고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "選んだサイズ以下で最も良い画質(動画にないサイズなら近いもの)", "고지 문서": "告知文書", "고치기": "編集", @@ -280,7 +291,12 @@ "권한 허용 화면 열기": "権限の許可画面を開く", "그 파일 하나만": "そのファイルだけ", "그리스어": "ギリシャ語", + "그림 {0}장으로 PDF 를 만들었습니다: {1}": "画像 {0} 枚から PDF を作成しました: {1}", + "그림 {0}장을 PDF 로 만들기": "画像 {0} 枚を PDF にする", + "그림 {0}장을 {1} 페이지 뒤에 넣었습니다 (저장해야 파일에 반영됩니다).": "画像 {0} 枚を {1} ページの後に追加しました (保存するとファイルに反映されます)。", "그림 · 만화 보기, PDF 보기 · 페이지 편집, ZIP 목록 · 만화 보기": "画像・マンガ表示、PDF 表示・ページ編集、ZIP 一覧・マンガ表示", + "그림 보기 대상": "画像表示の対象", + "그림을 페이지로 넣기 (이 페이지 뒤)": "画像をページとして追加 (このページの後)", "글을 격자에 반영": "テキストをグリッドに反映", "글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "文字・ボタンの大きさです。上部バーの − ・ + でその都度変更でき、中央の数字を押すとこの大きさに戻ります(現在 {0}%)", "금": "金", @@ -344,6 +360,9 @@ "내장 저장소": "内部ストレージ", "내장 플레이어": "内蔵プレーヤー", "내장 플레이어로 재생": "内蔵プレーヤーで再生", + "넘기는 방향: 오른쪽 → 왼쪽 (만화)": "めくる方向: 右 → 左 (マンガ)", + "넘기는 방향: 왼쪽 → 오른쪽": "めくる方向: 左 → 右", + "넣지 못했습니다: {0}": "追加できませんでした: {0}", "네덜란드어": "オランダ語", "네팔어": "ネパール語", "노랑": "黄色", @@ -363,6 +382,7 @@ "다른 언어로 번역 (AI · 이 {0}에서)": "他の言語に翻訳(AI・この{0}で)", "다른 이름으로 저장": "名前を付けて保存", "다른 이름으로 저장: {0} ({1})": "名前を付けて保存: {0}({1})", + "다른 창 폴더로": "もう一方の画面のフォルダーへ", "다른 창에서 열기": "もう一方のペインで開く", "다른 창으로 복사": "もう一方のペインへコピー", "다른 창으로 이동": "もう一方のペインへ移動", @@ -457,7 +477,10 @@ "루마니아어": "ルーマニア語", "마지막": "最後", "마지막 인코딩 설정을 기억합니다": "最後のエンコード設定を記憶します", + "마지막 장입니다.": "最後のページです。", + "마지막 한 페이지는 지울 수 없습니다.": "最後の 1 ページは削除できません。", "만들 자막 언어 (파일명_언어코드.srt)": "作成する字幕の言語(ファイル名_言語コード.srt)", + "만화 보기": "マンガ表示", "말레이어": "マレー語", "맞추기 (검은 여백)": "フィット(黒い余白)", "맞추기 (비율이 다를 때)": "合わせ方(比率が違うとき)", @@ -487,6 +510,7 @@ "모두 종료": "すべて終了", "모두 지우기": "すべて削除", "모두 초기화": "すべてリセット", + "모두 풀기": "すべて展開", "모든 작업 취소": "すべての作業をキャンセル", "목": "木", "목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "リスト・動画の横・動画の上に出る広告エリアを隠します", @@ -524,6 +548,7 @@ "받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "ダウンロードしたファイルが壊れているか別のファイルです(SHA256 不一致)。インストールを中止しました。", "받음 {0}": "受信 {0}", "받지 못했습니다: {0}": "ダウンロードできませんでした: {0}", + "밝게": "明るく", "밝기": "明るさ", "밤 0~6시": "夜 0~6 時", "방법: {0}": "方法: {0}", @@ -566,6 +591,7 @@ "복사할": "コピーする", "복사할까요?": "コピーしますか?", "복사했습니다.": "コピーしました。", + "볼 그림이 없습니다.": "表示する画像がありません。", "볼 수 없는 영상입니다 (삭제 · 지역 제한).": "視聴できない動画です(削除・地域制限)。", "분석 실패: {0} - {1}": "分析失敗: {0} - {1}", "붙여넣기": "貼り付け", @@ -575,6 +601,7 @@ "비공개 · 회원 전용 영상입니다.": "非公開・メンバー限定の動画です。", "비밀번호": "パスワード", "비밀번호 (선택)": "パスワード(任意)", + "비어 있는 압축 파일입니다.": "空の圧縮ファイルです。", "비어 있는 폴더": "空のフォルダー", "비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "空のフォルダーです。\n上の[ブックマーク追加]・[新しいフォルダー]を押すか、ブックマークを左のフォルダーにドラッグしてください。", "비어 있음": "空", @@ -718,6 +745,7 @@ "앱을 새로 설치해 설정이 없습니다. 저장해 둔 v{0} 의 설정 (동영상 목록 · 즐겨찾기 포함) 을 되살릴까요?\n(자막 API 키 · 비밀번호는 저장하지 않았으니 다시 넣어 주세요)": "アプリを新しくインストールしたため設定がありません。保存した v{0} の設定 (動画リスト · ブックマークを含む) を復元しますか?\n(字幕 API キー · パスワードは保存していないので再入力してください)", "앱이 켜져 있는 동안 늘 동기화합니다.": "アプリの起動中は常に同期します。", "양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "双方向: 受け取る側の方が新しいファイルは上書きしません (-u)。", + "어둡게": "暗く", "어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "どこからでも押すと JJ_MKVMaker ウィンドウが表示されます", "언어": "言語", "언어 추가": "言語を追加", @@ -736,6 +764,7 @@ "연결 확인": "接続テスト", "연결됨: 항목 {0}개{1}": "接続しました: 項目 {0} 個{1}", "연도": "年", + "열 수 없습니다: {0}": "開けません: {0}", "열기": "開く", "영상 {0}": "映像 {0}", "영상 · 음성 합치는 중": "映像・音声を結合中", @@ -846,6 +875,7 @@ "이 파일에 맞는 자막 우선 (영상 해시)": "このファイルに合う字幕を優先(動画ハッシュ)", "이 파일용": "このファイル用", "이 파일을 열 수 있는 앱이 없습니다.": "このファイルを開けるアプリがありません。", + "이 페이지 지우기": "このページを削除", "이 페이지를 {0}(으)로 번역": "このページを{0}に翻訳", "이 폴더 선택": "このフォルダーを選択", "이 폴더 전체": "このフォルダー全体", @@ -855,6 +885,7 @@ "이 폴더에 현재 페이지 추가": "このフォルダーに現在のページを追加", "이 폴더에는 고를 파일이 없습니다": "このフォルダーには選べるファイルがありません", "이 폴더의 동영상 재생": "このフォルダーの動画を再生", + "이 확장자의 파일을 두 번 누르면 그림 보기로 엽니다 (오른쪽을 누르면 다음, 왼쪽을 누르면 이전 그림).": "この拡張子のファイルをダブルタップすると画像表示で開きます (右をタップで次、左で前の画像)。", "이동": "移動", "이동 ({0})": "移動({0})", "이동 (붙여넣기로)": "移動 (貼り付け用)", @@ -970,6 +1001,7 @@ "저장 공간 정리": "ストレージの整理", "저장 공간 정리: 파일 {0}개 · {1}": "ストレージの整理: ファイル {0}個・{1}", "저장 실패: {0}": "保存失敗: {0}", + "저장 안 함": "保存しない", "저장 위치": "保存先", "저장: {0}": "保存: {0}", "저장하고 검색": "保存して検索", @@ -1013,6 +1045,7 @@ "좌 ⇄ 우": "左 ⇄ 右", "좌우": "左右", "좌우 두 폴더 맞추기 (rsync) · 모니터링 · 실시간 동기화 (lsync)": "左右 2 つのフォルダーを同期 (rsync)・モニタリング・リアルタイム同期 (lsync)", + "좌우 맞추기 (세로로 밀어 봄)": "幅に合わせる (縦にスクロール)", "좌우로 밀어 화면 이동": "左右スワイプで画面を移動", "주말 종일": "週末 終日", "주소": "アドレス", @@ -1083,6 +1116,7 @@ "처음 사용 시 내려받기 {0}": "初回使用時にダウンロード {0}", "처음 쓸 때 내려받기 (기본)": "初めて使うときにダウンロード (既定)", "처음 아이콘 (파랑)": "最初のアイコン(青)", + "첫 장입니다.": "最初のページです。", "첫 줄 추가": "最初の行を追加", "청각장애인용": "聴覚障害者用", "체코어": "チェコ語", @@ -1107,6 +1141,7 @@ "칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "塗った時間だけ同期し、それ以外は変更を貯めて次の開始時刻に同期します。マスをタップまたはドラッグして塗ります (曜日名 · 時の数字をタップするとその行 · 列全体)。", "칠한 시간이 없어 동기화하지 않습니다.": "塗った時間がないため同期しません。", "컴포넌트": "コンポーネント", + "켜면 두 번 눌렀을 때 안의 그림을 바로 넘겨 봅니다. 끄면 목록 (골라서 풀기) 을 엽니다.": "オンにするとダブルタップで中の画像をすぐめくって表示します。オフにすると一覧 (選んで展開) を開きます。", "켜져 있는 창에서 (기본)": "開いているウィンドウで(既定)", "코덱": "コーデック", "크기": "サイズ", @@ -1145,6 +1180,8 @@ "패키지 라이선스": "パッケージのライセンス", "페르시아어": "ペルシャ語", "페이지 열기": "ページを開く", + "페이지 지우기": "ページを削除", + "페이지를 바꾼 것을 저장할까요?": "ページの変更を保存しますか?", "편집 목록 {0}개": "編集リスト {0}個", "편집 목록에 있음": "編集リストにあり", "편집 목록에 추가": "編集リストに追加", @@ -1177,6 +1214,7 @@ "표시 지우기": "マークを解除", "표준": "標準", "푸는 중": "展開中", + "풀지 못했습니다: {0}": "展開できませんでした: {0}", "프랑스어": "フランス語", "프로그램": "プログラム", "프로그램 정보": "プログラム情報", @@ -1198,6 +1236,7 @@ "한 번: 바로 실행": "1 回: すぐに開く", "한 번: 선택 · 두 번: 실행 (기본)": "1 回: 選択 · 2 回: 開く (既定)", "한 번에": "まとめて 1 回", + "한 쪽 맞추기": "1 ページに合わせる", "한 창": "1 ペイン", "한국어": "韓国語", "한글": "ハングル", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 9ffb2e5..6390257 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -122,6 +122,10 @@ "OpenSubtitles 오류 {0}: {1}": "OpenSubtitles 错误 {0}: {1}", "PC 전체 CPU · 메모리 사용량": "整机 CPU · 内存使用率", "PC 전체 사용량\nCPU {0}%\n메모리 {1} / {2}GB ({3}%){4}": "整机使用率\nCPU {0}%\n内存 {1} / {2}GB({3}%){4}", + "PDF 로 만들기": "制作 PDF", + "PDF 를 만들지 못했습니다: {0}": "无法制作 PDF: {0}", + "PDF 를 열 수 없습니다: {0}": "无法打开 PDF: {0}", + "PDF 에 넣을 그림 선택": "选择要加入 PDF 的图片", "Rsync (지금 여기)": "Rsync (当前位置)", "SD 카드": "SD 卡", "SRT 형식이 아니거나 비어 있습니다.": "不是 SRT 格式或为空。", @@ -153,6 +157,9 @@ "YouTube 주소 · 마그넷 링크 · .torrent 주소가 아니거나 이미 받는 중입니다.": "不是 YouTube 地址 · 磁力链接 · .torrent 地址,或已在下载中。", "YouTube 쿠키 (로봇 확인이 나올 때)": "YouTube Cookie(出现机器人验证时)", "YouTube 화질": "YouTube 画质", + "ZIP · CBZ 를 만화로 보기": "将 ZIP · CBZ 作为漫画阅读", + "ZIP 옆 새 폴더 \"{0}\" 로": "到 ZIP 旁的新文件夹“{0}”", + "ZIP 이 있는 폴더로": "到 ZIP 所在文件夹", "aria2 RPC 에 연결할 수 없습니다.": "无法连接到 aria2 RPC。", "aria2 오류: {0}": "aria2 错误: {0}", "cookies.txt (Netscape 형식)": "cookies.txt(Netscape 格式)", @@ -204,6 +211,7 @@ "{0} 전체": "{0} 全部", "{0} 추가": "添加 {0}", "{0} 파일": "{0} 文件", + "{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "删除第 {0} 页? (保存后才会写入文件)", "{0}. {1} (대기)": "{0}. {1}(等待)", "{0}: 이 동영상을 {1} 로 옮깁니다": "{0}: 将此视频移到 {1}", "{0}: 이미 이동 폴더에 있습니다": "{0}: 已在移动文件夹中", @@ -216,6 +224,7 @@ "{0}개 선택": "已选 {0} 个", "{0}개 폴더를 lsync (실시간 동기화) 로 옮겼습니다.": "已将 {0} 个文件夹移到 lsync (实时同步)。", "{0}개 표시함": "已标记 {0} 个", + "{0}개 풀기": "解压 {0} 个", "{0}개 항목을 {1}": "{0} 个项目{1}", "{0}개 항목을 다음 폴더로 {1}\n{2}": "将 {0} 个项目到以下文件夹 — {1}\n{2}", "{0}개 항목을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "已暂存 {0} 个项目。请在目标文件夹中按 [粘贴] ({1})。", @@ -223,6 +232,7 @@ "{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "已将 {0} 个导入“其他书签”。", "{0}개를 옮겼습니다 → {1}": "已移动 {0} 个 → {1}", "{0}개를 옮겼습니다 → {1} ({2})": "已移动 {0} 个 → {1}({2})", + "{0}개를 풀었습니다: {1}": "已解压 {0} 个: {1}", "{0}분 {1}초": "{0} 分 {1} 秒", "{0}시간 {1}분": "{0} 小时 {1} 分", "{0}줄": "{0} 行", @@ -269,6 +279,7 @@ "고르기 취소": "取消选择", "고르지 않음": "未选择", "고른 것 시작": "开始所选", + "고른 것 풀기": "解压所选", "고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "不超过所选尺寸的最佳画质(视频没有该尺寸时取最接近的)", "고지 문서": "声明文件", "고치기": "编辑", @@ -280,7 +291,12 @@ "권한 허용 화면 열기": "打开权限授予界面", "그 파일 하나만": "仅该文件", "그리스어": "希腊语", + "그림 {0}장으로 PDF 를 만들었습니다: {1}": "已用 {0} 张图片制作 PDF: {1}", + "그림 {0}장을 PDF 로 만들기": "将 {0} 张图片制作为 PDF", + "그림 {0}장을 {1} 페이지 뒤에 넣었습니다 (저장해야 파일에 반영됩니다).": "已在第 {1} 页后加入 {0} 张图片 (保存后才会写入文件)。", "그림 · 만화 보기, PDF 보기 · 페이지 편집, ZIP 목록 · 만화 보기": "图片 · 漫画阅读,PDF 查看 · 页面编辑,ZIP 列表 · 漫画阅读", + "그림 보기 대상": "图片查看的文件类型", + "그림을 페이지로 넣기 (이 페이지 뒤)": "将图片作为页面加入 (本页之后)", "글을 격자에 반영": "将文本应用到网格", "글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "文字 · 按钮大小。可随时用顶部栏的 − · + 调整,点击中间的数字恢复为此大小(当前 {0}%)", "금": "五", @@ -344,6 +360,9 @@ "내장 저장소": "内部存储", "내장 플레이어": "内置播放器", "내장 플레이어로 재생": "用内置播放器播放", + "넘기는 방향: 오른쪽 → 왼쪽 (만화)": "翻页方向: 右 → 左 (漫画)", + "넘기는 방향: 왼쪽 → 오른쪽": "翻页方向: 左 → 右", + "넣지 못했습니다: {0}": "无法加入: {0}", "네덜란드어": "荷兰语", "네팔어": "尼泊尔语", "노랑": "黄色", @@ -363,6 +382,7 @@ "다른 언어로 번역 (AI · 이 {0}에서)": "翻译为其他语言(AI · 在此{0}上)", "다른 이름으로 저장": "另存为", "다른 이름으로 저장: {0} ({1})": "另存为: {0}({1})", + "다른 창 폴더로": "到另一窗格的文件夹", "다른 창에서 열기": "在另一窗格中打开", "다른 창으로 복사": "复制到另一窗格", "다른 창으로 이동": "移动到另一窗格", @@ -457,7 +477,10 @@ "루마니아어": "罗马尼亚语", "마지막": "上次", "마지막 인코딩 설정을 기억합니다": "记住上次的编码设置", + "마지막 장입니다.": "已是最后一页。", + "마지막 한 페이지는 지울 수 없습니다.": "无法删除仅剩的一页。", "만들 자막 언어 (파일명_언어코드.srt)": "要生成的字幕语言(文件名_语言代码.srt)", + "만화 보기": "漫画阅读", "말레이어": "马来语", "맞추기 (검은 여백)": "适应(黑边)", "맞추기 (비율이 다를 때)": "适配方式(比例不同时)", @@ -487,6 +510,7 @@ "모두 종료": "全部退出", "모두 지우기": "全部清除", "모두 초기화": "全部重置", + "모두 풀기": "全部解压", "모든 작업 취소": "取消所有任务", "목": "四", "목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "隐藏列表 · 视频旁 · 视频上方出现的广告区域", @@ -524,6 +548,7 @@ "받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "下载的文件已损坏或不是正确的文件(SHA256 不匹配)。已停止安装。", "받음 {0}": "已接收 {0}", "받지 못했습니다: {0}": "无法下载: {0}", + "밝게": "调亮", "밝기": "亮度", "밤 0~6시": "夜间 0~6 点", "방법: {0}": "方式: {0}", @@ -566,6 +591,7 @@ "복사할": "复制", "복사할까요?": "复制吗?", "복사했습니다.": "已复制。", + "볼 그림이 없습니다.": "没有可显示的图片。", "볼 수 없는 영상입니다 (삭제 · 지역 제한).": "该视频无法观看(已删除 · 地区限制)。", "분석 실패: {0} - {1}": "分析失败: {0} - {1}", "붙여넣기": "粘贴", @@ -575,6 +601,7 @@ "비공개 · 회원 전용 영상입니다.": "这是私享 · 会员专享视频。", "비밀번호": "密码", "비밀번호 (선택)": "密码(可选)", + "비어 있는 압축 파일입니다.": "压缩文件为空。", "비어 있는 폴더": "空文件夹", "비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "这是空文件夹。\n请点击上方的[添加书签] · [新建文件夹],或将书签拖到左侧文件夹。", "비어 있음": "空", @@ -718,6 +745,7 @@ "앱을 새로 설치해 설정이 없습니다. 저장해 둔 v{0} 의 설정 (동영상 목록 · 즐겨찾기 포함) 을 되살릴까요?\n(자막 API 키 · 비밀번호는 저장하지 않았으니 다시 넣어 주세요)": "应用为全新安装,没有设置。要恢复保存的 v{0} 设置 (包括视频列表 · 书签) 吗?\n(字幕 API 密钥 · 密码未保存,请重新输入)", "앱이 켜져 있는 동안 늘 동기화합니다.": "应用运行期间始终同步。", "양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "双向: 接收端较新的文件不会被覆盖 (-u)。", + "어둡게": "调暗", "어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "在任何地方按下即可显示 JJ_MKVMaker 窗口", "언어": "语言", "언어 추가": "添加语言", @@ -736,6 +764,7 @@ "연결 확인": "测试连接", "연결됨: 항목 {0}개{1}": "已连接: {0} 个项目{1}", "연도": "年份", + "열 수 없습니다: {0}": "无法打开: {0}", "열기": "打开", "영상 {0}": "视频 {0}", "영상 · 음성 합치는 중": "正在合并视频 · 音频", @@ -846,6 +875,7 @@ "이 파일에 맞는 자막 우선 (영상 해시)": "优先匹配此文件的字幕(视频哈希)", "이 파일용": "适用于此文件", "이 파일을 열 수 있는 앱이 없습니다.": "没有可以打开此文件的应用。", + "이 페이지 지우기": "删除此页", "이 페이지를 {0}(으)로 번역": "将此页面翻译为{0}", "이 폴더 선택": "选择此文件夹", "이 폴더 전체": "整个文件夹", @@ -855,6 +885,7 @@ "이 폴더에 현재 페이지 추가": "将当前页面添加到此文件夹", "이 폴더에는 고를 파일이 없습니다": "此文件夹中没有可选择的文件", "이 폴더의 동영상 재생": "播放此文件夹中的视频", + "이 확장자의 파일을 두 번 누르면 그림 보기로 엽니다 (오른쪽을 누르면 다음, 왼쪽을 누르면 이전 그림).": "双击这些扩展名的文件会用图片查看打开 (点右侧看下一张,点左侧看上一张)。", "이동": "移动", "이동 ({0})": "移动({0})", "이동 (붙여넣기로)": "移动 (用于粘贴)", @@ -970,6 +1001,7 @@ "저장 공간 정리": "存储空间清理", "저장 공간 정리: 파일 {0}개 · {1}": "存储空间清理: 文件 {0} 个 · {1}", "저장 실패: {0}": "保存失败: {0}", + "저장 안 함": "不保存", "저장 위치": "保存位置", "저장: {0}": "保存: {0}", "저장하고 검색": "保存并搜索", @@ -1013,6 +1045,7 @@ "좌 ⇄ 우": "左 ⇄ 右", "좌우": "左右", "좌우 두 폴더 맞추기 (rsync) · 모니터링 · 실시간 동기화 (lsync)": "同步左右两个文件夹 (rsync) · 监控 · 实时同步 (lsync)", + "좌우 맞추기 (세로로 밀어 봄)": "适应宽度 (上下滑动查看)", "좌우로 밀어 화면 이동": "左右滑动切换界面", "주말 종일": "周末 全天", "주소": "地址", @@ -1083,6 +1116,7 @@ "처음 사용 시 내려받기 {0}": "首次使用时下载 {0}", "처음 쓸 때 내려받기 (기본)": "首次使用时下载 (默认)", "처음 아이콘 (파랑)": "最初的图标(蓝色)", + "첫 장입니다.": "已是第一页。", "첫 줄 추가": "添加第一行", "청각장애인용": "听障字幕", "체코어": "捷克语", @@ -1107,6 +1141,7 @@ "칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "只在涂色的时间同步,其他时间的变化累积到下次开始再同步。点按或拖动格子涂色 (点按星期名 · 小时数字可选整行 · 整列)。", "칠한 시간이 없어 동기화하지 않습니다.": "没有涂色的时间,因此不会同步。", "컴포넌트": "组件", + "켜면 두 번 눌렀을 때 안의 그림을 바로 넘겨 봅니다. 끄면 목록 (골라서 풀기) 을 엽니다.": "开启后双击会直接翻看其中的图片;关闭则打开文件列表 (选择解压)。", "켜져 있는 창에서 (기본)": "在已打开的窗口中(默认)", "코덱": "编解码器", "크기": "大小", @@ -1145,6 +1180,8 @@ "패키지 라이선스": "软件包许可", "페르시아어": "波斯语", "페이지 열기": "打开页面", + "페이지 지우기": "删除页面", + "페이지를 바꾼 것을 저장할까요?": "保存页面的更改吗?", "편집 목록 {0}개": "编辑列表 {0} 个", "편집 목록에 있음": "已在编辑列表中", "편집 목록에 추가": "加入编辑列表", @@ -1177,6 +1214,7 @@ "표시 지우기": "清除标记", "표준": "标准", "푸는 중": "正在解压", + "풀지 못했습니다: {0}": "无法解压: {0}", "프랑스어": "法语", "프로그램": "程序", "프로그램 정보": "程序信息", @@ -1198,6 +1236,7 @@ "한 번: 바로 실행": "单击: 立即打开", "한 번: 선택 · 두 번: 실행 (기본)": "单击: 选择 · 双击: 打开 (默认)", "한 번에": "一次执行", + "한 쪽 맞추기": "适应整页", "한 창": "单窗格", "한국어": "韩语", "한글": "韩文", diff --git a/integration_test/pdf_test.dart b/integration_test/pdf_test.dart new file mode 100644 index 0000000..40dec40 --- /dev/null +++ b/integration_test/pdf_test.dart @@ -0,0 +1,52 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/core/reader_sources.dart'; +import 'package:path/path.dart' as p; + +/// 실제 pdfium: 그림 → PDF, 열기 · 그리기, 페이지 지우기 · 그림 넣기 · 저장 +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('PDF: 만들기 · 열기 · 지우기 · 넣기 · 저장', (tester) async { + final dir = Directory.systemTemp.createTempSync('jj_pdf_'); + addTearDown(() => dir.deleteSync(recursive: true)); + String image(String name, int w, int h, {bool jpg = false}) { + final im = img.Image(width: w, height: h); + img.fill(im, color: img.ColorRgb8(200, 50, 50)); + final f = File(p.join(dir.path, name)); + f.writeAsBytesSync(Uint8List.fromList(jpg ? img.encodeJpg(im) : img.encodePng(im))); + return f.path; + } + + final imgs = [image('1.png', 400, 600), image('2.jpg', 800, 600, jpg: true), image('3.webp.png', 300, 300)]; + final out = p.join(dir.path, 'book.pdf'); + await tester.runAsync(() => imagesToPdf(imgs, out, tempDir: dir.path)); + expect(File(out).lengthSync(), greaterThan(1000)); + + final src = (await tester.runAsync(() => PdfSource.open(out, tempDir: dir.path)))!; + expect(src.length, 3); + expect(src.pages[1].width, closeTo(800 * 0.75, 1)); // 그림 크기대로 + final page = (await tester.runAsync(() => src.load(0, maxWidth: 400)))!; + expect(page.image, isNotNull); + expect(page.image!.width, 400); + page.dispose(); + + await tester.runAsync(() => src.deletePage(1)); // 가로 그림 지우기 + expect(src.length, 2); + await tester.runAsync(() => src.insertImages([image('new.png', 100, 200)], after: -1, tempDir: dir.path)); // 맨 앞에 + expect(src.length, 3); + expect(src.dirty, isTrue); + await tester.runAsync(() => src.save()); + expect(src.dirty, isFalse); + await tester.runAsync(src.dispose); + + final again = (await tester.runAsync(() => PdfSource.open(out, tempDir: dir.path)))!; + expect(again.length, 3); + expect([for (final pg in again.pages) pg.width.round()], [75, 300, 225]); + await tester.runAsync(again.dispose); + }); +} diff --git a/lib/app/settings.dart b/lib/app/settings.dart index d620533..beefb24 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -5,6 +5,7 @@ import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'components.dart'; +import '../core/reader_sources.dart' show defaultImageExtensions; import '../core/download_detect.dart'; import '../core/encode_options.dart'; import '../core/playlist.dart'; @@ -342,6 +343,21 @@ class AppSettings { /// 화면 가운데를 좌우로 밀어 다음 · 이전 화면으로 bool swipeNav = true; + /// 그림 보기 (만화 보기) 대상 확장자 (파일 탐색기에서 두 번 누르면 보기로 연다) + List imageExts = [...defaultImageExtensions]; + + /// 보기: 'page' 한 쪽 맞추기 · 'width' 좌우 맞추기 (세로로 밀어 봄) + String readerFit = 'page'; + + /// 보기: 오른쪽에서 왼쪽으로 넘김 (일본 만화) + bool readerRtl = false; + + /// 보기: 밝기 (-0.7 어둡게 ~ 0.7 밝게) + double readerBrightness = 0; + + /// ZIP · CBZ 를 두 번 누르면 안의 그림을 만화처럼 보기 (끄면 목록) + bool zipComic = true; + /// WebDAV 서버 (파일 탐색기 · Rsync 화면 위쪽 "SD 카드" 옆 탭). 비밀번호는 이 설정 파일에만. List webdavServers = []; @@ -457,6 +473,11 @@ class AppSettings { 'components': components, 'navOrder': navOrder, 'swipeNav': swipeNav, + 'imageExts': imageExts, + 'readerFit': readerFit, + 'readerRtl': readerRtl, + 'readerBrightness': readerBrightness, + 'zipComic': zipComic, 'webdavServers': [for (final x in webdavServers) x.toJson()], 'rsyncOptions': rsyncOptions, 'robocopyOptions': robocopyOptions, @@ -570,6 +591,13 @@ class AppSettings { ? [for (final x in j['navOrder'] as List) if (AppComponent.defaultOrder.contains('$x')) '$x'] : AppComponent.defaultOrder ..swipeNav = j['swipeNav'] as bool? ?? true + ..imageExts = j['imageExts'] is List + ? [for (final x in j['imageExts'] as List) '$x'.toLowerCase()] + : [...defaultImageExtensions] + ..readerFit = j['readerFit'] == 'width' ? 'width' : 'page' + ..readerRtl = j['readerRtl'] as bool? ?? false + ..readerBrightness = ((j['readerBrightness'] as num?)?.toDouble() ?? 0).clamp(-0.7, 0.7) + ..zipComic = j['zipComic'] as bool? ?? true ..webdavServers = [ for (final x in (j['webdavServers'] as List?) ?? const []) if (x is Map) DavServer.fromJson(x), diff --git a/lib/core/reader_sources.dart b/lib/core/reader_sources.dart new file mode 100644 index 0000000..e396117 --- /dev/null +++ b/lib/core/reader_sources.dart @@ -0,0 +1,279 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:archive/archive_io.dart'; +import 'package:flutter/painting.dart'; +import 'package:image/image.dart' as img; +import 'package:path/path.dart' as p; +import 'package:pdfrx/pdfrx.dart'; + +import 'playlist.dart' show naturalCompare; +import 'vfs.dart'; + +/// 그림 보기 (만화 보기) 의 기본 대상 확장자 (환경 설정에서 바꿈) +const defaultImageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp']; + +/// 만화 압축 파일 (ZIP · CBZ) +const zipExtensions = ['zip', 'cbz']; + +String extOf(String path) { + final name = vBasename(path); + final i = name.lastIndexOf('.'); + return i < 0 ? '' : name.substring(i + 1).toLowerCase(); +} + +/// 보기 화면의 한 장: 그림 파일 · 압축 안 그림은 [provider], PDF 페이지는 그린 [image] +class ReaderImage { + final ImageProvider? provider; + final ui.Image? image; + const ReaderImage({this.provider, this.image}); + void dispose() => image?.dispose(); +} + +/// 넘겨 보는 페이지들 (그림 파일들 · 압축 파일 안 그림 · PDF) +abstract class ReaderSource { + String get title; + int get length; + String pageName(int i); + + /// [i] 번째 장을 너비 [maxWidth] 픽셀 정도로 (메모리를 아끼려고 화면 크기에 맞춰 줄임) + Future load(int i, {required int maxWidth}); + Future dispose() async {} +} + +/// 폴더의 그림 파일들 (로컬 · WebDAV). WebDAV 는 볼 때 임시 폴더로 받는다. +class ImageFilesSource extends ReaderSource { + final List paths; + final String tempDir; + @override + final String title; + ImageFilesSource(this.paths, {required this.tempDir, required this.title}); + + @override + int get length => paths.length; + @override + String pageName(int i) => vBasename(paths[i]); + + @override + Future load(int i, {required int maxWidth}) async { + final local = await vLocalCopy(paths[i], tempDir); + return ReaderImage(provider: ResizeImage.resizeIfNeeded(maxWidth, null, FileImage(File(local)))); + } +} + +/// ZIP · CBZ 안의 그림들 (이름의 자연 순서). 큰 파일도 목록만 읽고 그림은 볼 때 푼다. +class ZipImagesSource extends ReaderSource { + final Archive archive; + final InputFileStream _input; + final List entries; + @override + final String title; + ZipImagesSource._(this.archive, this._input, this.entries, this.title); + + /// [imageExts] 그림으로 볼 확장자 + static Future open(String path, {required String tempDir, required List imageExts}) async { + final local = await vLocalCopy(path, tempDir); + final input = InputFileStream(local); + final archive = ZipDecoder().decodeStream(input); + final images = [ + for (final f in archive.files) + if (f.isFile && imageExts.contains(extOf(f.name)) && !f.name.startsWith('__MACOSX/')) f, + ]..sort((a, b) => naturalCompare(a.name, b.name)); + return ZipImagesSource._(archive, input, images, vBasename(path)); + } + + @override + int get length => entries.length; + @override + String pageName(int i) => p.posix.basename(entries[i].name); + + @override + Future load(int i, {required int maxWidth}) async { + final bytes = entries[i].readBytes() ?? Uint8List(0); + return ReaderImage(provider: ResizeImage.resizeIfNeeded(maxWidth, null, MemoryImage(bytes))); + } + + @override + Future dispose() async { + await _input.close(); + } +} + +/// PDF: 보기 + 페이지 지우기 · 그림을 페이지로 넣기 · 저장 +class PdfSource extends ReaderSource { + final String path; + final String localPath; + final PdfDocument doc; + @override + final String title; + + /// 넣은 그림으로 만든 1쪽 문서들 (저장할 때까지 살아 있어야 함) + final _added = []; + bool dirty = false; + + /// 보이는 페이지 (원본 문서 · 넣은 그림 문서의 페이지). 문서 자체는 고치지 않고 저장할 때 한 번에 조립한다 + /// (같은 문서의 페이지 목록을 여러 번 바꾸면 pdfrx 가 저장할 때 엉뚱한 페이지를 남겼다). + late final List _view = [...doc.pages]; + + /// 보이는 페이지들 (시험용) + List get pages => List.unmodifiable(_view); + + PdfSource._(this.path, this.localPath, this.doc, this.title); + + static Future open(String path, {required String tempDir}) async { + await pdfrxFlutterInitialize(); + final local = await vLocalCopy(path, tempDir); + // 사본을 연다: pdfium 이 연 파일을 잡고 있어 (Windows) 저장할 때 원본을 덮어쓸 수 없으므로 + final work = p.join(tempDir, 'jj_pdf_${DateTime.now().microsecondsSinceEpoch}.pdf'); + await File(local).copy(work); + final doc = await PdfDocument.openFile(work); + return PdfSource._(path, work, doc, vBasename(path)); + } + + @override + int get length => _view.length; + @override + String pageName(int i) => '${i + 1} / ${_view.length}'; + + @override + Future load(int i, {required int maxWidth}) async { + final page = _view[i]; + final w = maxWidth.toDouble(); + final h = w * page.height / page.width; + final pdfImage = await page.render(fullWidth: w, fullHeight: h, backgroundColor: 0xffffffff); + if (pdfImage == null) return const ReaderImage(); + try { + return ReaderImage(image: await pdfImage.createImage()); + } finally { + pdfImage.dispose(); + } + } + + /// [i] 번째 페이지 지우기 (저장해야 파일에 반영) + Future deletePage(int i) async { + _view.removeAt(i); + dirty = true; + } + + /// 그림들을 한 장씩 [after] 번째 페이지 뒤에 넣는다 (-1 이면 맨 앞) + Future insertImages(List images, {required int after, required String tempDir}) async { + final newPages = []; + for (final (k, path) in images.indexed) { + final one = await imagePageDocument(path, tempDir: tempDir, sourceName: 'jj_add_${DateTime.now().microsecondsSinceEpoch}_$k'); + _added.add(one); + newPages.add(one.pages.first); + } + _view.insertAll(after + 1, newPages); + dirty = true; + } + + /// 저장 ([to] 가 없으면 원래 파일에 덮어쓰기, WebDAV 도): 빈 새 문서에 보이는 순서대로 한 번에 조립. + /// (원본 문서의 페이지 목록에 다른 문서의 페이지를 섞어 넣으면 pdfrx 가 엉뚱한 페이지를 남겨, 모두 바깥 페이지로) + Future save({String? to}) async { + final out = await PdfDocument.createNew(sourceName: 'jj_save_${DateTime.now().microsecondsSinceEpoch}'); + Uint8List bytes; + try { + out.pages = [..._view]; + bytes = await out.encodePdf(); + } finally { + await out.dispose(); + } + final target = to ?? path; + await vWrite(target, Stream.value(bytes), length: bytes.length); + dirty = false; + return target; + } + + @override + Future dispose() async { + await doc.dispose(); + for (final d in _added) { + await d.dispose(); + } + try { + await File(localPath).delete(); + } catch (_) {} + } +} + +/// 그림 한 장으로 1쪽 PDF (JPEG 가 아니면 JPEG 로 바꿈). 페이지 크기는 그림 픽셀 × 0.75 pt (96dpi 기준). +Future imagePageDocument(String path, {required String tempDir, required String sourceName}) async { + await pdfrxFlutterInitialize(); + final local = await vLocalCopy(path, tempDir); + final bytes = await File(local).readAsBytes(); + final decoded = img.decodeImage(bytes); + if (decoded == null) throw FormatException('그림을 읽을 수 없습니다', vBasename(path)); + final jpeg = extOf(path) == 'jpg' || extOf(path) == 'jpeg' ? bytes : Uint8List.fromList(img.encodeJpg(decoded, quality: 92)); + return PdfDocument.createFromJpegData(jpeg, + width: decoded.width * 0.75, height: decoded.height * 0.75, sourceName: sourceName); +} + +/// 그림들을 한 장씩 PDF 로 ([out] 에 저장, 로컬 · WebDAV) +Future imagesToPdf(List images, String out, {required String tempDir, void Function(int done)? onProgress}) async { + await pdfrxFlutterInitialize(); + final doc = await PdfDocument.createNew(sourceName: 'jj_new_${DateTime.now().microsecondsSinceEpoch}'); + final parts = []; + try { + final pages = []; + for (final (k, path) in images.indexed) { + final one = await imagePageDocument(path, tempDir: tempDir, sourceName: 'jj_img_${DateTime.now().microsecondsSinceEpoch}_$k'); + parts.add(one); + pages.add(one.pages.first); + onProgress?.call(k + 1); + } + doc.pages = pages; + final bytes = await doc.encodePdf(); + await vWrite(out, Stream.value(bytes), length: bytes.length); + } finally { + await doc.dispose(); + for (final d in parts) { + await d.dispose(); + } + } +} + +/// ZIP 목록 (폴더 · 파일 크기) +Future> zipEntries(String path, {required String tempDir}) async { + final local = await vLocalCopy(path, tempDir); + final input = InputFileStream(local); + try { + final archive = ZipDecoder().decodeStream(input); + return [for (final f in archive.files) f]; + } finally { + await input.close(); + } +} + +/// ZIP 에서 [names] (빈 목록이면 모두) 를 [dest] 폴더로 풀기 (로컬 · WebDAV). 푼 파일 수. +Future extractZip(String path, String dest, {required String tempDir, List names = const [], + void Function(int done, int total)? onProgress}) async { + final local = await vLocalCopy(path, tempDir); + final input = InputFileStream(local); + var n = 0; + try { + final archive = ZipDecoder().decodeStream(input); + final todo = [ + for (final f in archive.files) + if (f.isFile && (names.isEmpty || names.any((x) => f.name == x || f.name.startsWith(x.endsWith('/') ? x : '$x/')))) f, + ]; + for (final f in todo) { + // 압축 안 경로를 그대로 (../ 같은 바깥 경로는 막는다) + final parts = [for (final s in f.name.split('/')) if (s.isNotEmpty && s != '.' && s != '..') s]; + if (parts.isEmpty) continue; + var target = dest; + for (final s in parts.take(parts.length - 1)) { + target = vJoin(target, s); + } + await vMkdirs(target); + final bytes = f.readBytes() ?? Uint8List(0); + await vWrite(vJoin(target, parts.last), Stream.value(bytes), length: bytes.length); + n++; + onProgress?.call(n, todo.length); + } + } finally { + await input.close(); + } + return n; +} diff --git a/lib/platform/android/android_storage.dart b/lib/platform/android/android_storage.dart index dc652d4..5c03664 100644 --- a/lib/platform/android/android_storage.dart +++ b/lib/platform/android/android_storage.dart @@ -74,6 +74,10 @@ class AndroidStorageService extends DesktopStorageService { Future> pickSubtitles({String? initialDirectory}) => _pick(tr('자막 파일 선택'), subtitleExtensions, initialDirectory: initialDirectory); + @override + Future> pickFiles({required String title, required List extensions, String? initialDirectory}) => + _pick(title, extensions, initialDirectory: initialDirectory); + /// 임시 폴더: 캐시 폴더가 아니라 앱 데이터 폴더 아래 tmp. /// 저장 공간이 모자라면 Android 가 캐시 폴더를 마음대로 비워, 쓰는 중인 임시 파일 (AI 음성 · 자막 사본) 이 /// 사라질 수 있다. 대신 앱을 켤 때 (처음 쓸 때) 지난 실행의 임시 파일을 지운다. diff --git a/lib/platform/windows/desktop_storage_service.dart b/lib/platform/windows/desktop_storage_service.dart index 925c839..7c31523 100644 --- a/lib/platform/windows/desktop_storage_service.dart +++ b/lib/platform/windows/desktop_storage_service.dart @@ -30,6 +30,17 @@ class DesktopStorageService implements StorageService { return files.map((f) => f.path).whereType().toList(); } + @override + Future> pickFiles({required String title, required List extensions, String? initialDirectory}) async { + final files = await FilePicker.pickFiles( + dialogTitle: title, + initialDirectory: initialDirectory, + type: FileType.custom, + allowedExtensions: extensions, + ); + return files.map((f) => f.path).whereType().toList(); + } + @override Future> listFiles(String directory) async { final dir = Directory(directory); diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 94d1c47..d0274cd 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -9,6 +9,9 @@ abstract class StorageService { Future> pickSubtitles({String? initialDirectory}); + /// 확장자로 거른 파일 여러 개 고르기 (PDF 에 넣을 그림 등) + Future> pickFiles({required String title, required List extensions, String? initialDirectory}); + /// 폴더 안의 파일 경로 목록 (하위 폴더 제외) Future> listFiles(String directory); diff --git a/lib/ui/component_settings.dart b/lib/ui/component_settings.dart index 641500c..6cc1a48 100644 --- a/lib/ui/component_settings.dart +++ b/lib/ui/component_settings.dart @@ -6,6 +6,48 @@ import '../l10n/tr.dart'; import 'setting_tile.dart'; import 'theme.dart'; +/// 환경 설정 > 파일 탐색기 > 보기: 그림으로 볼 확장자 · ZIP 만화 보기 (이미지 · PDF · ZIP 보기 컴포넌트) +class ViewerSettings extends StatelessWidget { + final AppController c; + const ViewerSettings({super.key, required this.c}); + + static const _choices = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'heic', 'tif', 'tiff']; + + @override + Widget build(BuildContext context) { + final s = c.settings; + if (!s.components.contains('viewer')) return const SizedBox(); + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + SettingTile( + leading: const Icon(Icons.photo_library_outlined), + title: Text(tr('그림 보기 대상')), + subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(tr('이 확장자의 파일을 두 번 누르면 그림 보기로 엽니다 (오른쪽을 누르면 다음, 왼쪽을 누르면 이전 그림).')), + const SizedBox(height: 6), + Wrap(spacing: 6, runSpacing: 6, children: [ + for (final e in _choices) + FilterChip( + label: Text(e), + selected: s.imageExts.contains(e), + onSelected: (on) => c.updateSettings((x) => x.imageExts = [ + for (final y in x.imageExts) if (y != e) y, + if (on) e, + ]), + ), + ]), + ]), + ), + SwitchListTile( + secondary: const Icon(Icons.auto_stories_outlined), + value: s.zipComic, + onChanged: (v) => c.updateSettings((x) => x.zipComic = v), + title: Text(tr('ZIP · CBZ 를 만화로 보기')), + subtitle: Text(tr('켜면 두 번 눌렀을 때 안의 그림을 바로 넘겨 봅니다. 끄면 목록 (골라서 풀기) 을 엽니다.')), + ), + ]); + } +} + /// 환경 설정 > 컴포넌트: 기능 묶음 설치 (켜기) · 제거 (끄기), 화면 순서 (끌어서 바꿈), 좌우로 밀어 화면 이동. /// 화면이 하나만 남으면 위쪽 이동 버튼 줄에는 그것만 보인다. class ComponentSettings extends StatelessWidget { diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 5eeb93b..c33fa16 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -26,6 +26,9 @@ import 'player_page.dart'; import 'rsync_setup.dart'; import 'theme.dart'; import 'webdav_settings.dart'; +import 'reader_page.dart'; +import 'zip_page.dart'; +import '../core/reader_sources.dart'; import '../l10n/tr.dart'; /// 파일 탐색기 (X-plore 참고): 두 창 (트리 목록) + 가운데 기능 버튼 줄. @@ -510,7 +513,7 @@ class _ExplorerPageState extends State { } if (!list) pane.current = vDirname(e.path); pane.changed(); - await _open(e.path); + await _open(e.path, pane: pane); } /// 트리 폴더 펼치기 · 접기 (› 화살표 · 열기) @@ -533,11 +536,18 @@ class _ExplorerPageState extends State { } /// 파일 열기 (기본 동작): 동영상은 내장 플레이어 (또는 확장자별 프로그램), 그 밖은 기본 연결 프로그램 - Future _open(String path) async { + Future _open(String path, {_Pane? pane}) async { if (isVideoFile(path)) { await _play([path]); return; } + // 이미지 · PDF · ZIP 보기 (컴포넌트를 켰을 때) + if (c.settings.components.contains('viewer')) { + final ext = extOf(path); + if (c.settings.imageExts.contains(ext)) return _viewImages(path, pane); + if (ext == 'pdf') return _viewPdf(path); + if (zipExtensions.contains(ext)) return _openZip(path, pane); + } final local = await _fetch(path); if (local == null) return; final ok = await c.services.shell.openWith(local); @@ -598,21 +608,93 @@ class _ExplorerPageState extends State { await playFiles(context, c, paths, internal: internal, keepOrder: keepOrder); } + /// 그림 보기: 같은 폴더의 그림을 지금 정렬 순서대로 넘겨 본다 + Future _viewImages(String path, _Pane? pane) async { + final dir = vDirname(path); + final list = pane?.cache[dir] ?? _sorted(await listEntries(dir, showHidden: c.settings.explorerShowHidden)); + final images = [ + for (final e in list) + if (!e.isDir && c.settings.imageExts.contains(extOf(e.path))) e.path, + ]; + if (!images.any((x) => samePath(x, path))) images.insert(0, path); + final start = images.indexWhere((x) => samePath(x, path)); + final temp = await c.services.storage.tempDirectory(); + if (!mounted) return; + await openReader(context, c, ImageFilesSource(images, tempDir: temp, title: vBasename(dir)), start: start); + } + + Future _viewPdf(String path) async { + final temp = await c.services.storage.tempDirectory(); + PdfSource src; + try { + src = await PdfSource.open(path, tempDir: temp); + } catch (e) { + _snack(trf('PDF 를 열 수 없습니다: {0}', [e])); + return; + } + if (!mounted) return; + await openReader(context, c, src); + await _refreshAll([vDirname(path)]); + } + + /// ZIP: 환경 설정이 "만화 보기" 면 안의 그림을 바로 보고 (그림이 없으면 목록), 아니면 목록 + Future _openZip(String path, _Pane? pane) async { + try { + if (c.settings.zipComic && await openZipComic(context, c, path)) return; + } catch (e) { + _snack(trf('열 수 없습니다: {0}', [e])); + return; + } + if (!mounted) return; + final other = pane == null || !_dual ? null : _panes[1 - _panes.indexOf(pane)].current; + final out = await openZip(context, c, path, otherDir: other); + if (out != null) await _refreshAll([out, vDirname(out)]); + } + + /// 고른 그림들을 한 장씩 PDF 로 (같은 폴더에, 이름을 묻는다) + Future _imagesToPdf(List images) async { + if (images.isEmpty) return; + final dir = vDirname(images.first); + final first = vBasename(images.first); + final name = await _askName(tr('PDF 로 만들기'), '${first.contains('.') ? first.substring(0, first.lastIndexOf('.')) : first}.pdf'); + if (name == null || name.trim().isEmpty) return; + final out = vJoin(dir, name.toLowerCase().endsWith('.pdf') ? name : '$name.pdf'); + final temp = await c.services.storage.tempDirectory(); + try { + await imagesToPdf(images, out, tempDir: temp); + _snack(trf('그림 {0}장으로 PDF 를 만들었습니다: {1}', [images.length, vBasename(out)])); + } catch (e) { + _snack(trf('PDF 를 만들지 못했습니다: {0}', [e])); + } + await _refreshAll([dir]); + } + Future _menu(_Pane pane, FileEntry e, Offset at) async { setState(() => _active = _panes.indexOf(pane)); pane.focused = e.path; pane.changed(); final video = !e.isDir && isVideoFile(e.path); final dav = isDav(e.path); + // 그림 → PDF: 표시한 것 중 그림 (누른 것이 표시에 없으면 누른 것만) + final viewer = c.settings.components.contains('viewer'); + final picked = pane.marked.contains(e.path) ? pane.marked.toList() : [e.path]; + final images = [for (final x in picked) if (c.settings.imageExts.contains(extOf(x))) x]; + final ext = extOf(e.path); + final viewable = viewer && !e.isDir && + (c.settings.imageExts.contains(ext) || ext == 'pdf' || zipExtensions.contains(ext)); final dual = _dual; final items = <(String, IconData, String)>[ if (e.isDir) ('open', Icons.folder_open, tr('열기')), if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')), if (e.isDir) ('playFolder', Icons.play_circle_outline, tr('이 폴더의 동영상 재생')), if (video) ('playInternal', Icons.play_circle_outline, tr('내장 플레이어로 재생')), + if (viewable) ('view', Icons.visibility_outlined, tr('보기')), if (!e.isDir) ('openWith', Icons.open_in_new, tr('다른 앱으로 열기')), if (!e.isDir && !video) ('openDefault', Icons.launch, tr('기본 앱으로 열기')), if (video && !dav) ('addMkv', Icons.playlist_add, tr('MKV 목록에 추가')), + if (viewer && images.isNotEmpty) + ('toPdf', Icons.picture_as_pdf_outlined, + images.length == 1 ? tr('PDF 로 만들기') : trf('그림 {0}장을 PDF 로 만들기', [images.length])), ('select', Icons.check_circle_outline, tr('선택')), ('rename', Icons.drive_file_rename_outline, tr('이름 변경')), if (dual) ('copy', Icons.copy_outlined, tr('다른 창으로 복사')), @@ -675,6 +757,10 @@ class _ExplorerPageState extends State { await _paste(pane, e.isDir ? e.path : vDirname(e.path)); case 'delete': await _delete(pane, [e.path]); + case 'view': + await _open(e.path, pane: pane); + case 'toPdf': + await _imagesToPdf(images); case 'reveal': await c.services.shell.revealFile(e.path); case 'info': diff --git a/lib/ui/reader_page.dart b/lib/ui/reader_page.dart new file mode 100644 index 0000000..f29fef6 --- /dev/null +++ b/lib/ui/reader_page.dart @@ -0,0 +1,354 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../app/app_controller.dart'; +import '../core/reader_sources.dart'; +import '../core/vfs.dart'; +import '../l10n/tr.dart'; +import 'theme.dart'; + +/// 그림 · 만화 · PDF 보기를 연다. [start] 처음 볼 장. 닫으면 [source] 를 정리한다. +Future openReader(BuildContext context, AppController c, ReaderSource source, {int start = 0}) async { + await Navigator.of(context).push(MaterialPageRoute( + builder: (_) => ReaderPage(c: c, source: source, start: start), + )); + await source.dispose(); +} + +/// 그림 · 만화 · PDF 보기. +/// - 화면 오른쪽 (1/3) 을 누르면 다음 장, 왼쪽을 누르면 이전 장, 가운데를 누르면 위 · 아래 막대 보이기 · 숨기기 +/// (오른쪽에서 왼쪽으로 넘기기를 켜면 반대). 좌우로 밀어도 넘어간다. +/// - 위 막대: 한 쪽 맞추기 · 좌우 맞추기, 넘기는 방향, 어둡게 · 밝게, (PDF) 페이지 지우기 · 그림 넣기 · 저장 +class ReaderPage extends StatefulWidget { + final AppController c; + final ReaderSource source; + final int start; + const ReaderPage({super.key, required this.c, required this.source, this.start = 0}); + + @override + State createState() => _ReaderPageState(); +} + +class _ReaderPageState extends State { + AppController get c => widget.c; + ReaderSource get src => widget.source; + late final PageController _pages = PageController(initialPage: widget.start); + late int _index = widget.start; + bool _bars = true; + final _cache = >{}; + int _width = 0; + + /// PDF 는 페이지를 지우거나 넣으면 다시 그린다 + int _version = 0; + + @override + void initState() { + super.initState(); + // 보기 화면은 위 · 아래 시스템 막대까지 그림으로 + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + } + + @override + void dispose() { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + for (final f in _cache.values) { + unawaited(f.then((x) => x.dispose(), onError: (_) {})); + } + _pages.dispose(); + super.dispose(); + } + + Future _load(int i) => _cache.putIfAbsent(i, () => src.load(i, maxWidth: _width)); + + /// 보는 장 가까이만 남기고 (메모리) 다음 장은 미리 읽는다 + void _trim() { + for (final k in _cache.keys.toList()) { + if ((k - _index).abs() > 3) { + unawaited(_cache.remove(k)!.then((x) => x.dispose(), onError: (_) {})); + } + } + if (_index + 1 < src.length) unawaited(_load(_index + 1)); + } + + void _reset() { + for (final f in _cache.values) { + unawaited(f.then((x) => x.dispose(), onError: (_) {})); + } + _cache.clear(); + _version++; + } + + void _go(int delta) { + final to = (_index + delta).clamp(0, math.max(0, src.length - 1)).toInt(); + if (to == _index) { + ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar( + duration: const Duration(seconds: 1), content: Text(delta > 0 ? tr('마지막 장입니다.') : tr('첫 장입니다.')))); + return; + } + _pages.jumpToPage(to); + } + + void _onTap(TapUpDetails d, double width) { + final x = d.localPosition.dx; + final rtl = c.settings.readerRtl; + if (x > width * 2 / 3) { + _go(rtl ? -1 : 1); + } else if (x < width / 3) { + _go(rtl ? 1 : -1); + } else { + setState(() => _bars = !_bars); + } + } + + ColorFilter _brightness(double b) { + final double s = 1 + math.min(b, 0.0); + final double o = math.max(b, 0.0) * 160; + return ColorFilter.matrix([s, 0, 0, 0, o, 0, s, 0, 0, o, 0, 0, s, 0, o, 0, 0, 0, 1, 0]); + } + + Widget _page(int i, Size box) { + final fit = c.settings.readerFit; + return FutureBuilder( + key: ValueKey('$_version:$i'), + future: _load(i), + builder: (context, snap) { + if (snap.hasError) { + return Center( + child: Text(trf('열 수 없습니다: {0}', [snap.error]), style: const TextStyle(color: Colors.white70))); + } + final r = snap.data; + if (r == null) return const Center(child: CircularProgressIndicator()); + Widget picture(BoxFit f, {double? width}) => r.image != null + ? RawImage(image: r.image, fit: f, width: width) + : Image(image: r.provider!, fit: f, width: width, gaplessPlayback: true, filterQuality: FilterQuality.medium, + errorBuilder: (_, e, _) => + Center(child: Text(trf('열 수 없습니다: {0}', [e]), style: const TextStyle(color: Colors.white70)))); + if (fit == 'width') { + return SingleChildScrollView(child: picture(BoxFit.fitWidth, width: box.width)); + } + return SizedBox.expand(child: picture(BoxFit.contain)); + }, + ); + } + + Future _updateSettings(void Function(dynamic s) f) async { + await c.updateSettings((s) => f(s)); + if (mounted) setState(() {}); + } + + // ───────── PDF 편집 ───────── + + PdfSource? get _pdf => src is PdfSource ? src as PdfSource : null; + + Future _deletePage() async { + final pdf = _pdf!; + if (pdf.length <= 1) { + _snack(tr('마지막 한 페이지는 지울 수 없습니다.')); + return; + } + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(tr('페이지 지우기')), + content: Text(trf('{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)', [_index + 1])), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('지우기'))), + ], + ), + ); + if (ok != true) return; + await pdf.deletePage(_index); + if (!mounted) return; + setState(() { + _reset(); + _index = _index.clamp(0, pdf.length - 1); + }); + _pages.jumpToPage(_index); + } + + Future _insertImages() async { + final pdf = _pdf!; + final files = await c.services.storage.pickFiles(title: tr('PDF 에 넣을 그림 선택'), extensions: c.settings.imageExts); + if (files.isEmpty || !mounted) return; + final temp = await c.services.storage.tempDirectory(); + try { + await pdf.insertImages(files, after: _index, tempDir: temp); + } catch (e) { + _snack(trf('넣지 못했습니다: {0}', [e])); + return; + } + setState(_reset); + _snack(trf('그림 {0}장을 {1} 페이지 뒤에 넣었습니다 (저장해야 파일에 반영됩니다).', [files.length, _index + 1])); + } + + Future _save() async { + try { + final path = await _pdf!.save(); + _snack(trf('저장했습니다: {0}', [vDisplay(path)])); + setState(() {}); + } catch (e) { + _snack(trf('저장하지 못했습니다: {0}', [e])); + } + } + + Future _confirmLeave() async { + final pdf = _pdf; + if (pdf == null || !pdf.dirty) return true; + final r = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(tr('저장하지 않은 변경')), + content: Text(tr('페이지를 바꾼 것을 저장할까요?')), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, 'cancel'), child: Text(tr('취소'))), + TextButton(onPressed: () => Navigator.pop(ctx, 'discard'), child: Text(tr('저장 안 함'))), + FilledButton(onPressed: () => Navigator.pop(ctx, 'save'), child: Text(tr('저장'))), + ], + ), + ); + if (r == 'save') await _save(); + return r == 'save' || r == 'discard'; + } + + void _snack(String text) { + if (!mounted) return; + ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(text))); + } + + // ───────── 화면 ───────── + + Widget _topBar() { + final s = c.settings; + final pdf = _pdf; + IconButton btn(IconData i, String tip, VoidCallback? f, {bool on = false}) => IconButton( + tooltip: tip, + color: on ? JjColors.accent : Colors.white, + icon: Icon(i), + onPressed: f, + ); + return Container( + color: Colors.black.withValues(alpha: 0.75), + padding: EdgeInsets.only(top: MediaQuery.paddingOf(context).top), + child: SizedBox( + height: 52, + child: Row(children: [ + btn(Icons.arrow_back, tr('닫기'), () => Navigator.maybePop(context)), + Expanded( + child: Text( + '${src.title}${pdf?.dirty == true ? ' *' : ''}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 14), + ), + ), + // 좁아도 버튼은 옆으로 밀어 본다 + Flexible( + flex: 3, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + reverse: true, + child: Row(mainAxisSize: MainAxisSize.min, children: [ + btn(Icons.fit_screen_outlined, tr('한 쪽 맞추기'), () => _updateSettings((x) => x.readerFit = 'page'), + on: s.readerFit == 'page'), + btn(Icons.width_full_outlined, tr('좌우 맞추기 (세로로 밀어 봄)'), () => _updateSettings((x) => x.readerFit = 'width'), + on: s.readerFit == 'width'), + btn(Icons.swap_horiz, s.readerRtl ? tr('넘기는 방향: 오른쪽 → 왼쪽 (만화)') : tr('넘기는 방향: 왼쪽 → 오른쪽'), + () => _updateSettings((x) => x.readerRtl = !x.readerRtl), on: s.readerRtl), + btn(Icons.brightness_low, tr('어둡게'), + () => _updateSettings((x) => x.readerBrightness = (x.readerBrightness - 0.1).clamp(-0.7, 0.7))), + btn(Icons.brightness_high, tr('밝게'), + () => _updateSettings((x) => x.readerBrightness = (x.readerBrightness + 0.1).clamp(-0.7, 0.7))), + if (pdf != null) ...[ + btn(Icons.delete_outline, tr('이 페이지 지우기'), _deletePage), + btn(Icons.add_photo_alternate_outlined, tr('그림을 페이지로 넣기 (이 페이지 뒤)'), _insertImages), + btn(Icons.save_outlined, tr('저장'), pdf.dirty ? _save : null), + ], + ]), + ), + ), + ]), + ), + ); + } + + Widget _bottomBar() { + final n = src.length; + return Container( + color: Colors.black.withValues(alpha: 0.75), + padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom, left: 12, right: 12), + child: Row(children: [ + Text('${n == 0 ? 0 : _index + 1} / $n', style: const TextStyle(color: Colors.white, fontFamily: 'Consolas')), + Expanded( + child: n < 2 + ? const SizedBox(height: 48) + : Directionality( + textDirection: c.settings.readerRtl ? TextDirection.rtl : TextDirection.ltr, + child: Slider( + value: _index.toDouble(), + max: (n - 1).toDouble(), + divisions: n - 1, + onChanged: (v) => _pages.jumpToPage(v.round()), + ), + ), + ), + Flexible( + child: Text(n == 0 ? '' : src.pageName(_index), + maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: Colors.white70, fontSize: 12)), + ), + ]), + ); + } + + @override + Widget build(BuildContext context) { + final s = c.settings; + return PopScope( + canPop: _pdf?.dirty != true, + onPopInvokedWithResult: (didPop, _) async { + if (didPop) return; + if (await _confirmLeave() && context.mounted) { + _pdf?.dirty = false; + Navigator.of(context).pop(); + } + }, + child: Scaffold( + backgroundColor: Colors.black, + body: LayoutBuilder(builder: (context, box) { + // 화면 너비 (기기 픽셀) 에 맞춰 그림을 줄여 읽는다 + _width = (box.maxWidth * MediaQuery.devicePixelRatioOf(context)).round().clamp(320, 4096); + return Stack(children: [ + Positioned.fill( + child: GestureDetector( + onTapUp: (d) => _onTap(d, box.maxWidth), + child: ColorFiltered( + colorFilter: _brightness(s.readerBrightness), + child: src.length == 0 + ? Center(child: Text(tr('볼 그림이 없습니다.'), style: const TextStyle(color: Colors.white70))) + : PageView.builder( + key: ValueKey(_version), + controller: _pages, + reverse: s.readerRtl, + itemCount: src.length, + onPageChanged: (i) => setState(() { + _index = i; + _trim(); + }), + itemBuilder: (_, i) => _page(i, box.biggest), + ), + ), + ), + ), + if (_bars) ...[ + Positioned(left: 0, right: 0, top: 0, child: _topBar()), + Positioned(left: 0, right: 0, bottom: 0, child: _bottomBar()), + ], + ]); + }), + ), + ); + } +} diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index 373b20a..75a4760 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -612,6 +612,7 @@ class _SettingsPageState extends State { CopySyncSettings(c: c), // WebDAV 서버 (탐색기 · Rsync 화면 위쪽 탭) WebDavSettings(c: c), + ViewerSettings(c: c), ]), // Rsync 화면: rsync 옵션 · 가져오기 · 실시간 동기화 (lsync) · 백그라운드로 실행 _group('rsync', Icons.sync_alt, 'Rsync', [ diff --git a/lib/ui/zip_page.dart b/lib/ui/zip_page.dart new file mode 100644 index 0000000..1bdf1e9 --- /dev/null +++ b/lib/ui/zip_page.dart @@ -0,0 +1,182 @@ +import 'package:archive/archive.dart'; +import 'package:flutter/material.dart'; + +import '../app/app_controller.dart'; +import '../core/file_ops.dart' show formatSize; +import '../core/reader_sources.dart'; +import '../core/vfs.dart'; +import '../l10n/tr.dart'; +import 'reader_page.dart'; +import 'theme.dart'; + +/// ZIP · CBZ 목록: 안의 파일을 보고, 골라서 (또는 모두) 다른 곳으로 풀고, 그림이 있으면 만화처럼 본다. +/// [otherDir] 파일 탐색기의 다른 창 폴더 (풀 곳으로 먼저 보여 줌). 풀었으면 그 폴더를 돌려준다. +Future openZip(BuildContext context, AppController c, String path, {String? otherDir}) => + Navigator.of(context).push(MaterialPageRoute(builder: (_) => ZipPage(c: c, path: path, otherDir: otherDir))); + +/// ZIP 안 그림을 만화처럼 보기 (그림이 없으면 false) +Future openZipComic(BuildContext context, AppController c, String path) async { + final temp = await c.services.storage.tempDirectory(); + final src = await ZipImagesSource.open(path, tempDir: temp, imageExts: c.settings.imageExts); + if (src.length == 0) { + await src.dispose(); + return false; + } + if (!context.mounted) return true; + await openReader(context, c, src); + return true; +} + +class ZipPage extends StatefulWidget { + final AppController c; + final String path; + final String? otherDir; + const ZipPage({super.key, required this.c, required this.path, this.otherDir}); + + @override + State createState() => _ZipPageState(); +} + +class _ZipPageState extends State { + AppController get c => widget.c; + List? _entries; + Object? _error; + final _marked = {}; + (int, int)? _progress; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final temp = await c.services.storage.tempDirectory(); + final list = await zipEntries(widget.path, tempDir: temp); + if (mounted) setState(() => _entries = list.where((f) => f.isFile).toList()); + } catch (e) { + if (mounted) setState(() => _error = e); + } + } + + bool get _hasImages => _entries?.any((f) => c.settings.imageExts.contains(extOf(f.name))) ?? false; + + Future _extract({required bool all}) async { + final zipDir = vDirname(widget.path); + final base = vBasename(widget.path).replaceAll(RegExp(r'\.[^.]+$'), ''); + final dest = await showDialog( + context: context, + builder: (ctx) => SimpleDialog( + title: Text(all ? tr('모두 풀기') : trf('{0}개 풀기', [_marked.length])), + children: [ + if (widget.otherDir != null) + SimpleDialogOption( + onPressed: () => Navigator.pop(ctx, widget.otherDir), + child: ListTile( + leading: const Icon(Icons.vertical_split_outlined), + title: Text(tr('다른 창 폴더로')), + subtitle: Text(vDisplay(widget.otherDir!)), + ), + ), + SimpleDialogOption( + onPressed: () => Navigator.pop(ctx, vJoin(zipDir, base)), + child: ListTile( + leading: const Icon(Icons.create_new_folder_outlined), + title: Text(trf('ZIP 옆 새 폴더 "{0}" 로', [base])), + subtitle: Text(vDisplay(zipDir)), + ), + ), + SimpleDialogOption( + onPressed: () => Navigator.pop(ctx, zipDir), + child: ListTile( + leading: const Icon(Icons.folder_outlined), + title: Text(tr('ZIP 이 있는 폴더로')), + subtitle: Text(vDisplay(zipDir)), + ), + ), + ], + ), + ); + if (dest == null || !mounted) return; + setState(() => _progress = (0, 0)); + try { + final temp = await c.services.storage.tempDirectory(); + final n = await extractZip(widget.path, dest, + tempDir: temp, + names: all ? const [] : _marked.toList(), + onProgress: (d, t) { + if (mounted) setState(() => _progress = (d, t)); + }); + if (!mounted) return; + setState(() => _progress = null); + ScaffoldMessenger.maybeOf(context) + ?.showSnackBar(SnackBar(content: Text(trf('{0}개를 풀었습니다: {1}', [n, vDisplay(dest)])))); + Navigator.pop(context, dest); + } catch (e) { + if (!mounted) return; + setState(() => _progress = null); + ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(trf('풀지 못했습니다: {0}', [e])))); + } + } + + @override + Widget build(BuildContext context) { + final entries = _entries; + return Scaffold( + appBar: AppBar( + title: Text(vBasename(widget.path), overflow: TextOverflow.ellipsis), + actions: [ + if (_hasImages) + IconButton( + tooltip: tr('만화 보기'), + icon: const Icon(Icons.auto_stories_outlined), + onPressed: () => openZipComic(context, c, widget.path), + ), + IconButton( + tooltip: tr('고른 것 풀기'), + icon: const Icon(Icons.unarchive_outlined), + onPressed: _marked.isEmpty || _progress != null ? null : () => _extract(all: false), + ), + IconButton( + tooltip: tr('모두 풀기'), + icon: const Icon(Icons.folder_zip_outlined), + onPressed: entries == null || entries.isEmpty || _progress != null ? null : () => _extract(all: true), + ), + ], + ), + body: Column(children: [ + if (_progress != null) + LinearProgressIndicator(value: _progress!.$2 == 0 ? null : _progress!.$1 / _progress!.$2), + Expanded( + child: _error != null + ? Center(child: Text(trf('열 수 없습니다: {0}', [_error]))) + : entries == null + ? const Center(child: CircularProgressIndicator()) + : entries.isEmpty + ? Center(child: Text(tr('비어 있는 압축 파일입니다.'))) + : ListView.builder( + itemCount: entries.length, + itemBuilder: (_, i) { + final f = entries[i]; + final on = _marked.contains(f.name); + return CheckboxListTile( + dense: true, + value: on, + onChanged: (v) => setState(() => v == true ? _marked.add(f.name) : _marked.remove(f.name)), + secondary: Icon( + c.settings.imageExts.contains(extOf(f.name)) + ? Icons.image_outlined + : Icons.insert_drive_file_outlined, + color: JjColors.textDim, + ), + title: Text(f.name, maxLines: 2, overflow: TextOverflow.ellipsis), + subtitle: Text(formatSize(f.size)), + ); + }, + ), + ), + ]), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 0278694..76f08b4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -10,7 +10,7 @@ packages: source: hosted version: "2.0.0" archive: - dependency: transitive + dependency: "direct main" description: name: archive sha256: "6c5bcd986e06b94e3c40244af471750840a3d2341d1f9763a1100a14add517b4" @@ -33,6 +33,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + barcode: + dependency: transitive + description: + name: barcode + sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" + url: "https://pub.dev" + source: hosted + version: "2.2.9" + bidi: + dependency: transitive + description: + name: bidi + sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d" + url: "https://pub.dev" + source: hosted + version: "2.0.13" boolean_selector: dependency: transitive description: @@ -121,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" + cupertino_ui: + dependency: transitive + description: + name: cupertino_ui + sha256: ea0b8170b40a86262b2fc21ac0625303f52df209dbf6fccc2b98b27eff44f6ce + url: "https://pub.dev" + source: hosted + version: "1.1.2" dbus: dependency: transitive description: @@ -313,6 +337,11 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_test: dependency: "direct dev" description: flutter @@ -401,7 +430,7 @@ packages: source: hosted version: "4.1.2" image: - dependency: transitive + dependency: "direct main" description: name: image sha256: a1e7f4951e538a568e14b856702afc9ae1d2f4b202daced8d22c1b9cd211ce89 @@ -413,6 +442,14 @@ packages: description: flutter source: sdk version: "0.0.0" + intl: + dependency: transitive + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" jni: dependency: transitive description: @@ -501,6 +538,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.0" + material_ui: + dependency: transitive + description: + name: material_ui + sha256: "98ab9eda579ecae623e4b376c603035c5def78ce706f735f30bd632af5814695" + url: "https://pub.dev" + source: hosted + version: "1.6.0" media_kit: dependency: "direct main" description: @@ -629,6 +674,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: "direct main" description: @@ -677,6 +730,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + pdf: + dependency: "direct main" + description: + name: pdf + sha256: "6e40827f370d196e97c6611724d923a3d86c431ce8e2ce1e59edd03a67a1a6e6" + url: "https://pub.dev" + source: hosted + version: "3.13.1" + pdfium_dart: + dependency: transitive + description: + name: pdfium_dart + sha256: e296a26c030e6e0fb34b32ce7481a3cfa0c2d35ce80f6269551c685759f4c91f + url: "https://pub.dev" + source: hosted + version: "0.3.1" + pdfium_flutter: + dependency: transitive + description: + name: pdfium_flutter + sha256: "59897274513984b347fc98e7bf71ba457678daea8b5568cd7c688979aeb77765" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + pdfrx: + dependency: "direct main" + description: + name: pdfrx + sha256: "52e78d84a706e5d63d2502689fe8e800d823bc780ac4341b2d3683a4d726505a" + url: "https://pub.dev" + source: hosted + version: "2.6.5" + pdfrx_engine: + dependency: transitive + description: + name: pdfrx_engine + sha256: f74ca9ea4ec722545f054a1d6ee1bf01222409ecd9458e5945c0cc5feaa3d2c6 + url: "https://pub.dev" + source: hosted + version: "0.6.1" petitparser: dependency: transitive description: @@ -725,6 +818,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.1" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" record_use: dependency: transitive description: @@ -733,6 +834,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" safe_local_storage: dependency: transitive description: @@ -898,6 +1007,70 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda + url: "https://pub.dev" + source: hosted + version: "6.3.3" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610" + url: "https://pub.dev" + source: hosted + version: "6.3.33" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a" + url: "https://pub.dev" + source: hosted + version: "6.4.2" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201" + url: "https://pub.dev" + source: hosted + version: "3.2.6" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" + url: "https://pub.dev" + source: hosted + version: "3.1.6" uuid: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 464601a..ca3b5ba 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -61,6 +61,10 @@ dependencies: xml: ^7.0.1 flutter_inappwebview: ^6.1.5 ffmpeg_kit_flutter_new_min_gpl: 2.6.2 + pdfrx: ^2.6.5 + pdf: ^3.13.1 + archive: ^4.3.0 + image: ^4.10.1 # Android 빌드 도구 (AGP 9) 에 맞게 고친 사본 (packages/flutter_inappwebview_android/JJ_PATCH.md) dependency_overrides: flutter_inappwebview_android: diff --git a/test/reader_test.dart b/test/reader_test.dart new file mode 100644 index 0000000..7244009 --- /dev/null +++ b/test/reader_test.dart @@ -0,0 +1,114 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:archive/archive_io.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/reader_sources.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/reader_page.dart'; +import 'package:path/path.dart' as p; + +Uint8List _png(int r, int g, int b, {int w = 40, int h = 60}) { + final im = img.Image(width: w, height: h); + img.fill(im, color: img.ColorRgb8(r, g, b)); + return Uint8List.fromList(img.encodePng(im)); +} + +void main() { + late Directory tmp; + setUp(() => tmp = Directory.systemTemp.createTempSync('jj_reader_')); + tearDown(() => tmp.deleteSync(recursive: true)); + + test('ZIP: 목록 · 그림만 자연 순서 · 골라서 / 모두 풀기 (바깥 경로 막기)', () async { + final zip = p.join(tmp.path, 'comic.zip'); + final a = Archive() + ..add(ArchiveFile.bytes('ch1/10.png', _png(1, 2, 3))) + ..add(ArchiveFile.bytes('ch1/2.png', _png(4, 5, 6))) + ..add(ArchiveFile.bytes('ch1/1.jpg', img.encodeJpg(img.Image(width: 8, height: 8)))) + ..add(ArchiveFile.bytes('readme.txt', 'hi'.codeUnits)) + ..add(ArchiveFile.bytes('../evil.txt', 'x'.codeUnits)); + File(zip).writeAsBytesSync(ZipEncoder().encode(a)); + + final entries = await zipEntries(zip, tempDir: tmp.path); + expect(entries.where((f) => f.isFile).length, 5); + final src = await ZipImagesSource.open(zip, tempDir: tmp.path, imageExts: defaultImageExtensions); + expect([for (var i = 0; i < src.length; i++) src.pageName(i)], ['1.jpg', '2.png', '10.png']); + final page = await src.load(1, maxWidth: 800); + expect(page.provider, isNotNull); + await src.dispose(); + + final out1 = p.join(tmp.path, 'out1'); + expect(await extractZip(zip, out1, tempDir: tmp.path, names: ['readme.txt']), 1); + expect(File(p.join(out1, 'readme.txt')).readAsStringSync(), 'hi'); + final out2 = p.join(tmp.path, 'out2'); + expect(await extractZip(zip, out2, tempDir: tmp.path, names: ['ch1']), 3); // 폴더째 + expect(File(p.join(out2, 'ch1', '10.png')).existsSync(), isTrue); + final out3 = p.join(tmp.path, 'out3'); + await extractZip(zip, out3, tempDir: tmp.path); + expect(File(p.join(out3, 'evil.txt')).existsSync(), isTrue); // ../ 는 떼고 안에 + expect(File(p.join(tmp.path, 'evil.txt')).existsSync(), isFalse); + }); + + test('보기 설정 저장 · 읽기', () { + final s = AppSettings() + ..imageExts = ['png', 'webp'] + ..readerFit = 'width' + ..readerRtl = true + ..readerBrightness = 0.3 + ..zipComic = false; + final b = AppSettings.fromJson(s.toJson()); + expect([b.imageExts, b.readerFit, b.readerRtl, b.readerBrightness, b.zipComic], [ + ['png', 'webp'], 'width', true, 0.3, false, + ]); + expect(AppSettings.fromJson({}).imageExts, defaultImageExtensions); + }); + + testWidgets('그림 보기: 오른쪽을 누르면 다음, 왼쪽을 누르면 이전, 가운데는 막대 숨기기 · 오른쪽→왼쪽 넘기기', (tester) async { + tester.view.physicalSize = const Size(900, 1200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final files = [ + for (final (i, n) in ['a.png', 'b.png', 'c.png'].indexed) + (File(p.join(tmp.path, n))..writeAsBytesSync(_png(i * 80, 0, 0))).path, + ]; + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + final src = ImageFilesSource(files, tempDir: tmp.path, title: 'imgs'); + await tester.pumpWidget(MaterialApp(home: ReaderPage(c: c, source: src))); + await tester.pump(); + expect(find.text('1 / 3'), findsOneWidget); + expect(find.text('a.png'), findsOneWidget); + Future tapAt(double x) async { + await tester.tapAt(Offset(x, 600)); + await tester.pumpAndSettle(); + } + + await tapAt(850); // 오른쪽 → 다음 + expect(find.text('2 / 3'), findsOneWidget); + await tapAt(850); + expect(find.text('3 / 3'), findsOneWidget); + await tapAt(50); // 왼쪽 → 이전 + expect(find.text('2 / 3'), findsOneWidget); + await tapAt(450); // 가운데 → 막대 숨김 + expect(find.text('2 / 3'), findsNothing); + await tapAt(450); + expect(find.text('2 / 3'), findsOneWidget); + // 만화 (오른쪽 → 왼쪽): 왼쪽을 누르면 다음 + await tester.tap(find.byTooltip('넘기는 방향: 왼쪽 → 오른쪽')); + await tester.pumpAndSettle(); + expect(c.settings.readerRtl, isTrue); + await tapAt(50); + expect(find.text('3 / 3'), findsOneWidget); + // 맞추기 · 밝기 + await tester.tap(find.byTooltip('좌우 맞추기 (세로로 밀어 봄)')); + await tester.tap(find.byTooltip('밝게')); + await tester.pump(); + expect(c.settings.readerFit, 'width'); + expect(c.settings.readerBrightness, closeTo(0.1, 1e-9)); + }); +} diff --git a/test/ui_test.dart b/test/ui_test.dart index 537bf28..6c6ee7b 100644 --- a/test/ui_test.dart +++ b/test/ui_test.dart @@ -42,6 +42,9 @@ class _FakeStorage implements StorageService { @override Future> pickSubtitles({String? initialDirectory}) async => []; @override + Future> pickFiles({required String title, required List extensions, String? initialDirectory}) async => + []; + @override Future> listFiles(String directory) async => [r'D:\m\영화.mkv', r'D:\m\영화.ko.srt']; @override