이미지 · PDF · ZIP 보기 (컴포넌트 "viewer")

- 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 <noreply@anthropic.com>
묶음3
RyuJongJin 2026-10-08 15:24:47 +09:00
parent 6eb0dfc70a
commit ac8fd8ad9a
18 changed files with 1457 additions and 4 deletions

View File

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

View File

@ -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 ペイン",
"한국어": "韓国語",
"한글": "ハングル",

View File

@ -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 @@
"한 번: 바로 실행": "单击: 立即打开",
"한 번: 선택 · 두 번: 실행 (기본)": "单击: 选择 · 双击: 打开 (默认)",
"한 번에": "一次执行",
"한 쪽 맞추기": "适应整页",
"한 창": "单窗格",
"한국어": "韩语",
"한글": "韩文",

View File

@ -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);
});
}

View File

@ -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<String> 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<DavServer> 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),

View File

@ -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<ReaderImage> load(int i, {required int maxWidth});
Future<void> dispose() async {}
}
/// 폴더의 그림 파일들 (로컬 · WebDAV). WebDAV 는 볼 때 임시 폴더로 받는다.
class ImageFilesSource extends ReaderSource {
final List<String> 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<ReaderImage> 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<ArchiveFile> entries;
@override
final String title;
ZipImagesSource._(this.archive, this._input, this.entries, this.title);
/// [imageExts] 그림으로 볼 확장자
static Future<ZipImagesSource> open(String path, {required String tempDir, required List<String> 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<ReaderImage> 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<void> 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 = <PdfDocument>[];
bool dirty = false;
/// 보이는 페이지 (원본 문서 · 넣은 그림 문서의 페이지). 문서 자체는 고치지 않고 저장할 때 한 번에 조립한다
/// (같은 문서의 페이지 목록을 여러 번 바꾸면 pdfrx 가 저장할 때 엉뚱한 페이지를 남겼다).
late final List<PdfPage> _view = [...doc.pages];
/// 보이는 페이지들 (시험용)
List<PdfPage> get pages => List.unmodifiable(_view);
PdfSource._(this.path, this.localPath, this.doc, this.title);
static Future<PdfSource> 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<ReaderImage> 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<void> deletePage(int i) async {
_view.removeAt(i);
dirty = true;
}
/// 그림들을 한 장씩 [after] 번째 페이지 뒤에 넣는다 (-1 이면 맨 앞)
Future<void> insertImages(List<String> images, {required int after, required String tempDir}) async {
final newPages = <PdfPage>[];
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<String> 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<void> 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<PdfDocument> 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<void> imagesToPdf(List<String> 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 = <PdfDocument>[];
try {
final pages = <PdfPage>[];
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<List<ArchiveFile>> 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<int> extractZip(String path, String dest, {required String tempDir, List<String> 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;
}

View File

@ -74,6 +74,10 @@ class AndroidStorageService extends DesktopStorageService {
Future<List<String>> pickSubtitles({String? initialDirectory}) =>
_pick(tr('자막 파일 선택'), subtitleExtensions, initialDirectory: initialDirectory);
@override
Future<List<String>> pickFiles({required String title, required List<String> extensions, String? initialDirectory}) =>
_pick(title, extensions, initialDirectory: initialDirectory);
/// 임시 폴더: 캐시 폴더가 아니라 앱 데이터 폴더 아래 tmp.
/// 저장 공간이 모자라면 Android 가 캐시 폴더를 마음대로 비워, 쓰는 중인 임시 파일 (AI 음성 · 자막 사본) 이
/// 사라질 수 있다. 대신 앱을 켤 때 (처음 쓸 때) 지난 실행의 임시 파일을 지운다.

View File

@ -30,6 +30,17 @@ class DesktopStorageService implements StorageService {
return files.map((f) => f.path).whereType<String>().toList();
}
@override
Future<List<String>> pickFiles({required String title, required List<String> 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<String>().toList();
}
@override
Future<List<String>> listFiles(String directory) async {
final dir = Directory(directory);

View File

@ -9,6 +9,9 @@ abstract class StorageService {
Future<List<String>> pickSubtitles({String? initialDirectory});
/// 확장자로 거른 파일 여러 개 고르기 (PDF 에 넣을 그림 등)
Future<List<String>> pickFiles({required String title, required List<String> extensions, String? initialDirectory});
/// 폴더 안의 파일 경로 목록 (하위 폴더 제외)
Future<List<String>> listFiles(String directory);

View File

@ -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 {

View File

@ -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<ExplorerPage> {
}
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<ExplorerPage> {
}
/// 파일 열기 (기본 동작): 동영상은 내장 플레이어 (또는 확장자별 프로그램), 그 밖은 기본 연결 프로그램
Future<void> _open(String path) async {
Future<void> _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<ExplorerPage> {
await playFiles(context, c, paths, internal: internal, keepOrder: keepOrder);
}
/// 그림 보기: 같은 폴더의 그림을 지금 정렬 순서대로 넘겨 본다
Future<void> _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<void> _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<void> _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<void> _imagesToPdf(List<String> 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<void> _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<ExplorerPage> {
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':

354
lib/ui/reader_page.dart Normal file
View File

@ -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<void> openReader(BuildContext context, AppController c, ReaderSource source, {int start = 0}) async {
await Navigator.of(context).push(MaterialPageRoute<void>(
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<ReaderPage> createState() => _ReaderPageState();
}
class _ReaderPageState extends State<ReaderPage> {
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, Future<ReaderImage>>{};
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<ReaderImage> _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(<double>[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<ReaderImage>(
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<void> _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<void> _deletePage() async {
final pdf = _pdf!;
if (pdf.length <= 1) {
_snack(tr('마지막 한 페이지는 지울 수 없습니다.'));
return;
}
final ok = await showDialog<bool>(
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<void> _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<void> _save() async {
try {
final path = await _pdf!.save();
_snack(trf('저장했습니다: {0}', [vDisplay(path)]));
setState(() {});
} catch (e) {
_snack(trf('저장하지 못했습니다: {0}', [e]));
}
}
Future<bool> _confirmLeave() async {
final pdf = _pdf;
if (pdf == null || !pdf.dirty) return true;
final r = await showDialog<String>(
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()),
],
]);
}),
),
);
}
}

View File

@ -612,6 +612,7 @@ class _SettingsPageState extends State<SettingsPage> {
CopySyncSettings(c: c),
// WebDAV 서버 (탐색기 · Rsync 화면 위쪽 탭)
WebDavSettings(c: c),
ViewerSettings(c: c),
]),
// Rsync 화면: rsync 옵션 · 가져오기 · 실시간 동기화 (lsync) · 백그라운드로 실행
_group('rsync', Icons.sync_alt, 'Rsync', [

182
lib/ui/zip_page.dart Normal file
View File

@ -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<String?> openZip(BuildContext context, AppController c, String path, {String? otherDir}) =>
Navigator.of(context).push<String>(MaterialPageRoute(builder: (_) => ZipPage(c: c, path: path, otherDir: otherDir)));
/// ZIP 안 그림을 만화처럼 보기 (그림이 없으면 false)
Future<bool> 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<ZipPage> createState() => _ZipPageState();
}
class _ZipPageState extends State<ZipPage> {
AppController get c => widget.c;
List<ArchiveFile>? _entries;
Object? _error;
final _marked = <String>{};
(int, int)? _progress;
@override
void initState() {
super.initState();
_load();
}
Future<void> _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<void> _extract({required bool all}) async {
final zipDir = vDirname(widget.path);
final base = vBasename(widget.path).replaceAll(RegExp(r'\.[^.]+$'), '');
final dest = await showDialog<String>(
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)),
);
},
),
),
]),
);
}
}

View File

@ -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:

View File

@ -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:

114
test/reader_test.dart Normal file
View File

@ -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<void> 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));
});
}

View File

@ -42,6 +42,9 @@ class _FakeStorage implements StorageService {
@override
Future<List<String>> pickSubtitles({String? initialDirectory}) async => [];
@override
Future<List<String>> pickFiles({required String title, required List<String> extensions, String? initialDirectory}) async =>
[];
@override
Future<List<String>> listFiles(String directory) async =>
[r'D:\m\영화.mkv', r'D:\m\영화.ko.srt'];
@override