Compare commits

...

26 Commits

Author SHA1 Message Date
RyuJongJin 189e51b45e WIP 시험 흔들림 (10/21 전체 실행): ① 가짜 DNS - OS 가 TCP 번호를 차례로 주는데 그 둘레가 통째로 UDP 막힌 범위 (10013) 라 50번 모두 실패 → 홀수 번째는 UDP 를 먼저 OS 가 고른 번호로 열고 그 번호로 TCP ② QueuedHwpDoc "제 시작부터 잰다" - 뒤 묶음 제한 300ms 가 무거울 때 놓아주기까지의 틈보다 짧았다 → 제한 1.5초 · 줄에서 2초 기다림 (뜻은 같음) · 3번 반복 통과 (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 01:45:27 +09:00
RyuJongJin 77710c2a5f WIP 좁은 창 열 숨기기 · 보기 화면 창 띄우기 (관리자 지시 10/20): ① Windows · TC 스타일에서 이름 열이 120 (× 글자 배율) 보다 좁으면 유형 (TC 확장자) → 날짜 순으로 숨김, 크기는 남김 - 화면이 모자랄 때만, 넓어지면 다시 보임, 열 설정은 그대로 (열 머리 · 줄이 같은 창 폭으로 정함) · 글자 배율을 곱한 것은 d3 판단 (1.95 배 글자의 120px 은 네 글자쯤) ② 보기 화면 PDF 창 (페이지 지우기 · 그림 넣기) 과 저장 안 한 변경 창도 지금 장면이 끝난 뒤 띄움 ('node.built' 와 같은 모양 - 미리 막음, 저장 안 한 변경 창은 d3 판단) · 시험 (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 01:45:26 +09:00
RyuJongJin cb50d28486 WIP 큰 글자 · 줄 간격 (관리자 지시 10/20, 28 검토): ① 글자가 커서 (기기 글자 × 목록 글자 크기) 좁은 화면에 "폴더 + 파일 목록" 두 창이 다 들어가지 않으면 그동안만 한 창 (트리 + 위 경로 막대) - 설정은 split 그대로, 넓어지거나 글자가 작아지면 다시 두 창 (d3 판단: 두 창 필요 폭 = 182 + 90 × 글자 배율, 1배는 360 폭에도 들어가고 1.3 × 150% 는 412 에도 모자람) ② 트리 이름 자리를 48 또는 줄 폭의 40% 로 (깊은 폴더 · 큰 글자에서 들여쓰기에 밀려 몇 글자만 남던 것) ③ 줄 간격 설명에 "좁게는 손가락으로 누르기 어려울 수 있습니다" (하한으로 막지 않음) · 시험 · 캡처 (기기 1.3) (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:54:47 +09:00
RyuJongJin f3f1cebbc8 WIP 보기 화면 사진 정보 창: 디버그 의미 트리 assertion ('node.built', 28 지적 10/20) - 장을 손으로 넘긴 뒤 위 막대 단추를 누른 그 이벤트 안에서 창을 띄우면 창을 닫을 때 났다 (갤러리 줄과 상관없음 - 끄고도 남, 시험이 띄운 창 · 넘기지 않은 때는 안 남). 지금 장면이 끝난 뒤 띄움 (재현 4번 → 0번) · 통합 시험 integration_test/reader_dialog_semantics_test.dart (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:26:40 +09:00
RyuJongJin fda636ac32 WIP 시험 흔들림: dns TC 시험의 가짜 DNS - 막힌 포트 (Windows 10013, Hyper-V 막아 둔 범위) 의 UDP 열기 오류가 try 밖으로 (잡히지 않은 비동기 오류) 나와 실패하던 것 (wt_b3 반복 3번 중 1번 · 관리자 전체 실행) - 열기를 따로 zone 에서 돌려 어느 쪽으로 오든 다른 번호로 다시 (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:51:39 +09:00
RyuJongJin 4ec505db13 WIP 계속 보기 시계는 그 장이 다 보인 뒤부터 (관리자 지시 10/20, 28 검토): WebDAV 처럼 느린 원본에서 받는 동안 시간이 흘러 보지도 못한 장을 넘겼다 - 장마다 한 번 타이머 (그림 첫 장면 · 실패 글이 그려진 뒤 시작, 받는 중에는 재지 않음) · 시험: 느린 원본에서 10초 동안 넘기지 않고 다 받은 뒤 3초에 넘김, 기존 시험은 넘긴 뒤 한 장면을 더 지나게 (FutureBuilder 가 그림을 받아 그리는 장면) (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:51:38 +09:00
RyuJongJin 9ff8343835 WIP 좁은 화면 "폴더 + 파일 목록" 트리 창 (관리자 지시 10/20): 폭 < 600 이면 트리에 최소 폭 (130px 또는 버튼 줄을 뺀 폭의 42%) · 트리에는 ✔ 칸 · 크기 줄 없이 이름만 (선택은 오른쪽 목록), 줄에 들어가면 이름을 두 줄까지 · 작은 아이콘 칸 (28) · 화살표 36 · 들여쓰기 반 (폭의 1/10 까지) - 412 폭에서 트리 77px 로 이름이 한 글자도 안 보이던 것 · 넓은 화면은 그대로 (2 : 3, ✔) · 끌어서 폭 바꾸기는 지금 없음 · 시험 (412 · 360 × 3 스타일, Android 화살표 폭) · 캡처는 Android 플랫폼으로 (Windows 로 그리면 화살표가 20 이라 폰보다 넉넉해 보였다) (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:39:31 +09:00
RyuJongJin ba968cbed9 WIP X-plore 비교 40 (관리자 지시 10/20): 파일 탐색기 목록 글자 크기 (80~150%, 처음 100%) · 줄 간격 (좁게 0.8 · 보통 · 넓게 1.25, 처음 보통) - 환경 설정 > 파일 탐색기, 세 스타일 모두, 앱 화면 크기 · 기기 글자 크기와 곱함 (처음 값은 예전 줄 높이 그대로) · 버그 고침: 412 폭 "폴더 + 파일 목록" 의 좁은 트리 (77px) 에서 X-plore · TC 줄이 넘치던 것 (100% 에서도) - 아이콘 칸을 남은 폭만큼 · 시험 (세 스타일 × 100/150 넓게/기기 1.3×120 좁게/80) · 캡처 시험 integration_test/explorer_text_shot_test.dart (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:45:24 +09:00
RyuJongJin 52b3f7e4d1 WIP .nomedia 뒤 갤러리 다시 읽기 (관리자 지시 10/20, 판단 ② 바꿈): 넣을 때 · 지울 때 모두 .nomedia 와 그 폴더를 MediaScannerConnection.scanFile 로 (MainActivity "mediaScan", AppShell.mediaScan - Windows 는 할 일 없음, 실패해도 조용히) · 안내 글 "반영되기까지 잠깐 걸릴 수 있습니다" · 시험 (넣고 지울 때 부름) (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:06:55 +09:00
RyuJongJin fc0f6316f9 WIP X-plore 맞추기 (관리자 지시 10/20): 숨기기 - [목록에서 숨기기] 전에 무엇이 바뀌는지 확인 창 (목록에서만 빠지고 파일 · 다른 앱은 그대로, 다시 보는 곳) · [다음부터 묻지 않기] (환경 설정 > 파일 탐색기 "목록에서 숨기기 전에 묻기", 처음 켬) · 폴더 길게 누르기 [갤러리 · 음악 앱에서 숨기기 (.nomedia)] / [다시 보이기 (.nomedia 지우기)] (Android, 확인 창 뒤 빈 .nomedia 만 만들고 지움 - 안의 파일 그대로) · 시험 (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:43:51 +09:00
RyuJongJin 1f0c094656 WIP X-plore 맞추기 (관리자 지시 10/20): 보기 화면 사진 정보 (Exif) - 위 막대 [사진 정보 (Exif)] (폴더의 그림만): 파일 · 크기 (픽셀 · 바이트) · 찍은 날 · 기기 · 위치 - 위치는 [위치 보기] 를 눌러야 보이고 지도 · 인터넷으로 보내지 않음 (링크 없음) · Exif 없으면 안내 · image 패키지 (MIT, 이미 씀) 로 읽어 새 의존 없음 · 시험 (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:35:34 +09:00
RyuJongJin ed24463192 WIP X-plore 맞추기 (관리자 지시 10/20): 보기 화면 볼륨 키로 넘기기 (아래 = 다음 장, 위 = 이전 장, Android) - 처음 끔 (환경 설정 > 보기), 켰을 때만 보기 화면에서 가로채고 (처리한 키는 시스템 볼륨을 바꾸지 않음) 동영상 · 다른 화면은 그대로 · 시험 (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:29:55 +09:00
RyuJongJin 99de4f4bc9 WIP X-plore 맞추기 (관리자 지시 10/20): 그림 보기 아래 갤러리 줄 - 썸네일 줄, 누르면 그 장으로, 지금 장은 테두리 · 장이 바뀌면 따라감 · 그림 원본 (폴더 · ZIP) 만 · 처음 끔 (위 막대 버튼 · 환경 설정 > 보기에서 켬) · 시험 (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:27:52 +09:00
RyuJongJin dc77998e63 WIP X-plore 맞추기 (관리자 지시 10/20): 폴더 길게 누르기 [사진 슬라이드쇼] - 그 폴더의 그림 (지금 정렬 · 숨긴 것 빼고) 을 보기 화면으로 열고 [계속 보기] (117) 를 켠 채 시작, 그림이 없으면 알림 · 시험 (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:23:40 +09:00
RyuJongJin 6b4743c9e9 WIP 보기 화면 [다시 시도] (28 지적, 관리자 지시 10/20): 그림 · ZIP 원본도 다시 읽음 - 그 장의 화면 키를 올리고 (같은 키 · 같은 그림이면 Image 가 다시 읽지 않았다) 그림 캐시에서도 비움 · 그림 디코드 오류는 "그림 파일을 읽을 수 없습니다 - 깨졌거나 지원하지 않는 형식", 원문은 [자세히] · 시험: 깨진 그림 → 고친 뒤 [다시 시도] 하면 열림 (키 올리기를 빼면 실패함을 확인) (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:05:47 +09:00
RyuJongJin ae4e29620e next11: rc_next 44c69af + hwp2 (7bc452e: Android HWP · HWPX 보기 1 · 2단계 · 고침) - 시험판 988
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:34:50 +09:00
RyuJongJin 7bc452e54c WIP HWP 고침 (관리자 지시 10/20, 28 검토): ① 앱 시작의 HWP 작업 폴더 정리를 기다리지 않음 (queue_test 가 가짜 시간 안에서 10분 걸리던 회귀) ② 시간 제한은 바꾸기가 실제로 시작된 때부터 · 멈춤은 요청마다 (QueuedHwpDoc 로 줄 세우기 · 시간 · 멈춤을 FFI 에서 떼어 시험할 수 있게), Rust ABI 3: export 에 요청 번호 · jj_rhwp_cancel(번호) · jj_rhwp_cancel_all (시작할 때 깃발을 지우지 않음), 닫으면 줄에 선 것은 시작하지 않음 · check_apk 필요 함수 8개 ③ 진행 창 close 는 그 창이 맨 위일 때만 · 시험: 줄 세우기 3개 · ABI 판 3 (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:11:11 +09:00
RyuJongJin 76fd2f3cbf WIP HWP ABI 판 확인 (관리자 지시 10/20, 28 지적 - 옛 .so 에 새 인수를 넘기면 힙 손상): Rust jj_rhwp_abi_version() = 2 · Dart 는 처음 HWP 를 열 때 꼭 있어야 할 함수 7개와 판을 보고 다르면 앱 안 보기를 쓰지 않고 다른 앱으로 · check_apk 가 APK 안 libjj_rhwp.so 의 내보낸 기호를 NDK llvm-nm 으로 보고 없으면 실패 (986 의 옛 .so 를 잡음) · 시험 hwp_abi_test (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:19:05 +09:00
RyuJongJin 0d2b14ce68 WIP HWP: 닫을 때 다 연 PDF 묶음은 닫기를 기다림 (Windows 는 연 파일을 잡아 임시 폴더를 못 지웠음 - 통합 시험이 잡음), 만드는 중인 것만 기다리지 않음 · hwp_pdf_ffi 줄 끝 CRLF (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:04:45 +09:00
RyuJongJin d18b3b02ad WIP HWP 고침 (관리자 지시 10/20, 28 지적): ① 라이선스 - .so 에 실제로 링크되는 crate 167개 (Android 대상 · 일반 의존 · proc-macro 뺌) 마다 이름 · 판 · 라이선스 · 원문을 tool/licenses/rhwp_crates.py 가 RHWP_LICENSES.txt 로 (빌드 스크립트가 부름, npm 쪽 THIRD_PARTY_LICENSES.md 는 뺌), licenses_test 가 Cargo.lock 과 견줌 ③ 진짜 멈추기 - jj_rhwp_cancel (AtomicBool, 쪽마다 확인), 시간 제한 · 닫을 때 부름 ④ 오류 종류 코드 (JJ_RHWP_E_*) → 번역 글, 원문은 [자세히] · 작업 기록 (.so 다시 빌드 전, 검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:58:15 +09:00
RyuJongJin c43637b9cc WIP HWP 고침 (관리자 지시 10/20, 28 지적): 진행 창 (여는 중 · PDF 로 바꾸는 중) 에 [취소] - ← · [취소] 로 먼저 닫혔으면 끝난 뒤 pop 하지 않음 (예전에는 다른 화면을 닫을 수 있었음, Windows 문서 미리보기도) · 그만두면 결과를 보이지 않고 연 문서를 닫음 · 앱이 켜질 때 남은 HWP 작업 폴더 (jj_hwp_) 정리 · 시험 progress_dialog_test · 정리 시험 (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:51:04 +09:00
RyuJongJin 34efa34193 WIP HWP 2단계 (관리자 지시 10/20): 열어 두는 PDF 묶음은 최근 3개까지 (몇백 쪽도 메모리가 늘지 않게, 그리는 묶음은 닫지 않음, 닫은 묶음은 파일만 다시 열기) · 보기 화면의 "열 수 없습니다" 에 [다시 시도] (그 장의 기억을 지우고 다시 부름 - 모든 보기 원본) · 닫을 때 만드는 중인 묶음을 기다리지 않음 · 묶음 여는 부분을 바꿔 끼울 수 있게 (HwpChunkOpener) 해서 단위 시험 hwp_source_unit_test (PDFium 없이 4개) (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:46:30 +09:00
RyuJongJin 65098287e8 시험: remote_idle_test 흔들림 고침을 rc_next f43f067 에서 가져옴 (slow() 0.1초 간격 · 3초 제한, SFTP 준비 단계 10초 - 15, 코드 변경 없음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:34:31 +09:00
RyuJongJin 56c632d372 next10: hwp_pdf (ced0ae9) + rsync_narrow (dfed3fa: 412 Rsync 화면 오른쪽 창 · 옵션 이름 줄바꿈) - 시험판 986
합침 (d3): copy_sync_settings 의 import 한 줄 (optionProblemText · keepOptionsTogether 둘 다).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:54:41 +09:00
RyuJongJin ced0ae9e5d WIP Android HWP · HWPX 보기 1단계 (관리자 지시 10/20): rhwp v0.8.7 (MIT, 커밋 1a76570 고정) 를 얇은 C ABI (tool/rhwp_ffi) 와 함께 NDK 로 빌드 (tool/build_rhwp_android.sh - git sparse 받기 · Rust 1.93.1 · Cargo.lock --locked · 16KB 정렬 · 경로 /jj 로 바꿈) → APK 의 libjj_rhwp.so 를 dart:ffi 로 · 보기 화면 HwpSource: 보는 쪽의 5쪽 묶음만 PDF 로 바꾸고 다음 묶음을 미리, 묶음마다 3분 제한, 실패한 묶음은 그 쪽에 이유 · 다시 보면 다시 · 닫으면 멈춤 · 파일 탐색기: 보기 컴포넌트를 켰을 때 앱 안에서, 열지 못하면 이유를 알리고 다른 앱으로 · 문서의 가로 쪽은 자동으로 반 나누지 않음 (내 판단) · 라이선스 화면 · Android 고지 · 시험 hwp_source_test (가짜 엔진, Windows 통합) (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:43:20 +09:00
RyuJongJin 4b450e2e5c WIP 시험: Tor · CEF · DNS 실제 연결 시험의 데이터 폴더는 heldByApp (tor · WebView 가 앱이 끝날 때까지 잡아 %TEMP% 에 jj_tor_real_ 이 남았음 - test_temp_check 가 실행 폴더와 함께 지움, 관리자 지시 10/20) (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:39:17 +09:00
43 changed files with 17138 additions and 91 deletions

View File

@ -67,9 +67,12 @@ android {
sourceSets["main"].jniLibs.srcDir("../../third_party/tor/android")
// 86: 7-Zip 25.01 (lib7zz.so 라는 이름의 실행 파일 - 7z · RAR 풀기): tool/build_7zz_android.sh (공식 소스, SHA-256 고정)
sourceSets["main"].jniLibs.srcDir("../../third_party/7zip/android")
// 관리자 지시 (10/20): HWP · HWPX → PDF (libjj_rhwp.so - dart:ffi 로 읽는 라이브러리): tool/build_rhwp_android.sh (rhwp v0.8.7, 커밋 고정)
sourceSets["main"].jniLibs.srcDir("../../third_party/rhwp/android")
// 195: third_party 의 실행 파일은 third_party_libs.txt 에 적은 것만 (공유 폴더에 다른 판이 있어도 섞이지 않게)
val thirdPartyLibs = file("third_party_libs.txt").readLines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") }.toSet()
listOf("../../third_party/rsync/android", "../../third_party/sdcpp/android", "../../third_party/tor/android", "../../third_party/7zip/android").forEach { dir ->
listOf("../../third_party/rsync/android", "../../third_party/sdcpp/android", "../../third_party/tor/android", "../../third_party/7zip/android",
"../../third_party/rhwp/android").forEach { dir ->
fileTree(dir).matching { include("**/*.so") }.forEach { so ->
if (so.name !in thirdPartyLibs) packaging.jniLibs.excludes += "**/${so.name}"
}

View File

@ -4,6 +4,7 @@ import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Build
import android.os.Environment
@ -165,6 +166,14 @@ class MainActivity : FlutterActivity() {
Build.VERSION.SDK_INT < Build.VERSION_CODES.O || packageManager.canRequestPackageInstalls()
)
"openFolder" -> result.success(openFolder(call.argument<String>("path") ?: ""))
// X-plore 맞추기 (관리자 지시 10/20): .nomedia 를 넣고 지운 뒤 갤러리 · 음악 앱이 다시 읽게 (실패해도 조용히)
"mediaScan" -> result.success(try {
val paths = call.argument<List<String>>("paths") ?: emptyList()
if (paths.isNotEmpty()) MediaScannerConnection.scanFile(applicationContext, paths.toTypedArray(), null, null)
true
} catch (e: Exception) {
false
})
// 예전 버전으로 되돌리기: Android 는 낮은 버전을 위에 설치하지 못해 앱을 지우는 확인 창을 연다
"uninstallSelf" -> {
startActivity(Intent(Intent.ACTION_DELETE, Uri.parse("package:$packageName")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))

View File

@ -7,3 +7,4 @@ libsdcli.so
libsdcli_v80.so
libtor.so
lib7zz.so
libjj_rhwp.so

View File

@ -72,6 +72,7 @@
"+30분": "+30 min",
"- 감지한 언어": "- detected",
"-1 은 아무렇게나": "-1 = random",
".nomedia 를 지웠습니다. 갤러리 · 음악 앱에 다시 보입니다.": ".nomedia removed. Gallery and music apps will show it again.",
".{0} 재생 프로그램 (exe)": ".{0} player (exe)",
"/ 로 시작하는 전체 경로로": "use a full path starting with /",
"0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = unlimited. Applies to every method: rsync --bwlimit, the built-in method throttles itself, robocopy approximates with /IPG. You can also put it in the options field (that takes priority)",
@ -213,6 +214,7 @@
"GitHub 의 최신 버전과 비교해, 새 버전이 있으면 받아서 설치합니다": "Compares with the latest version on GitHub and downloads and installs it if newer",
"HTML 파일로 내보내기": "Export to HTML file",
"HTML 파일에서 가져오기": "Import from HTML file",
"HWP · HWPX 문서로 읽을 수 없습니다 (깨졌거나 지원하지 않는 형식)": "Can't read it as an HWP / HWPX document (damaged or unsupported format)",
"IPv4 · IPv6 주소를 넣으세요 (예: 8.8.4.4)": "Enter an IPv4 or IPv6 address (e.g. 8.8.4.4)",
"IPv4 · IPv6 주소를 넣으세요 (예: 8.8.8.8)": "Enter an IPv4 or IPv6 address (e.g. 8.8.8.8)",
"JJ_MKVMaker - 다운로드 {0}개": "JJ_MKVMaker - {0} downloads",
@ -498,6 +500,7 @@
"{0} · 그림 · 동영상 {1}개": "{0} · {1} pictures / videos",
"{0} · 링크 {1}개 제외": "{0} · {1} links excluded",
"{0} · 파일 {1}개": "{0} · {1} files",
"{0} × {1} 픽셀": "{0} × {1} px",
"{0} → {1} 번역 {2}%": "{0} → {1} translation {2}%",
"{0} 는 이미 끝났습니다": "{0} has already finished",
"{0} 라이선스": "{0} license",
@ -540,6 +543,7 @@
"{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "Delete page {0}? (Saved to the file only when you save)",
"{0} 펼치기": "Expand {0}",
"{0} 폴더가 없거나 열 수 없어 내역에서 뺐습니다": "Folder {0} is missing or can't be opened, so it was removed from history",
"{0}% (기본)": "{0}% (default)",
"{0}% 에서 2분 넘게 나아가지 않아 멈췄습니다. 망이 Tor 를 막고 있거나 느릴 수 있습니다. 잠시 뒤 [다시 시도] 하세요.": "Stopped because it made no progress past {0}% for over 2 minutes. The network may be blocking Tor or be slow. Press [Try again] in a moment.",
"{0}. {1} (대기)": "{0}. {1} (waiting)",
"{0}: 이 동영상을 {1} 로 옮깁니다": "{0}: moves this video to {1}",
@ -547,6 +551,7 @@
"{0}: 작업 중이라 옮기지 않았습니다": "{0}: not moved because it is being processed",
"{0}: 저장하지 않았습니다": "{0}: not saved",
"{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: moves the {1} checked videos to {2}",
"{0}~{1}쪽을 {2}초 안에 바꾸지 못했습니다": "Couldn't convert pages {0}-{1} within {2} s",
"{0}개": "{0}",
"{0}개 ZIP 으로 묶기": "Compress {0} items to ZIP",
"{0}개 · {1}": "{0} · {1}",
@ -701,6 +706,9 @@
"개인 키를 넣었습니다 (원래 파일 경로는 저장하지 않습니다)": "Private key added (the original file path isn't saved)",
"개인 키를 읽을 수 없습니다 (OpenSSH · PEM 형식)": "Can't read the private key (OpenSSH or PEM format)",
"개인 키의 암호가 맞지 않습니다": "The private key passphrase is incorrect",
"갤러리 · 음악 앱에 다시 보이기 (.nomedia 지우기)": "Show in gallery and music apps again (remove .nomedia)",
"갤러리 · 음악 앱에서 숨겼습니다 (.nomedia). 파일은 그대로입니다.": "Hidden from gallery and music apps (.nomedia). The files are unchanged.",
"갤러리 · 음악 앱에서 숨기기 (.nomedia)": "Hide from gallery and music apps (.nomedia)",
"거꾸로": "Reverse",
"거꾸로 된 범위: {0}": "Reversed range: {0}",
"거른 것 {0}개도 지워집니다 (--delete-excluded)": "{0} excluded items will also be deleted (--delete-excluded)",
@ -798,15 +806,19 @@
"그림 · 만화 보기, PDF 보기 · 페이지 편집, ZIP 목록 · 만화 보기": "Images · comic reading, PDF view · page editing, ZIP list · comic reading",
"그림 · 프롬프트가 기기 밖으로 나갑니다 ({0})": "Images and prompts leave this device ({0})",
"그림 보기 대상": "Image viewer file types",
"그림 보기 아래 갤러리 줄": "Gallery strip in the image viewer",
"그림 파일을 읽을 수 없습니다 - 깨졌거나 지원하지 않는 형식": "Can't read the image file - damaged or unsupported format",
"그림으로 바꾸는 중": "Turning into an image",
"그림을 만들지 못했습니다: {0}": "Couldn't make the image: {0}",
"그림을 바꿔 만들기": "Make from this picture",
"그림을 받을 수 없습니다 ({0})": "Couldn't get the image ({0})",
"그림을 볼 때 아래에 작은 그림 줄을 보입니다 (누르면 그 장으로)": "Shows a row of thumbnails at the bottom while viewing images (tap one to jump to it)",
"그림을 열 수 없습니다": "Can't open the image",
"그림을 올릴 수 없습니다 ({0})": "Couldn't upload the image ({0})",
"그림을 읽을 수 없습니다": "Cannot read the image",
"그림을 페이지로 넣기 (이 페이지 뒤)": "Add images as pages (after this page)",
"그림이 거의 같고 마지막 단계가 10배쯤 빠릅니다 (받아 두었을 때)": "Almost the same image, and the last step is about 10× faster (when downloaded)",
"그만두었습니다": "Stopped",
"그중 {0}개는 받는 쪽이 더 새것입니다 - 더 새 파일을 예전 것으로 덮어씁니다": "{0} of them are newer on the receiving side - the newer files will be overwritten with older ones",
"그중 폴더 {0}개 - 안의 파일 · 폴더도 모두 함께": "Including {0} folders - everything inside them too",
"글 → 그림 · 그림 → 그림 (기기 안에서, 모델은 환경 설정에서 받기)": "Text → image · image → image (on this device; download models in Settings)",
@ -916,6 +928,7 @@
"내장 저장소": "Internal storage",
"내장 플레이어": "Built-in player",
"내장 플레이어로 재생": "Play in built-in player",
"넓게": "Wide",
"넘기는 방향: 오른쪽 → 왼쪽 (만화)": "Page direction: right → left (manga)",
"넘기는 방향: 왼쪽 → 오른쪽": "Page direction: left → right",
"넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP": "If set, encrypts with AES-256. Leave empty for a normal ZIP",
@ -981,6 +994,7 @@
"다시 만들기 대기로": "Back to waiting for remake",
"다시 만들기 대기로 (지금 묶음이 끝난 뒤)": "Queue again to rebuild (after the current batch finishes)",
"다시 받기": "Download again",
"다시 보이기": "Show again",
"다시 비교": "Compare again",
"다시 설치": "Reinstall",
"다시 세기": "Recount",
@ -1020,12 +1034,14 @@
"다음 {0}개 기다림": "{0} more waiting",
"다음 페이지": "Next page",
"다음 항목 (→)": "Next item (→)",
"다음부터 묻지 않기": "Don't ask again",
"다음부터 묻지 않기 (환경 설정 > 실행 에서 바꿀 수 있음)": "Don't ask again (can be changed in Settings > Run)",
"단계": "Steps",
"단어 첫 글자만 크게 (Abc)": "Capitalize words (Abc)",
"단축키 - Total Commander: F5 복사 · F6 이동 · F7 새 폴더 · F8 지우기 · Insert/Space 표시 · Ctrl+R 새로 고침. 그 밖: Ctrl+C · X · V (복사 · 잘라내기 · 붙여넣기) · Ctrl+A 모두 표시 · F5 새로 고침": "Shortcuts - Total Commander: F5 copy · F6 move · F7 new folder · F8 delete · Insert/Space mark · Ctrl+R refresh. Others: Ctrl+C · X · V (copy · cut · paste) · Ctrl+A mark all · F5 refresh",
"단축키 해제": "Unregister hotkey",
"닫기": "Close",
"닫힌 문서입니다": "The document is closed",
"닫힘 (실행 안 함)": "Closed (not run)",
"담아 두고 붙여넣기 (기본)": "Hold and paste (default)",
"답이 없습니다": "No answer",
@ -1243,6 +1259,8 @@
"목": "Thu",
"목록": "list",
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "Hides ad areas in lists · next to · on top of videos",
"목록 글자 크기": "List text size",
"목록 맨 아래의 [숨긴 항목 보이기] 나 환경 설정 > 파일 탐색기에서 다시 볼 수 있습니다.": "You can see them again with [Show hidden items] at the bottom of the list, or in Settings > File explorer.",
"목록 보기": "View list",
"목록 파일은 전체 경로로 적어 주세요: {0}": "Please write the full path of the list file: {0}",
"목록 파일의 전체 경로를 적어 주세요": "write the full path of the list file",
@ -1253,6 +1271,7 @@
"목록에서 숨겼습니다 ({0}개). 파일은 그대로이고, 목록 맨 아래의 [숨긴 항목 보이기] 로 다시 볼 수 있습니다.": "Hidden from the list ({0}). The files are unchanged - use [Show hidden items] at the bottom of the list to see them again.",
"목록에서 숨겼습니다 ({0}개). 파일은 그대로입니다.": "Hidden from the list ({0}). The files are unchanged.",
"목록에서 숨기기": "Hide from list",
"목록에서 숨기기 전에 묻기": "Ask before hiding from the list",
"목록에서 숨긴 항목 ({0}개)": "Items hidden from the list ({0})",
"목록에서 숨긴 항목 다시 숨기기": "Hide the hidden items again",
"목록에서 숨긴 항목 보이기 ({0}개)": "Show items hidden from the list ({0})",
@ -1263,6 +1282,7 @@
"목록의 동영상 {0}개 모두": "All {0} videos in the list",
"몽골어": "Mongolian",
"무엇을 그릴지 적어 주세요": "Write what to draw",
"무엇이 바뀌는지 (목록에서만 빠지고 파일은 그대로) 확인 창을 띄웁니다": "Shows a dialog explaining what changes (removed from the list only; files stay as they are)",
"무엇이 바뀌는지 비교하는 중…": "Comparing what will change…",
"무한": "Unlimited",
"묶기": "Compress",
@ -1295,6 +1315,8 @@
"바꾸기 ({0}개)": "Rename ({0})",
"바꾸는 정도 {0}%": "Change amount {0}%",
"바꾸면 바로 저장됩니다": "Saved automatically",
"바꾸지 못했습니다: {0}": "Could not change it: {0}",
"바꾼 PDF 를 저장하지 못했습니다 (저장 공간을 확인해 주세요)": "Couldn't save the converted PDF (check storage space)",
"바꾼 것이 없습니다 (원본 그대로)": "Nothing changed (original as is)",
"바꿀 글": "Replace with",
"바꿀 글 (비우면 지움)": "Replace with (empty = remove)",
@ -1397,11 +1419,13 @@
"벵골어": "Bengali",
"변경 내용을 저장하지 않고 나갈까요?": "Leave without saving the changes?",
"변환": "Convert",
"변환기 안에서 오류가 났습니다": "An error occurred inside the converter",
"보관한 설정으로 되살리고 다시 시작": "Restore kept settings and restart",
"보관해 둔 설정을 되살렸습니다: {0}": "Restored the kept settings: {0}",
"보기": "View",
"보기 {0}초 · Ctrl+휠 확대/축소": "View {0} s · Ctrl+wheel to zoom",
"보기 화면에 [품질 올리기]: AI 그림 (그림 → 그림, 약하게) 으로 다듬은 뒤 해상도 올리기를 이어서. 새 파일 (이름_q) 로 저장하며 AI 그림 모델이 필요합니다": "Shows [Improve quality] in the viewer: refines with AI image (image → image, light) and then upscales. Saves a new file (name_q); needs the AI image model",
"보기 화면에서 볼륨 아래 = 다음 장, 위 = 이전 장 (동영상 · 다른 화면의 볼륨은 그대로)": "In the viewer, volume down = next, volume up = previous (videos and other screens keep normal volume keys)",
"보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "How often [Slideshow] in the viewer turns to the next page",
"보내지 못했습니다": "Could not send",
"보낼 것 {0} / 있는 것 {1}": "sending {0} / existing {1}",
@ -1413,6 +1437,7 @@
"보조 DNS (비워 둘 수 있음)": "Secondary DNS (may be empty)",
"보통": "Normal",
"보통 (0.25, 기본)": "Normal (0.25, default)",
"보통 (기본)": "Normal (default)",
"보통 비워 둡니다": "Usually left empty",
"보통: {0}": "Normal: {0}",
"복사": "Copy",
@ -1452,6 +1477,7 @@
"복사했습니다.": "copied.",
"볼 그림이 없습니다.": "No images to show.",
"볼 수 없는 영상입니다 (삭제 · 지역 제한).": "This video is unavailable (deleted · region restricted).",
"볼륨 키로 넘기기": "Turn pages with volume keys",
"분석": "Analyze",
"분석 실패: {0} - {1}": "Analysis failed: {0} - {1}",
"분석에 실패했던 동영상 {0}개를 다시 분석했습니다": "Re-analyzed {0} videos that had failed",
@ -1514,6 +1540,8 @@
"사용자가 취소했습니다.": "Cancelled by the user.",
"사용할 수 있는 자막 사이트가 없습니다.": "No subtitle site is available.",
"사진": "Photos",
"사진 슬라이드쇼": "Photo slideshow",
"사진 정보 (Exif)": "Photo info (Exif)",
"사진에는 '사진' 모델이 더 낫지만, 이 기기 (그래픽카드 없음) 에서는 한 장에 약 20분 걸립니다.": "The 'Photos' model is better for photos, but on this device (no graphics card) it takes about 20 minutes per image.",
"사진용": "Photos",
"삭제": "Delete",
@ -1724,6 +1752,8 @@
"쓸 수 없는 이름": "Invalid name",
"쓸 수 없는 이름입니다": "This name cannot be used",
"아랍어": "Arabic",
"아래 갤러리 줄 보이기": "Show the gallery strip",
"아래 갤러리 줄 숨기기": "Hide the gallery strip",
"아래 프로그램이 없어 일부 기능을 쓸 수 없습니다. 공식 배포처에서 내려받아 프로그램 폴더에 설치할까요?": "Some features are unavailable because the programs below are missing. Download them from the official sources and install them in the program folder?",
"아래로": "Down",
"아래에 줄 추가": "Add line below",
@ -1756,6 +1786,8 @@
"알리지 않음": "Don't notify",
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "Notifications show as JJ_MKVMaker and open the app when clicked (current user only, no admin rights)",
"암호 ZIP 으로 묶는 중 · {0}": "Making password ZIP · {0}",
"암호가 걸린 문서입니다": "This document is password-protected",
"암호가 맞지 않습니다": "The password is incorrect",
"암호로 묶기": "Make password ZIP",
"암호화": "Encryption",
"암호화 (TLS) 연결을 맺지 못했습니다: {0}": "Couldn't establish an encrypted (TLS) connection: {0}",
@ -1788,6 +1820,7 @@
"앱 안 브라우저의 쿠키 (로그인) · 사이트 데이터 · 캐시 · 방문 기록과, 다운로드용으로 내보낸 쿠키 파일을 지웁니다. 사이트에 다시 로그인해야 합니다. 즐겨찾기는 그대로입니다.": "Deletes the in-app browser's cookies (logins), site data, cache and history, plus the cookie file exported for downloads. You'll need to log in to sites again. Bookmarks are kept.",
"앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "In-app web browser · bookmarks · video downloads",
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "In-app web browser: presses [Skip] for you, and mutes and fast-forwards ads that can't be skipped (\"Skipping ad…\" shows at the top while skipping)",
"앱 안에서 열지 못해 다른 앱으로 엽니다: {0}": "Couldn't open it in the app, opening with another app: {0}",
"앱 지우기": "Uninstall app",
"앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "Kept in the app trash for {0} days, then deleted (restore from [Trash] in the explorer).",
"앱에 들어 있는 것 (기본)": "Built into the app (default)",
@ -1827,6 +1860,7 @@
"엔진": "Engine",
"여기까지 범위 고르기": "Select range up to here",
"여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "Drop here to add to the Make MKV video list",
"여는 중: {0}": "Opening: {0}",
"여러 개를 골랐을 때": "When several are selected",
"여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "How many to process at once when making several videos into MKV (re-encoding uses a lot of CPU)",
"여러 번 틀려 {0}초 뒤에 다시 넣을 수 있습니다": "Too many wrong tries. You can try again in {0} s",
@ -2004,6 +2038,9 @@
"위아래 ⇅": "Rows ⇅",
"위쪽 이동 버튼과 좌우로 밀기의 순서입니다. ▲ ▼ 로 바꿉니다.": "Order of the top navigation buttons and of swiping. Change it with ▲ ▼.",
"위쪽 이동 버튼과 좌우로 밀기의 순서입니다. 줄을 끌어 바꿉니다.": "Order of the top navigation buttons and of swiping. Drag a row to change it.",
"위치": "Location",
"위치 보기": "Show location",
"위치는 이 기기에서만 보이며 지도 · 인터넷으로 보내지 않습니다": "The location is shown only on this device and is never sent to a map or the internet",
"유형": "Type",
"음성": "Audio",
"음성 {0}개": "{0} audio",
@ -2023,6 +2060,7 @@
"이 WebDAV 서버는 로그인이 필요합니다": "This WebDAV server requires sign-in",
"이 {0}의 설정 파일에 저장됩니다": "Saved in this {0}'s settings file",
"이 {0}의 안전 저장소에 저장됩니다 (설정 파일에는 쓰지 않음)": "Saved in this {0}'s secure storage (not in the settings file)",
"이 그림에는 Exif 정보가 없습니다 (JPEG 사진만 읽음)": "This image has no Exif info (only JPEG photos are read)",
"이 그림을 바탕으로 (그림 → 그림)": "Use this as the base (image → image)",
"이 기기 (SD1.5 + LCM)": "This device (SD1.5 + LCM)",
"이 기기 ({0})": "This device ({0})",
@ -2063,6 +2101,7 @@
"이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)": "This install has no rsync (downloaded when copying with rsync · or use [Download] on the right)",
"이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "This install has no rsync. Choose one yourself.",
"이 앱 (APK) 에는 Tor 가 들어 있지 않습니다.": "This app (APK) does not contain Tor.",
"이 앱의 파일 탐색기 목록에서만 빠집니다. 파일은 지우거나 옮기거나 이름을 바꾸지 않고, 다른 앱 · 갤러리에서는 그대로 보입니다.": "It is removed only from this app's file explorer list. Files are not deleted, moved or renamed, and other apps and the gallery still show them.",
"이 언어 지우기": "Remove this language",
"이 연결은 서명도 암호화도 되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 보거나 바꿀 수 있으니, NAS 설정에서 SMB 서명이나 SMB3 암호화를 켜는 것이 좋습니다": "This connection is neither signed nor encrypted ({0}). Others on the same network could read or change the files, so it's best to turn on SMB signing or SMB3 encryption in the NAS settings",
"이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "This connection isn't encrypted ({0}). Others on the same network could see file contents, so it's best to turn on SMB3 encryption in the NAS settings",
@ -2071,6 +2110,7 @@
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "This location (network drive, network share, USB memory etc.) has no Recycle Bin, so items are deleted right away.",
"이 이동 버튼 지우기": "Delete this move button",
"이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "These options will be used the next time this move runs. Run it now with the new options?",
"이 쪽을 그리지 못했습니다": "Couldn't render this page",
"이 파일에 맞는 자막 우선 (영상 해시)": "Prefer subtitles that match this file (video hash)",
"이 파일용": "For this file",
"이 파일을 열 수 있는 앱이 없습니다.": "No app can open this file.",
@ -2087,11 +2127,14 @@
"이 폴더를 볼 권한이 없습니다": "No permission to view this folder",
"이 폴더를 읽을 권한이 없습니다": "No permission to read this folder",
"이 폴더에 동영상이 없습니다.": "There are no videos in this folder.",
"이 폴더에 빈 .nomedia 파일을 만듭니다. 갤러리 · 음악 앱이 이 폴더 (안의 폴더 포함) 의 그림 · 동영상 · 음악을 보이지 않게 됩니다.": "Creates an empty .nomedia file in this folder. Gallery and music apps will stop showing the pictures, videos and music in this folder (including subfolders).",
"이 폴더에 새 폴더": "New folder in this folder",
"이 폴더에 현재 페이지 추가": "Add the current page to this folder",
"이 폴더에는 고를 파일이 없습니다": "There are no files to choose in this folder",
"이 폴더에는 그림 · 동영상이 없습니다": "No pictures or videos in this folder",
"이 폴더에는 그림이 없습니다": "There are no images in this folder",
"이 폴더에만: {0}": "This folder only: {0}",
"이 폴더의 .nomedia 파일을 지웁니다. 갤러리 · 음악 앱이 이 폴더의 그림 · 동영상 · 음악을 다시 보이게 됩니다.": "Deletes the .nomedia file in this folder. Gallery and music apps will show the pictures, videos and music in this folder again.",
"이 폴더의 동영상 재생": "Play videos in this folder",
"이 폴더의 파일": "Files in this folder",
"이 프로그램이 관리자 권한으로 실행 중입니다. 권장하지 않습니다.\n\n· 내장 Chrome 엔진은 샌드박스 (웹 페이지를 가두는 보호) 가 꺼져 있어, 웹 페이지가 관리자 권한 프로세스 안에서 돕니다. 페이지의 취약점이 곧 이 PC 의 관리자 권한이 될 수 있습니다.\n· Edge 엔진은 이 영향이 없습니다.\n· 관리자 권한 없이 프로그램을 실행하면 이 설정 없이도 Chrome 엔진을 쓸 수 있습니다.": "This app is running as administrator. This is not recommended.\n\n· The built-in Chrome engine has its sandbox (the protection that confines web pages) turned off, so web pages run inside an administrator process. A flaw in a page could become administrator access to this PC.\n· The Edge engine is not affected.\n· If you run the app without administrator rights, you can use the Chrome engine without this setting.",
@ -2359,6 +2402,7 @@
"정해 둠. 저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능을 쓸 때 묻습니다 (앱을 끌 때까지 한 번)": "Set. Asked when using saved passwords, settings or features that need a password (once until the app is closed)",
"제목": "Title",
"조정 전": "Before",
"좁게": "Compact",
"좁은 화면에서 동영상 목록이 차지하는 높이 (자동: 목록은 줄 수만큼, 자세히 칸 자리는 남김)": "Height of the video list on narrow screens (Auto: as tall as its rows, keeping room for the details)",
"종료": "Quit",
"종료 (창 닫기 ✕ · 종료 버튼) 를 누르면": "When you press quit (close window ✕ · quit button)",
@ -2391,6 +2435,7 @@
"준비 중": "Preparing",
"준비 중…": "Preparing…",
"준비하는 중": "Preparing",
"줄 간격": "Row spacing",
"줄 번호": "Line numbers",
"줄 삭제": "Delete line",
"중간에서 가로채는 공격일 수 있어 연결하지 않았습니다. 서버 인증서를 바꾼 것이 확실할 때만, 서버 설정의 [믿은 인증서 지우기] 뒤 다시 연결하세요.": "Not connected because this could be an interception attack. Only if you're sure the server certificate was changed, use [Forget trusted certificate] in the server settings and connect again.",
@ -2450,6 +2495,7 @@
"지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "The last run did not end normally (last check {0}:{1}). It has been noted in the job log.",
"지난 업데이트가 남긴 임시 파일을 지움: {0}": "Removed temporary files left by the last update: {0}",
"지난 작업 기록": "Old job logs",
"지난번에 남은 문서 보기 임시 폴더를 지움: {0}개": "Removed {0} leftover document-view temp folders",
"지문 확인하고 연결": "Check fingerprint and connect",
"지우고 맞추기": "Delete and sync",
"지우고 초기화": "Delete and reset",
@ -2491,6 +2537,8 @@
"진행 패널 보기": "Show progress panel",
"집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 \"인증서 확인 안 함\" 을 켜세요.": "If it uses a self-signed certificate (like a home NAS), turn on \"Don't verify certificate\" in [Edit server settings].",
"짧을수록 빨리 맞추지만 배터리를 더 씁니다.": "Shorter means faster syncing but more battery use.",
"쪽 범위가 맞지 않습니다": "The page range is invalid",
"찍은 날": "Taken",
"차례 기다림": "Waiting in queue",
"창": "Panes",
"창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "Icon of the window, taskbar, tray and the top-left button in the app (the program file and desktop shortcut icons stay the same)",
@ -2649,6 +2697,8 @@
"파일 탐색기 가운데에 [모니터링] 버튼: 복사 · 이동을 기억해 진행 · 대상 디스크 용량 · 옵션을 보여 주고, 옵션을 고쳐 다시 실행 · 재개하거나 lsync 로 옮깁니다 (복사 · rsync 와 lsync 를 따로).": "[Monitor] button in the middle of the file explorer: remembers copies · moves and shows progress · target disk space · options; change options to run again · resume, or move to lsync (copy · rsync and lsync shown separately).",
"파일 탐색기 누르기: 한 번 누르면 선택 → 한 번 누르면 바로 실행. 되돌리기: 환경 설정 > 파일 탐색기 > 누르기": "File explorer tap: single tap selects → single tap opens. To revert: Settings > File explorer > Tap",
"파일 탐색기 두 창 배치: 화면 모양 따라 → 좌우. 되돌리기: 파일 탐색기의 [좌우 ⇆ / 위아래 ⇅] 버튼 또는 창 배치": "File explorer two-pane layout: follow screen shape → side by side. To revert: the [Columns ⇆ / Rows ⇅] button in the file explorer, or Pane layout",
"파일 탐색기 목록의 이름 · 크기 글자만. 앱 화면 크기 · 기기 글자 크기와 곱해집니다": "Only the name and size text in the file explorer list. Multiplied by the app screen size and the device text size",
"파일 탐색기 목록의 줄 높이. 좁게는 손가락으로 누르기 어려울 수 있습니다": "Row height in the file explorer list. Compact rows may be hard to tap with a finger",
"파일 탐색기 위쪽 ⋮ 메뉴에서도 바꿀 수 있습니다 (좌우 · 위아래 · 버튼 줄 위치 · 버튼 구성)": "You can also change it from the ⋮ menu at the top of the file explorer (side by side · top and bottom · button bar position · buttons)",
"파일 탐색기 창: 두 창 → 화면 크기 따라 (폰 세로는 한 창). 되돌리기: 환경 설정 > 파일 탐색기 > 창 배치": "File explorer panes: two panes → by screen size (one pane on a portrait phone). To revert: Settings > File explorer > Pane layout",
"파일 탐색기: 파일을 한 번 누르면 바로 엽니다 (고르기 · 메뉴는 길게 누르기). 바꾸기: 환경 설정 > 파일 탐색기 > 누르기": "File explorer: a single tap now opens a file right away (long-press to select or for the menu). To change: Settings > File explorer > Tap",
@ -2658,6 +2708,7 @@
"파일만 골랐을 때": "When only files are selected",
"파일을 복사하지 못했습니다": "Could not copy the file",
"파일을 분석할 수 없습니다: {0}": "Cannot analyze the file: {0}",
"파일을 읽을 수 없습니다": "Can't read the file",
"파일을 읽지 못했습니다": "Could not read the file",
"파일이 깨졌습니다": "The file is damaged",
"파일이 깨졌습니다 · [다시 받기] 를 눌러 주세요": "The file is damaged · tap [Download again]",
@ -2701,6 +2752,7 @@
"폴더 수정": "Edit folder",
"폴더 안 파일 크기의 합을 목록에 보입니다. 큰 폴더 · NAS 는 읽는 데 오래 걸릴 수 있습니다. 길게 누르기 [폴더 크기 계산] 은 늘 됩니다": "Shows the total size of the files inside each folder. Large folders or a NAS can take a while to read. Long-press [Calculate folder size] always works",
"폴더 안의 것을 맞춥니다 (대상에 같은 이름의 폴더를 새로 만들지 않음).": "Syncs the folder contents (no same-named folder is created in the target).",
"폴더 안의 파일은 바꾸지 않습니다. 갤러리에 반영되기까지 잠깐 걸릴 수 있습니다.": "Files in the folder are not changed. It may take a moment to show in the gallery.",
"폴더 열기": "Open folder",
"폴더 이름": "Folder name",
"폴더 이름 바꾸기 · 이동": "Rename · move folder",
@ -2719,6 +2771,7 @@
"폴더는 보여도 그 안의 동영상 · 파일은 이 권한이 있어야 보입니다. 허용 화면에서 JJ_MKVMaker 를 켜고 돌아오세요.": "Folders are shown, but the videos and files inside need this permission. Turn on JJ_MKVMaker on the permission screen and come back.",
"폴더를 만들 수 없습니다: {0}": "Cannot create the folder: {0}",
"폴더를 읽는 중…": "Reading folders…",
"폴더를 읽을 수 없습니다: {0}": "Can't read the folder: {0}",
"폴더를 자기 안으로 복사 · 이동할 수 없습니다": "Cannot copy or move a folder into itself",
"폴더를 자기 안으로 복사할 수 없습니다": "Cannot copy a folder into itself",
"폴더를 자기 안으로 옮길 수 없습니다": "Cannot move a folder into itself",

View File

@ -72,6 +72,7 @@
"+30분": "+30分",
"- 감지한 언어": "- 検出した言語",
"-1 은 아무렇게나": "-1 はランダム",
".nomedia 를 지웠습니다. 갤러리 · 음악 앱에 다시 보입니다.": ".nomedia を削除しました。ギャラリー・音楽アプリに再び表示されます。",
".{0} 재생 프로그램 (exe)": ".{0} 再生プログラム(exe)",
"/ 로 시작하는 전체 경로로": "/ で始まるフルパスで",
"0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 制限なし。すべての方法に適用: rsync は --bwlimit、現在の方式はアプリが調整、robocopy は /IPG で近似。オプション欄に直接入れても構いません (そちらが優先)",
@ -213,6 +214,7 @@
"GitHub 의 최신 버전과 비교해, 새 버전이 있으면 받아서 설치합니다": "GitHub の最新版と比べ、新しい版があればダウンロードしてインストールします",
"HTML 파일로 내보내기": "HTML ファイルに書き出す",
"HTML 파일에서 가져오기": "HTML ファイルから取り込む",
"HWP · HWPX 문서로 읽을 수 없습니다 (깨졌거나 지원하지 않는 형식)": "HWP・HWPX 文書として読み込めません (破損しているか未対応の形式)",
"IPv4 · IPv6 주소를 넣으세요 (예: 8.8.4.4)": "IPv4・IPv6 アドレスを入力してください (例: 8.8.4.4)",
"IPv4 · IPv6 주소를 넣으세요 (예: 8.8.8.8)": "IPv4・IPv6 アドレスを入力してください (例: 8.8.8.8)",
"JJ_MKVMaker - 다운로드 {0}개": "JJ_MKVMaker - ダウンロード {0}個",
@ -498,6 +500,7 @@
"{0} · 그림 · 동영상 {1}개": "{0} · 画像・動画 {1} 個",
"{0} · 링크 {1}개 제외": "{0} · リンク {1} 件を除く",
"{0} · 파일 {1}개": "{0} · ファイル {1} 件",
"{0} × {1} 픽셀": "{0} × {1} ピクセル",
"{0} → {1} 번역 {2}%": "{0} → {1} 翻訳 {2}%",
"{0} 는 이미 끝났습니다": "{0} はすでに終わりました",
"{0} 라이선스": "{0} ライセンス",
@ -540,6 +543,7 @@
"{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "{0} ページを削除しますか? (保存するとファイルに反映されます)",
"{0} 펼치기": "{0} を展開",
"{0} 폴더가 없거나 열 수 없어 내역에서 뺐습니다": "{0} フォルダーが存在しないか開けないため、履歴から削除しました",
"{0}% (기본)": "{0}% (既定)",
"{0}% 에서 2분 넘게 나아가지 않아 멈췄습니다. 망이 Tor 를 막고 있거나 느릴 수 있습니다. 잠시 뒤 [다시 시도] 하세요.": "{0}% から 2 分以上進まなかったため停止しました。ネットワークが Tor をブロックしているか、遅い可能性があります。少し待ってから [再試行] してください。",
"{0}. {1} (대기)": "{0}. {1}(待機)",
"{0}: 이 동영상을 {1} 로 옮깁니다": "{0}: この動画を {1} に移動します",
@ -547,6 +551,7 @@
"{0}: 작업 중이라 옮기지 않았습니다": "{0}: 作業中のため移動しませんでした",
"{0}: 저장하지 않았습니다": "{0}: 保存しませんでした",
"{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: チェックした動画 {1}個を {2} に移動します",
"{0}~{1}쪽을 {2}초 안에 바꾸지 못했습니다": "{0}~{1} ページを {2} 秒以内に変換できませんでした",
"{0}개": "{0}個",
"{0}개 ZIP 으로 묶기": "{0} 件を ZIP にまとめる",
"{0}개 · {1}": "{0}個・{1}",
@ -701,6 +706,9 @@
"개인 키를 넣었습니다 (원래 파일 경로는 저장하지 않습니다)": "秘密鍵を登録しました (元のファイルのパスは保存しません)",
"개인 키를 읽을 수 없습니다 (OpenSSH · PEM 형식)": "秘密鍵を読み込めません (OpenSSH・PEM 形式)",
"개인 키의 암호가 맞지 않습니다": "秘密鍵のパスフレーズが正しくありません",
"갤러리 · 음악 앱에 다시 보이기 (.nomedia 지우기)": "ギャラリー・音楽アプリに再表示 (.nomedia を削除)",
"갤러리 · 음악 앱에서 숨겼습니다 (.nomedia). 파일은 그대로입니다.": "ギャラリー・音楽アプリから隠しました (.nomedia)。ファイルはそのままです。",
"갤러리 · 음악 앱에서 숨기기 (.nomedia)": "ギャラリー・音楽アプリから隠す (.nomedia)",
"거꾸로": "逆順",
"거꾸로 된 범위: {0}": "逆順の範囲: {0}",
"거른 것 {0}개도 지워집니다 (--delete-excluded)": "除外したもの {0} 件も削除されます (--delete-excluded)",
@ -798,15 +806,19 @@
"그림 · 만화 보기, PDF 보기 · 페이지 편집, ZIP 목록 · 만화 보기": "画像・マンガ表示、PDF 表示・ページ編集、ZIP 一覧・マンガ表示",
"그림 · 프롬프트가 기기 밖으로 나갑니다 ({0})": "画像・プロンプトが端末の外に送られます ({0})",
"그림 보기 대상": "画像表示の対象",
"그림 보기 아래 갤러리 줄": "画像ビューアー下のギャラリー列",
"그림 파일을 읽을 수 없습니다 - 깨졌거나 지원하지 않는 형식": "画像ファイルを読み込めません - 破損しているか未対応の形式",
"그림으로 바꾸는 중": "画像に変換中",
"그림을 만들지 못했습니다: {0}": "画像を作成できませんでした: {0}",
"그림을 바꿔 만들기": "画像を変えて作成",
"그림을 받을 수 없습니다 ({0})": "画像を受け取れません ({0})",
"그림을 볼 때 아래에 작은 그림 줄을 보입니다 (누르면 그 장으로)": "画像を見るとき下にサムネイルの列を表示します (タップでその画像へ)",
"그림을 열 수 없습니다": "画像を開けません",
"그림을 올릴 수 없습니다 ({0})": "画像をアップロードできません ({0})",
"그림을 읽을 수 없습니다": "画像を読み込めません",
"그림을 페이지로 넣기 (이 페이지 뒤)": "画像をページとして追加 (このページの後)",
"그림이 거의 같고 마지막 단계가 10배쯤 빠릅니다 (받아 두었을 때)": "画像はほぼ同じで、最後の段階が約 10 倍速くなります (ダウンロード済みの場合)",
"그만두었습니다": "中止しました",
"그중 {0}개는 받는 쪽이 더 새것입니다 - 더 새 파일을 예전 것으로 덮어씁니다": "そのうち {0} 件は受け取る側の方が新しいです - 新しいファイルを古いもので上書きします",
"그중 폴더 {0}개 - 안의 파일 · 폴더도 모두 함께": "うちフォルダー {0} 件 - 中のファイル・フォルダーもすべて一緒に",
"글 → 그림 · 그림 → 그림 (기기 안에서, 모델은 환경 설정에서 받기)": "テキスト → 画像 · 画像 → 画像 (端末内で。モデルは設定でダウンロード)",
@ -916,6 +928,7 @@
"내장 저장소": "内部ストレージ",
"내장 플레이어": "内蔵プレーヤー",
"내장 플레이어로 재생": "内蔵プレーヤーで再生",
"넓게": "広く",
"넘기는 방향: 오른쪽 → 왼쪽 (만화)": "めくる方向: 右 → 左 (マンガ)",
"넘기는 방향: 왼쪽 → 오른쪽": "めくる方向: 左 → 右",
"넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP": "入力すると AES-256 で暗号化します。空欄なら通常の ZIP",
@ -981,6 +994,7 @@
"다시 만들기 대기로": "作り直し待ちに戻す",
"다시 만들기 대기로 (지금 묶음이 끝난 뒤)": "再作成待ちに戻す (今のまとめ作業が終わった後)",
"다시 받기": "再ダウンロード",
"다시 보이기": "再表示",
"다시 비교": "もう一度比較",
"다시 설치": "再インストール",
"다시 세기": "再カウント",
@ -1020,12 +1034,14 @@
"다음 {0}개 기다림": "次の {0} 件が待機中",
"다음 페이지": "次のページ",
"다음 항목 (→)": "次の項目 (→)",
"다음부터 묻지 않기": "次回から確認しない",
"다음부터 묻지 않기 (환경 설정 > 실행 에서 바꿀 수 있음)": "次回から確認しない(環境設定 > 実行 で変更できます)",
"단계": "ステップ",
"단어 첫 글자만 크게 (Abc)": "単語の先頭だけ大文字 (Abc)",
"단축키 - Total Commander: F5 복사 · F6 이동 · F7 새 폴더 · F8 지우기 · Insert/Space 표시 · Ctrl+R 새로 고침. 그 밖: Ctrl+C · X · V (복사 · 잘라내기 · 붙여넣기) · Ctrl+A 모두 표시 · F5 새로 고침": "ショートカット - Total Commander: F5 コピー · F6 移動 · F7 新しいフォルダー · F8 削除 · Insert/Space マーク · Ctrl+R 更新。その他: Ctrl+C · X · V (コピー · 切り取り · 貼り付け) · Ctrl+A すべてマーク · F5 更新",
"단축키 해제": "ショートカット解除",
"닫기": "閉じる",
"닫힌 문서입니다": "文書は閉じられています",
"닫힘 (실행 안 함)": "閉じた (実行しない)",
"담아 두고 붙여넣기 (기본)": "保持して貼り付け (既定)",
"답이 없습니다": "応答がありません",
@ -1243,6 +1259,8 @@
"목": "木",
"목록": "一覧",
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "リスト・動画の横・動画の上に出る広告エリアを隠します",
"목록 글자 크기": "一覧の文字サイズ",
"목록 맨 아래의 [숨긴 항목 보이기] 나 환경 설정 > 파일 탐색기에서 다시 볼 수 있습니다.": "リスト最下部の [隠した項目を表示] または 設定 > ファイルエクスプローラー で再び表示できます。",
"목록 보기": "リストを見る",
"목록 파일은 전체 경로로 적어 주세요: {0}": "リストファイルはフルパスで書いてください: {0}",
"목록 파일의 전체 경로를 적어 주세요": "リストファイルのフルパスを書いてください",
@ -1253,6 +1271,7 @@
"목록에서 숨겼습니다 ({0}개). 파일은 그대로이고, 목록 맨 아래의 [숨긴 항목 보이기] 로 다시 볼 수 있습니다.": "リストから隠しました ({0} 件)。ファイルはそのままで、リストの一番下の [隠した項目を表示] で再び見られます。",
"목록에서 숨겼습니다 ({0}개). 파일은 그대로입니다.": "リストから隠しました ({0} 件)。ファイルはそのままです。",
"목록에서 숨기기": "リストから隠す",
"목록에서 숨기기 전에 묻기": "リストから隠す前に確認",
"목록에서 숨긴 항목 ({0}개)": "リストから隠した項目 ({0} 件)",
"목록에서 숨긴 항목 다시 숨기기": "リストから隠した項目を再び隠す",
"목록에서 숨긴 항목 보이기 ({0}개)": "リストから隠した項目を表示 ({0} 件)",
@ -1263,6 +1282,7 @@
"목록의 동영상 {0}개 모두": "リストの動画 {0}個すべて",
"몽골어": "モンゴル語",
"무엇을 그릴지 적어 주세요": "何を描くか入力してください",
"무엇이 바뀌는지 (목록에서만 빠지고 파일은 그대로) 확인 창을 띄웁니다": "何が変わるか (リストから外れるだけでファイルはそのまま) の確認画面を表示します",
"무엇이 바뀌는지 비교하는 중…": "何が変わるか比較中…",
"무한": "無制限",
"묶기": "まとめる",
@ -1295,6 +1315,8 @@
"바꾸기 ({0}개)": "変更 ({0} 件)",
"바꾸는 정도 {0}%": "変える度合い {0}%",
"바꾸면 바로 저장됩니다": "変更するとすぐ保存されます",
"바꾸지 못했습니다: {0}": "変更できませんでした: {0}",
"바꾼 PDF 를 저장하지 못했습니다 (저장 공간을 확인해 주세요)": "変換した PDF を保存できませんでした (空き容量を確認してください)",
"바꾼 것이 없습니다 (원본 그대로)": "変更なし(元のまま)",
"바꿀 글": "置き換える文字",
"바꿀 글 (비우면 지움)": "置き換える文字 (空なら削除)",
@ -1397,11 +1419,13 @@
"벵골어": "ベンガル語",
"변경 내용을 저장하지 않고 나갈까요?": "変更を保存せずに終了しますか?",
"변환": "変換",
"변환기 안에서 오류가 났습니다": "変換エンジン内でエラーが発生しました",
"보관한 설정으로 되살리고 다시 시작": "保管した設定で復元して再起動",
"보관해 둔 설정을 되살렸습니다: {0}": "保管した設定を復元しました: {0}",
"보기": "表示",
"보기 {0}초 · Ctrl+휠 확대/축소": "表示 {0}秒・Ctrl+ホイールで拡大/縮小",
"보기 화면에 [품질 올리기]: AI 그림 (그림 → 그림, 약하게) 으로 다듬은 뒤 해상도 올리기를 이어서. 새 파일 (이름_q) 로 저장하며 AI 그림 모델이 필요합니다": "ビューアーに [画質を上げる]: AI 画像 (画像 → 画像、弱め) で整えてから解像度を上げます。新しいファイル (名前_q) に保存し、AI 画像モデルが必要です",
"보기 화면에서 볼륨 아래 = 다음 장, 위 = 이전 장 (동영상 · 다른 화면의 볼륨은 그대로)": "ビューアーで音量下 = 次、音量上 = 前 (動画・ほかの画面の音量はそのまま)",
"보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "表示画面の [連続表示] が次のページへ進む間隔",
"보내지 못했습니다": "送信できませんでした",
"보낼 것 {0} / 있는 것 {1}": "送る方 {0} / 既存 {1}",
@ -1413,6 +1437,7 @@
"보조 DNS (비워 둘 수 있음)": "代替 DNS (空欄可)",
"보통": "普通",
"보통 (0.25, 기본)": "普通 (0.25、既定)",
"보통 (기본)": "標準 (既定)",
"보통 비워 둡니다": "通常は空欄",
"보통: {0}": "普通: {0}",
"복사": "コピー",
@ -1452,6 +1477,7 @@
"복사했습니다.": "コピーしました。",
"볼 그림이 없습니다.": "表示する画像がありません。",
"볼 수 없는 영상입니다 (삭제 · 지역 제한).": "視聴できない動画です(削除・地域制限)。",
"볼륨 키로 넘기기": "音量キーでページ送り",
"분석": "分析",
"분석 실패: {0} - {1}": "分析失敗: {0} - {1}",
"분석에 실패했던 동영상 {0}개를 다시 분석했습니다": "分析に失敗していた動画 {0} 件を再分析しました",
@ -1514,6 +1540,8 @@
"사용자가 취소했습니다.": "ユーザーがキャンセルしました。",
"사용할 수 있는 자막 사이트가 없습니다.": "使用できる字幕サイトがありません。",
"사진": "写真",
"사진 슬라이드쇼": "写真スライドショー",
"사진 정보 (Exif)": "写真情報 (Exif)",
"사진에는 '사진' 모델이 더 낫지만, 이 기기 (그래픽카드 없음) 에서는 한 장에 약 20분 걸립니다.": "写真には「写真」モデルが適していますが、この端末 (グラフィックカードなし) では 1 枚に約 20 分かかります。",
"사진용": "写真用",
"삭제": "削除",
@ -1724,6 +1752,8 @@
"쓸 수 없는 이름": "使えない名前",
"쓸 수 없는 이름입니다": "使用できない名前です",
"아랍어": "アラビア語",
"아래 갤러리 줄 보이기": "下のギャラリー列を表示",
"아래 갤러리 줄 숨기기": "下のギャラリー列を隠す",
"아래 프로그램이 없어 일부 기능을 쓸 수 없습니다. 공식 배포처에서 내려받아 프로그램 폴더에 설치할까요?": "以下のプログラムがないため一部の機能が使えません。公式配布元からダウンロードしてプログラムフォルダーにインストールしますか?",
"아래로": "下へ",
"아래에 줄 추가": "下に行を追加",
@ -1756,6 +1786,8 @@
"알리지 않음": "知らせない",
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知が JJ_MKVMaker の名前で表示され、クリックするとアプリが開きます (管理者権限なしで現在のユーザーのみ)",
"암호 ZIP 으로 묶는 중 · {0}": "パスワード付き ZIP にまとめています · {0}",
"암호가 걸린 문서입니다": "パスワードで保護された文書です",
"암호가 맞지 않습니다": "パスワードが正しくありません",
"암호로 묶기": "パスワード付きでまとめる",
"암호화": "暗号化",
"암호화 (TLS) 연결을 맺지 못했습니다: {0}": "暗号化 (TLS) 接続を確立できませんでした: {0}",
@ -1788,6 +1820,7 @@
"앱 안 브라우저의 쿠키 (로그인) · 사이트 데이터 · 캐시 · 방문 기록과, 다운로드용으로 내보낸 쿠키 파일을 지웁니다. 사이트에 다시 로그인해야 합니다. 즐겨찾기는 그대로입니다.": "アプリ内ブラウザの Cookie (ログイン)・サイトデータ・キャッシュ・閲覧履歴と、ダウンロード用に書き出した Cookie ファイルを削除します。サイトへの再ログインが必要です。お気に入りはそのままです。",
"앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "アプリ内 Web ブラウザー・ブックマーク・動画ダウンロード",
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "アプリ内ウェブブラウザー:[スキップ]を代わりに押し、スキップできない広告は音を消して早送りします(スキップ中は画面上部に「広告をスキップ中…」)",
"앱 안에서 열지 못해 다른 앱으로 엽니다: {0}": "アプリ内で開けないため、他のアプリで開きます: {0}",
"앱 지우기": "アプリを削除",
"앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "アプリのゴミ箱に {0} 日間置いてから削除します (エクスプローラーの [ゴミ箱] から復元できます)。",
"앱에 들어 있는 것 (기본)": "アプリに内蔵 (既定)",
@ -1827,6 +1860,7 @@
"엔진": "エンジン",
"여기까지 범위 고르기": "ここまで範囲選択",
"여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "ここにドロップすると MKV 作成の動画リストに追加します",
"여는 중: {0}": "開いています: {0}",
"여러 개를 골랐을 때": "複数選んだとき",
"여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "複数の動画を MKV にするとき同時に処理する数(再エンコードは CPU を多く使います)",
"여러 번 틀려 {0}초 뒤에 다시 넣을 수 있습니다": "何度も間違えたため {0} 秒後に再入力できます",
@ -2004,6 +2038,9 @@
"위아래 ⇅": "上下 ⇅",
"위쪽 이동 버튼과 좌우로 밀기의 순서입니다. ▲ ▼ 로 바꿉니다.": "上部の移動ボタンと左右スワイプの順番です。▲ ▼ で変更します。",
"위쪽 이동 버튼과 좌우로 밀기의 순서입니다. 줄을 끌어 바꿉니다.": "上部の移動ボタンと左右スワイプの順番です。行をドラッグして変更します。",
"위치": "位置",
"위치 보기": "位置を表示",
"위치는 이 기기에서만 보이며 지도 · 인터넷으로 보내지 않습니다": "位置はこの端末でのみ表示され、地図やインターネットには送信しません",
"유형": "種類",
"음성": "音声",
"음성 {0}개": "音声 {0}個",
@ -2023,6 +2060,7 @@
"이 WebDAV 서버는 로그인이 필요합니다": "この WebDAV サーバーはログインが必要です",
"이 {0}의 설정 파일에 저장됩니다": "この{0}の設定ファイルに保存されます",
"이 {0}의 안전 저장소에 저장됩니다 (설정 파일에는 쓰지 않음)": "この {0} の安全な保存領域に保存されます (設定ファイルには書きません)",
"이 그림에는 Exif 정보가 없습니다 (JPEG 사진만 읽음)": "この画像には Exif 情報がありません (JPEG 写真のみ読み込み)",
"이 그림을 바탕으로 (그림 → 그림)": "この画像を元に (画像 → 画像)",
"이 기기 (SD1.5 + LCM)": "この端末 (SD1.5 + LCM)",
"이 기기 ({0})": "この端末 ({0})",
@ -2063,6 +2101,7 @@
"이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)": "このインストールには rsync が含まれていません (rsync でコピーするとき · 右の [ダウンロード] で取得します)",
"이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "このインストールには rsync が含まれていません。直接指定してください。",
"이 앱 (APK) 에는 Tor 가 들어 있지 않습니다.": "このアプリ (APK) には Tor が入っていません。",
"이 앱의 파일 탐색기 목록에서만 빠집니다. 파일은 지우거나 옮기거나 이름을 바꾸지 않고, 다른 앱 · 갤러리에서는 그대로 보입니다.": "このアプリのファイルエクスプローラーの一覧から外れるだけです。ファイルは削除・移動・名前変更されず、他のアプリやギャラリーではそのまま表示されます。",
"이 언어 지우기": "この言語を削除",
"이 연결은 서명도 암호화도 되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 보거나 바꿀 수 있으니, NAS 설정에서 SMB 서명이나 SMB3 암호화를 켜는 것이 좋습니다": "この接続は署名も暗号化もされません ({0})。同じネットワークの他の人がファイルの内容を見たり変えたりできるため、NAS の設定で SMB 署名か SMB3 暗号化をオンにすることをおすすめします",
"이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "この接続は暗号化されていません ({0})。同じネットワークの他の人がファイルの内容を見られるため、NAS の設定で SMB3 暗号化をオンにすることをお勧めします",
@ -2071,6 +2110,7 @@
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "この場所 (ネットワークドライブ・ネットワーク共有・USB メモリなど) にはごみ箱がないため、すぐに削除されます。",
"이 이동 버튼 지우기": "この移動ボタンを削除",
"이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "この移動を次に行うときにこのオプションを使います。今すぐ新しいオプションで実行しますか?",
"이 쪽을 그리지 못했습니다": "このページを描画できませんでした",
"이 파일에 맞는 자막 우선 (영상 해시)": "このファイルに合う字幕を優先(動画ハッシュ)",
"이 파일용": "このファイル用",
"이 파일을 열 수 있는 앱이 없습니다.": "このファイルを開けるアプリがありません。",
@ -2087,11 +2127,14 @@
"이 폴더를 볼 권한이 없습니다": "このフォルダーを表示する権限がありません",
"이 폴더를 읽을 권한이 없습니다": "このフォルダーを読む権限がありません",
"이 폴더에 동영상이 없습니다.": "このフォルダーに動画がありません。",
"이 폴더에 빈 .nomedia 파일을 만듭니다. 갤러리 · 음악 앱이 이 폴더 (안의 폴더 포함) 의 그림 · 동영상 · 음악을 보이지 않게 됩니다.": "このフォルダーに空の .nomedia ファイルを作ります。ギャラリー・音楽アプリはこのフォルダー (サブフォルダーを含む) の画像・動画・音楽を表示しなくなります。",
"이 폴더에 새 폴더": "このフォルダーに新しいフォルダー",
"이 폴더에 현재 페이지 추가": "このフォルダーに現在のページを追加",
"이 폴더에는 고를 파일이 없습니다": "このフォルダーには選べるファイルがありません",
"이 폴더에는 그림 · 동영상이 없습니다": "このフォルダーには画像・動画がありません",
"이 폴더에는 그림이 없습니다": "このフォルダーには画像がありません",
"이 폴더에만: {0}": "このフォルダーだけ: {0}",
"이 폴더의 .nomedia 파일을 지웁니다. 갤러리 · 음악 앱이 이 폴더의 그림 · 동영상 · 음악을 다시 보이게 됩니다.": "このフォルダーの .nomedia ファイルを削除します。ギャラリー・音楽アプリはこのフォルダーの画像・動画・音楽を再び表示します。",
"이 폴더의 동영상 재생": "このフォルダーの動画を再生",
"이 폴더의 파일": "このフォルダーのファイル",
"이 프로그램이 관리자 권한으로 실행 중입니다. 권장하지 않습니다.\n\n· 내장 Chrome 엔진은 샌드박스 (웹 페이지를 가두는 보호) 가 꺼져 있어, 웹 페이지가 관리자 권한 프로세스 안에서 돕니다. 페이지의 취약점이 곧 이 PC 의 관리자 권한이 될 수 있습니다.\n· Edge 엔진은 이 영향이 없습니다.\n· 관리자 권한 없이 프로그램을 실행하면 이 설정 없이도 Chrome 엔진을 쓸 수 있습니다.": "このアプリは管理者権限で実行されています。おすすめしません。\n\n· 内蔵 Chrome エンジンはサンドボックス (Web ページを閉じ込める保護) がオフのため、Web ページが管理者権限のプロセス内で動きます。ページの脆弱性がそのままこの PC の管理者権限になるおそれがあります。\n· Edge エンジンにはこの影響がありません。\n· 管理者権限なしでアプリを実行すれば、この設定なしで Chrome エンジンを使えます。",
@ -2359,6 +2402,7 @@
"정해 둠. 저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능을 쓸 때 묻습니다 (앱을 끌 때까지 한 번)": "設定済み。保存したパスワード・設定・パスワードが必要な機能を使うときに尋ねます (アプリを終了するまで 1 回)",
"제목": "タイトル",
"조정 전": "調整前",
"좁게": "狭く",
"좁은 화면에서 동영상 목록이 차지하는 높이 (자동: 목록은 줄 수만큼, 자세히 칸 자리는 남김)": "狭い画面で動画リストが占める高さ (自動: 行の数だけ、詳細欄の場所は残す)",
"종료": "終了",
"종료 (창 닫기 ✕ · 종료 버튼) 를 누르면": "終了(ウィンドウを閉じる ✕・終了ボタン)を押したとき",
@ -2391,6 +2435,7 @@
"준비 중": "準備中",
"준비 중…": "準備中…",
"준비하는 중": "準備中",
"줄 간격": "行の間隔",
"줄 번호": "行番号",
"줄 삭제": "行を削除",
"중간에서 가로채는 공격일 수 있어 연결하지 않았습니다. 서버 인증서를 바꾼 것이 확실할 때만, 서버 설정의 [믿은 인증서 지우기] 뒤 다시 연결하세요.": "中間者攻撃の可能性があるため接続しませんでした。サーバー証明書を変えたことが確かな場合だけ、サーバー設定の [信頼した証明書を削除] の後に再接続してください。",
@ -2450,6 +2495,7 @@
"지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "前回の実行が正常に終了しませんでした(最終確認 {0}:{1})。作業記録に残しました。",
"지난 업데이트가 남긴 임시 파일을 지움: {0}": "前回のアップデートが残した一時ファイルを削除: {0}",
"지난 작업 기록": "過去の作業記録",
"지난번에 남은 문서 보기 임시 폴더를 지움: {0}개": "前回残った文書表示の一時フォルダーを削除: {0} 個",
"지문 확인하고 연결": "フィンガープリントを確認して接続",
"지우고 맞추기": "削除して同期",
"지우고 초기화": "削除して初期化",
@ -2491,6 +2537,8 @@
"진행 패널 보기": "進行パネルを表示",
"집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 \"인증서 확인 안 함\" 을 켜세요.": "自宅 NAS のように自己署名証明書なら、[サーバー設定を修正] で「証明書を検証しない」をオンにしてください。",
"짧을수록 빨리 맞추지만 배터리를 더 씁니다.": "短いほど早く同期しますが、バッテリーをより使います。",
"쪽 범위가 맞지 않습니다": "ページ範囲が正しくありません",
"찍은 날": "撮影日",
"차례 기다림": "順番待ち",
"창": "ペイン",
"창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "ウィンドウ・タスクバー・トレイとアプリ内左上のボタンのアイコンです(実行ファイル・デスクトップのショートカットのアイコンはそのまま)",
@ -2649,6 +2697,8 @@
"파일 탐색기 가운데에 [모니터링] 버튼: 복사 · 이동을 기억해 진행 · 대상 디스크 용량 · 옵션을 보여 주고, 옵션을 고쳐 다시 실행 · 재개하거나 lsync 로 옮깁니다 (복사 · rsync 와 lsync 를 따로).": "ファイルエクスプローラー中央に [モニタリング] ボタン: コピー · 移動を記憶して進捗 · 対象ディスク容量 · オプションを表示し、オプションを変えて再実行 · 再開、または lsync へ移動します (コピー · rsync と lsync は別々)。",
"파일 탐색기 누르기: 한 번 누르면 선택 → 한 번 누르면 바로 실행. 되돌리기: 환경 설정 > 파일 탐색기 > 누르기": "ファイルエクスプローラーのタップ: 1 回で選択 → 1 回ですぐ開く。元に戻す: 環境設定 > ファイルエクスプローラー > タップ",
"파일 탐색기 두 창 배치: 화면 모양 따라 → 좌우. 되돌리기: 파일 탐색기의 [좌우 ⇆ / 위아래 ⇅] 버튼 또는 창 배치": "ファイルエクスプローラーの 2 画面配置: 画面の形に合わせる → 左右。元に戻す: ファイルエクスプローラーの [左右 ⇆ / 上下 ⇅] ボタンまたは画面配置",
"파일 탐색기 목록의 이름 · 크기 글자만. 앱 화면 크기 · 기기 글자 크기와 곱해집니다": "ファイルエクスプローラー一覧の名前・サイズの文字のみ。アプリの画面サイズ・端末の文字サイズと掛け合わされます",
"파일 탐색기 목록의 줄 높이. 좁게는 손가락으로 누르기 어려울 수 있습니다": "ファイルエクスプローラー一覧の行の高さ。狭くすると指でタップしにくい場合があります",
"파일 탐색기 위쪽 ⋮ 메뉴에서도 바꿀 수 있습니다 (좌우 · 위아래 · 버튼 줄 위치 · 버튼 구성)": "ファイルエクスプローラー上部の ⋮ メニューからも変えられます (左右 · 上下 · ボタン列の位置 · ボタン構成)",
"파일 탐색기 창: 두 창 → 화면 크기 따라 (폰 세로는 한 창). 되돌리기: 환경 설정 > 파일 탐색기 > 창 배치": "ファイルエクスプローラーの画面: 2 画面 → 画面サイズに合わせる (縦向きのスマホは 1 画面)。元に戻す: 環境設定 > ファイルエクスプローラー > 画面配置",
"파일 탐색기: 파일을 한 번 누르면 바로 엽니다 (고르기 · 메뉴는 길게 누르기). 바꾸기: 환경 설정 > 파일 탐색기 > 누르기": "ファイルエクスプローラー: ファイルを 1 回タップするとすぐ開きます (選択 · メニューは長押し)。変更: 環境設定 > ファイルエクスプローラー > タップ",
@ -2658,6 +2708,7 @@
"파일만 골랐을 때": "ファイルだけを選んだとき",
"파일을 복사하지 못했습니다": "ファイルをコピーできませんでした",
"파일을 분석할 수 없습니다: {0}": "ファイルを分析できません: {0}",
"파일을 읽을 수 없습니다": "ファイルを読み込めません",
"파일을 읽지 못했습니다": "ファイルを読み取れませんでした",
"파일이 깨졌습니다": "ファイルが壊れています",
"파일이 깨졌습니다 · [다시 받기] 를 눌러 주세요": "ファイルが壊れています・[再ダウンロード] を押してください",
@ -2701,6 +2752,7 @@
"폴더 수정": "フォルダーを編集",
"폴더 안 파일 크기의 합을 목록에 보입니다. 큰 폴더 · NAS 는 읽는 데 오래 걸릴 수 있습니다. 길게 누르기 [폴더 크기 계산] 은 늘 됩니다": "フォルダー内のファイルサイズの合計を一覧に表示します。大きなフォルダー・NAS は読み込みに時間がかかることがあります。長押しの [フォルダーサイズを計算] はいつでも使えます",
"폴더 안의 것을 맞춥니다 (대상에 같은 이름의 폴더를 새로 만들지 않음).": "フォルダーの中身を合わせます (対象に同じ名前のフォルダーは作りません)。",
"폴더 안의 파일은 바꾸지 않습니다. 갤러리에 반영되기까지 잠깐 걸릴 수 있습니다.": "フォルダー内のファイルは変更しません。ギャラリーに反映されるまで少しかかる場合があります。",
"폴더 열기": "フォルダーを開く",
"폴더 이름": "フォルダー名",
"폴더 이름 바꾸기 · 이동": "フォルダー名の変更・移動",
@ -2719,6 +2771,7 @@
"폴더는 보여도 그 안의 동영상 · 파일은 이 권한이 있어야 보입니다. 허용 화면에서 JJ_MKVMaker 를 켜고 돌아오세요.": "フォルダーは見えても、中の動画・ファイルはこの権限が必要です。許可画面で JJ_MKVMaker をオンにして戻ってください。",
"폴더를 만들 수 없습니다: {0}": "フォルダーを作成できません: {0}",
"폴더를 읽는 중…": "フォルダーを読み込み中…",
"폴더를 읽을 수 없습니다: {0}": "フォルダーを読み込めません: {0}",
"폴더를 자기 안으로 복사 · 이동할 수 없습니다": "フォルダーを自分自身の中にコピー · 移動できません",
"폴더를 자기 안으로 복사할 수 없습니다": "フォルダーを自分自身の中にコピーできません",
"폴더를 자기 안으로 옮길 수 없습니다": "フォルダーを自分自身の中に移動できません",

View File

@ -72,6 +72,7 @@
"+30분": "+30分钟",
"- 감지한 언어": "- 检测到的语言",
"-1 은 아무렇게나": "-1 为随机",
".nomedia 를 지웠습니다. 갤러리 · 음악 앱에 다시 보입니다.": "已删除 .nomedia。图库和音乐应用将重新显示。",
".{0} 재생 프로그램 (exe)": ".{0} 播放程序(exe)",
"/ 로 시작하는 전체 경로로": "请使用以 / 开头的完整路径",
"0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 不限制。适用于所有方式: rsync 用 --bwlimit,当前方式由应用调节,robocopy 用 /IPG 近似。也可直接写在选项栏中 (优先)",
@ -213,6 +214,7 @@
"GitHub 의 최신 버전과 비교해, 새 버전이 있으면 받아서 설치합니다": "与 GitHub 上的最新版本比较,如有新版本则下载并安装",
"HTML 파일로 내보내기": "导出为 HTML 文件",
"HTML 파일에서 가져오기": "从 HTML 文件导入",
"HWP · HWPX 문서로 읽을 수 없습니다 (깨졌거나 지원하지 않는 형식)": "无法作为 HWP · HWPX 文档读取 (已损坏或格式不受支持)",
"IPv4 · IPv6 주소를 넣으세요 (예: 8.8.4.4)": "请输入 IPv4 · IPv6 地址 (例: 8.8.4.4)",
"IPv4 · IPv6 주소를 넣으세요 (예: 8.8.8.8)": "请输入 IPv4 · IPv6 地址 (例: 8.8.8.8)",
"JJ_MKVMaker - 다운로드 {0}개": "JJ_MKVMaker - 下载 {0} 个",
@ -498,6 +500,7 @@
"{0} · 그림 · 동영상 {1}개": "{0} · {1} 个图片 / 视频",
"{0} · 링크 {1}개 제외": "{0} · 已排除 {1} 个链接",
"{0} · 파일 {1}개": "{0} · {1} 个文件",
"{0} × {1} 픽셀": "{0} × {1} 像素",
"{0} → {1} 번역 {2}%": "{0} → {1} 翻译 {2}%",
"{0} 는 이미 끝났습니다": "{0} 已经完成",
"{0} 라이선스": "{0} 许可证",
@ -540,6 +543,7 @@
"{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "删除第 {0} 页? (保存后才会写入文件)",
"{0} 펼치기": "展开 {0}",
"{0} 폴더가 없거나 열 수 없어 내역에서 뺐습니다": "文件夹 {0} 不存在或无法打开,已从历史记录中移除",
"{0}% (기본)": "{0}% (默认)",
"{0}% 에서 2분 넘게 나아가지 않아 멈췄습니다. 망이 Tor 를 막고 있거나 느릴 수 있습니다. 잠시 뒤 [다시 시도] 하세요.": "在 {0}% 停滞超过 2 分钟,已停止。网络可能在阻止 Tor 或速度较慢。请稍后点 [重试]。",
"{0}. {1} (대기)": "{0}. {1}(等待)",
"{0}: 이 동영상을 {1} 로 옮깁니다": "{0}: 将此视频移到 {1}",
@ -547,6 +551,7 @@
"{0}: 작업 중이라 옮기지 않았습니다": "{0}: 正在处理,未移动",
"{0}: 저장하지 않았습니다": "{0}:未保存",
"{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: 将勾选的 {1} 个视频移到 {2}",
"{0}~{1}쪽을 {2}초 안에 바꾸지 못했습니다": "无法在 {2} 秒内转换第 {0}~{1} 页",
"{0}개": "{0} 个",
"{0}개 ZIP 으로 묶기": "将 {0} 项压缩为 ZIP",
"{0}개 · {1}": "{0} 个 · {1}",
@ -701,6 +706,9 @@
"개인 키를 넣었습니다 (원래 파일 경로는 저장하지 않습니다)": "已添加私钥 (不保存原文件路径)",
"개인 키를 읽을 수 없습니다 (OpenSSH · PEM 형식)": "无法读取私钥 (OpenSSH · PEM 格式)",
"개인 키의 암호가 맞지 않습니다": "私钥密码不正确",
"갤러리 · 음악 앱에 다시 보이기 (.nomedia 지우기)": "在图库和音乐应用中重新显示 (删除 .nomedia)",
"갤러리 · 음악 앱에서 숨겼습니다 (.nomedia). 파일은 그대로입니다.": "已在图库和音乐应用中隐藏 (.nomedia)。文件保持不变。",
"갤러리 · 음악 앱에서 숨기기 (.nomedia)": "在图库和音乐应用中隐藏 (.nomedia)",
"거꾸로": "倒序",
"거꾸로 된 범위: {0}": "范围顺序颠倒: {0}",
"거른 것 {0}개도 지워집니다 (--delete-excluded)": "已排除的 {0} 项也会被删除 (--delete-excluded)",
@ -798,15 +806,19 @@
"그림 · 만화 보기, PDF 보기 · 페이지 편집, ZIP 목록 · 만화 보기": "图片 · 漫画阅读,PDF 查看 · 页面编辑,ZIP 列表 · 漫画阅读",
"그림 · 프롬프트가 기기 밖으로 나갑니다 ({0})": "图片和提示词将离开本设备 ({0})",
"그림 보기 대상": "图片查看的文件类型",
"그림 보기 아래 갤러리 줄": "图片查看器底部图库条",
"그림 파일을 읽을 수 없습니다 - 깨졌거나 지원하지 않는 형식": "无法读取图片文件 - 已损坏或格式不受支持",
"그림으로 바꾸는 중": "正在生成图像",
"그림을 만들지 못했습니다: {0}": "无法生成图片: {0}",
"그림을 바꿔 만들기": "基于图片生成",
"그림을 받을 수 없습니다 ({0})": "无法获取图片 ({0})",
"그림을 볼 때 아래에 작은 그림 줄을 보입니다 (누르면 그 장으로)": "查看图片时在底部显示缩略图条 (点按可跳到该图)",
"그림을 열 수 없습니다": "无法打开图片",
"그림을 올릴 수 없습니다 ({0})": "无法上传图片 ({0})",
"그림을 읽을 수 없습니다": "无法读取图片",
"그림을 페이지로 넣기 (이 페이지 뒤)": "将图片作为页面加入 (本页之后)",
"그림이 거의 같고 마지막 단계가 10배쯤 빠릅니다 (받아 두었을 때)": "图片几乎相同,最后一步快约 10 倍 (已下载时)",
"그만두었습니다": "已停止",
"그중 {0}개는 받는 쪽이 더 새것입니다 - 더 새 파일을 예전 것으로 덮어씁니다": "其中 {0} 个在接收端较新 - 将用旧文件覆盖较新的文件",
"그중 폴더 {0}개 - 안의 파일 · 폴더도 모두 함께": "其中 {0} 个文件夹 - 其中的文件和文件夹也一并处理",
"글 → 그림 · 그림 → 그림 (기기 안에서, 모델은 환경 설정에서 받기)": "文字 → 图片 · 图片 → 图片(在本设备上;模型在设置中下载)",
@ -916,6 +928,7 @@
"내장 저장소": "内部存储",
"내장 플레이어": "内置播放器",
"내장 플레이어로 재생": "用内置播放器播放",
"넓게": "宽松",
"넘기는 방향: 오른쪽 → 왼쪽 (만화)": "翻页方向: 右 → 左 (漫画)",
"넘기는 방향: 왼쪽 → 오른쪽": "翻页方向: 左 → 右",
"넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP": "填写后使用 AES-256 加密。留空则为普通 ZIP",
@ -981,6 +994,7 @@
"다시 만들기 대기로": "改回等待重新制作",
"다시 만들기 대기로 (지금 묶음이 끝난 뒤)": "重新排队制作 (当前批次结束后)",
"다시 받기": "重新下载",
"다시 보이기": "重新显示",
"다시 비교": "重新比较",
"다시 설치": "重新安装",
"다시 세기": "重新统计",
@ -1020,12 +1034,14 @@
"다음 {0}개 기다림": "另有 {0} 个等待中",
"다음 페이지": "下一页",
"다음 항목 (→)": "下一项 (→)",
"다음부터 묻지 않기": "以后不再询问",
"다음부터 묻지 않기 (환경 설정 > 실행 에서 바꿀 수 있음)": "下次不再询问(可在 设置 > 运行 中更改)",
"단계": "步数",
"단어 첫 글자만 크게 (Abc)": "单词首字母大写 (Abc)",
"단축키 - Total Commander: F5 복사 · F6 이동 · F7 새 폴더 · F8 지우기 · Insert/Space 표시 · Ctrl+R 새로 고침. 그 밖: Ctrl+C · X · V (복사 · 잘라내기 · 붙여넣기) · Ctrl+A 모두 표시 · F5 새로 고침": "快捷键 - Total Commander: F5 复制 · F6 移动 · F7 新建文件夹 · F8 删除 · Insert/Space 标记 · Ctrl+R 刷新。其他: Ctrl+C · X · V (复制 · 剪切 · 粘贴) · Ctrl+A 全部标记 · F5 刷新",
"단축키 해제": "注销快捷键",
"닫기": "关闭",
"닫힌 문서입니다": "文档已关闭",
"닫힘 (실행 안 함)": "已关闭 (未执行)",
"담아 두고 붙여넣기 (기본)": "暂存后粘贴 (默认)",
"답이 없습니다": "没有应答",
@ -1243,6 +1259,8 @@
"목": "四",
"목록": "列表",
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "隐藏列表 · 视频旁 · 视频上方出现的广告区域",
"목록 글자 크기": "列表文字大小",
"목록 맨 아래의 [숨긴 항목 보이기] 나 환경 설정 > 파일 탐색기에서 다시 볼 수 있습니다.": "可通过列表底部的 [显示隐藏项] 或 设置 > 文件管理器 重新查看。",
"목록 보기": "查看列表",
"목록 파일은 전체 경로로 적어 주세요: {0}": "请填写列表文件的完整路径: {0}",
"목록 파일의 전체 경로를 적어 주세요": "请填写列表文件的完整路径",
@ -1253,6 +1271,7 @@
"목록에서 숨겼습니다 ({0}개). 파일은 그대로이고, 목록 맨 아래의 [숨긴 항목 보이기] 로 다시 볼 수 있습니다.": "已从列表中隐藏 ({0} 项)。文件本身未改变,可用列表底部的 [显示已隐藏项] 再次查看。",
"목록에서 숨겼습니다 ({0}개). 파일은 그대로입니다.": "已从列表中隐藏 ({0} 项)。文件本身未改变。",
"목록에서 숨기기": "从列表中隐藏",
"목록에서 숨기기 전에 묻기": "从列表中隐藏前询问",
"목록에서 숨긴 항목 ({0}개)": "从列表中隐藏的项 ({0} 项)",
"목록에서 숨긴 항목 다시 숨기기": "再次隐藏已从列表中隐藏的项",
"목록에서 숨긴 항목 보이기 ({0}개)": "显示从列表中隐藏的项 ({0} 项)",
@ -1263,6 +1282,7 @@
"목록의 동영상 {0}개 모두": "列表中的全部 {0} 个视频",
"몽골어": "蒙古语",
"무엇을 그릴지 적어 주세요": "请输入要画的内容",
"무엇이 바뀌는지 (목록에서만 빠지고 파일은 그대로) 확인 창을 띄웁니다": "显示确认窗口,说明会发生什么 (仅从列表中移除,文件不变)",
"무엇이 바뀌는지 비교하는 중…": "正在比较将要更改的内容…",
"무한": "无限制",
"묶기": "压缩",
@ -1295,6 +1315,8 @@
"바꾸기 ({0}개)": "重命名 ({0} 个)",
"바꾸는 정도 {0}%": "改变程度 {0}%",
"바꾸면 바로 저장됩니다": "更改后立即保存",
"바꾸지 못했습니다: {0}": "无法更改: {0}",
"바꾼 PDF 를 저장하지 못했습니다 (저장 공간을 확인해 주세요)": "无法保存转换后的 PDF (请检查存储空间)",
"바꾼 것이 없습니다 (원본 그대로)": "未做更改(保持原样)",
"바꿀 글": "替换为",
"바꿀 글 (비우면 지움)": "替换为 (留空则删除)",
@ -1397,11 +1419,13 @@
"벵골어": "孟加拉语",
"변경 내용을 저장하지 않고 나갈까요?": "不保存更改就离开吗?",
"변환": "转换",
"변환기 안에서 오류가 났습니다": "转换器内部出错",
"보관한 설정으로 되살리고 다시 시작": "用保存的设置恢复并重新启动",
"보관해 둔 설정을 되살렸습니다: {0}": "已恢复保存的设置: {0}",
"보기": "查看",
"보기 {0}초 · Ctrl+휠 확대/축소": "显示 {0} 秒 · Ctrl+滚轮缩放",
"보기 화면에 [품질 올리기]: AI 그림 (그림 → 그림, 약하게) 으로 다듬은 뒤 해상도 올리기를 이어서. 새 파일 (이름_q) 로 저장하며 AI 그림 모델이 필요합니다": "在查看器中显示 [提升画质]: 先用 AI 图片 (图生图,轻度) 优化再提高分辨率。保存为新文件 (名称_q),需要 AI 图片模型",
"보기 화면에서 볼륨 아래 = 다음 장, 위 = 이전 장 (동영상 · 다른 화면의 볼륨은 그대로)": "在查看器中音量减 = 下一张, 音量加 = 上一张 (视频和其他界面的音量不变)",
"보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "浏览画面中 [连续浏览] 翻到下一页的间隔",
"보내지 못했습니다": "无法发送",
"보낼 것 {0} / 있는 것 {1}": "要发送 {0} / 已有 {1}",
@ -1413,6 +1437,7 @@
"보조 DNS (비워 둘 수 있음)": "备用 DNS (可留空)",
"보통": "普通",
"보통 (0.25, 기본)": "普通 (0.25,默认)",
"보통 (기본)": "正常 (默认)",
"보통 비워 둡니다": "通常留空",
"보통: {0}": "普通:{0}",
"복사": "复制",
@ -1452,6 +1477,7 @@
"복사했습니다.": "已复制。",
"볼 그림이 없습니다.": "没有可显示的图片。",
"볼 수 없는 영상입니다 (삭제 · 지역 제한).": "该视频无法观看(已删除 · 地区限制)。",
"볼륨 키로 넘기기": "用音量键翻页",
"분석": "分析",
"분석 실패: {0} - {1}": "分析失败: {0} - {1}",
"분석에 실패했던 동영상 {0}개를 다시 분석했습니다": "已重新分析之前分析失败的 {0} 个视频",
@ -1514,6 +1540,8 @@
"사용자가 취소했습니다.": "用户已取消。",
"사용할 수 있는 자막 사이트가 없습니다.": "没有可用的字幕网站。",
"사진": "照片",
"사진 슬라이드쇼": "照片幻灯片",
"사진 정보 (Exif)": "照片信息 (Exif)",
"사진에는 '사진' 모델이 더 낫지만, 이 기기 (그래픽카드 없음) 에서는 한 장에 약 20분 걸립니다.": "照片更适合使用“照片”模型,但在本设备 (无显卡) 上每张约需 20 分钟。",
"사진용": "照片",
"삭제": "删除",
@ -1724,6 +1752,8 @@
"쓸 수 없는 이름": "无效名称",
"쓸 수 없는 이름입니다": "不能使用此名称",
"아랍어": "阿拉伯语",
"아래 갤러리 줄 보이기": "显示底部图库条",
"아래 갤러리 줄 숨기기": "隐藏底部图库条",
"아래 프로그램이 없어 일부 기능을 쓸 수 없습니다. 공식 배포처에서 내려받아 프로그램 폴더에 설치할까요?": "由于缺少以下程序,部分功能无法使用。要从官方来源下载并安装到程序文件夹吗?",
"아래로": "向下",
"아래에 줄 추가": "在下方添加一行",
@ -1756,6 +1786,8 @@
"알리지 않음": "不提醒",
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知以 JJ_MKVMaker 名义显示,点击即可打开应用 (仅当前用户,无需管理员权限)",
"암호 ZIP 으로 묶는 중 · {0}": "正在创建加密 ZIP · {0}",
"암호가 걸린 문서입니다": "这是受密码保护的文档",
"암호가 맞지 않습니다": "密码不正确",
"암호로 묶기": "加密压缩",
"암호화": "加密",
"암호화 (TLS) 연결을 맺지 못했습니다: {0}": "无法建立加密 (TLS) 连接:{0}",
@ -1788,6 +1820,7 @@
"앱 안 브라우저의 쿠키 (로그인) · 사이트 데이터 · 캐시 · 방문 기록과, 다운로드용으로 내보낸 쿠키 파일을 지웁니다. 사이트에 다시 로그인해야 합니다. 즐겨찾기는 그대로입니다.": "删除应用内浏览器的 Cookie (登录) · 网站数据 · 缓存 · 浏览记录,以及为下载导出的 Cookie 文件。需要重新登录网站。收藏保持不变。",
"앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "应用内浏览器 · 书签 · 视频下载",
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "应用内网页浏览器: 代为点击[跳过],无法跳过的广告会静音并快进(跳过时屏幕顶部显示“正在跳过广告…”)",
"앱 안에서 열지 못해 다른 앱으로 엽니다: {0}": "无法在应用内打开,改用其他应用打开: {0}",
"앱 지우기": "卸载应用",
"앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "在应用回收站保留 {0} 天后删除 (可在资源管理器的 [回收站] 中还原)。",
"앱에 들어 있는 것 (기본)": "应用内置 (默认)",
@ -1827,6 +1860,7 @@
"엔진": "引擎",
"여기까지 범위 고르기": "选择到此处的范围",
"여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "拖放到此处即可加入制作 MKV 的视频列表",
"여는 중: {0}": "正在打开: {0}",
"여러 개를 골랐을 때": "选择多个时",
"여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "将多个视频制作成 MKV 时同时处理的数量(重新编码会占用大量 CPU)",
"여러 번 틀려 {0}초 뒤에 다시 넣을 수 있습니다": "错误次数过多,{0} 秒后可再次输入",
@ -2004,6 +2038,9 @@
"위아래 ⇅": "上下 ⇅",
"위쪽 이동 버튼과 좌우로 밀기의 순서입니다. ▲ ▼ 로 바꿉니다.": "顶部导航按钮和左右滑动的顺序。用 ▲ ▼ 更改。",
"위쪽 이동 버튼과 좌우로 밀기의 순서입니다. 줄을 끌어 바꿉니다.": "顶部导航按钮和左右滑动的顺序。拖动行即可更改。",
"위치": "位置",
"위치 보기": "显示位置",
"위치는 이 기기에서만 보이며 지도 · 인터넷으로 보내지 않습니다": "位置仅在本设备上显示,不会发送到地图或互联网",
"유형": "类型",
"음성": "音频",
"음성 {0}개": "音频 {0} 个",
@ -2023,6 +2060,7 @@
"이 WebDAV 서버는 로그인이 필요합니다": "此 WebDAV 服务器需要登录",
"이 {0}의 설정 파일에 저장됩니다": "保存在此{0}的设置文件中",
"이 {0}의 안전 저장소에 저장됩니다 (설정 파일에는 쓰지 않음)": "保存在此 {0} 的安全存储中 (不写入设置文件)",
"이 그림에는 Exif 정보가 없습니다 (JPEG 사진만 읽음)": "此图片没有 Exif 信息 (仅读取 JPEG 照片)",
"이 그림을 바탕으로 (그림 → 그림)": "以此图片为基础 (图片 → 图片)",
"이 기기 (SD1.5 + LCM)": "本设备 (SD1.5 + LCM)",
"이 기기 ({0})": "本设备 ({0})",
@ -2063,6 +2101,7 @@
"이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)": "此安装中没有 rsync (使用 rsync 复制时 · 或用右侧 [下载] 获取)",
"이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "此安装中没有 rsync。请自行指定。",
"이 앱 (APK) 에는 Tor 가 들어 있지 않습니다.": "此应用 (APK) 未包含 Tor。",
"이 앱의 파일 탐색기 목록에서만 빠집니다. 파일은 지우거나 옮기거나 이름을 바꾸지 않고, 다른 앱 · 갤러리에서는 그대로 보입니다.": "仅从本应用的文件管理器列表中移除。文件不会被删除、移动或重命名,其他应用和图库中仍然可见。",
"이 언어 지우기": "删除此语言",
"이 연결은 서명도 암호화도 되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 보거나 바꿀 수 있으니, NAS 설정에서 SMB 서명이나 SMB3 암호화를 켜는 것이 좋습니다": "此连接既未签名也未加密 ({0})。同一网络中的其他人可能查看或篡改文件内容,建议在 NAS 设置中开启 SMB 签名或 SMB3 加密",
"이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "此连接未加密 ({0})。同一网络中的其他人可能看到文件内容,建议在 NAS 设置中开启 SMB3 加密",
@ -2071,6 +2110,7 @@
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "此位置 (网络驱动器 · 网络共享 · U 盘等) 没有回收站,会直接删除。",
"이 이동 버튼 지우기": "删除此移动按钮",
"이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "下次执行此移动时将使用这些选项。现在用新选项执行吗?",
"이 쪽을 그리지 못했습니다": "无法渲染此页",
"이 파일에 맞는 자막 우선 (영상 해시)": "优先匹配此文件的字幕(视频哈希)",
"이 파일용": "适用于此文件",
"이 파일을 열 수 있는 앱이 없습니다.": "没有可以打开此文件的应用。",
@ -2087,11 +2127,14 @@
"이 폴더를 볼 권한이 없습니다": "没有查看此文件夹的权限",
"이 폴더를 읽을 권한이 없습니다": "没有读取此文件夹的权限",
"이 폴더에 동영상이 없습니다.": "此文件夹中没有视频。",
"이 폴더에 빈 .nomedia 파일을 만듭니다. 갤러리 · 음악 앱이 이 폴더 (안의 폴더 포함) 의 그림 · 동영상 · 음악을 보이지 않게 됩니다.": "在此文件夹中创建空的 .nomedia 文件。图库和音乐应用将不再显示此文件夹 (含子文件夹) 中的图片、视频和音乐。",
"이 폴더에 새 폴더": "在此文件夹中新建文件夹",
"이 폴더에 현재 페이지 추가": "将当前页面添加到此文件夹",
"이 폴더에는 고를 파일이 없습니다": "此文件夹中没有可选择的文件",
"이 폴더에는 그림 · 동영상이 없습니다": "此文件夹中没有图片或视频",
"이 폴더에는 그림이 없습니다": "此文件夹中没有图片",
"이 폴더에만: {0}": "仅此文件夹: {0}",
"이 폴더의 .nomedia 파일을 지웁니다. 갤러리 · 음악 앱이 이 폴더의 그림 · 동영상 · 음악을 다시 보이게 됩니다.": "删除此文件夹中的 .nomedia 文件。图库和音乐应用将重新显示此文件夹中的图片、视频和音乐。",
"이 폴더의 동영상 재생": "播放此文件夹中的视频",
"이 폴더의 파일": "此文件夹中的文件",
"이 프로그램이 관리자 권한으로 실행 중입니다. 권장하지 않습니다.\n\n· 내장 Chrome 엔진은 샌드박스 (웹 페이지를 가두는 보호) 가 꺼져 있어, 웹 페이지가 관리자 권한 프로세스 안에서 돕니다. 페이지의 취약점이 곧 이 PC 의 관리자 권한이 될 수 있습니다.\n· Edge 엔진은 이 영향이 없습니다.\n· 관리자 권한 없이 프로그램을 실행하면 이 설정 없이도 Chrome 엔진을 쓸 수 있습니다.": "本应用正以管理员权限运行,不推荐这样做。\n\n· 内置 Chrome 引擎的沙盒(限制网页的保护)已关闭,网页会在管理员权限进程中运行。网页中的漏洞可能直接变成此电脑的管理员权限。\n· Edge 引擎不受此影响。\n· 不以管理员权限运行本应用时,无需此设置即可使用 Chrome 引擎。",
@ -2359,6 +2402,7 @@
"정해 둠. 저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능을 쓸 때 묻습니다 (앱을 끌 때까지 한 번)": "已设置。使用已保存的密码 · 设置 · 需要密码的功能时询问 (在关闭应用前只问一次)",
"제목": "标题",
"조정 전": "调整前",
"좁게": "紧凑",
"좁은 화면에서 동영상 목록이 차지하는 높이 (자동: 목록은 줄 수만큼, 자세히 칸 자리는 남김)": "窄屏时视频列表所占高度 (自动: 按行数,并为详情区留出空间)",
"종료": "退出",
"종료 (창 닫기 ✕ · 종료 버튼) 를 누르면": "点击退出(关闭窗口 ✕ · 退出按钮)时",
@ -2391,6 +2435,7 @@
"준비 중": "准备中",
"준비 중…": "准备中…",
"준비하는 중": "正在准备",
"줄 간격": "行间距",
"줄 번호": "行号",
"줄 삭제": "删除行",
"중간에서 가로채는 공격일 수 있어 연결하지 않았습니다. 서버 인증서를 바꾼 것이 확실할 때만, 서버 설정의 [믿은 인증서 지우기] 뒤 다시 연결하세요.": "可能是中间人攻击,因此未连接。仅当确定服务器证书已更换时,在服务器设置中 [删除已信任的证书] 后重新连接。",
@ -2450,6 +2495,7 @@
"지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "上次运行未正常结束(最后检查 {0}:{1})。已记录在任务记录中。",
"지난 업데이트가 남긴 임시 파일을 지움: {0}": "已删除上次更新留下的临时文件: {0}",
"지난 작업 기록": "旧任务记录",
"지난번에 남은 문서 보기 임시 폴더를 지움: {0}개": "已删除上次留下的文档查看临时文件夹: {0} 个",
"지문 확인하고 연결": "确认指纹并连接",
"지우고 맞추기": "删除并同步",
"지우고 초기화": "删除并重置",
@ -2491,6 +2537,8 @@
"진행 패널 보기": "显示进度面板",
"집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 \"인증서 확인 안 함\" 을 켜세요.": "如果是自签名证书 (如家用 NAS),请在 [修改服务器设置] 中开启“不验证证书”。",
"짧을수록 빨리 맞추지만 배터리를 더 씁니다.": "间隔越短同步越快,但更耗电。",
"쪽 범위가 맞지 않습니다": "页面范围无效",
"찍은 날": "拍摄日期",
"차례 기다림": "排队等待",
"창": "窗格",
"창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "窗口、任务栏、托盘以及应用内左上角按钮的图标(程序文件和桌面快捷方式的图标不变)",
@ -2649,6 +2697,8 @@
"파일 탐색기 가운데에 [모니터링] 버튼: 복사 · 이동을 기억해 진행 · 대상 디스크 용량 · 옵션을 보여 주고, 옵션을 고쳐 다시 실행 · 재개하거나 lsync 로 옮깁니다 (복사 · rsync 와 lsync 를 따로).": "文件浏览器中间的 [监控] 按钮: 记住复制 · 移动并显示进度 · 目标磁盘容量 · 选项,可修改选项后重新运行 · 继续,或移到 lsync (复制 · rsync 与 lsync 分开显示)。",
"파일 탐색기 누르기: 한 번 누르면 선택 → 한 번 누르면 바로 실행. 되돌리기: 환경 설정 > 파일 탐색기 > 누르기": "文件浏览器点击: 单击选择 → 单击直接打开。恢复: 设置 > 文件浏览器 > 点击",
"파일 탐색기 두 창 배치: 화면 모양 따라 → 좌우. 되돌리기: 파일 탐색기의 [좌우 ⇆ / 위아래 ⇅] 버튼 또는 창 배치": "文件浏览器双窗格布局: 跟随屏幕形状 → 左右。恢复: 文件浏览器中的 [左右 ⇆ / 上下 ⇅] 按钮或窗格布局",
"파일 탐색기 목록의 이름 · 크기 글자만. 앱 화면 크기 · 기기 글자 크기와 곱해집니다": "仅文件管理器列表中的名称和大小文字。会与应用界面大小及设备文字大小相乘",
"파일 탐색기 목록의 줄 높이. 좁게는 손가락으로 누르기 어려울 수 있습니다": "文件管理器列表的行高。紧凑时可能难以用手指点按",
"파일 탐색기 위쪽 ⋮ 메뉴에서도 바꿀 수 있습니다 (좌우 · 위아래 · 버튼 줄 위치 · 버튼 구성)": "也可以在文件浏览器顶部的 ⋮ 菜单中更改 (左右 · 上下 · 按钮栏位置 · 按钮配置)",
"파일 탐색기 창: 두 창 → 화면 크기 따라 (폰 세로는 한 창). 되돌리기: 환경 설정 > 파일 탐색기 > 창 배치": "文件浏览器窗格: 双窗格 → 按屏幕大小 (竖屏手机为单窗格)。恢复: 设置 > 文件浏览器 > 窗格布局",
"파일 탐색기: 파일을 한 번 누르면 바로 엽니다 (고르기 · 메뉴는 길게 누르기). 바꾸기: 환경 설정 > 파일 탐색기 > 누르기": "文件浏览器:单击文件会立即打开(选择 · 菜单请长按)。更改:设置 > 文件浏览器 > 点按",
@ -2658,6 +2708,7 @@
"파일만 골랐을 때": "只选择文件时",
"파일을 복사하지 못했습니다": "无法复制文件",
"파일을 분석할 수 없습니다: {0}": "无法分析文件: {0}",
"파일을 읽을 수 없습니다": "无法读取文件",
"파일을 읽지 못했습니다": "无法读取文件",
"파일이 깨졌습니다": "文件已损坏",
"파일이 깨졌습니다 · [다시 받기] 를 눌러 주세요": "文件已损坏 · 请点按 [重新下载]",
@ -2701,6 +2752,7 @@
"폴더 수정": "编辑文件夹",
"폴더 안 파일 크기의 합을 목록에 보입니다. 큰 폴더 · NAS 는 읽는 데 오래 걸릴 수 있습니다. 길게 누르기 [폴더 크기 계산] 은 늘 됩니다": "在列表中显示文件夹内文件大小的总和。大型文件夹或 NAS 可能需要较长时间读取。长按 [计算文件夹大小] 始终可用",
"폴더 안의 것을 맞춥니다 (대상에 같은 이름의 폴더를 새로 만들지 않음).": "同步文件夹内的内容 (不会在目标中新建同名文件夹)。",
"폴더 안의 파일은 바꾸지 않습니다. 갤러리에 반영되기까지 잠깐 걸릴 수 있습니다.": "不会更改文件夹中的文件。图库可能需要片刻才能反映。",
"폴더 열기": "打开文件夹",
"폴더 이름": "文件夹名称",
"폴더 이름 바꾸기 · 이동": "重命名 · 移动文件夹",
@ -2719,6 +2771,7 @@
"폴더는 보여도 그 안의 동영상 · 파일은 이 권한이 있어야 보입니다. 허용 화면에서 JJ_MKVMaker 를 켜고 돌아오세요.": "虽然能看到文件夹,但其中的视频和文件需要此权限。请在权限界面开启 JJ_MKVMaker 后返回。",
"폴더를 만들 수 없습니다: {0}": "无法创建文件夹: {0}",
"폴더를 읽는 중…": "正在读取文件夹…",
"폴더를 읽을 수 없습니다: {0}": "无法读取文件夹: {0}",
"폴더를 자기 안으로 복사 · 이동할 수 없습니다": "不能将文件夹复制或移动到其自身内部",
"폴더를 자기 안으로 복사할 수 없습니다": "不能将文件夹复制到其自身内部",
"폴더를 자기 안으로 옮길 수 없습니다": "不能将文件夹移动到其自身内部",

File diff suppressed because it is too large Load Diff

View File

@ -58,6 +58,10 @@ JJ_MKVMaker (Android) - 포함된 오픈 소스 구성 요소 및 라이선스
(이 앱은 RAR 을 만들지 않습니다). 공식 소스 (https://www.7-zip.org/a/7z2501-src.7z) 를 고치지 않고 Android NDK 로 빌드
(빌드 방법: 소스의 tool/build_7zz_android.sh). 따로 실행하는 프로그램이며, 대응 소스는 위 주소와 이 앱 판의 태그에 있습니다.
원문: 앱 안 "오픈 소스 라이선스" 의 7-Zip (7ZIP_LICENSE.txt) · GNU LGPL 2.1
- rhwp v0.8.7 (libjj_rhwp.so - HWP · HWPX 를 PDF 로 바꿔 보기) MIT https://github.com/edwardkim/rhwp
Copyright (c) 2025-2026 Edward Kim. 커밋 1a76570 을 고치지 않고, 얇은 C 함수 묶음 (소스의 tool/rhwp_ffi) 과 함께 Android NDK 로
빌드 (빌드 방법: 소스의 tool/build_rhwp_android.sh). 함께 묶인 Rust crate 들 (MIT · Apache-2.0 등) 의 목록과 원문:
앱 안 "오픈 소스 라이선스" 의 rhwp (RHWP_LICENSES.txt)
- stable-diffusion.cpp · ggml (libsdcli.so 라는 이름의 실행 파일) MIT https://github.com/leejet/stable-diffusion.cpp
커밋 228c707 을 고치지 않고 Android NDK 로 빌드 (AI 그림 · 해상도 올리기, 빌드 방법: 소스의 tool/build_sdcpp_android.sh)
함께 묶인 구성 요소: ggml (MIT) · libwebp · libwebm (BSD-3) · Oniguruma · Darts-clone (BSD-2) · utf8proc · nlohmann/json ·

View File

@ -0,0 +1,67 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/explorer_page.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'package:path/path.dart' as p;
/// X-plore 비교 40 (관리자 지시 10/20): 412 폭 · 목록 글자 150% · 넓게 (그리고 처음 값) 를 실제 글꼴로 그려 PNG 로 (JJ_SHOT_DIR).
/// 세 스타일 모두. 넘치면 (RenderFlex overflow) 시험이 실패한다
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('40: 목록 글자 크기 · 줄 간격 화면 그림 (412 폭)', (tester) async {
final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path;
final tmp = Directory.systemTemp.createTempSync('jj_textshot_');
addTearDown(() => tmp.deleteSync(recursive: true));
File(p.join(tmp.path, '아주 긴 이름의 동영상 파일 - 글자 크기 시험용 2026 최종본.mp4')).writeAsStringSync('v' * 123456);
File(p.join(tmp.path, 'Very long English file name for the text size check.txt')).writeAsStringSync('t' * 4321);
File(p.join(tmp.path, 'a.jpg')).writeAsStringSync('x');
Directory(p.join(tmp.path, '폴더 하나')).createSync();
Directory(p.join(tmp.path, 'Another folder with a long name')).createSync();
final c = AppController(PlatformServices.create());
c.settings.explorerPaths = [tmp.path, tmp.path];
final key = GlobalKey();
// 폰처럼 (Android 는 트리 펼치기 화살표가 44 로 넓다 - Windows 로 그리면 폰보다 넉넉해 보인다)
debugDefaultTargetPlatformOverride = TargetPlatform.android;
for (final (layout, width, device) in [('single', 412.0, 1.0), ('split', 412.0, 1.0), ('split', 360.0, 1.0), ('split', 412.0, 1.3)]) {
for (final style in ['xplore', 'windows', 'totalcmd']) {
for (final (scale, spacing) in [(100, 'normal'), (150, 'wide'), (80, 'compact')]) {
c.settings
..explorerLayout = layout
..explorerStyle = style
..explorerTextScale = scale
..explorerRowSpacing = spacing;
tester.view.physicalSize = Size(width, 900);
tester.view.devicePixelRatio = 1;
await tester.runAsync(() => tester.pumpWidget(MaterialApp(
theme: buildTheme(),
// 기기 글자 크기 (관리자 지시 10/20: 1.3 × 150% 에서 두 창이 모자라면 한 창)
builder: (ctx, child) => MediaQuery(data: MediaQuery.of(ctx).copyWith(textScaler: TextScaler.linear(device)), child: child!),
home: RepaintBoundary(key: key, child: ExplorerPage(c: c)),
)));
for (var i = 0; i < 12; i++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 250)));
await tester.pump();
}
expect(tester.takeException(), isNull, reason: '$layout $width ×$device $style $scale% $spacing 넘치지 않음');
final boundary = key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final image = await tester.runAsync(() => boundary.toImage());
final bytes = await tester.runAsync(() => image!.toByteData(format: ui.ImageByteFormat.png));
File('$out/explorer_text_$layout${width.round()}${device == 1 ? '' : '_dev${(device * 10).round()}'}_${style}_${scale}_$spacing.png').writeAsBytesSync(bytes!.buffer.asUint8List());
await tester.pumpWidget(const SizedBox());
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 300)));
}
}
}
debugDefaultTargetPlatformOverride = null;
tester.view.reset();
});
}

View File

@ -0,0 +1,145 @@
import 'dart:async';
import 'dart:io';
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/hwp_source.dart';
import 'package:jj_mkvmaker/core/reader_sources.dart';
import 'package:path/path.dart' as p;
import 'package:pdfrx/pdfrx.dart';
import 'support/temp_dir.dart';
/// 쪽마다 그림 한 장인 진짜 PDF 를 만드는 가짜 엔진 (rhwp 대신) - 부른 범위를 적어 둔다
class _FakeEngine implements HwpPdfEngine {
final int pages;
final String tempDir;
final calls = <(int, int)>[];
var failNext = 0;
Completer<void>? hold;
_FakeEngine(this.pages, this.tempDir);
@override
Future<HwpPdfDoc> open(String path, {String? password}) async {
if (password == 'bad') throw const HwpPdfException(HwpErrorKind.passwordWrong, 'CryptoError WrongPassword');
return QueuedHwpDoc(_FakeNative(this));
}
}
/// 앱과 같은 [QueuedHwpDoc] 아래의 가짜 C 함수
class _FakeNative implements HwpNative {
final _FakeEngine e;
var closed = false;
_FakeNative(this.e);
@override
int get pageCount => e.pages;
@override
void cancel(int req) {
final h = e.hold;
if (h != null && !h.isCompleted) h.complete(); // Rust 처럼 다음 쪽에서 멈춤
}
@override
void cancelAll() => cancel(0);
@override
Future<(int, String?)> export(int req, int from, int to, String fontDirs, String out) async {
e.calls.add((from, to));
// 끝나지 않는 변환 (시간 제한 시험): 멈춤으로 풀리면 아무것도 쓰지 않는다 (시험이 끝난 뒤 폴더에 쓰지 않게)
if (e.hold != null) {
await e.hold!.future;
return (1, 'cancelled');
}
if (e.failNext > 0) {
e.failNext--;
return (7, 'page 1: boom');
}
final docs = <PdfDocument>[];
for (var i = from; i < to; i++) {
final png = p.join(e.tempDir, 'pg$i.png');
// 쪽마다 폭이 달라 (100 + 쪽 번호) 어느 쪽이 그려졌는지 크기로 안다
File(png).writeAsBytesSync(img.encodePng(img.Image(width: 100 + i, height: 140)));
docs.add(await imagePageDocument(png, tempDir: e.tempDir, sourceName: 'jj_fake_$i'));
}
final outDoc = await PdfDocument.createNew(sourceName: 'jj_fake_out_$from');
outDoc.pages = [for (final d in docs) d.pages.first];
File(out).writeAsBytesSync(await outDoc.encodePdf());
await outDoc.dispose();
for (final d in docs) {
await d.dispose();
}
return (0, null);
}
@override
Future<void> close() async => closed = true;
}
/// 관리자 지시 (10/20): HWP · HWPX 보기 - 보는 쪽의 묶음만 PDF 로 바꾸고 다음 묶음을 미리 만든다 · 시간 제한 · 실패하면 다시 · 닫기.
/// (rhwp 대신 가짜 엔진, PDF 를 그리는 PDFium 이 필요해 Windows 통합 시험)
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('보는 쪽의 묶음 (5쪽) 만 바꾸고 다음 묶음을 미리 · 끝 묶음은 남은 쪽만', (tester) async {
final dir = testTempDir('jj_hwpsrc_');
final doc = File(p.join(dir.path, 'a.hwp'))..writeAsStringSync('x');
final engine = _FakeEngine(12, dir.path);
final src = await tester.runAsync(() => HwpSource.open(doc.path, engine: engine, tempDir: dir.path, chunk: 5));
expect(src!.length, 12);
expect(src.pageName(0), '1 / 12');
final first = await tester.runAsync(() => src.load(0, maxWidth: 200));
expect(first!.image, isNotNull);
first.dispose();
await tester.runAsync(() => Future<void>.delayed(const Duration(seconds: 2)));
expect(engine.calls, [(0, 5), (5, 10)], reason: '첫 묶음 + 다음 묶음 미리');
// 끝 쪽: 남은 2쪽만 (11번째 = 폭 110)
final last = await tester.runAsync(() => src.load(11, maxWidth: 200));
expect(last!.image, isNotNull);
last.dispose();
expect(engine.calls.last, (10, 12));
// 이미 만든 묶음은 다시 만들지 않는다
final again = await tester.runAsync(() => src.load(3, maxWidth: 200));
again!.dispose();
expect(engine.calls.where((c) => c == (0, 5)), hasLength(1));
await tester.runAsync(src.dispose);
expect(Directory(src.work.path).existsSync(), isFalse, reason: '닫으면 임시 PDF 를 지움');
});
testWidgets('묶음이 실패하면 그 쪽에 이유, 다시 보면 다시 만든다 · 시간 제한 · 열기 실패', (tester) async {
final dir = testTempDir('jj_hwpsrc_');
final doc = File(p.join(dir.path, 'b.hwpx'))..writeAsStringSync('x');
final engine = _FakeEngine(3, dir.path)..failNext = 1;
final src = await tester.runAsync(() => HwpSource.open(doc.path, engine: engine, tempDir: dir.path, chunk: 5,
timeout: const Duration(seconds: 2)));
Object? err;
await tester.runAsync(() => src!.load(0, maxWidth: 200).then((r) => r.dispose(), onError: (Object e) {
err = e;
}));
expect('$err', contains('이 쪽을 그리지 못했습니다'));
final ok = await tester.runAsync(() => src!.load(0, maxWidth: 200));
expect(ok!.image, isNotNull, reason: '실패한 묶음은 다시');
ok.dispose();
await tester.runAsync(src!.dispose);
// 시간 제한: 끝나지 않는 변환
final slow = _FakeEngine(3, dir.path)..hold = Completer<void>();
final s2 = await tester.runAsync(() => HwpSource.open(doc.path, engine: slow, tempDir: dir.path, timeout: const Duration(seconds: 1)));
Object? err2;
await tester.runAsync(() => s2!.load(1, maxWidth: 200).then((r) => r.dispose(), onError: (Object e) {
err2 = e;
}));
expect(err2, isA<HwpPdfException>());
expect('$err2', contains('1~3'));
expect(slow.hold!.isCompleted, isTrue, reason: '시간 제한이 변환을 멈춤 (기다림만 끊지 않음)');
await tester.runAsync(s2!.dispose);
// 열기 실패 (암호 등) 는 그대로 알린다 - 파일 탐색기는 다른 앱으로 연다
Object? err3;
await tester.runAsync(() => HwpSource.open(doc.path, engine: engine, tempDir: dir.path, password: 'bad').then((_) {}, onError: (Object e) {
err3 = e;
}));
expect('$err3', contains('암호가 맞지 않습니다'));
});
}

View File

@ -0,0 +1,60 @@
import 'dart:io';
import 'package:flutter/material.dart';
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/app/app_controller.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;
/// 28 지적 (10/20): 보기 화면에서 장을 손으로 몇 번 넘긴 뒤 위 막대 [사진 정보 (Exif)] 창을 닫으면 디버그 판에서
/// 의미 트리 assertion ('node.built': is not true, rendering/object.dart) 이 났다. 실제 프레임 · 끌기 애니메이션 (통합 시험) 에서만 나온다.
/// 실행: flutter test integration_test\reader_dialog_semantics_test.dart -d windows
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
for (final gallery in [false, true]) {
testWidgets('장을 넘긴 뒤 사진 정보 창을 열고 닫아도 오류 없음 (갤러리 줄 $gallery)', (t) async {
t.view.physicalSize = const Size(960, 600);
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
final tmp = Directory.systemTemp.createTempSync('jj_readersem_');
addTearDown(() => tmp.deleteSync(recursive: true));
final files = [
for (var i = 0; i < 7; i++)
(File(p.join(tmp.path, 'p$i.png'))..writeAsBytesSync(img.encodePng(img.Image(width: 300, height: 200)))).path,
];
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.readerGallery = gallery;
Future<void> wait(int ms) async {
for (var i = 0; i < ms ~/ 100; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 100)));
await t.pump();
}
}
final nav = GlobalKey<NavigatorState>();
await t.pumpWidget(MaterialApp(navigatorKey: nav, home: const Scaffold(body: SizedBox.expand())));
nav.currentState!.push(MaterialPageRoute<void>(
builder: (_) => ReaderPage(c: c, source: ImageFilesSource(files, tempDir: tmp.path, title: 'x'), start: 3)));
await wait(1500);
for (var k = 0; k < 3; k++) {
await t.drag(find.byType(PageView).first, const Offset(-960 * 0.8, 0));
await wait(500);
}
await t.tap(find.byTooltip('사진 정보 (Exif)').first);
await wait(600);
expect(find.text('이 그림에는 Exif 정보가 없습니다 (JPEG 사진만 읽음)'), findsOneWidget);
await t.tap(find.text('닫기').last);
await wait(500);
expect(t.takeException(), isNull);
await t.pumpWidget(const SizedBox());
await wait(300);
});
}
}

View File

@ -32,6 +32,7 @@ import '../services/platform_services.dart';
import '../services/system_usage.dart';
import 'settings.dart';
import 'cleanup.dart' show removeUpdateLeftovers;
import '../core/hwp_source.dart' show removeStaleHwpWork;
import '../l10n/tr.dart';
import '../core/remote_idle.dart';
@ -213,6 +214,14 @@ class AppController extends ChangeNotifier {
final gone = removeUpdateLeftovers(Directory.systemTemp.path);
if (gone.isNotEmpty) _log(trf('지난 업데이트가 남긴 임시 파일을 지움: {0}', [gone.join(', ')]));
}
// 관리자 지시 (10/20): 강제로 끝나 남은 HWP 보기 작업 폴더 (바꾼 PDF 들). 앱 시작을 기다리게 하지 않는다 (28 지적: 파일 IO 를
// 기다리면 시험의 가짜 시간 안에서 끝나지 않았다 - 시작을 늦출 이유도 없음)
unawaited(() async {
try {
final hwp = removeStaleHwpWork(await services.storage.tempDirectory());
if (hwp.isNotEmpty) _log(trf('지난번에 남은 문서 보기 임시 폴더를 지움: {0}개', [hwp.length]));
} catch (_) {}
}());
ffmpegVersion = await _tool.version();
if (ffmpegVersion == null) {
_log(tr('⚠ FFmpeg 를 찾을 수 없습니다. MKV 만들기를 사용할 수 없습니다.'));

View File

@ -418,6 +418,14 @@ class AppSettings {
/// 다시 · 두 번 눌러도 아래 줄이 움직이지 않게) 와 부딪혀 설정으로만. 켜도 두 번 누르기의 두 번째는 접지 않는다
bool explorerRetapCollapse = false;
/// X-plore 비교 40 (관리자 지시 10/20): 파일 탐색기 목록만의 글자 크기 (%, 80~150, 처음 100 = 지금 그대로).
/// 앱 화면 크기 · 기기 글자 크기와 곱해진다
int explorerTextScale = 100;
static const explorerTextScaleMin = 80, explorerTextScaleMax = 150;
/// X-plore 비교 40: 목록 줄 간격 'compact' 좁게 · 'normal' 보통 (처음 값 = 지금 그대로) · 'wide' 넓게
String explorerRowSpacing = 'normal';
/// X-plore 비교 i: 일괄 이름 바꾸기의 마지막 규칙 (다음에 창이 그 값으로 열림, 비면 처음 값)
Map<String, dynamic> batchRenameLast = {};
@ -490,6 +498,9 @@ class AppSettings {
/// 지우기 · 이동 대상에서는 빠지지 않는다. 목록 맨 아래 [숨긴 항목 보이기] 로 다시 본다
List<String> explorerHiddenItems = [];
/// X-plore 맞추기 (관리자 지시 10/20): [목록에서 숨기기] 전에 무엇이 바뀌는지 확인 창 (처음 켬, 창의 [다음부터 묻지 않기] · 환경 설정에서 끔)
bool explorerHideConfirm = true;
// ── 복사 · 이동 (파일 탐색기) · rsync · lsync (Rsync 화면) ──
/// 파일 탐색기의 복사 · 이동 방법: 'builtin' 현재 방식 (기본) · 'robocopy' (Windows). 파일만 고를 때 / 폴더가 들어 있을 때.
/// rsync 는 Rsync 화면에서 따로 (파일 탐색기에서는 쓰지 않음)
@ -555,6 +566,13 @@ class AppSettings {
/// 117: 보기 화면 "계속 보기" 간격 (초, 1 ~ 60)
int readerAutoSeconds = 3;
/// X-plore 맞추기 (관리자 지시 10/20): 그림 보기 화면 아래 갤러리 줄 (썸네일, 누르면 그 장으로). 처음 끔
bool readerGallery = false;
/// X-plore 맞추기 (관리자 지시 10/20): 보기 화면에서 볼륨 키로 넘기기 (아래 = 다음 장, 위 = 이전 장, Android). 처음 끔.
/// 보기 화면에서만 가로챈다 (동영상 · 다른 화면의 볼륨은 그대로)
bool readerVolumeKeys = false;
/// 119: 한 장에 두 쪽이 붙은 그림을 반씩 나눠 보기 - 'auto' 가로가 세로보다 긴 그림만 · 'on' 늘 · 'off' 안 함
String readerSplit = 'auto';
@ -822,6 +840,8 @@ class AppSettings {
'explorerLayout': explorerLayout,
'explorerOnePaneCopy': explorerOnePaneCopy,
'explorerRetapCollapse': explorerRetapCollapse,
'explorerTextScale': explorerTextScale,
'explorerRowSpacing': explorerRowSpacing,
'batchRenameLast': batchRenameLast,
'explorerRootsInTree': explorerRootsInTree,
'explorerAlwaysMarks': explorerAlwaysMarks,
@ -849,6 +869,7 @@ class AppSettings {
'explorerFolderSorts': explorerFolderSorts,
'explorerShowHidden': explorerShowHidden,
'explorerHiddenItems': explorerHiddenItems,
'explorerHideConfirm': explorerHideConfirm,
'explorerPaths': explorerPaths,
'copyMethodFile': copyMethodFile,
'copyConflict': copyConflict,
@ -867,6 +888,8 @@ class AppSettings {
'readerBrightness': readerBrightness,
'readerSystemBars': readerSystemBars,
'readerAutoSeconds': readerAutoSeconds,
'readerGallery': readerGallery,
'readerVolumeKeys': readerVolumeKeys,
'readerSplit': readerSplit,
'startMenuShortcut': startMenuShortcut,
'syncStopToast': syncStopToast,
@ -1050,6 +1073,11 @@ class AppSettings {
// 'auto' = 넓은 화면은 두 창, 좁은 화면 (폰 세로) 은 한 창. 예전 기본 'dual' 은 한 번 'auto' 로
..explorerOnePaneCopy = j['explorerOnePaneCopy'] == 'other' ? 'other' : 'clip'
..explorerRetapCollapse = j['explorerRetapCollapse'] == true
// 값이 없거나 틀리면 지금 그대로 (100% · 보통), 범위 밖은 끝으로
..explorerTextScale = j['explorerTextScale'] is num
? (j['explorerTextScale'] as num).round().clamp(explorerTextScaleMin, explorerTextScaleMax)
: 100
..explorerRowSpacing = const {'compact', 'normal', 'wide'}.contains(j['explorerRowSpacing']) ? j['explorerRowSpacing'] as String : 'normal'
..batchRenameLast = j['batchRenameLast'] is Map ? Map<String, dynamic>.from(j['batchRenameLast'] as Map) : {}
..explorerRootsInTree = j['explorerRootsInTree'] != false
..explorerAlwaysMarks = j['explorerAlwaysMarks'] != false
@ -1093,6 +1121,7 @@ class AppSettings {
}
..explorerShowHidden = j['explorerShowHidden'] as bool? ?? false
..explorerHiddenItems = [for (final x in (j['explorerHiddenItems'] as List?) ?? const []) if (x is String && x.isNotEmpty) x]
..explorerHideConfirm = j['explorerHideConfirm'] as bool? ?? true
..explorerPaths = [for (final x in (j['explorerPaths'] as List?) ?? const []) '$x']
..copyMethodFile = _method(j['copyMethodFile'])
..copyConflict = const ['rename', 'overwrite', 'skip'].contains(j['copyConflict']) ? j['copyConflict'] as String : 'ask'
@ -1122,6 +1151,8 @@ class AppSettings {
..readerBrightness = ((j['readerBrightness'] as num?)?.toDouble() ?? 0).clamp(-0.7, 0.7)
..readerSystemBars = j['readerSystemBars'] != false
..readerAutoSeconds = ((j['readerAutoSeconds'] as num?)?.toInt() ?? 3).clamp(1, 60)
..readerGallery = j['readerGallery'] as bool? ?? false
..readerVolumeKeys = j['readerVolumeKeys'] as bool? ?? false
..readerSplit = const ['auto', 'on', 'off'].contains(j['readerSplit']) ? j['readerSplit'] as String : 'auto'
..startMenuShortcut = j['startMenuShortcut'] != false
..syncStopToast = j['syncStopToast'] != false

363
lib/core/hwp_source.dart Normal file
View File

@ -0,0 +1,363 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'package:path/path.dart' as p;
import 'package:pdfrx/pdfrx.dart';
import 'reader_sources.dart';
import 'vfs.dart';
import '../l10n/tr.dart';
/// HWP · HWPX 확장자 (앱 안에서 PDF 로 바꿔 보기 - Android, rhwp)
const hwpExtensions = ['hwp', 'hwpx'];
/// 관리자 지시 (10/20): 앱이 강제로 끝나 남은 HWP 작업 폴더 (앱 임시 폴더의 jj_hwp_…, 바꾼 PDF 들) 를 앱이 켜질 때 지운다.
/// 막 생긴 것 ([minAge] 안) 은 남긴다. 지운 이름을 돌려준다
List<String> removeStaleHwpWork(String tempDir, {Duration minAge = const Duration(minutes: 1), DateTime? now}) {
final before = (now ?? DateTime.now()).subtract(minAge);
final removed = <String>[];
try {
for (final e in Directory(tempDir).listSync(followLinks: false)) {
final name = p.basename(e.path);
if (e is! Directory || !name.startsWith('jj_hwp_')) continue;
try {
if (!e.statSync().modified.isBefore(before)) continue;
e.deleteSync(recursive: true);
removed.add(name);
} catch (_) {}
}
} catch (_) {}
return removed;
}
/// HWP · HWPX → PDF 엔진 (Android: APK 의 libjj_rhwp.so - lib/platform/android/hwp_pdf_ffi.dart, 시험: 가짜)
abstract class HwpPdfEngine {
/// 문서 열기 (암호가 있으면 [password]). 실패하면 [HwpPdfException]
Future<HwpPdfDoc> open(String path, {String? password});
}
/// 연 문서: 쪽 수와 쪽 범위 → PDF
abstract class HwpPdfDoc {
int get pageCount;
/// [from] 부터 [to] 앞까지 (0 부터) 를 PDF 파일 [out] 으로. [fontDirs] 는 글꼴을 찾을 폴더들.
/// [timeout]: 바꾸기가 실제로 시작된 때부터 재고, 넘으면 그 요청만 멈추고 [HwpErrorKind.timeout]
Future<void> exportPdf(int from, int to, String out, {List<String> fontDirs = const [], Duration? timeout});
/// 닫는다: 도는 바꾸기는 멈추고, 줄에 선 것은 시작하지 않는다 ([HwpErrorKind.closed])
Future<void> close();
}
/// 변환기의 C 함수 (Android: libjj_rhwp.so - lib/platform/android/hwp_pdf_ffi.dart, 시험: 가짜).
/// 요청 번호 [export] 의 req 는 [cancel] 로 그 요청만 멈출 때 쓴다
abstract class HwpNative {
int get pageCount;
/// (오류 코드 - 0 이면 성공, 원문)
Future<(int, String?)> export(int req, int from, int to, String fontDirs, String out);
/// 요청 [req] 만 멈춘다 (도는 중이면 다음 쪽에서, 시작 전이면 시작하자마자)
void cancel(int req);
/// 닫기 전에 모두 멈춘다
void cancelAll();
Future<void> close();
}
/// 관리자 지시 (10/20, 28 지적): 한 문서의 바꾸기를 한 번에 하나씩 줄 세우고, 시간 제한 · 멈춤을 요청마다 한다.
/// - 시간 제한은 바꾸기가 실제로 시작된 때부터 잰다 (예전에는 줄에 선 때부터 - 앞 묶음이 길면 뒤 묶음이 시작 전에 넘었다).
/// - 넘으면 그 요청만 멈춘다 (예전에는 문서 하나의 깃발이라 도는 앞 묶음이 멈췄다). 멈춘 바꾸기가 끝날 때까지 다음 것은
/// 시작하지 않는다 (한 문서를 두 스레드가 함께 쓰지 않게).
/// - 닫으면 도는 것은 멈추고 줄에 선 것은 시작하지 않는다 (예전에는 시작할 때 깃발을 지워 닫기 요청이 사라졌다).
class QueuedHwpDoc implements HwpPdfDoc {
final HwpNative native;
QueuedHwpDoc(this.native);
@override
int get pageCount => native.pageCount;
var _seq = 0;
var _closed = false;
Future<void> _queue = Future.value();
Future<void>? _closing;
@override
Future<void> exportPdf(int from, int to, String out, {List<String> fontDirs = const [], Duration? timeout}) {
if (_closed) return Future.error(const HwpPdfException(HwpErrorKind.closed));
final req = ++_seq;
final result = Completer<void>();
_queue = _queue.then((_) async {
if (_closed) {
result.completeError(const HwpPdfException(HwpErrorKind.closed));
return;
}
Timer? timer;
if (timeout != null) {
timer = Timer(timeout, () {
if (result.isCompleted) return;
native.cancel(req);
result.completeError(HwpPdfException(
HwpErrorKind.timeout, 'timeout ${timeout.inSeconds}s (pages ${from + 1}~$to)', [from + 1, to, timeout.inSeconds]));
});
}
try {
final (code, err) = await native.export(req, from, to, fontDirs.join('\n'), out);
if (!result.isCompleted) {
if (code == 0) {
result.complete();
} else {
result.completeError(HwpPdfException(_closed ? HwpErrorKind.closed : hwpErrorKindOf(code), err ?? ''));
}
}
} catch (e, st) {
if (!result.isCompleted) result.completeError(e, st);
} finally {
timer?.cancel();
}
});
return result.future;
}
@override
Future<void> close() => _closing ??= () async {
_closed = true;
native.cancelAll();
await _queue; // 도는 것이 멈춰 끝날 때까지 (줄에 선 것은 시작하지 않고 넘어감)
await native.close();
}();
}
/// 관리자 지시 (10/20, 28 지적): 변환기 오류의 종류 - 화면에는 종류에 맞는 번역 글, 원문은 [HwpPdfException.detail] ([자세히] · 작업 기록).
/// 앞 9개는 tool/rhwp_ffi 의 JJ_RHWP_E_* 와 같은 순서 (1 부터)
enum HwpErrorKind { cancelled, read, invalid, passwordRequired, passwordWrong, range, render, write, internal, timeout, closed }
/// tool/rhwp_ffi 의 오류 코드 (JJ_RHWP_E_*) → 종류 (모르는 코드는 내부 오류)
HwpErrorKind hwpErrorKindOf(int code) => code >= 1 && code <= 9 ? HwpErrorKind.values[code - 1] : HwpErrorKind.internal;
class HwpPdfException implements Exception {
final HwpErrorKind kind;
/// 변환기 원문 (화면에는 보이지 않고 [자세히] · 작업 기록에)
final String detail;
/// 글에 넣을 값 (시간 제한: 첫 쪽 · 끝 쪽 · 초)
final List<Object> args;
const HwpPdfException(this.kind, [this.detail = '', this.args = const []]);
/// 화면에 보일 글 (화면 언어)
String get message => switch (kind) {
HwpErrorKind.cancelled => tr('그만두었습니다'),
HwpErrorKind.read => tr('파일을 읽을 수 없습니다'),
HwpErrorKind.invalid => tr('HWP · HWPX 문서로 읽을 수 없습니다 (깨졌거나 지원하지 않는 형식)'),
HwpErrorKind.passwordRequired => tr('암호가 걸린 문서입니다'),
HwpErrorKind.passwordWrong => tr('암호가 맞지 않습니다'),
HwpErrorKind.range => tr('쪽 범위가 맞지 않습니다'),
HwpErrorKind.render => tr('이 쪽을 그리지 못했습니다'),
HwpErrorKind.write => tr('바꾼 PDF 를 저장하지 못했습니다 (저장 공간을 확인해 주세요)'),
HwpErrorKind.internal => tr('변환기 안에서 오류가 났습니다'),
HwpErrorKind.timeout => trf('{0}~{1}쪽을 {2}초 안에 바꾸지 못했습니다', args),
HwpErrorKind.closed => tr('닫힌 문서입니다'),
};
@override
String toString() => message;
}
/// 바꾼 PDF 한 묶음 (몇 쪽). 기본은 pdfrx ([pdfChunkOpener]) - 시험은 PDFium 없이 가짜로
abstract class HwpChunk {
int get pageCount;
Future<ReaderImage> render(int index, {required int maxWidth});
Future<void> dispose();
}
/// PDF 파일을 묶음으로 연다
typedef HwpChunkOpener = Future<HwpChunk> Function(String pdfPath);
/// 기본: pdfrx 로 연다
Future<HwpChunk> pdfChunkOpener(String pdfPath) async {
await pdfrxFlutterInitialize();
return _PdfChunk(await PdfDocument.openFile(pdfPath));
}
class _PdfChunk implements HwpChunk {
final PdfDocument doc;
_PdfChunk(this.doc);
@override
int get pageCount => doc.pages.length;
@override
Future<ReaderImage> render(int index, {required int maxWidth}) async {
final page = doc.pages[index];
final w = maxWidth.toDouble();
final h = w * page.height / page.width;
final image = await page.render(fullWidth: w, fullHeight: h, backgroundColor: 0xffffffff);
if (image == null) return const ReaderImage();
try {
return ReaderImage(image: await image.createImage());
} finally {
image.dispose();
}
}
@override
Future<void> dispose() => doc.dispose();
}
/// 관리자 지시 (10/20): HWP · HWPX 를 보기 화면으로 - 큰 문서 (편람 383쪽은 PC 에서도 14분) 를 통째로 바꾸지 않고,
/// 보는 쪽이 든 [chunk] 쪽 묶음만 PDF 로 바꿔 보이면서 다음 묶음을 뒤에서 미리 만든다. 묶음마다 [timeout],
/// 닫으면 (dispose) 더 만들지 않는다. 실패한 묶음은 그 쪽에 이유가 보이고 [다시 시도] 로 다시 만든다 (보기 화면).
/// 2단계 (10/20): 메모리 - 열어 둔 PDF 묶음은 최근 것 [maxOpenChunks] 개까지 (몇백 쪽 문서도 늘지 않게). 닫은 묶음의 파일은
/// 남겨 다시 볼 때 바꾸지 않고 파일만 연다. 그리고 있는 묶음은 닫지 않는다.
class HwpSource extends ReaderSource {
final String path;
final HwpPdfDoc doc;
final Directory work;
final List<String> fontDirs;
final int chunk;
final Duration timeout;
final int maxOpenChunks;
final HwpChunkOpener opener;
@override
final String title;
/// 열어 둔 묶음 (넣은 순서 = 최근에 쓴 순서, 앞이 오래된 것)
final _open = <int, Future<HwpChunk>>{};
/// PDF 파일을 다 만든 묶음
final _made = <int>{};
/// 지금 그리고 있는 묶음 (닫지 않는다)
final _busy = <int, int>{};
var _disposed = false;
HwpSource._(this.path, this.doc, this.work, this.title,
{required this.fontDirs, required this.chunk, required this.timeout, required this.maxOpenChunks, required this.opener, this.log});
/// 실패의 원문을 남길 곳 (작업 기록 - 파일 탐색기가 AppController.note 를 준다)
final void Function(String line)? log;
/// 문서를 연다 (WebDAV 등은 [tempDir] 로 받은 뒤). 열지 못하면 [HwpPdfException]
static Future<HwpSource> open(String path,
{required HwpPdfEngine engine,
required String tempDir,
String? password,
List<String> fontDirs = const [],
int chunk = 5,
Duration timeout = const Duration(minutes: 3),
int maxOpenChunks = 3,
HwpChunkOpener opener = pdfChunkOpener,
void Function(String line)? log}) async {
final local = await vLocalCopy(path, tempDir);
final HwpPdfDoc doc;
try {
doc = await engine.open(local, password: password);
} on HwpPdfException catch (e) {
log?.call('HWP ${vBasename(path)}: ${e.kind.name} ${e.detail}');
rethrow;
}
final work = Directory(p.join(tempDir, 'jj_hwp_${DateTime.now().microsecondsSinceEpoch}'))..createSync(recursive: true);
return HwpSource._(path, doc, work, vBasename(path),
fontDirs: fontDirs, chunk: chunk, timeout: timeout, maxOpenChunks: math.max(2, maxOpenChunks), opener: opener, log: log);
}
@override
int get length => doc.pageCount;
@override
String pageName(int i) => '${i + 1} / $length';
/// 열어 둔 묶음 번호 (오래된 것부터 - 시험용)
List<int> get openChunks => _open.keys.toList();
/// PDF 파일을 만든 묶음 번호 (시험용)
Set<int> get madeChunks => Set.unmodifiable(_made);
/// [k] 번째 묶음 (실패하면 지워서 다음에 다시)
Future<HwpChunk> _chunk(int k) {
final had = _open.remove(k);
if (had != null) {
_open[k] = had; // 최근에 쓴 것으로
return had;
}
late final Future<HwpChunk> f;
f = _openChunk(k).onError<Object>((e, st) {
if (identical(_open[k], f)) _open.remove(k);
Error.throwWithStackTrace(e, st);
});
_open[k] = f;
_evict();
return f;
}
Future<HwpChunk> _openChunk(int k) async {
if (_disposed) throw StateError(tr('닫힌 문서입니다'));
final out = p.join(work.path, 'p$k.pdf');
if (!_made.contains(k) || !File(out).existsSync()) {
final from = k * chunk, to = math.min(from + chunk, length);
try {
// 시간 제한은 문서 ([QueuedHwpDoc]) 가 바꾸기가 시작된 때부터 재고, 넘으면 그 요청만 멈춘다
await doc.exportPdf(from, to, out, fontDirs: fontDirs, timeout: timeout);
} catch (e) {
// 닫은 뒤에 끝난 변환은 (임시 폴더를 이미 지워 · 멈춰) 실패한다 - 그 이유 대신 "닫힌 문서"
if (_disposed) throw StateError(tr('닫힌 문서입니다'));
if (e is HwpPdfException) log?.call('HWP ${vBasename(path)} ${from + 1}~$to: ${e.kind.name} ${e.detail}');
rethrow;
}
_made.add(k);
}
if (_disposed) throw StateError(tr('닫힌 문서입니다'));
final c = await opener(out);
if (_disposed) {
await c.dispose();
throw StateError(tr('닫힌 문서입니다'));
}
return c;
}
/// 열어 둔 묶음이 [maxOpenChunks] 를 넘으면 오래된 것부터 닫는다 (그리고 있는 것은 빼고)
void _evict() {
while (_open.length > maxOpenChunks) {
final victim = _open.keys.where((k) => (_busy[k] ?? 0) == 0).firstOrNull;
if (victim == null) return;
final f = _open.remove(victim)!;
unawaited(f.then((c) => c.dispose(), onError: (Object _) {}));
}
}
@override
Future<ReaderImage> load(int i, {required int maxWidth}) async {
final k = i ~/ chunk;
_busy[k] = (_busy[k] ?? 0) + 1;
try {
final c = await _chunk(k);
// 다음 묶음을 미리 (넘겨 볼 때 기다리지 않게) - 실패해도 여기서는 알리지 않는다 (그 쪽을 볼 때 보임)
if ((k + 1) * chunk < length) unawaited(_chunk(k + 1).then((_) {}, onError: (Object _) {}));
return await c.render(i - k * chunk, maxWidth: maxWidth);
} finally {
final n = (_busy[k] ?? 1) - 1;
if (n <= 0) {
_busy.remove(k);
} else {
_busy[k] = n;
}
if (!_disposed) _evict();
}
}
@override
Future<void> dispose() async {
_disposed = true;
final open = [..._open.values];
_open.clear();
// 다 연 묶음은 닫기를 기다리고 (열린 PDF 를 닫아야 임시 폴더를 지울 수 있다 - Windows 는 연 파일을 잡는다),
// 만드는 중인 묶음은 기다리지 않는다 (몇 분 걸릴 수 있음 - 끝나면 스스로 닫힌다, [_openChunk] 가 _disposed 를 본다)
for (final f in open) {
final closing = f.then((c) => c.dispose(), onError: (Object _) {});
await Future.any([closing, Future<void>.delayed(const Duration(milliseconds: 200))]);
}
await doc.close(); // 도는 바꾸기는 다음 쪽에서 멈추고, 줄에 선 것은 시작하지 않는다
try {
if (work.existsSync()) work.deleteSync(recursive: true);
} catch (_) {}
}
}

70
lib/core/photo_info.dart Normal file
View File

@ -0,0 +1,70 @@
import 'dart:typed_data';
import 'package:image/image.dart' as img;
/// X-plore 맞추기 (관리자 지시 10/20): 보기 화면의 사진 정보 (Exif) - 찍은 날 · 기기 · 크기 · 위치.
/// image 패키지 (MIT - 앱이 이미 쓰는 것, 라이선스 화면에 있음) 로 읽는다. 새 의존 없음. 위치는 보기 화면이 누를 때만 보이고,
/// 지도 · 인터넷으로 보내지 않는다 (이 파일은 숫자만 읽는다).
class PhotoInfo {
/// 그림 크기 (픽셀, 모르면 null)
final int? width, height;
/// 찍은 날 ("2024-05-01 12:30:00", Exif 의 DateTimeOriginal · 없으면 DateTime)
final String? taken;
/// 기기 ("Canon EOS 5D" - Make · Model)
final String? device;
/// 위치 (도, 남 · 서는 음수)
final double? latitude, longitude;
const PhotoInfo({this.width, this.height, this.taken, this.device, this.latitude, this.longitude});
bool get hasLocation => latitude != null && longitude != null;
/// Exif 에서 읽은 것이 있는지 (크기는 Exif 가 없어도 읽음)
bool get hasExif => taken != null || device != null || hasLocation;
/// 위치 글 ("37.500000, 127.000000")
String get locationText => hasLocation ? '${latitude!.toStringAsFixed(6)}, ${longitude!.toStringAsFixed(6)}' : '';
}
/// 그림 파일의 바이트에서 사진 정보를 읽는다 (Exif 는 JPEG 만 - 다른 형식은 크기만). 읽지 못하는 것은 null
PhotoInfo readPhotoInfo(Uint8List bytes) {
int? w, h;
try {
final info = img.findDecoderForData(bytes)?.startDecode(bytes);
if (info != null && info.width > 0 && info.height > 0) {
w = info.width;
h = info.height;
}
} catch (_) {}
String? taken, device;
double? lat, lon;
try {
final e = img.decodeJpgExif(bytes);
if (e != null) {
String? s(img.IfdValue? v) {
final t = v?.toString().replaceAll('\u0000', '').trim();
return t == null || t.isEmpty ? null : t;
}
final when = s(e.exifIfd['DateTimeOriginal']) ?? s(e.imageIfd['DateTime']);
// "2024:05:01 12:30:00" → "2024-05-01 12:30:00"
taken = when?.replaceFirstMapped(RegExp(r'^(\d{4}):(\d{2}):(\d{2})'), (m) => '${m[1]}-${m[2]}-${m[3]}');
final make = s(e.imageIfd['Make']), model = s(e.imageIfd['Model']);
device = model == null ? make : (make == null || model.toLowerCase().startsWith(make.toLowerCase()) ? model : '$make $model');
// GPS: 1 위도 N/S · 2 위도 (도 · 분 · 초) · 3 경도 E/W · 4 경도
lat = _coord(e.gpsIfd[2], s(e.gpsIfd[1]));
lon = _coord(e.gpsIfd[4], s(e.gpsIfd[3]));
}
} catch (_) {}
return PhotoInfo(width: w, height: h, taken: taken, device: device, latitude: lat, longitude: lon);
}
double? _coord(img.IfdValue? v, String? ref) {
if (v == null || v.length < 3) return null;
final d = v.toDouble(0) + v.toDouble(1) / 60 + v.toDouble(2) / 3600;
if (d.isNaN || d.isInfinite) return null;
return ref == 'S' || ref == 'W' ? -d : d;
}

View File

@ -52,6 +52,15 @@ class AndroidShell extends NoopShell {
} catch (_) {}
}
/// 미디어 목록 다시 읽기 (MainActivity "mediaScan" - MediaScannerConnection.scanFile). 실패해도 조용히
@override
Future<void> mediaScan(List<String> paths) async {
if (paths.isEmpty) return;
try {
await _ch.invokeMethod<bool>('mediaScan', {'paths': paths});
} catch (_) {}
}
/// 다른 앱으로 열기 (MainActivity "openWith"): [choose] 면 늘 앱 고르기 창, 아니면 기본 앱 (없으면 고르기 창)
@override
Future<bool> openWith(String path, {bool choose = false}) async {

View File

@ -0,0 +1,162 @@
import 'dart:async';
import 'dart:ffi';
import 'dart:isolate';
import 'package:ffi/ffi.dart';
import '../../core/hwp_source.dart';
/// 관리자 지시 (10/20): Android 의 HWP · HWPX → PDF - APK 에 든 libjj_rhwp.so (rhwp v0.8.7, MIT - tool/build_rhwp_android.sh ·
/// tool/rhwp_ffi) 를 dart:ffi 로 부른다. 여는 것 · 바꾸는 것은 다른 isolate 에서 (화면을 막지 않게). 줄 세우기 · 시간 제한 ·
/// 멈춤은 [QueuedHwpDoc] 가 한다 (요청마다). 라이브러리는 HWP 를 처음 열 때 읽는다 (앱을 켤 때 읽지 않음).
class RhwpEngine implements HwpPdfEngine {
static const libName = 'libjj_rhwp.so';
/// 글꼴을 찾을 폴더 (Android 는 rhwp 가 시스템 글꼴을 스스로 찾지 않는다 - 한글은 Noto Sans/Serif CJK KR 로 대신 그림).
/// 관리자 판단 (10/20): 한글 글꼴 컴포넌트를 받으면 그 폴더를 앞에 더한다 (아직 없음)
static const fontDirs = ['/system/fonts'];
/// 이 Dart 가 기대하는 C ABI 의 판 (tool/rhwp_ffi 의 JJ_RHWP_ABI_VERSION 과 같아야 함).
/// 3: 요청별 멈춤 (export 에 요청 번호 · jj_rhwp_cancel(번호) · jj_rhwp_cancel_all)
static const abiVersion = 3;
/// 꼭 있어야 하는 C 함수
static const requiredSymbols = [
'jj_rhwp_abi_version', 'jj_rhwp_open', 'jj_rhwp_page_count', 'jj_rhwp_export_pdf', 'jj_rhwp_cancel', 'jj_rhwp_cancel_all', //
'jj_rhwp_close', 'jj_rhwp_free',
];
static bool? _available;
/// 라이브러리를 읽을 수 있고 ABI 가 맞는지 (아니면 다른 앱으로 연다). 처음 HWP 를 열 때 한 번 본다.
/// 관리자 지시 (10/20, 28 지적): 옛 .so 에 새 인수를 넘기면 힙이 망가지므로, 기호와 판이 맞을 때만
static bool get available => _available ??= () {
try {
final lib = DynamicLibrary.open(libName);
return abiCompatible(
has: lib.providesSymbol, abi: () => lib.lookupFunction<Int32 Function(), int Function()>('jj_rhwp_abi_version')());
} catch (_) {
return false;
}
}();
/// [requiredSymbols] 가 모두 있고 [abi] 가 [abiVersion] 인지 (시험용으로 따로 - 기호를 찾다 · 부르다 실패하면 false)
static bool abiCompatible({required bool Function(String name) has, required int Function() abi}) {
try {
if (!requiredSymbols.every(has)) return false;
return abi() == abiVersion;
} catch (_) {
return false;
}
}
@override
Future<HwpPdfDoc> open(String path, {String? password}) async {
final (handle, code, err) = await Isolate.run(() => _open(path, password ?? ''));
if (handle == 0) throw HwpPdfException(hwpErrorKindOf(code), err ?? '');
final pages = await Isolate.run(() => _pageCount(handle));
return QueuedHwpDoc(_RhwpNative(handle, pages));
}
}
class _RhwpNative implements HwpNative {
final int _handle;
@override
final int pageCount;
var _closed = false;
_RhwpNative(this._handle, this.pageCount);
@override
Future<(int, String?)> export(int req, int from, int to, String fontDirs, String out) {
final h = _handle;
return Isolate.run(() => _export(h, req, from, to, fontDirs, out));
}
/// 줄을 서지 않고 바로 (다른 isolate 의 바꾸기가 다음 쪽에서 끝난다)
@override
void cancel(int req) {
if (!_closed) _cancel(_handle, req);
}
@override
void cancelAll() {
if (!_closed) _cancelAll(_handle);
}
/// [QueuedHwpDoc.close] 가 도는 바꾸기가 끝난 뒤에 부른다
@override
Future<void> close() async {
if (_closed) return;
_closed = true;
_close(_handle);
}
}
// ───────── C 함수 (isolate 안에서 부른다 - 핸들은 주소 숫자로 넘긴다) ─────────
typedef _OpenC = Pointer<Void> Function(Pointer<Utf8>, Pointer<Utf8>, Pointer<Int32>, Pointer<Pointer<Utf8>>);
typedef _CountC = Int32 Function(Pointer<Void>);
typedef _CountD = int Function(Pointer<Void>);
typedef _ExportC = Int32 Function(Pointer<Void>, Uint64, Int32, Int32, Pointer<Utf8>, Pointer<Utf8>, Pointer<Int32>, Pointer<Pointer<Utf8>>);
typedef _ExportD = int Function(Pointer<Void>, int, int, int, Pointer<Utf8>, Pointer<Utf8>, Pointer<Int32>, Pointer<Pointer<Utf8>>);
typedef _CancelC = Void Function(Pointer<Void>, Uint64);
typedef _CancelD = void Function(Pointer<Void>, int);
typedef _VoidC = Void Function(Pointer<Void>);
typedef _VoidD = void Function(Pointer<Void>);
typedef _FreeC = Void Function(Pointer<Utf8>);
typedef _FreeD = void Function(Pointer<Utf8>);
DynamicLibrary _lib() => DynamicLibrary.open(RhwpEngine.libName);
/// 돌려받은 오류 글을 읽고 푼다
String? _takeError(DynamicLibrary lib, Pointer<Pointer<Utf8>> err) {
final p = err.value;
if (p == nullptr) return null;
final s = p.toDartString();
lib.lookupFunction<_FreeC, _FreeD>('jj_rhwp_free')(p);
return s;
}
(int, int, String?) _open(String path, String password) {
final lib = _lib();
final open = lib.lookupFunction<_OpenC, _OpenC>('jj_rhwp_open');
final a = path.toNativeUtf8(), b = password.toNativeUtf8();
final code = calloc<Int32>();
final err = calloc<Pointer<Utf8>>();
try {
final doc = open(a, b, code, err);
return (doc.address, code.value, _takeError(lib, err));
} finally {
calloc.free(a);
calloc.free(b);
calloc.free(code);
calloc.free(err);
}
}
int _pageCount(int handle) => _lib().lookupFunction<_CountC, _CountD>('jj_rhwp_page_count')(Pointer<Void>.fromAddress(handle));
(int, String?) _export(int handle, int req, int from, int to, String fontDirs, String out) {
final lib = _lib();
final export = lib.lookupFunction<_ExportC, _ExportD>('jj_rhwp_export_pdf');
final d = fontDirs.toNativeUtf8(), o = out.toNativeUtf8();
final code = calloc<Int32>();
final err = calloc<Pointer<Utf8>>();
try {
final r = export(Pointer<Void>.fromAddress(handle), req, from, to, d, o, code, err);
final e = _takeError(lib, err);
return r == 0 ? (0, null) : (code.value == 0 ? 9 : code.value, e);
} finally {
calloc.free(d);
calloc.free(o);
calloc.free(code);
calloc.free(err);
}
}
void _cancel(int handle, int req) =>
_lib().lookupFunction<_CancelC, _CancelD>('jj_rhwp_cancel')(Pointer<Void>.fromAddress(handle), req);
void _cancelAll(int handle) => _lib().lookupFunction<_VoidC, _VoidD>('jj_rhwp_cancel_all')(Pointer<Void>.fromAddress(handle));
void _close(int handle) => _lib().lookupFunction<_VoidC, _VoidD>('jj_rhwp_close')(Pointer<Void>.fromAddress(handle));

View File

@ -376,6 +376,10 @@ class DesktopShell with WindowListener implements AppShell {
@override
Future<bool> shareFiles(List<String> paths) async => false;
/// Windows 는 갤러리 미디어 목록이 없다 (.nomedia 는 Android 만)
@override
Future<void> mediaScan(List<String> paths) async {}
/// 기본 연결 프로그램 (탐색기에서 두 번 누른 것과 같음) 또는 "연결 프로그램" 고르기 창
@override
Future<bool> openWith(String path, {bool choose = false}) async {

View File

@ -74,6 +74,10 @@ abstract class AppShell {
bool get canShare;
Future<bool> shareFiles(List<String> paths);
/// X-plore 맞추기 (관리자 지시 10/20): 바뀐 파일 · 폴더를 기기의 미디어 목록에 다시 읽게 한다 (Android MediaScanner -
/// .nomedia 를 넣고 지운 뒤 갤러리에 반영). 다른 기기는 할 일 없음. 실패해도 조용히 넘긴다
Future<void> mediaScan(List<String> paths);
/// 설치된 외부 브라우저 ('chrome', 'firefox', 'edge', 'whale' 중)
List<String> installedBrowsers();
@ -147,6 +151,8 @@ class NoopShell implements AppShell {
@override
Future<bool> shareFiles(List<String> paths) async => false;
@override
Future<void> mediaScan(List<String> paths) async {}
@override
List<String> installedBrowsers() => const [];
@override
Future<List<String>> missingTools() async => const [];

View File

@ -64,6 +64,23 @@ class ViewerSettings extends StatelessWidget {
onChanged: (v) => c.updateSettings((x) => x.readerSplit = v!),
),
),
// X-plore 맞추기 (관리자 지시 10/20): 아래 갤러리 줄 (보기 화면 위 막대에서도 켜고 끔)
SwitchListTile(
secondary: const Icon(Icons.view_carousel_outlined),
value: s.readerGallery,
onChanged: (v) => c.updateSettings((x) => x.readerGallery = v),
title: Text(tr('그림 보기 아래 갤러리 줄')),
subtitle: Text(tr('그림을 볼 때 아래에 작은 그림 줄을 보입니다 (누르면 그 장으로)')),
),
// X-plore 맞추기 (관리자 지시 10/20): 볼륨 키로 넘기기 (Android)
if (Platform.isAndroid)
SwitchListTile(
secondary: const Icon(Icons.volume_down_outlined),
value: s.readerVolumeKeys,
onChanged: (v) => c.updateSettings((x) => x.readerVolumeKeys = v),
title: Text(tr('볼륨 키로 넘기기')),
subtitle: Text(tr('보기 화면에서 볼륨 아래 = 다음 장, 위 = 이전 장 (동영상 · 다른 화면의 볼륨은 그대로)')),
),
// 117: 계속 보기 간격
SettingTile(
leading: const Icon(Icons.slideshow_outlined),

View File

@ -22,10 +22,11 @@ enum ExplorerStyle {
double get rowHeight => switch (this) { xplore => 52, windows => 34, totalcmd => 26 };
/// 253: 줄 높이 - 기기 글자 크기를 키우면 그만큼 (고정 높이라 1.6배에서 이름 · 크기 두 줄이 넘쳤다)
double rowHeightOf(BuildContext context) {
final s = MediaQuery.textScalerOf(context).scale(fontSize) / fontSize;
return s <= 1 ? rowHeight : (rowHeight * s).roundToDouble();
/// 253: 줄 높이 - 기기 글자 크기를 키우면 그만큼 (고정 높이라 1.6배에서 이름 · 크기 두 줄이 넘쳤다).
/// X-plore 비교 40: [textScale] 목록 글자 크기 (기기 글자 크기와 곱함) · [spacing] 줄 간격 ([rowSpacingFactor])
double rowHeightOf(BuildContext context, {double textScale = 1, double spacing = 1}) {
final s = MediaQuery.textScalerOf(context).scale(fontSize) * textScale / fontSize;
return ((s <= 1 ? rowHeight : rowHeight * s) * spacing).roundToDouble();
}
double get iconSize => switch (this) { xplore => 30, windows => 20, totalcmd => 16 };
double get indent => switch (this) { xplore => 16, windows => 14, totalcmd => 12 };
@ -48,6 +49,52 @@ const _sheets = {'xls', 'xlsx', 'ods'};
const _slides = {'ppt', 'pptx', 'odp'};
const _programs = {'exe', 'msi', 'bat', 'cmd', 'ps1', 'apk'};
/// X-plore 비교 40: 줄 간격 설정 → 줄 높이 배수 (좁게 · 보통 (지금 그대로) · 넓게)
double rowSpacingFactor(String spacing) => switch (spacing) { 'compact' => 0.8, 'wide' => 1.25, _ => 1.0 };
/// X-plore 비교 40: 목록 글자만 [factor] 배 (앱 화면 크기 · 기기 글자 크기에 곱함). 1 이면 그대로
Widget scaleListText(BuildContext context, double factor, Widget child) {
if (factor == 1) return child;
final mq = MediaQuery.of(context);
return MediaQuery(data: mq.copyWith(textScaler: _TimesScaler(mq.textScaler, factor)), child: child);
}
class _TimesScaler extends TextScaler {
final TextScaler base;
final double factor;
const _TimesScaler(this.base, this.factor);
@override
double scale(double fontSize) => base.scale(fontSize) * factor;
// ignore: deprecated_member_use
@override
double get textScaleFactor => base.scale(14) / 14 * factor;
@override
bool operator ==(Object other) => other is _TimesScaler && other.base == base && other.factor == factor;
@override
int get hashCode => Object.hash(base, factor);
}
/// 관리자 지시 10/20: 좁은 창의 열 (Windows 탐색기 · Total Commander) - 이름 열이 120 (× 글자 배율) 보다 좁으면 유형 (TC 는 확장자) →
/// 날짜 순으로 숨긴다 (크기는 남김). 0 = 다 보임 · 1 = 유형 숨김 · 2 = 유형 · 날짜 숨김. 화면이 모자랄 때만 - 열 설정은 그대로.
/// [width] 창 폭, [marks] 줄 끝 ✔ 칸 (36) 이 있는지, [textScale] 목록 글자 배율 (기기 × 목록 글자 크기)
int columnHideLevel(ExplorerStyle style, double width, {bool marks = false, double textScale = 1}) {
if (!style.columns) return 0;
// 이름 열 120 (글자가 크면 그만큼 - 1.95 배 글자의 120 은 네 글자쯤이었다)
final minName = 120 * (textScale < 1 ? 1 : textScale);
final inner = width - 16 - (marks ? 36 : 0);
// 열 비율: Windows 이름 6 · 날짜 3 · 유형 2 · 크기 2, TC 이름 6 · 확장자 1 · 크기 2 · (8px) · 날짜 3
final flexes = style == ExplorerStyle.windows ? [13, 11, 8] : [12, 11, 8];
final extra = style == ExplorerStyle.totalcmd ? 8 : 0;
for (var level = 0; level < 2; level++) {
if ((inner - (level < 2 ? extra : 0)) * 6 / flexes[level] >= minName) return level;
}
return 2;
}
/// 파일 종류별 아이콘 · 색 (스타일마다 다르게)
(IconData, Color) fileIcon(ExplorerStyle style, FileEntry e, {bool open = false}) {
final x = e.ext;
@ -116,6 +163,9 @@ class ExplorerColumnsHeader extends StatelessWidget {
/// 선택 모드: 줄 끝의 선택 동그라미 칸만큼 비운다
final bool markColumn;
/// 관리자 지시 10/20: 좁아서 숨긴 열 ([columnHideLevel]: 1 = 유형, 2 = 유형 · 날짜)
final int hide;
/// 84: 지금 정렬 ('name' · 'date' · 'size' · 'type') · 거꾸로 - 그 열에 ▲ ▼
final String? sortBy;
final bool sortDesc;
@ -123,7 +173,7 @@ class ExplorerColumnsHeader extends StatelessWidget {
/// 84: 열 머리를 누르면 (같은 열을 다시 누르면 거꾸로 - 부르는 쪽이 정한다)
final void Function(String by)? onSort;
const ExplorerColumnsHeader(
{super.key, required this.style, this.markColumn = false, this.sortBy, this.sortDesc = false, this.onSort});
{super.key, required this.style, this.markColumn = false, this.hide = 0, this.sortBy, this.sortDesc = false, this.onSort});
@override
Widget build(BuildContext context) {
@ -163,16 +213,15 @@ class ExplorerColumnsHeader extends StatelessWidget {
children: style == ExplorerStyle.windows
? [
cell(tr('이름'), 6, by: 'name'),
cell(tr('수정한 날짜'), 3, by: 'date'),
cell(tr('유형'), 2, by: 'type'),
if (hide < 2) cell(tr('수정한 날짜'), 3, by: 'date'),
if (hide < 1) cell(tr('유형'), 2, by: 'type'),
cell(tr('크기'), 2, align: TextAlign.end, by: 'size'),
]
: [
cell(tr('이름'), 6, by: 'name'),
cell(tr('확장자'), 1, by: 'type'),
if (hide < 1) cell(tr('확장자'), 1, by: 'type'),
cell(tr('크기'), 2, align: TextAlign.end, by: 'size'),
const SizedBox(width: 8),
cell(tr('날짜'), 3, by: 'date'),
if (hide < 2) ...[const SizedBox(width: 8), cell(tr('날짜'), 3, by: 'date')],
],
),
);

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart' show defaultTargetPlatform;
@ -25,6 +26,7 @@ import '../core/ftp.dart' show FtpCertException, FtpRegistry;
import '../core/smb.dart' show SmbRegistry;
import '../core/webdav.dart' show DavRegistry;
import '../platform/android/android_storage.dart';
import '../platform/android/hwp_pdf_ffi.dart' show RhwpEngine;
import 'app_actions.dart';
import 'explorer_look.dart';
import 'key_hint.dart';
@ -40,6 +42,7 @@ import '../platform/windows/recycle_bin.dart';
import '../core/rsync_filter.dart';
import '../core/sync_preview.dart';
import 'path_label.dart';
import 'progress_dialog.dart';
import 'sync_preview_view.dart';
import 'sftp_settings.dart';
import 'ftp_settings.dart';
@ -50,6 +53,7 @@ import '../app/component_store.dart';
import '../app/components.dart';
import 'zip_page.dart';
import '../core/reader_sources.dart';
import '../core/hwp_source.dart';
import '../l10n/tr.dart';
import 'explorer_bookmarks.dart';
import 'explorer_search.dart';
@ -112,6 +116,10 @@ class ExplorerPage extends StatefulWidget {
@visibleForTesting
static bool? debugAndroidTrash;
/// 시험: 폴더 .nomedia 넣기 · 지우기 (Android 갤러리 · 음악 앱에서 숨기기) 를 Windows 에서. null 이면 Android 만
@visibleForTesting
static bool? debugNomedia;
/// 시험: 경로 입력 창이 넣은 경로를 고쳐 쓰고 확인하는 것 (갈 경로, 칸 아래 이유)
@visibleForTesting
static Future<(String?, String?)> checkPathForTest(String input) => _ExplorerPageState.checkEnteredPath(input);
@ -226,6 +234,9 @@ enum ExplorerButton {
class _Pane extends ChangeNotifier {
String root;
/// 관리자 지시 10/20: 좁아서 숨긴 열 ([columnHideLevel]) - 열을 그리는 창 (목록) 의 폭으로 정하고 열 머리 · 줄이 같이 쓴다
int colHide = 0;
/// 지금 폴더. 75: 바뀔 때마다 뒤로 가기 내역에 (뒤로 · 앞으로로 움직일 때는 빼고)
String get current => _current;
set current(String v) {
@ -1069,7 +1080,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
final pos = pane.scroll.position;
// 180: 끝 (maxScrollExtent) 으로 자르지 않는다 - 줄이 막 늘어난 때는 끝이 아직 앞의 것이라 보던 폴더로 못 간다.
// 넘치면 다음 배치 때 실제 끝으로 맞춰진다
pane.scroll.jumpTo((i * _look.rowHeightOf(context) - pos.viewportDimension / 4).clamp(0.0, double.infinity));
pane.scroll.jumpTo((i * _rowH() - pos.viewportDimension / 4).clamp(0.0, double.infinity));
}
/// 28 확인 (10/20): 창의 목록이 화면에 새로 붙으면 (좁은 Rsync 화면의 [오른쪽] 탭을 처음 볼 때 · 폭이 바뀌어 두 창이 될 때)
@ -1138,8 +1149,21 @@ class _ExplorerPageState extends State<ExplorerPage> {
/// 지금 모양 (환경 설정 > 파일 탐색기 > 스타일)
ExplorerStyle get _look => ExplorerStyle.of(c.settings.explorerStyle);
/// X-plore 비교 40: 목록 글자 크기 (설정 %) · 줄 간격을 넣은 줄 높이
double get _listTextScale => c.settings.explorerTextScale / 100;
double _rowH() => _look.rowHeightOf(context, textScale: _listTextScale, spacing: rowSpacingFactor(c.settings.explorerRowSpacing));
/// 왼쪽 폴더 트리 + 오른쪽 파일 목록 배치
bool get _split => !widget.rsync && c.settings.explorerLayout == 'split';
bool get _split => !widget.rsync && c.settings.explorerLayout == 'split' && !_splitTight;
/// 관리자 지시 10/20 (28 검토): 글자가 커서 (기기 글자 × 목록 글자 크기) 좁은 화면에 트리 · 목록 두 창이 다 들어가지 않으면
/// 그동안만 한 창 (트리 + 위 경로 막대) 으로 보인다 - 고른 배치 (설정) 는 그대로, 화면이 넓어지거나 글자가 작아지면 다시 두 창. [_body] 가 정한다
bool _splitTight = false;
/// 관리자 지시 10/20: 좁은 화면 (폭 < [narrowSplitWidth]) 의 "폴더 + 파일 목록" 좌우 배치 - 트리 창에 최소 폭을 두고
/// ✔ 칸 · 크기 줄을 숨긴다 (412 폭에서 트리가 77px 이라 폴더 이름이 한 글자도 안 보였다). [_body] 가 정한다
bool _narrowSplit = false;
static const narrowSplitWidth = 600.0;
/// 좌우 두 창 (Rsync 화면은 늘)
bool get _dual =>
@ -1686,6 +1710,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
if (ext == 'pdf') return _viewPdf(path);
if (zipExtensions.contains(ext)) return _openZip(path, pane);
}
// 관리자 지시 (10/20): Android 의 HWP · HWPX 는 앱 안에서 PDF 로 바꿔 본다 (rhwp - APK 에 들어 있음, 보기 컴포넌트를 켰을 때).
// 열지 못하면 (암호 · 깨진 파일 · 라이브러리 없음) 이유를 알리고 예전처럼 다른 앱으로
if (Platform.isAndroid && c.settings.components.contains('viewer') && hwpExtensions.contains(extOf(path)) && RhwpEngine.available) {
if (await _viewHwp(path)) return;
}
// 문서 미리보기 (Windows: 설치한 변환기로 PDF 로 바꿔 봄)
if (c.settings.components.contains('docs') && Platform.isWindows &&
AppComponent.docExtensions.contains(extOf(path))) {
@ -1757,21 +1786,33 @@ class _ExplorerPageState extends State<ExplorerPage> {
await openReader(context, c, ImageFilesSource(images, tempDir: temp, title: vBasename(dir)), start: start);
}
/// X-plore 맞추기 (관리자 지시 10/20): 폴더의 그림 (지금 정렬 순서) 을 보기 화면으로 열고 [계속 보기] 를 켠 채 시작
Future<void> _slideshow(_Pane pane, String dir) async {
List<FileEntry> list;
try {
list = pane.cache[dir] ?? _sorted(await listEntries(dir, showHidden: c.settings.explorerShowHidden), dir);
} catch (e) {
_snack(trf('폴더를 읽을 수 없습니다: {0}', [e]));
return;
}
final images = [
for (final e in list)
if (!e.isDir && c.settings.imageExts.contains(extOf(e.path)) && (_showAppHidden || !_appHidden(e.path))) e.path,
];
if (images.isEmpty) {
_snack(tr('이 폴더에는 그림이 없습니다'));
return;
}
final temp = await c.services.storage.tempDirectory();
if (!mounted) return;
await openReader(context, c, ImageFilesSource(images, tempDir: temp, title: vBasename(dir)), autoPlay: true);
}
/// 문서 → PDF (진행 창) → 보기
Future<void> _viewDoc(String path) async {
final temp = await c.services.storage.tempDirectory();
if (!mounted) return;
final dialog = showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
content: Row(children: [
const CircularProgressIndicator(),
const SizedBox(width: 16),
Expanded(child: Text(trf('PDF 로 바꾸는 중: {0}', [vBasename(path)]))),
]),
),
);
final dialog = showProgressDialog(context, trf('PDF 로 바꾸는 중: {0}', [vBasename(path)]));
String? pdf;
Object? error;
try {
@ -1780,8 +1821,10 @@ class _ExplorerPageState extends State<ExplorerPage> {
} catch (e) {
error = e;
}
if (mounted) Navigator.of(context).pop();
await dialog;
// 사용자가 [취소] · ← 로 그만두었으면 결과를 보이지 않는다 (바꾸기는 끝까지 돌았음)
if (dialog.cancelled()) return;
dialog.close();
await dialog.done;
if (pdf == null) {
_snack(trf('미리 볼 수 없습니다: {0}', [error]));
return;
@ -1797,6 +1840,37 @@ class _ExplorerPageState extends State<ExplorerPage> {
await openReader(context, c, src);
}
/// 관리자 지시 (10/20): HWP · HWPX → 보기 화면 (보는 쪽부터 몇 쪽씩 PDF 로 바꿈 - [HwpSource]). 열지 못하면 false
Future<bool> _viewHwp(String path) async {
final temp = await c.services.storage.tempDirectory();
if (!mounted) return true;
final dialog = showProgressDialog(context, trf('여는 중: {0}', [vBasename(path)]));
HwpSource? src;
Object? error;
try {
src = await HwpSource.open(path, engine: RhwpEngine(), tempDir: temp, fontDirs: RhwpEngine.fontDirs, log: c.note);
} catch (e) {
error = e;
}
// 사용자가 [취소] · ← 로 그만두었으면 연 문서를 닫고 끝 (다른 앱으로도 열지 않음)
if (dialog.cancelled()) {
await src?.dispose();
return true;
}
dialog.close();
await dialog.done;
if (src == null) {
_snack(trf('앱 안에서 열지 못해 다른 앱으로 엽니다: {0}', [error]));
return false;
}
if (!mounted) {
await src.dispose();
return true;
}
await openReader(context, c, src);
return true;
}
Future<void> _viewPdf(String path) async {
final temp = await c.services.storage.tempDirectory();
PdfSource src;
@ -1968,6 +2042,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
if (!e.isDir && !dav) ('hash', Icons.tag, tr('해시 (MD5 · SHA-256)')),
if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')),
if (e.isDir) ('playFolder', Icons.play_circle_outline, tr('이 폴더의 동영상 재생')),
// X-plore 맞추기 (관리자 지시 10/20): 이 폴더의 그림을 [계속 보기] 로
if (e.isDir && viewer) ('slideshow', Icons.slideshow_outlined, tr('사진 슬라이드쇼')),
// X-plore 비교 g: 그림 · 동영상을 썸네일 격자로
if (e.isDir && !dav) ('grid', Icons.grid_view, tr('격자로 보기 (그림 · 동영상)')),
if (video || audio) ('playInternal', Icons.play_circle_outline, tr('내장 플레이어로 재생')),
@ -1985,6 +2061,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
targets.every(_appHidden)
? ('appUnhide', Icons.visibility_outlined, many ? trf('{0}개 숨기기 풀기', [targets.length]) : tr('숨기기 풀기'))
: ('appHide', Icons.visibility_off_outlined, many ? trf('{0}개 목록에서 숨기기', [targets.length]) : tr('목록에서 숨기기')),
// X-plore 맞추기 (관리자 지시 10/20): 폴더에 .nomedia 를 두면 갤러리 · 음악 앱이 그 안의 그림 · 동영상 · 음악을 보이지 않는다 (Android)
if (e.isDir && !dav && !inArchive && (ExplorerPage.debugNomedia ?? Platform.isAndroid))
File(p.join(e.path, '.nomedia')).existsSync()
? ('nomediaOff', Icons.photo_library_outlined, tr('갤러리 · 음악 앱에 다시 보이기 (.nomedia 지우기)'))
: ('nomediaOn', Icons.hide_image_outlined, tr('갤러리 · 음악 앱에서 숨기기 (.nomedia)')),
// 두 창이면 아이콘 줄의 복사 · 이동은 다른 창으로 - 담아 두었다가 원하는 폴더에 붙여넣기는 여기
if (dual) ('clipCopy', Icons.content_copy, tr('복사 (붙여넣기로)')),
if (dual) ('clipMove', Icons.content_cut, tr('이동 (붙여넣기로)')),
@ -2044,6 +2125,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
setState(() => _active = _panes.indexOf(other));
case 'playFolder':
await _playFolder(e.path);
case 'slideshow':
await _slideshow(pane, e.path);
case 'playInternal':
await _play([e.path], internal: true);
case 'grid':
@ -2081,6 +2164,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
pane.changed();
}
case 'appHide':
if (c.settings.explorerHideConfirm && !await _confirmAppHide(targets)) return;
await c.updateSettings((s) => s.explorerHiddenItems = [
...s.explorerHiddenItems,
for (final x in targets)
@ -2104,6 +2188,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
q.changed();
}
if (mounted) setState(() {});
case 'nomediaOn' || 'nomediaOff':
await _nomedia(pane, e.path, on: pick == 'nomediaOn');
case 'props':
await showFolderProperties(context, e.path);
case 'hash':
@ -3982,6 +4068,85 @@ class _ExplorerPageState extends State<ExplorerPage> {
);
}
/// X-plore 맞추기 (관리자 지시 10/20): [목록에서 숨기기] 전에 무엇이 바뀌는지 알림. [다음부터 묻지 않기] 는 환경 설정에서 다시 켬
Future<bool> _confirmAppHide(List<String> targets) async {
var dontAsk = false;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) => AlertDialog(
scrollable: true,
title: Text(targets.length > 1 ? trf('{0}개 목록에서 숨기기', [targets.length]) : tr('목록에서 숨기기')),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
if (targets.length == 1) Text(vBasename(targets.single), style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 6),
Text(tr('이 앱의 파일 탐색기 목록에서만 빠집니다. 파일은 지우거나 옮기거나 이름을 바꾸지 않고, 다른 앱 · 갤러리에서는 그대로 보입니다.')),
const SizedBox(height: 6),
Text(tr('목록 맨 아래의 [숨긴 항목 보이기] 나 환경 설정 > 파일 탐색기에서 다시 볼 수 있습니다.'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
value: dontAsk,
onChanged: (v) => set(() => dontAsk = v ?? false),
title: Text(tr('다음부터 묻지 않기')),
),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('숨기기'))),
],
),
),
);
if (ok != true) return false;
if (dontAsk) await c.updateSettings((s) => s.explorerHideConfirm = false);
return true;
}
/// X-plore 맞추기 (관리자 지시 10/20): 폴더에 빈 .nomedia 파일을 넣거나 지운다 (Android 갤러리 · 음악 앱에서 숨기기 · 다시 보이기).
/// 그 폴더의 파일은 그대로 - 무엇이 바뀌는지 먼저 묻는다. 갤러리에 반영되는 때는 그 앱이 다시 읽을 때다
Future<void> _nomedia(_Pane pane, String dir, {required bool on}) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(on ? tr('갤러리 · 음악 앱에서 숨기기 (.nomedia)') : tr('갤러리 · 음악 앱에 다시 보이기 (.nomedia 지우기)')),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(vBasename(dir), style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 6),
Text(on
? tr('이 폴더에 빈 .nomedia 파일을 만듭니다. 갤러리 · 음악 앱이 이 폴더 (안의 폴더 포함) 의 그림 · 동영상 · 음악을 보이지 않게 됩니다.')
: tr('이 폴더의 .nomedia 파일을 지웁니다. 갤러리 · 음악 앱이 이 폴더의 그림 · 동영상 · 음악을 다시 보이게 됩니다.')),
const SizedBox(height: 6),
Text(tr('폴더 안의 파일은 바꾸지 않습니다. 갤러리에 반영되기까지 잠깐 걸릴 수 있습니다.'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(on ? tr('숨기기') : tr('다시 보이기'))),
],
),
);
if (ok != true || !mounted) return;
final f = File(p.join(dir, '.nomedia'));
try {
if (on) {
if (!await f.exists()) await f.writeAsBytes(const []);
} else if (await f.exists()) {
await f.delete();
}
} catch (err) {
_snack(trf('바꾸지 못했습니다: {0}', [err]));
return;
}
// 관리자 지시 10/20: 갤러리가 바로 알도록 .nomedia 와 그 폴더를 다시 읽게 (넣을 때 · 지울 때 모두, 실패해도 조용히)
await c.services.shell.mediaScan([f.path, dir]);
await _refreshAll([dir]);
pane.changed();
_snack(on ? tr('갤러리 · 음악 앱에서 숨겼습니다 (.nomedia). 파일은 그대로입니다.') : tr('.nomedia 를 지웠습니다. 갤러리 · 음악 앱에 다시 보입니다.'));
}
Future<void> _sortDialog() async {
// 84: "폴더마다 기억" 이면 지금 창의 폴더 정렬
final dir = _pane.current;
@ -4423,8 +4588,18 @@ class _ExplorerPageState extends State<ExplorerPage> {
_shape = shape;
}
final dual = _dual;
final split = _split;
return LayoutBuilder(builder: (context, box) {
// 두 창이 다 들어가는지: 트리 (화살표 · 아이콘 · 여백 78 + 이름 40) + 목록 (아이콘 · ✔ · 여백 104 + 이름 50), 이름은 글자 배율만큼.
// 1배면 360 폭에도 들어가고, 기기 글자 1.3 × 150% (약 2배) 면 412 폭에도 모자란다
final wantSplit = !widget.rsync && s.explorerLayout == 'split';
final textScale = MediaQuery.textScalerOf(context).scale(14) / 14 * _listTextScale;
final barW = s.explorerToolbar == 'hidden' ? 0.0 : 72.0;
final sideNow = switch (s.explorerOrientation) { 'side' => true, 'stacked' => false, _ => box.maxWidth >= box.maxHeight };
final tightWas = _splitTight;
_splitTight = wantSplit && sideNow && box.maxWidth - barW < 182 + 90 * textScale;
// 바뀌면 이 builder 밖 (버튼 이름 "두 창" 등) 도 한 번 다시 그린다
if (tightWas != _splitTight) WidgetsBinding.instance.addPostFrameCallback((_) => mounted ? setState(() {}) : null);
final split = _split;
// 폭이 바뀌면 (좁은 한 창 ↔ 두 창) 이 builder 만 다시 불린다 - 그때도 새로 붙은 창을 보이게
if (_ready) _revealNewViews();
final side = !(dual || split) ||
@ -4436,6 +4611,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
// 191: Rsync 화면이 좁으면 (폴드 접은 화면 · 폰 세로의 좌우 두 창) 창 하나씩 - [왼쪽 | 오른쪽] 으로 바꿔 본다
// (두 창 + 가운데 버튼 줄이면 창이 140px 남짓이라 폴더 이름이 잘렸다)
final rsyncOne = widget.rsync && side && box.maxWidth < rsyncTwoPaneMinWidth;
_narrowSplit = split && side && box.maxWidth < narrowSplitWidth;
final bar = s.explorerToolbar == 'hidden' && !widget.rsync
? null
: _Toolbar(
@ -4474,8 +4650,14 @@ class _ExplorerPageState extends State<ExplorerPage> {
Expanded(child: keep(1, _paneView(1, foldersOnly: widget.rsync))),
if (edge) bar,
] else if (split) ...[
// 왼쪽 폴더 트리 (좁게) · 오른쪽 그 폴더의 파일 목록
Expanded(flex: 2, child: keep(0, _paneView(0, foldersOnly: true))),
// 왼쪽 폴더 트리 (좁게) · 오른쪽 그 폴더의 파일 목록. 좁은 화면은 트리에 최소 폭 (130px 또는 버튼 줄 (72) 을 뺀 폭의 42%) -
// 버튼 줄까지 넣어 38% 로 하면 360 폭에서 오른쪽 목록의 파일 이름이 두세 글자만 남았다
if (_narrowSplit)
SizedBox(
width: math.max(130.0, (box.maxWidth - (middle || edge ? 72 : 0)) * 0.42),
child: keep(0, _paneView(0, foldersOnly: true)))
else
Expanded(flex: 2, child: keep(0, _paneView(0, foldersOnly: true))),
if (middle) bar,
Expanded(flex: 3, child: keep(2, _listView(_panes[0]))),
if (edge) bar,
@ -4750,7 +4932,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
final rows = _rows(pane, foldersOnly: foldersOnly);
final look = _look;
// 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다
return LayoutBuilder(builder: (context, box) => _frame(i, [
return LayoutBuilder(builder: (context, box) {
// 열을 그리는 창만 숨길 열을 정한다 ("폴더 + 파일 목록" 의 트리는 열이 없고 목록과 같은 창을 쓴다)
if (look.columns && !foldersOnly) {
pane.colHide = columnHideLevel(look, box.maxWidth,
marks: _selecting, textScale: MediaQuery.textScalerOf(context).scale(14) / 14 * _listTextScale);
}
return _frame(i, [
_paneHeader(pane),
const Divider(height: 1),
// 보이는 수는 맞는 것만 (맞는 것이 든 위 폴더는 빼고)
@ -4762,28 +4950,30 @@ class _ExplorerPageState extends State<ExplorerPage> {
ExplorerColumnsHeader(
style: look,
markColumn: _selecting,
hide: pane.colHide,
sortBy: _sortOf(pane.current).$1,
sortDesc: _sortOf(pane.current).$2,
onSort: (by) => _sortByColumn(pane, by),
),
Expanded(
child: Stack(children: [
ListView.builder(
scaleListText(context, _listTextScale, ListView.builder(
controller: pane.scroll,
itemCount: rows.length,
itemExtent: look.rowHeightOf(context),
itemExtent: _rowH(),
// 76: 줄 위젯은 항목을 따라간다 (아래 [_rowKey])
itemBuilder: (_, k) => KeyedSubtree(
key: _rowKey(rows[k]),
child: rows[k].kind != 0 ? _extraRow(pane, rows[k]) : _rowView(pane, rows[k], compact: foldersOnly)),
findChildIndexCallback: _rowIndexOf(rows),
),
)),
// 읽는 중 (WebDAV 서버를 기다릴 때 등). 180: 목록 위에 겹쳐 그린다 (끼어들면 목록이 밀려 다음 것을 누르려다 엉뚱한 것을 누른다)
if (pane.loading.isNotEmpty) const Positioned(left: 0, right: 0, top: 0, child: LinearProgressIndicator(minHeight: 2)),
]),
),
if ((_selecting || (_alwaysMarks && pane.marked.isNotEmpty)) && !foldersOnly) _marksBar(pane),
]));
]);
});
}
/// 76: 목록 줄의 키 = 항목 경로. 키가 없으면 줄 목록이 바뀔 때 (빠른 필터 · 펼치기 · 새로 고침) 같은 자리의 줄 위젯을
@ -4813,7 +5003,12 @@ class _ExplorerPageState extends State<ExplorerPage> {
];
final look = _look;
// 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다
return LayoutBuilder(builder: (context, box) => _frame(0, [
return LayoutBuilder(builder: (context, box) {
if (look.columns) {
pane.colHide = columnHideLevel(look, box.maxWidth,
marks: _selecting, textScale: MediaQuery.textScalerOf(context).scale(14) / 14 * _listTextScale);
}
return _frame(0, [
Container(
height: 40,
alignment: Alignment.centerLeft,
@ -4827,6 +5022,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
ExplorerColumnsHeader(
style: look,
markColumn: _selecting,
hide: pane.colHide,
sortBy: _sortOf(dir).$1,
sortDesc: _sortOf(dir).$2,
onSort: (by) => _sortByColumn(pane, by),
@ -4837,17 +5033,18 @@ class _ExplorerPageState extends State<ExplorerPage> {
? const Center(child: CircularProgressIndicator())
: entries.isEmpty && !up
? Center(child: Text(tr('비어 있음'), style: const TextStyle(color: JjColors.textDim)))
: ListView.builder(
: scaleListText(context, _listTextScale, ListView.builder(
controller: pane.listScroll,
itemCount: rows.length,
itemExtent: look.rowHeightOf(context),
itemExtent: _rowH(),
itemBuilder: (_, k) => KeyedSubtree(
key: _rowKey(rows[k]), child: rows[k].kind != 0 ? _extraRow(pane, rows[k]) : _rowView(pane, rows[k], list: true)),
findChildIndexCallback: _rowIndexOf(rows),
),
)),
),
if (_selecting) _marksBar(pane, listDir: dir),
]));
]);
});
}
/// 76: 빠른 필터 열기 ([text] 가 있으면 그 글자로 시작)
@ -5323,6 +5520,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
Widget _rowView(_Pane pane, _Row row, {bool list = false, bool compact = false}) {
final e = row.entry;
final look = _look;
// 좁은 "폴더 + 파일 목록" 의 트리 (관리자 지시 10/20): ✔ 칸 · 크기 줄 없이 이름만, 줄에 두 줄이 들어가면 이름을 두 줄까지
final slim = compact && _narrowSplit && !list;
final nameLines = slim && _rowH() >= MediaQuery.textScalerOf(context).scale(look.fontSize) * _listTextScale * 2.5 + 6 ? 2 : 1;
final isCurrent = e.isDir && !list && !row.isUp && samePath(pane.current, e.path);
final isFocused = !row.isUp && pane.focused != null && samePath(pane.focused!, e.path);
final marked = _isMarked(pane, e.path);
@ -5342,12 +5542,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
color: isCurrent ? JjColors.accent : null,
);
final chevronWidth = treeChevronWidth(defaultTargetPlatform);
// 좁은 트리 (slim) 는 조금 좁게 (36 - 줄 높이 전체를 누르므로 손가락으로도 눌림)
final chevronWidth = slim ? math.min(36.0, treeChevronWidth(defaultTargetPlatform)) : treeChevronWidth(defaultTargetPlatform);
// 트리의 펼치기 화살표 (따로 누름). 189: 접기 · 펼치기는 이것으로만 하므로 줄 높이 전체를 누를 수 있게,
// 손가락으로 누르는 기기 (Android) 는 폭도 넓게
Widget chevron() => SizedBox(
width: chevronWidth,
height: look.rowHeightOf(context),
height: _rowH(),
child: (e.isDir || _canBrowseArchive(e)) && !list && !row.isUp
? (loading
? const Center(
@ -5371,17 +5572,28 @@ class _ExplorerPageState extends State<ExplorerPage> {
// 좁은 창 (폰 세로의 좌우 두 창) 에서 깊은 폴더: 들여쓰기는 이름 자리를 남기는 만큼만 (넘치지 않게)
Widget nameCell() => LayoutBuilder(builder: (context, box) {
final fixed = (list ? 0 : chevronWidth) + (look.rich ? 64 : look.iconSize + 12) + 48.0;
final indent = list ? 0.0 : (row.depth * look.indent).clamp(0.0, (box.maxWidth - fixed).clamp(0.0, double.infinity));
// 이름 자리는 48 또는 줄 폭의 40% (관리자 지시 10/20: 글자가 크고 폴더가 깊으면 들여쓰기에 밀려 이름이 몇 글자만 남았다)
final fixed = (list ? 0 : chevronWidth) + (look.rich ? 64 : look.iconSize + 12) + math.max(48.0, box.maxWidth * 0.4);
// 좁은 트리 (slim): 들여쓰기는 반만 (폭의 1/10 까지) - 깊은 폴더에서 이름이 한 글자도 안 보이지 않게
final indent = list
? 0.0
: slim
? (row.depth * look.indent / 2).clamp(0.0, box.maxWidth * 0.1).toDouble()
: (row.depth * look.indent).clamp(0.0, (box.maxWidth - fixed).clamp(0.0, double.infinity)).toDouble();
// 아주 좁은 창 (412 폭 "폴더 + 파일 목록" 의 트리, 77px 쯤): 아이콘 칸 · 틈을 남은 폭만큼만 (아이콘은 줄여 그림) - 넘치지 않게
final room = (box.maxWidth - indent - (list ? 0 : chevronWidth)).clamp(0.0, double.infinity);
// 좁은 트리 (slim) 는 큰 썸네일 칸 (56) 대신 작은 아이콘 칸 (28 까지, 아이콘은 줄여 그림)
final iconW = math.min(slim ? math.min(look.iconSize, 24.0) + 4 : look.rich ? 56.0 : look.iconSize + 6, room);
final gapW = math.min(slim ? 4.0 : look.rich ? 8.0 : 6.0, room - iconW);
return Row(children: [
SizedBox(width: indent),
if (!list) chevron(),
SizedBox(
width: look.rich ? 56 : look.iconSize + 6,
height: look.rowHeightOf(context) - 8,
child: Center(child: _icon(e, row.isRoot, open, up: row.isUp)),
width: iconW,
height: _rowH() - 8,
child: Center(child: FittedBox(fit: BoxFit.scaleDown, child: _icon(e, row.isRoot, open, up: row.isUp))),
),
SizedBox(width: look.rich ? 8 : 6),
SizedBox(width: gapW),
Expanded(
child: look.rich && !e.isDir && !row.isUp
? Column(
@ -5400,8 +5612,10 @@ class _ExplorerPageState extends State<ExplorerPage> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(name, maxLines: 1, overflow: TextOverflow.ellipsis, style: nameStyle),
if (look.rich && e.isDir && pane.errors[e.path] != null)
Text(name, maxLines: nameLines, overflow: TextOverflow.ellipsis, style: nameStyle),
if (slim)
const SizedBox.shrink()
else if (look.rich && e.isDir && pane.errors[e.path] != null)
Text(tr('읽을 수 없음'), style: dim.copyWith(color: Colors.redAccent))
// 80: 열 없는 모양 (X-plore · 좁은 화면) 은 폴더 이름 아래에 크기
else if (e.isDir && !row.isRoot && !row.isUp && (!look.columns || compact) && _dirSize(pane, e) != null)
@ -5417,21 +5631,22 @@ class _ExplorerPageState extends State<ExplorerPage> {
flex: flex,
child: Text(t, style: dim, textAlign: align, maxLines: 1, overflow: TextOverflow.ellipsis),
);
// 관리자 지시 10/20: 좁으면 유형 → 날짜 열을 숨김 (크기는 남김, [columnHideLevel])
final hide = pane.colHide;
final cells = !look.columns || compact || !real
? <Widget>[Expanded(child: nameCell())]
: look == ExplorerStyle.windows
? [
Expanded(flex: 6, child: nameCell()),
cell(_date(e.modified), 3),
cell(typeLabel(e), 2),
if (hide < 2) cell(_date(e.modified), 3),
if (hide < 1) cell(typeLabel(e), 2),
cell(e.isDir ? (_dirSize(pane, e) ?? '') : formatSize(e.size), 2, align: TextAlign.end),
]
: [
Expanded(flex: 6, child: nameCell()),
cell(e.isDir ? '' : e.ext, 1),
if (hide < 1) cell(e.isDir ? '' : e.ext, 1),
cell(e.isDir ? (_dirSize(pane, e) ?? '<DIR>') : formatSize(e.size), 2, align: TextAlign.end),
const SizedBox(width: 8),
cell(_date(e.modified), 3),
if (hide < 2) ...[const SizedBox(width: 8), cell(_date(e.modified), 3)],
];
final paneIndex = _panes.indexOf(pane);
@ -5473,7 +5688,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
),
),
// 표시 동그라미는 두 번 누르기 영역 밖 (기다리지 않고 바로)
if ((real || (widget.rsync && row.isRoot)) && (_selecting || widget.rsync || _alwaysMarks))
if (slim)
const SizedBox.shrink()
else if ((real || (widget.rsync && row.isRoot)) && (_selecting || widget.rsync || _alwaysMarks))
SizedBox(
width: 36,
child: IconButton(

View File

@ -40,6 +40,8 @@ List<(List<String>, String)> get _licenseAssets => [
(['smbj 0.15.0 · asn-one · Bouncy Castle · SLF4J · MBassador (Android SMB)'], 'assets/licenses/SMBJ_LICENSES.txt'),
// Tor 브라우저: tor 와 함께 묶인 Libevent · OpenSSL · zlib · zstd (공식 번들의 docs)
(['Tor 0.4.9.13 (Libevent · OpenSSL · zlib · Zstandard)'], 'assets/licenses/TOR_LICENSES.txt'),
// 관리자 지시 (10/20): Android 의 HWP · HWPX 보기 (libjj_rhwp.so - rhwp MIT 와 함께 묶인 Rust crate 들)
if (Platform.isAndroid) (['rhwp 0.8.7 (HWP · HWPX → PDF)'], 'assets/licenses/RHWP_LICENSES.txt'),
// 175: 함께 빌드한 네이티브 라이브러리 (Dart 쪽 패키지 라이선스와 따로)
([tr('whisper.cpp · ggml (whisper_ggml 안)')], 'assets/licenses/WHISPER_CPP_LICENSE.txt'),
(['ONNX Runtime 1.15.1 (Microsoft)'], 'assets/licenses/ONNXRUNTIME_LICENSES.txt'),

View File

@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import '../l10n/tr.dart';
/// 바꾸는 · 여는 동안의 진행 창 ([취소] 있음). 관리자 지시 (10/20, 28 지적): Android ← · [취소] 로 창이 먼저 닫힐 수 있어,
/// 일이 끝난 뒤에는 그 창이 아직 열려 있을 때만 닫는다 (예전에는 끝난 뒤의 pop() 이 다른 화면을 닫을 수 있었다).
/// - [ProgressDialog.cancelled]: 사용자가 [취소] · ← 로 그만두었는지 (그러면 결과를 보이지 않는다)
/// - [ProgressDialog.close]: 일이 끝나 창을 닫는다 (이미 닫혔으면 아무것도 하지 않는다)
class ProgressDialog {
final Future<void> done;
final bool Function() cancelled;
final void Function() close;
const ProgressDialog._(this.done, this.cancelled, this.close);
}
ProgressDialog showProgressDialog(BuildContext context, String text) {
BuildContext? dctx;
var open = true;
var byUser = true;
final done = showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) {
dctx = ctx;
return AlertDialog(
content: Row(children: [
const CircularProgressIndicator(),
const SizedBox(width: 16),
Expanded(child: Text(text)),
]),
actions: [TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(tr('취소')))],
);
},
).whenComplete(() => open = false);
return ProgressDialog._(done, () => !open && byUser, () {
if (!open) return;
byUser = false;
final x = dctx;
// 그 창이 맨 위일 때만 (28 지적: 이론상 그 위에 다른 화면이 올라와 있으면 그것을 닫을 수 있다)
if (x != null && x.mounted && (ModalRoute.of(x)?.isCurrent ?? false)) Navigator.of(x).pop();
});
}

View File

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io' show File, Platform;
import 'dart:math' as math;
import 'package:flutter/material.dart';
@ -7,6 +8,9 @@ import 'package:wakelock_plus/wakelock_plus.dart';
import '../app/app_controller.dart';
import '../core/reader_sources.dart';
import '../core/hwp_source.dart' show HwpPdfException, HwpSource;
import '../core/file_ops.dart' show formatSize;
import '../core/photo_info.dart';
import '../core/vfs.dart';
import '../l10n/tr.dart';
import 'ai_image_page.dart';
@ -14,9 +18,9 @@ import 'ai_upscale_ui.dart';
import 'theme.dart';
/// 그림 · 만화 · PDF 보기를 연다. [start] 처음 볼 장. 닫으면 [source] 를 정리한다.
Future<void> openReader(BuildContext context, AppController c, ReaderSource source, {int start = 0}) async {
Future<void> openReader(BuildContext context, AppController c, ReaderSource source, {int start = 0, bool autoPlay = false}) async {
await Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => ReaderPage(c: c, source: source, start: start),
builder: (_) => ReaderPage(c: c, source: source, start: start, autoPlay: autoPlay),
));
await source.dispose();
}
@ -44,7 +48,13 @@ 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});
/// X-plore 맞추기 (관리자 지시 10/20): 폴더 [사진 슬라이드쇼] - [계속 보기] (117) 를 켠 채 연다
final bool autoPlay;
const ReaderPage({super.key, required this.c, required this.source, this.start = 0, this.autoPlay = false});
/// 볼륨 키로 넘기기를 쓰는 기기인지 (Android - 시험에서 바꿈)
static bool Function() volumeKeysSupported = () => Platform.isAndroid;
/// 계속 보기 동안 화면 켜 두기 (시험에서 바꿈)
static Future<void> Function(bool on) keepScreenOn = (on) => on ? WakelockPlus.enable() : WakelockPlus.disable();
@ -67,6 +77,15 @@ class _ReaderPageState extends State<ReaderPage> {
/// PDF 는 페이지를 지우거나 넣으면 다시 그린다
int _version = 0;
/// X-plore 맞추기 (관리자 지시 10/20): 아래 갤러리 줄 - 그림 원본 (폴더 · ZIP) 에서만
bool get _pictures => src is ImageFilesSource || src is ZipImagesSource;
final _thumbs = <int, Future<ReaderImage>>{};
final _gallery = ScrollController();
static const _thumbExtent = 64.0;
/// [다시 시도] 를 누른 횟수 (장마다) - 그 장의 화면을 새로 만든다 (28 지적: 같은 키면 실패한 그림을 그대로 다시 썼다)
final _retry = <int, int>{};
@override
void initState() {
super.initState();
@ -77,6 +96,7 @@ class _ReaderPageState extends State<ReaderPage> {
_applySystemBars();
// 224: 앱으로 돌아올 때 그사이 밖에서 지운 그림을 목록에서 뺀다
_life = AppLifecycleListener(onResume: () => unawaited(dropMissingFiles()));
if (widget.autoPlay) WidgetsBinding.instance.addPostFrameCallback((_) => mounted ? _play(true) : null);
}
late final AppLifecycleListener _life;
@ -159,7 +179,8 @@ class _ReaderPageState extends State<ReaderPage> {
if (d == null) return false;
Size turned(Size s) => _turns.isOdd ? Size(s.height, s.width) : s;
return autoSplitsPage(turned(d), [for (final e in _dims.entries) if (e.key != page) turned(e.value)],
bundle: src is! ImageFilesSource);
// 관리자 지시 (10/20) · 내 판단: HWP · HWPX 문서의 가로 쪽 (가로 표 등) 은 만화 두 쪽 스캔이 아니라 나누지 않는다
bundle: src is! ImageFilesSource && src is! HwpSource);
}
/// 223: 나눠 본 장에서 바로 한 장으로 (설정을 "끄기" 로)
@ -248,32 +269,57 @@ class _ReaderPageState extends State<ReaderPage> {
/// 계속 보기를 멈추게 한 누름은 장 넘기기 · 막대 숨기기로 쓰지 않는다
bool _swallowTap = false;
/// 관리자 지시 10/20 (28 검토): 다 보인 장 ('$_version:$_index'). 계속 보기 시계는 그 장이 보인 뒤부터 잰다 -
/// WebDAV 처럼 느린 원본에서 받는 동안 시간이 흘러 보지도 못한 장을 넘겼다
String? _shownKey;
/// [i] 장이 화면에 다 그려졌다 (그림 첫 장면 · 실패 글). 지금 장이면 계속 보기 시계를 시작
void _markShown(int i) {
if (!mounted || i != _index) return;
final key = '$_version:$i';
if (_shownKey == key) return;
_shownKey = key;
if (_playing && _timer == null) _startTimer();
}
void _startTimer() {
_timer?.cancel();
_timer = Timer(Duration(seconds: c.settings.readerAutoSeconds), _tick);
}
void _play(bool on) {
_timer?.cancel();
_timer = null;
if (on && _v.isNotEmpty) {
_timer = Timer.periodic(Duration(seconds: c.settings.readerAutoSeconds), (_) => _tick());
}
// 지금 장이 이미 보이면 바로 재기 시작, 아직 받는 중이면 보인 뒤에 ([_markShown])
if (on && _v.isNotEmpty && _shownKey == '$_version:$_index') _startTimer();
if (on == _playing) return;
setState(() => _playing = on);
unawaited(ReaderPage.keepScreenOn(on).catchError((_) {}));
}
void _tick() {
if (!mounted) return;
_timer = null;
if (!mounted || !_playing) return;
if (_index >= _v.length - 1) {
_play(false);
_snack(tr('마지막 장이라 계속 보기를 멈췄습니다.'));
return;
}
_pages.jumpToPage(_index + 1);
// 다음 장이 이미 받아 둔 것이라 다시 그리지 않으면 그 자리에서 알린다
WidgetsBinding.instance.addPostFrameCallback((_) => _rearmIfShown());
}
/// 넘긴 뒤 그 장이 곧바로 보였는데 (캐시) 알림을 놓쳤으면 시계를 다시 건다
void _rearmIfShown() {
if (mounted && _playing && _timer == null && _shownKey == '$_version:$_index') _startTimer();
}
Future<void> _setSeconds(int d) async {
await c.updateSettings((x) => x.readerAutoSeconds = (x.readerAutoSeconds + d).clamp(1, 60));
if (!mounted) return;
setState(() {});
if (_playing) _play(true); // 새 간격으로
if (_playing) _play(true); // 새 간격으로 (지금 장이 보이면 처음부터 다시)
}
// ───────── 확대 (30) ─────────
@ -335,6 +381,10 @@ class _ReaderPageState extends State<ReaderPage> {
@override
void dispose() {
for (final f in _thumbs.values) {
unawaited(f.then((x) => x.dispose(), onError: (_) {}));
}
_gallery.dispose();
_life.dispose();
_timer?.cancel();
if (_playing) unawaited(ReaderPage.keepScreenOn(false).catchError((_) {}));
@ -393,6 +443,15 @@ class _ReaderPageState extends State<ReaderPage> {
_jumpLater();
}
/// X-plore 맞추기 (관리자 지시 10/20): 볼륨 키로 넘기기 (설정을 켰고 Android 일 때만 - 처리한 키는 시스템 볼륨을 바꾸지 않는다)
KeyEventResult _onKey(FocusNode _, KeyEvent e) {
if (!c.settings.readerVolumeKeys || !ReaderPage.volumeKeysSupported()) return KeyEventResult.ignored;
final k = e.logicalKey;
if (k != LogicalKeyboardKey.audioVolumeDown && k != LogicalKeyboardKey.audioVolumeUp) return KeyEventResult.ignored;
if (e is KeyDownEvent || e is KeyRepeatEvent) _go(k == LogicalKeyboardKey.audioVolumeDown ? 1 : -1);
return KeyEventResult.handled;
}
void _go(int delta) {
final to = (_index + delta).clamp(0, math.max(0, _v.length - 1)).toInt();
if (to == _index) {
@ -439,22 +498,74 @@ class _ReaderPageState extends State<ReaderPage> {
final fit = c.settings.readerFit;
final slot = _v[i];
final half = slot.part >= 0;
Widget failed(Object? e) => SizedBox(
width: 320,
height: 120,
child: Center(child: Text(trf('열 수 없습니다: {0}', [e]), style: const TextStyle(color: Colors.white70))));
// 관리자 지시 (10/20, HWP 2단계): 이유와 [다시 시도] - 그 장의 기억을 지우고 다시 부른다 (시간 제한을 넘긴 HWP 묶음 등)
// [provider]: 그림 파일 · ZIP 의 그림을 읽다 (디코드) 실패한 것 - 화면 글은 종류에 맞게, 원문은 [자세히] (28 지적)
Widget failed(Object? e, {ImageProvider? provider}) {
final text = e is HwpPdfException
? trf('열 수 없습니다: {0}', [e.message])
: provider != null
? tr('그림 파일을 읽을 수 없습니다 - 깨졌거나 지원하지 않는 형식')
: trf('열 수 없습니다: {0}', [e]);
final detail = e is HwpPdfException ? e.detail : (provider != null ? '$e' : '');
return SizedBox(
width: 320,
height: 160,
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Text(text, textAlign: TextAlign.center, style: const TextStyle(color: Colors.white70)),
const SizedBox(height: 8),
// 관리자 지시 (10/20, 28 지적): 변환기 · 그림 디코드 원문은 화면 글 대신 [자세히] 에
if (detail.isNotEmpty)
TextButton(
onPressed: () => showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
content: SelectableText(detail, style: const TextStyle(fontSize: 12)),
actions: [TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(tr('닫기')))],
),
),
child: Text(tr('자세히')),
),
OutlinedButton(
onPressed: () {
// 그림은 Flutter 의 그림 캐시에 실패가 남아 같은 키로 다시 쓰인다 - 비우고 (ResizeImage 의 키째) 다시 읽는다
if (provider != null) unawaited(provider.evict());
setState(() {
_cache.remove(slot.page);
_cacheHalf.remove(slot.page);
_sharp.remove(i);
_sharpWidth.remove(i);
_retry[slot.page] = (_retry[slot.page] ?? 0) + 1;
});
},
child: Text(tr('다시 시도')),
),
]));
}
// 확대해 다시 그린 것이 있으면 그것 (다 그릴 때까지는 앞의 그림을 그대로 보인다)
final page = FutureBuilder<ReaderImage>(
key: ValueKey('$_version:${slot.page}:${slot.part}'),
key: ValueKey('$_version:${slot.page}:${slot.part}:${_retry[slot.page] ?? 0}'),
future: _sharp[i] ?? (half ? _loadHalf(slot.page) : _load(slot.page)),
builder: (context, snap) {
if (snap.hasError) return Center(child: failed(snap.error));
// 계속 보기: 받는 동안은 시계를 재지 않고, 보인 (또는 실패 글이 보인) 뒤부터
void shown() => WidgetsBinding.instance.addPostFrameCallback((_) => _markShown(i));
if (snap.hasError) {
shown();
return Center(child: failed(snap.error));
}
final r = snap.data;
if (r == null) return const Center(child: CircularProgressIndicator());
if (r.image != null) shown();
Widget picture = r.image != null
? RawImage(image: r.image, filterQuality: FilterQuality.medium)
: Image(image: r.provider!, gaplessPlayback: true, filterQuality: FilterQuality.medium,
errorBuilder: (_, e, _) => failed(e));
frameBuilder: (_, child, frame, sync) {
if (frame != null || sync) shown();
return child;
},
errorBuilder: (_, e, _) {
shown();
return failed(e, provider: r.provider);
});
// 120: 회전 · 상하 반전 (보기만, 파일은 그대로)
if (_turns != 0) picture = RotatedBox(quarterTurns: _turns, child: picture);
if (_flipV) picture = Transform.flip(flipY: true, child: picture);
@ -498,7 +609,13 @@ class _ReaderPageState extends State<ReaderPage> {
PdfSource? get _pdf => src is PdfSource ? src as PdfSource : null;
/// 28 지적 (10/20): 위 막대 단추를 누른 그 이벤트 안에서 창을 띄우면 (장을 손으로 넘긴 뒤) 창을 닫을 때 디버그 판에서
/// 의미 트리 assertion ('node.built') 이 났다 - 창은 지금 장면이 끝난 뒤에 띄운다 (부른 쪽은 뒤에 mounted 확인)
Future<void> _afterFrame() => WidgetsBinding.instance.endOfFrame;
Future<void> _deletePage() async {
await _afterFrame();
if (!mounted) return;
final pdf = _pdf!;
if (pdf.length <= 1) {
_snack(tr('마지막 한 페이지는 지울 수 없습니다.'));
@ -523,6 +640,8 @@ class _ReaderPageState extends State<ReaderPage> {
}
Future<void> _insertImages() async {
await _afterFrame();
if (!mounted) return;
final pdf = _pdf!;
final page = _cur.page;
final files = await c.services.storage.pickFiles(title: tr('PDF 에 넣을 그림 선택'), extensions: c.settings.imageExts);
@ -551,6 +670,8 @@ class _ReaderPageState extends State<ReaderPage> {
Future<bool> _confirmLeave() async {
final pdf = _pdf;
if (pdf == null || !pdf.dirty) return true;
await _afterFrame();
if (!mounted) return false;
final r = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
@ -605,6 +726,12 @@ class _ReaderPageState extends State<ReaderPage> {
one(_Act(const Icon(Icons.delete_outline), tr('이 페이지 지우기'), _deletePage)),
one(_Act(const Icon(Icons.add_photo_alternate_outlined), tr('그림을 페이지로 넣기 (이 페이지 뒤)'), _insertImages)),
],
// X-plore 맞추기 (관리자 지시 10/20): 사진 정보 (Exif) - 폴더의 그림만
if (src is ImageFilesSource) one(_Act(const Icon(Icons.info_outline), tr('사진 정보 (Exif)'), _showPhotoInfo)),
// X-plore 맞추기 (관리자 지시 10/20): 아래 갤러리 줄 켜고 끄기 (그림 원본만)
if (_pictures)
one(_Act(const Icon(Icons.view_carousel_outlined), s.readerGallery ? tr('아래 갤러리 줄 숨기기') : tr('아래 갤러리 줄 보이기'),
_toggleGallery, on: s.readerGallery)),
// 119 · 120
one(_Act(const Icon(Icons.vertical_split_outlined), split, _cycleSplit, on: s.readerSplit != 'off')),
one(_Act(const Icon(Icons.rotate_right), tr('90° 회전 (시계 방향)'), () => _rotate(1), on: _turns != 0)),
@ -762,13 +889,151 @@ class _ReaderPageState extends State<ReaderPage> {
/// 쪽 번호: 통째면 "12", 반쪽이면 "12-1" · "12-2"
String _label(ReaderSlot s) => s.part < 0 ? '${s.page + 1}' : '${s.page + 1}-${s.part + 1}';
/// X-plore 맞추기 (관리자 지시 10/20): 지금 그림의 사진 정보 (찍은 날 · 기기 · 크기 · 위치). 위치는 [위치 보기] 를 누를 때만
/// 보이고, 지도 · 인터넷으로 보내지 않는다 (링크 없음)
Future<void> _showPhotoInfo() async {
// 지금 장면이 끝난 뒤에 띄운다 ([_afterFrame], Windows 통합 시험에서 재현 4번 → 0번)
await _afterFrame();
if (!mounted) return;
final s = src;
if (s is! ImageFilesSource || _v.isEmpty) return;
final path = s.paths[_cur.page];
PhotoInfo? info;
int? size;
Object? error;
try {
final local = await vLocalCopy(path, s.tempDir);
final f = File(local);
size = await f.length();
final bytes = await f.readAsBytes();
info = readPhotoInfo(bytes);
} catch (e) {
error = e;
}
if (!mounted) return;
var showLocation = false;
await showDialog<void>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) {
final i = info;
Widget row(String k, Widget v) => Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
SizedBox(width: 90, child: Text(k, style: const TextStyle(color: JjColors.textDim))),
Expanded(child: v),
]),
);
return AlertDialog(
scrollable: true,
title: Text(tr('사진 정보 (Exif)')),
content: SizedBox(
width: 420,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
row(tr('파일'), SelectableText(vBasename(path))),
if (i == null)
Text(trf('읽을 수 없습니다: {0}', [error]))
else ...[
row(tr('크기'), Text([
if (i.width != null) trf('{0} × {1} 픽셀', [i.width!, i.height!]),
if (size != null) formatSize(size),
].join(' · '))),
row(tr('찍은 날'), Text(i.taken ?? '-')),
row(tr('기기'), Text(i.device ?? '-')),
row(
tr('위치'),
!i.hasLocation
? const Text('-')
: showLocation
? SelectableText(i.locationText)
: Align(
alignment: Alignment.centerLeft,
child: TextButton(onPressed: () => set(() => showLocation = true), child: Text(tr('위치 보기'))))),
if (i.hasLocation)
Text(tr('위치는 이 기기에서만 보이며 지도 · 인터넷으로 보내지 않습니다'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
if (!i.hasExif)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(tr('이 그림에는 Exif 정보가 없습니다 (JPEG 사진만 읽음)'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
),
],
]),
),
actions: [TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(tr('닫기')))],
);
},
),
);
}
Future<void> _toggleGallery() async {
await c.updateSettings((x) => x.readerGallery = !x.readerGallery);
if (!mounted) return;
setState(() {});
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollGallery(animate: false));
}
/// [page] 번째 장 (파일) 으로 (두 쪽 나눠 보고 있으면 그 장의 첫 쪽)
void _goPage(int page) {
final i = _v.indexWhere((e) => e.page == page);
if (i >= 0 && _pages.hasClients) _pages.jumpToPage(i);
}
/// 갤러리 줄을 지금 장이 가운데 오게
void _scrollGallery({bool animate = true}) {
if (!_gallery.hasClients) return;
final pos = _gallery.position;
final to = (_cur.page * _thumbExtent - pos.viewportDimension / 2 + _thumbExtent / 2).clamp(0.0, pos.maxScrollExtent);
animate ? _gallery.animateTo(to, duration: const Duration(milliseconds: 200), curve: Curves.easeOut) : _gallery.jumpTo(to);
}
Future<ReaderImage> _thumb(int i) => _thumbs.putIfAbsent(i, () => src.load(i, maxWidth: 160));
Widget _galleryStrip() => SizedBox(
height: 76,
child: ListView.builder(
controller: _gallery,
scrollDirection: Axis.horizontal,
reverse: c.settings.readerRtl,
itemExtent: _thumbExtent,
itemCount: src.length,
itemBuilder: (_, i) => GestureDetector(
key: ValueKey('thumb$i'),
onTap: () => _goPage(i),
child: Container(
margin: const EdgeInsets.all(3),
decoration: BoxDecoration(
border: Border.all(color: i == _cur.page ? JjColors.accent : Colors.white24, width: i == _cur.page ? 2 : 1)),
child: FutureBuilder<ReaderImage>(
future: _thumb(i),
builder: (_, snap) {
final r = snap.data;
if (snap.hasError) return const Icon(Icons.broken_image_outlined, color: Colors.white38, size: 20);
if (r == null) return const SizedBox();
return r.image != null
? RawImage(image: r.image, fit: BoxFit.cover)
: Image(
image: r.provider!,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => const Icon(Icons.broken_image_outlined, color: Colors.white38, size: 20));
},
),
),
),
),
);
Widget _bottomBar() {
final n = src.length;
final count = _v.length;
return Container(
color: _barColor,
padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom, left: 12, right: 12),
child: Row(children: [
child: Column(mainAxisSize: MainAxisSize.min, children: [
if (_pictures && c.settings.readerGallery && n > 0) _galleryStrip(),
Row(children: [
// 125: 반으로 나눈 쪽이 있으면 전체 수가 파일 수 (장) 임을 단위로 ("2-1 / 3장")
Text(
count != n ? trf('{0} / {1}장', [_label(_cur), n]) : '${n == 0 ? 0 : _label(_cur)} / $n',
@ -795,6 +1060,7 @@ class _ReaderPageState extends State<ReaderPage> {
textAlign: TextAlign.end,
style: const TextStyle(color: Colors.white70, fontSize: 12)),
),
]),
]),
);
}
@ -811,7 +1077,10 @@ class _ReaderPageState extends State<ReaderPage> {
Navigator.of(context).pop();
}
},
child: Scaffold(
child: Focus(
autofocus: true,
onKeyEvent: _onKey,
child: Scaffold(
backgroundColor: Colors.black,
body: LayoutBuilder(builder: (context, box) {
// 화면 너비 (기기 픽셀) 에 맞춰 그림을 줄여 읽는다
@ -835,11 +1104,14 @@ class _ReaderPageState extends State<ReaderPage> {
// 확대해 보는 동안은 밀어도 넘기지 않는다 (그림 옮기기)
physics: _holdPage ? const NeverScrollableScrollPhysics() : null,
itemCount: _v.length,
onPageChanged: (i) => setState(() {
_index = i;
_resetZoom();
_trim();
}),
onPageChanged: (i) {
setState(() {
_index = i;
_resetZoom();
_trim();
});
_scrollGallery();
},
itemBuilder: (_, i) => _page(i, box.biggest),
),
),
@ -852,6 +1124,7 @@ class _ReaderPageState extends State<ReaderPage> {
],
]);
}),
),
),
);
}

View File

@ -896,6 +896,34 @@ class _SettingsPageState extends State<SettingsPage> {
value: s.explorerRetapCollapse,
onChanged: (v) => c.updateSettings((x) => x.explorerRetapCollapse = v),
),
// X-plore 비교 40 (관리자 지시 10/20): 목록만의 글자 크기 · 줄 간격 (세 스타일 모두, 앱 화면 크기 · 기기 글자 크기와 곱함)
SettingTile(
title: Text(tr('목록 글자 크기')),
subtitle: Text(tr('파일 탐색기 목록의 이름 · 크기 글자만. 앱 화면 크기 · 기기 글자 크기와 곱해집니다')),
trailing: DropdownButton<int>(
value: s.explorerTextScale,
items: [
for (var v = AppSettings.explorerTextScaleMin; v <= AppSettings.explorerTextScaleMax; v += 10)
DropdownMenuItem(value: v, child: Text(v == 100 ? trf('{0}% (기본)', [v]) : '$v%')),
if (s.explorerTextScale % 10 != 0)
DropdownMenuItem(value: s.explorerTextScale, child: Text('${s.explorerTextScale}%')),
],
onChanged: (v) => c.updateSettings((x) => x.explorerTextScale = v!),
),
),
SettingTile(
title: Text(tr('줄 간격')),
subtitle: Text(tr('파일 탐색기 목록의 줄 높이. 좁게는 손가락으로 누르기 어려울 수 있습니다')),
trailing: DropdownButton<String>(
value: s.explorerRowSpacing,
items: [
DropdownMenuItem(value: 'compact', child: Text(tr('좁게'))),
DropdownMenuItem(value: 'normal', child: Text(tr('보통 (기본)'))),
DropdownMenuItem(value: 'wide', child: Text(tr('넓게'))),
],
onChanged: (v) => c.updateSettings((x) => x.explorerRowSpacing = v!),
),
),
// X-plore 숨기기: 목록에서 숨긴 것 보기 · 풀기 (관리자 결정 10/18)
ListTile(
title: Text(trf('목록에서 숨긴 항목 ({0}개)', [s.explorerHiddenItems.length])),
@ -904,6 +932,13 @@ class _SettingsPageState extends State<SettingsPage> {
enabled: s.explorerHiddenItems.isNotEmpty,
onTap: () => showHiddenItems(context, c),
),
// X-plore 맞추기 (관리자 지시 10/20): 숨기기 전 확인 창
SwitchListTile(
title: Text(tr('목록에서 숨기기 전에 묻기')),
subtitle: Text(tr('무엇이 바뀌는지 (목록에서만 빠지고 파일은 그대로) 확인 창을 띄웁니다')),
value: s.explorerHideConfirm,
onChanged: (v) => c.updateSettings((x) => x.explorerHideConfirm = v),
),
// X-plore 비교 a: 한 창일 때 복사 · 이동이 갈 곳
SettingTile(
title: Text(tr('한 창일 때 복사 · 이동')),

View File

@ -24,6 +24,8 @@ import 'package:jj_mkvmaker/ui/explorer_grid.dart';
import 'package:jj_mkvmaker/ui/text_viewer.dart';
import 'package:jj_mkvmaker/ui/storage_page.dart';
import 'package:jj_mkvmaker/ui/path_label.dart';
import 'package:image/image.dart' as img;
import 'package:jj_mkvmaker/ui/reader_page.dart' show ReaderPage;
import 'package:path/path.dart' as p;
import 'support/fake_recycle_bin.dart';
import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart' show recycleBinForTest;
@ -32,6 +34,9 @@ import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether;
/// 다른 앱으로 열기를 기록만 하는 셸 (실제로 프로그램을 띄우지 않게)
class _Shell extends NoopShell {
final opened = <(String, bool)>[];
final scanned = <List<String>>[];
@override
Future<void> mediaScan(List<String> paths) async => scanned.add(paths);
@override
Future<bool> openWith(String path, {bool choose = false}) async {
opened.add((path, choose));
@ -167,6 +172,31 @@ void main() {
await tester.pump(const Duration(seconds: 2));
});
testWidgets('X-plore 맞추기 (관리자 지시 10/20): 폴더 [사진 슬라이드쇼] - 그 폴더 그림을 [계속 보기] 로 열기 · 그림이 없으면 알림', (tester) async {
for (final n in ['b.png', 'a.png']) {
File(p.join(left, 'sub', n)).writeAsBytesSync(img.encodePng(img.Image(width: 20, height: 30)));
}
Directory(p.join(left, 'empty')).createSync();
await open(tester);
await tester.longPress(find.text('sub'));
await calm(tester);
await act(tester, () => tester.tap(find.text('사진 슬라이드쇼')));
await settle(tester, () => find.byType(ReaderPage).evaluate().isNotEmpty);
expect(find.text('1 / 2'), findsOneWidget);
expect(find.text('a.png'), findsOneWidget, reason: '지금 정렬 순서 (이름) 의 첫 그림부터');
expect(find.byTooltip('계속 보기 멈추기'), findsOneWidget, reason: '[계속 보기] 를 켠 채 시작');
await act(tester, () async => tester.state<NavigatorState>(find.byType(Navigator).first).pop());
await calm(tester);
// 그림이 없는 폴더
await tester.longPress(find.text('empty'));
await calm(tester);
await act(tester, () => tester.tap(find.text('사진 슬라이드쇼')));
await settle(tester, () => find.text('이 폴더에는 그림이 없습니다').evaluate().isNotEmpty);
expect(find.byType(ReaderPage), findsNothing);
ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars();
await calm(tester);
});
testWidgets('X-plore P3: 길게 누르기 [해시] (MD5 · SHA-256) · 폴더 [폴더 속성] · 빠른 보기 [16진]', (tester) async {
await open(tester);
// 해시: doc.txt = "hello"
@ -1661,6 +1691,10 @@ void main() {
await tester.longPress(find.text('doc.txt'));
await calm(tester);
await act(tester, () => tester.tap(find.text('목록에서 숨기기')));
await calm(tester);
// X-plore 맞추기 (관리자 지시 10/20): 무엇이 바뀌는지 확인 창
expect(find.textContaining('파일 탐색기 목록에서만 빠집니다'), findsOneWidget);
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '숨기기')));
await settle(tester, () => find.text('doc.txt').evaluate().isEmpty);
expect(find.text('doc.txt'), findsNothing);
expect(File(doc).existsSync(), isTrue, reason: '파일은 바꾸지 않는다');
@ -1683,6 +1717,86 @@ void main() {
expect(find.ancestor(of: find.text('doc.txt'), matching: find.byType(Opacity)), findsNothing);
});
testWidgets('X-plore 숨기기 확인 창: [취소] 면 그대로 · [다음부터 묻지 않기] 를 고르면 다음엔 묻지 않고 바로 숨김 (설정 저장)', (tester) async {
c.settings.explorerLayout = 'split';
expect(AppSettings().explorerHideConfirm, isTrue, reason: '처음은 묻기');
File(p.join(left, 'two.txt')).writeAsStringSync('2');
await open(tester);
await tester.longPress(find.text('doc.txt'));
await calm(tester);
await act(tester, () => tester.tap(find.text('목록에서 숨기기')));
await calm(tester);
await act(tester, () => tester.tap(find.text('취소')));
await calm(tester);
expect(c.settings.explorerHiddenItems, isEmpty, reason: '취소하면 숨기지 않는다');
expect(find.text('doc.txt'), findsOneWidget);
// 다음부터 묻지 않기 + 숨기기
await tester.longPress(find.text('doc.txt'));
await calm(tester);
await act(tester, () => tester.tap(find.text('목록에서 숨기기')));
await calm(tester);
await act(tester, () => tester.tap(find.text('다음부터 묻지 않기')));
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '숨기기')));
await settle(tester, () => find.text('doc.txt').evaluate().isEmpty);
expect(c.settings.explorerHideConfirm, isFalse);
expect(AppSettings.fromJson(c.settings.toJson()).explorerHideConfirm, isFalse);
expect(AppSettings.fromJson({}).explorerHideConfirm, isTrue, reason: '예전 설정 (값 없음) 은 묻기');
// 이제는 창 없이 바로
await tester.longPress(find.text('two.txt'));
await calm(tester);
await act(tester, () => tester.tap(find.text('목록에서 숨기기')));
await settle(tester, () => find.text('two.txt').evaluate().isEmpty);
expect(find.textContaining('파일 탐색기 목록에서만 빠집니다'), findsNothing);
expect(c.settings.explorerHiddenItems, [p.join(left, 'doc.txt'), p.join(left, 'two.txt')]);
});
testWidgets('X-plore 맞추기: 폴더 길게 누르기 [갤러리 · 음악 앱에서 숨기기 (.nomedia)] - 확인 창 뒤 빈 .nomedia, 다시 누르면 지우기 · 안의 파일은 그대로', (tester) async {
ExplorerPage.debugNomedia = true;
addTearDown(() => ExplorerPage.debugNomedia = null);
final mark = File(p.join(left, 'sub', '.nomedia'));
await open(tester);
expect(find.text('갤러리 · 음악 앱에서 숨기기 (.nomedia)'), findsNothing);
await tester.longPress(find.text('sub'));
await calm(tester);
await act(tester, () => tester.tap(find.text('갤러리 · 음악 앱에서 숨기기 (.nomedia)')));
await calm(tester);
expect(find.textContaining('빈 .nomedia 파일을 만듭니다'), findsOneWidget);
// 취소하면 아무것도 안 바뀜
await act(tester, () => tester.tap(find.text('취소')));
await calm(tester);
expect(mark.existsSync(), isFalse);
await tester.longPress(find.text('sub'));
await calm(tester);
await act(tester, () => tester.tap(find.text('갤러리 · 음악 앱에서 숨기기 (.nomedia)')));
await calm(tester);
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '숨기기')));
await settle(tester, () => mark.existsSync() && shell.scanned.isNotEmpty);
expect(mark.lengthSync(), 0);
expect(shell.scanned, [
[mark.path, p.join(left, 'sub')]
], reason: '갤러리가 다시 읽게 (MediaScanner)');
expect(File(p.join(left, 'sub', 'inner.txt')).readAsStringSync(), 'inner', reason: '안의 파일은 그대로');
// 이제 메뉴는 [다시 보이기]
await idle(tester);
await tester.longPress(find.text('sub'));
await calm(tester);
expect(find.text('갤러리 · 음악 앱에서 숨기기 (.nomedia)'), findsNothing);
await act(tester, () => tester.tap(find.text('갤러리 · 음악 앱에 다시 보이기 (.nomedia 지우기)')));
await calm(tester);
expect(find.textContaining('.nomedia 파일을 지웁니다'), findsOneWidget);
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '다시 보이기')));
await settle(tester, () => !mark.existsSync());
await settle(tester, () => shell.scanned.length == 2);
expect(shell.scanned.last, [mark.path, p.join(left, 'sub')], reason: '지울 때도 다시 읽게');
expect(File(p.join(left, 'sub', 'inner.txt')).existsSync(), isTrue);
// 파일에는 없음
await idle(tester);
await tester.longPress(find.text('doc.txt'));
await calm(tester);
expect(find.text('갤러리 · 음악 앱에서 숨기기 (.nomedia)'), findsNothing);
expect(find.text('갤러리 · 음악 앱에 다시 보이기 (.nomedia 지우기)'), findsNothing);
});
testWidgets('X-plore 숨기기: 숨긴 것이 든 폴더를 지울 때 확인 창에 알림 (숨긴 것도 함께 지워진다)', (tester) async {
c.settings.explorerHiddenItems = [p.join(left, 'sub', 'inner.txt')];
await open(tester);

View File

@ -0,0 +1,214 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/settings.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/explorer_look.dart';
import 'package:jj_mkvmaker/ui/explorer_page.dart';
import 'package:path/path.dart' as p;
/// X-plore 비교 40 (관리자 지시 10/20): 파일 탐색기 목록만의 글자 크기 (80~150%) · 줄 간격 (좁게 · 보통 · 넓게)
void main() {
late Directory tmp;
setUp(() => tmp = Directory.systemTemp.createTempSync('jj_textscale_'));
tearDown(() => tmp.deleteSync(recursive: true));
test('설정: 처음은 100% · 보통 (지금 그대로), 저장 · 읽기, 틀린 값은 그대로 쪽으로 · 범위 밖은 끝으로', () {
final s = AppSettings();
expect((s.explorerTextScale, s.explorerRowSpacing), (100, 'normal'));
s
..explorerTextScale = 130
..explorerRowSpacing = 'wide';
final back = AppSettings.fromJson(s.toJson());
expect((back.explorerTextScale, back.explorerRowSpacing), (130, 'wide'));
expect(AppSettings.fromJson({}).explorerTextScale, 100);
expect(AppSettings.fromJson({'explorerTextScale': 'x', 'explorerRowSpacing': 'huge'}).explorerRowSpacing, 'normal');
expect(AppSettings.fromJson({'explorerTextScale': 300}).explorerTextScale, 150);
expect(AppSettings.fromJson({'explorerTextScale': 10}).explorerTextScale, 80);
});
/// [name] 글자의 실제 글자 크기 (기기 · 목록 글자 크기를 곱한 것)
double shownSize(WidgetTester tester, String name) {
final para = tester.renderObject<RenderParagraph>(find.text(name).last);
final size = para.text.style?.fontSize ?? 14;
return para.textScaler.scale(size);
}
/// 이름 줄 높이
double rowHeight(WidgetTester tester, String name) =>
tester.getSize(find.ancestor(of: find.text(name).last, matching: find.byType(KeyedSubtree)).first).height;
Future<AppController> open(WidgetTester tester, String style, String wait,
{double device = 1, int scale = 100, String spacing = 'normal'}) async {
const pp = MethodChannel('plugins.flutter.io/path_provider');
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(pp, (_) async => tmp.path);
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(pp, null));
File(p.join(tmp.path, '아주 긴 이름의 동영상 파일 - 글자 크기 시험용 2026 최종본.mp4')).writeAsStringSync('v' * 2000);
File(p.join(tmp.path, 'a.txt')).writeAsStringSync('a');
Directory(p.join(tmp.path, 'sub')).createSync();
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerPaths = [tmp.path, tmp.path]
// 폴더 + 파일 목록: 오른쪽 목록은 그 폴더의 것만이라 짧다 (트리는 Temp 의 다른 폴더가 많아 아래로 밀린다)
..explorerLayout = 'split'
..explorerStyle = style
..explorerTextScale = scale
..explorerRowSpacing = spacing;
await tester.runAsync(() => tester.pumpWidget(MaterialApp(
builder: (ctx, child) => MediaQuery(data: MediaQuery.of(ctx).copyWith(textScaler: TextScaler.linear(device)), child: child!),
home: ExplorerPage(c: c))));
// 목록을 다 읽을 때까지 (다른 시험과 함께 돌면 느리다 - 10초까지)
for (var i = 0; i < 200 && (i < 15 || find.text(wait).evaluate().isEmpty); i++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 50)));
await tester.pump();
}
return c;
}
Future<void> close(WidgetTester tester) async {
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(seconds: 1));
}
// 관리자 지시 10/20: 좁은 화면의 "폴더 + 파일 목록" - 트리 창 최소 폭 (120px 또는 38%), 트리에는 ✔ 칸 · 크기 줄 없이 이름이 보임
for (final width in [412.0, 360.0]) {
for (final style in ['xplore', 'windows', 'totalcmd']) {
testWidgets('좁은 폴더 + 파일 목록 ($width 폭, $style): 트리 창이 넓어 폴더 이름이 보이고 ✔ 칸은 오른쪽 목록에만', (tester) async {
tester.view.physicalSize = Size(width, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await open(tester, style, 'sub');
expect(tester.takeException(), isNull);
// 트리 (먼저 그려짐) 의 'sub' 이름이 다 보인다
final tree = find.text(style == 'totalcmd' ? '[sub]' : 'sub').first;
final para = tester.renderObject<RenderParagraph>(tree);
// 시험 글꼴 (Ahem) 은 글자마다 글자 크기만큼 넓어 실제보다 훨씬 길다 - 이름 칸 폭으로 본다 (실제 글꼴은 캡처 시험)
expect(para.constraints.maxWidth, greaterThanOrEqualTo(20), reason: '트리의 폴더 이름 칸이 남는다 (예전 412 폭은 0, 실제 글꼴로 360 폭에서 한 줄 5글자쯤)');
final treeRight = tester.getTopRight(tree).dx;
final listLeft = tester.getTopLeft(find.text(style == 'totalcmd' ? 'a' : 'a.txt')).dx;
expect(listLeft, greaterThan(treeRight), reason: '트리는 왼쪽, 목록은 오른쪽');
// 트리 줄에는 ✔ 단추가 없다 (오른쪽 목록 줄에만) - X-plore 는 늘 ✔
if (style == 'xplore') {
final marks = find.byTooltip('표시 (여러 개 고르기)');
expect(marks, findsWidgets);
for (final m in marks.evaluate()) {
expect(tester.getTopLeft(find.byWidget(m.widget)).dx, greaterThan(treeRight), reason: '✔ 는 오른쪽 목록에만');
}
}
await close(tester);
});
}
}
// 관리자 지시 10/20 (28 검토): 글자가 아주 크면 (기기 1.3 × 150%) 좁은 화면에 두 창이 다 들어가지 않는다 - 그동안만 한 창 (설정은 그대로)
for (final style in ['xplore', 'windows', 'totalcmd']) {
testWidgets('글자가 아주 크면 (기기 1.3 × 150% · 넓게, 412 폭, $style) 폴더 + 파일 목록 대신 한 창 - 설정은 split 그대로 · 넘침 없음', (tester) async {
tester.view.physicalSize = const Size(412, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final sub = style == 'totalcmd' ? '[sub]' : 'sub';
final a = style == 'totalcmd' ? 'a' : 'a.txt';
final c = await open(tester, style, sub, device: 1.3, scale: 150, spacing: 'wide');
await tester.pump();
expect(tester.takeException(), isNull);
expect(c.settings.explorerLayout, 'split', reason: '고른 배치는 바꾸지 않는다');
expect(find.text(sub), findsOneWidget, reason: '트리 · 목록 두 창이 아니라 한 창');
expect(find.text(a), findsOneWidget, reason: '한 창 트리에 파일도 보인다');
final para = tester.renderObject<RenderParagraph>(find.text(sub));
// X-plore 는 이름이 줄 폭을 다 쓰고, Windows · TC 는 열 (날짜 · 유형 · 크기) 이 있어 이름 열이 6/13 쯤
expect(para.constraints.maxWidth, greaterThan(style == 'xplore' ? 100 : 55), reason: '이름이 보인다');
await close(tester);
// 글자가 보통이면 같은 폭에서 두 창
await open(tester, style, sub);
await tester.pump();
expect(find.text(sub), findsNWidgets(2), reason: '보통 글자면 폴더 + 파일 목록');
await close(tester);
});
}
// 관리자 지시 10/20: 좁은 창의 열 - 이름 열이 120 보다 좁으면 유형 (TC 확장자) → 날짜 순으로 숨김, 크기는 남김
test('숨길 열 정하기: 넓으면 0 · 좁아질수록 유형 → 날짜 · 열 없는 X-plore 는 0', () {
expect(columnHideLevel(ExplorerStyle.windows, 1280), 0);
expect(columnHideLevel(ExplorerStyle.windows, 300), 0, reason: '이름 열 (300-16)*6/13 = 131');
expect(columnHideLevel(ExplorerStyle.windows, 300, marks: true), 1, reason: '✔ 칸 (36) 이 있으면 114');
expect(columnHideLevel(ExplorerStyle.windows, 260), 1);
expect(columnHideLevel(ExplorerStyle.windows, 180), 2);
expect(columnHideLevel(ExplorerStyle.totalcmd, 1280), 0);
expect(columnHideLevel(ExplorerStyle.totalcmd, 180), 2);
expect(columnHideLevel(ExplorerStyle.xplore, 100), 0);
});
for (final style in ['windows', 'totalcmd']) {
testWidgets('좁은 한 창 ($style, 412 · 기기 글자 1.3): 이름 열이 좁으면 유형 → 날짜 열을 숨기고 크기는 남김 · 넓으면 다 보임', (tester) async {
tester.view.physicalSize = const Size(412, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final type = style == 'windows' ? '유형' : '확장자';
final date = style == 'windows' ? '수정한 날짜' : '날짜';
final sub = style == 'totalcmd' ? '[sub]' : 'sub';
final c = await open(tester, style, sub, device: 1.3, scale: 150);
await tester.pump();
expect(c.settings.explorerLayout, 'split');
expect(tester.takeException(), isNull);
expect(find.text(type), findsNothing, reason: '좁아서 유형 열 숨김');
expect(find.text('크기'), findsOneWidget, reason: '크기는 남김');
await close(tester);
tester.view.physicalSize = const Size(1600, 900);
await open(tester, style, sub);
await tester.pump();
expect(find.text(type), findsOneWidget);
expect(find.text(date), findsOneWidget);
expect(find.text('크기'), findsOneWidget);
await close(tester);
});
}
testWidgets('넓은 화면 (1280) 의 폴더 + 파일 목록은 그대로 (2 : 3, 트리에도 ✔)', (tester) async {
tester.view.physicalSize = const Size(1280, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await open(tester, 'xplore', 'sub');
final treeMarks = find.byTooltip('표시 (여러 개 고르기)').evaluate().where((m) => tester.getTopLeft(find.byWidget(m.widget)).dx < 500);
expect(treeMarks, isNotEmpty, reason: '넓은 화면은 지금 그대로');
await close(tester);
});
for (final style in ['xplore', 'windows', 'totalcmd']) {
final a = style == 'totalcmd' ? 'a' : 'a.txt';
testWidgets('40: $style - 처음 값 (100% · 보통) 은 지금 그대로 · 150% · 넓게는 글자 1.5배 · 줄 높이 늘어남 · 412 폭에서 넘치지 않음', (tester) async {
tester.view.physicalSize = const Size(412, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await open(tester, style, a);
expect(tester.takeException(), isNull);
final base = shownSize(tester, a), baseH = rowHeight(tester, a);
expect(baseH, {'xplore': 52.0, 'windows': 34.0, 'totalcmd': 26.0}[style], reason: '처음 값은 예전 줄 높이 그대로');
await close(tester);
await open(tester, style, a, scale: 150, spacing: 'wide');
expect(tester.takeException(), isNull, reason: '412 폭 · 150% · 넓게에서 넘치지 않는다');
expect(shownSize(tester, a), closeTo(base * 1.5, 0.01));
expect(rowHeight(tester, a), (baseH * 1.5 * 1.25).roundToDouble());
await close(tester);
// 기기 글자 크기 1.3 과 곱해진다 · 좁게
await open(tester, style, a, device: 1.3, scale: 120, spacing: 'compact');
expect(tester.takeException(), isNull);
expect(shownSize(tester, a), closeTo(base * 1.3 * 1.2, 0.01));
expect(rowHeight(tester, a), (baseH * 1.3 * 1.2 * 0.8).roundToDouble());
await close(tester);
// 80% 는 글자만 작게 (줄은 예전 높이 - 누르기 크기를 줄이지 않음)
await open(tester, style, a, scale: 80);
expect(shownSize(tester, a), closeTo(base * 0.8, 0.01));
expect(rowHeight(tester, a), baseH);
await close(tester);
});
}
}

30
test/hwp_abi_test.dart Normal file
View File

@ -0,0 +1,30 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/platform/android/hwp_pdf_ffi.dart';
/// 관리자 지시 (10/20, 28 지적): 옛 libjj_rhwp.so (ABI 판 1 - jj_rhwp_cancel · jj_rhwp_abi_version 없음, 오류 code 칸 없음) 이면
/// 앱 안 보기를 쓰지 않고 다른 앱으로 연다 (새 인수를 넘기면 힙이 망가진다)
void main() {
const v1 = {'jj_rhwp_open', 'jj_rhwp_page_count', 'jj_rhwp_export_pdf', 'jj_rhwp_close', 'jj_rhwp_free', 'jj_rhwp_version'};
final v2 = {...v1, 'jj_rhwp_cancel', 'jj_rhwp_abi_version'};
final v3 = {...v2, 'jj_rhwp_cancel_all'};
test('옛 .so (기호 없음) · 판이 다름 · 부르다 실패 → 쓰지 않음, 맞으면 씀', () {
expect(RhwpEngine.abiCompatible(has: v1.contains, abi: () => throw ArgumentError('no symbol')), isFalse, reason: '판 1');
expect(RhwpEngine.abiCompatible(has: {...v1, 'jj_rhwp_abi_version'}.contains, abi: () => 2), isFalse, reason: 'cancel 없음');
expect(RhwpEngine.abiCompatible(has: v2.contains, abi: () => 2), isFalse, reason: '판 2 (cancel_all 없음 - 문서 하나의 멈춤 깃발)');
expect(RhwpEngine.abiCompatible(has: v3.contains, abi: () => 2), isFalse, reason: '판이 다름');
expect(RhwpEngine.abiCompatible(has: v3.contains, abi: () => 4), isFalse, reason: '더 새 판도');
expect(RhwpEngine.abiCompatible(has: (_) => throw StateError('x'), abi: () => 2), isFalse);
expect(RhwpEngine.abiCompatible(has: v3.contains, abi: () => RhwpEngine.abiVersion), isTrue);
});
test('Dart 의 판 = tool/rhwp_ffi 의 JJ_RHWP_ABI_VERSION · 꼭 있어야 하는 함수는 lib.rs 에 모두', () {
final rs = File('tool/rhwp_ffi/src/lib.rs').readAsStringSync();
expect(rs, contains('pub const JJ_RHWP_ABI_VERSION: c_int = ${RhwpEngine.abiVersion};'));
for (final s in RhwpEngine.requiredSymbols) {
expect(rs, contains('pub extern "C" fn $s('), reason: s);
}
});
}

View File

@ -0,0 +1,317 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/core/hwp_source.dart';
import 'package:jj_mkvmaker/core/reader_sources.dart';
import 'package:path/path.dart' as p;
/// 쪽 범위를 글로 적은 파일을 "PDF" 로 만드는 가짜 엔진 (rhwp 대신) - 부른 범위를 적어 둔다.
/// 문서는 앱과 같은 [QueuedHwpDoc] (줄 세우기 · 시간 제한 · 멈춤) 위의 가짜 C 함수 ([_Native])
class _Engine implements HwpPdfEngine {
final int pages;
final calls = <(int, int)>[];
/// 있으면 바꾸기가 이것을 기다린다 (멈춤 · 닫기를 받으면 바로 끝남 - Rust 가 쪽마다 멈춤 표시를 보는 것처럼)
Completer<void>? hold;
var closed = false;
final cancelled = <int>{};
var cancelledAll = false;
_Engine(this.pages);
@override
Future<HwpPdfDoc> open(String path, {String? password}) async => QueuedHwpDoc(_Native(this));
}
class _Native implements HwpNative {
final _Engine e;
_Native(this.e);
@override
int get pageCount => e.pages;
@override
Future<(int, String?)> export(int req, int from, int to, String fontDirs, String out) async {
e.calls.add((from, to));
final h = e.hold;
if (h != null) await h.future;
if (e.cancelledAll || e.cancelled.contains(req)) return (1, 'cancelled');
File(out).writeAsStringSync('$from-$to');
return (0, null);
}
void _wake() {
final h = e.hold;
if (h != null && !h.isCompleted) h.complete();
}
@override
void cancel(int req) {
e.cancelled.add(req);
_wake();
}
@override
void cancelAll() {
e.cancelledAll = true;
_wake();
}
@override
Future<void> close() async => e.closed = true;
}
/// 파일을 읽어 쪽 수를 아는 가짜 묶음 (PDFium 없이) - 연 것 · 닫은 것을 적어 둔다
class _Opener {
final opened = <String>[];
final disposed = <String>[];
Completer<void>? renderHold;
Future<HwpChunk> call(String path) async {
opened.add(p.basename(path));
final parts = File(path).readAsStringSync().split('-').map(int.parse).toList();
return _Chunk(this, p.basename(path), parts[1] - parts[0]);
}
}
class _Chunk implements HwpChunk {
final _Opener o;
final String name;
@override
final int pageCount;
_Chunk(this.o, this.name, this.pageCount);
@override
Future<ReaderImage> render(int index, {required int maxWidth}) async {
expect(index, lessThan(pageCount));
final h = o.renderHold;
if (h != null) await h.future;
return const ReaderImage();
}
@override
Future<void> dispose() async => o.disposed.add(name);
}
/// 관리자 지시 (10/20, HWP 2단계): 보기 원본 HwpSource 의 흐름 - 묶음 나누기 · 미리 만들기 · 열어 두는 묶음 상한 (메모리) ·
/// 그리는 묶음은 닫지 않음 · 닫은 묶음은 다시 바꾸지 않고 파일만 · 시간 제한 → 이유 · 다시 시도 · 닫기
void main() {
late Directory dir;
late File doc;
setUp(() {
dir = Directory.systemTemp.createTempSync('jj_hwpunit_');
doc = File(p.join(dir.path, 'a.hwp'))..writeAsStringSync('x');
});
tearDown(() {
if (dir.existsSync()) dir.deleteSync(recursive: true);
});
Future<void> settle() => Future<void>.delayed(const Duration(milliseconds: 50));
test('보는 쪽의 묶음 + 다음 묶음만 · 열어 둔 묶음은 3개까지 (오래된 것부터 닫음) · 다시 보면 파일만 연다', () async {
final e = _Engine(40), o = _Opener();
final s = await HwpSource.open(doc.path, engine: e, tempDir: dir.path, chunk: 5, maxOpenChunks: 3, opener: o.call);
expect(s.length, 40);
await s.load(0, maxWidth: 100);
await settle();
expect(e.calls, [(0, 5), (5, 10)]);
expect(s.openChunks, [0, 1]);
await s.load(12, maxWidth: 100); // 묶음 2 + 미리 3 → 4개 → 0 을 닫음
await settle();
expect(e.calls, [(0, 5), (5, 10), (10, 15), (15, 20)]);
expect(s.openChunks, [1, 2, 3]);
expect(o.disposed, ['p0.pdf']);
// 닫은 묶음 0 을 다시: 바꾸지 않고 (calls 그대로) 파일만 연다
await s.load(1, maxWidth: 100);
await settle();
expect(e.calls.where((c) => c == (0, 5)), hasLength(1));
expect(o.opened.where((n) => n == 'p0.pdf'), hasLength(2));
expect(s.openChunks.length, lessThanOrEqualTo(3));
expect(s.madeChunks, containsAll([0, 1, 2, 3]));
// 끝 묶음은 남은 쪽만
await s.load(39, maxWidth: 100);
expect(e.calls.last, (35, 40));
await s.dispose();
expect(e.closed, isTrue);
expect(s.work.existsSync(), isFalse, reason: '닫으면 만든 PDF 들을 지움');
});
test('그리고 있는 묶음은 상한을 넘어도 닫지 않고, 다 그린 뒤에 닫는다', () async {
final e = _Engine(40), o = _Opener();
final s = await HwpSource.open(doc.path, engine: e, tempDir: dir.path, chunk: 5, maxOpenChunks: 2, opener: o.call);
final hold = Completer<void>();
o.renderHold = hold;
final drawing = s.load(2, maxWidth: 100); // 묶음 0 을 그리는 중
await settle();
o.renderHold = null;
await s.load(20, maxWidth: 100); // 묶음 4 (+ 5)
await s.load(30, maxWidth: 100); // 묶음 6 (+ 7)
await settle();
expect(o.disposed, isNot(contains('p0.pdf')), reason: '그리는 중');
expect(s.openChunks, contains(0));
hold.complete();
await drawing;
await s.load(35, maxWidth: 100);
await settle();
expect(o.disposed, contains('p0.pdf'), reason: '다 그린 뒤에는 상한에 맞춰 닫힘');
expect(s.openChunks.length, lessThanOrEqualTo(2));
await s.dispose();
});
test('시간 제한을 넘기면 쪽 범위 · 초가 든 이유, 다시 부르면 다시 만든다', () async {
final e = _Engine(8)..hold = Completer<void>();
final o = _Opener();
final s = await HwpSource.open(doc.path, engine: e, tempDir: dir.path, chunk: 5, timeout: const Duration(milliseconds: 200),
opener: o.call);
Object? err;
try {
await s.load(0, maxWidth: 100);
} catch (x) {
err = x;
}
expect(err, isA<HwpPdfException>());
expect((err! as HwpPdfException).kind, HwpErrorKind.timeout);
expect('$err', allOf(contains('1~5'), contains('0초')));
expect(e.cancelled, {1}, reason: '시간 제한을 넘기면 그 요청의 변환도 멈춘다 (기다림만 끊지 않음)');
expect(s.openChunks, isNot(contains(0)), reason: '실패한 묶음은 기억하지 않음 - [다시 시도] 가 다시 만든다');
e.hold = null; // 멈춤이 기다림을 깨웠다
await s.load(0, maxWidth: 100);
expect(e.calls.where((c) => c == (0, 5)).length, greaterThanOrEqualTo(2));
await s.dispose();
});
test('닫으면 만드는 중인 묶음은 끝난 뒤 스스로 닫히고 더 만들지 않는다', () async {
final e = _Engine(10)..hold = Completer<void>();
final o = _Opener();
final s = await HwpSource.open(doc.path, engine: e, tempDir: dir.path, chunk: 5, opener: o.call);
final pending = s.load(0, maxWidth: 100).then<Object?>((_) => null, onError: (Object x) => x);
await settle();
await s.dispose(); // 도는 바꾸기는 멈춤으로 곧 끝나고, 닫힌다
expect(e.cancelledAll, isTrue, reason: '닫으면 바꾸던 것을 멈춘다');
expect(e.closed, isTrue);
final r = await pending;
expect(r, isA<StateError>(), reason: '닫힌 문서');
expect(o.opened, isEmpty, reason: '닫은 뒤에는 PDF 를 열지 않음');
});
test('앱이 켜질 때 남은 HWP 작업 폴더 (jj_hwp_) 만 지운다 - 막 생긴 것 · 다른 이름은 남김', () {
final old = Directory(p.join(dir.path, 'jj_hwp_1'))..createSync();
File(p.join(old.path, 'p0.pdf')).writeAsStringSync('x');
final fresh = Directory(p.join(dir.path, 'jj_hwp_2'))..createSync();
final other = Directory(p.join(dir.path, 'jj_hwpx_keep'))..createSync();
final file = File(p.join(dir.path, 'jj_hwp_file'))..writeAsStringSync('x');
// 5분 뒤로 보면 old · fresh 둘 다 1분이 지났다 - fresh 는 지금 기준으로는 남는다
expect(removeStaleHwpWork(dir.path), isEmpty, reason: '모두 막 생김');
final gone = removeStaleHwpWork(dir.path, now: DateTime.now().add(const Duration(minutes: 5)));
expect(gone..sort(), ['jj_hwp_1', 'jj_hwp_2']);
expect(old.existsSync() || fresh.existsSync(), isFalse);
expect(other.existsSync(), isTrue, reason: '이름이 jj_hwp_ 로 시작하지 않음 (jj_hwpx_…)');
expect(file.existsSync(), isTrue, reason: '폴더만');
});
test('변환기 오류 코드 → 종류 · 화면 글 (tool/rhwp_ffi 의 JJ_RHWP_E_* 순서), 원문은 detail 에만', () {
expect([for (var c = 1; c <= 9; c++) hwpErrorKindOf(c)], [
HwpErrorKind.cancelled, HwpErrorKind.read, HwpErrorKind.invalid, HwpErrorKind.passwordRequired, HwpErrorKind.passwordWrong,
HwpErrorKind.range, HwpErrorKind.render, HwpErrorKind.write, HwpErrorKind.internal, //
]);
expect(hwpErrorKindOf(0), HwpErrorKind.internal);
expect(hwpErrorKindOf(42), HwpErrorKind.internal);
const e = HwpPdfException(HwpErrorKind.passwordRequired, '비밀번호가 필요한 암호 문서입니다 (parse_document_with_password …)');
expect('$e', '암호가 걸린 문서입니다', reason: '화면에는 원문 대신 번역 글');
expect(e.detail, contains('parse_document_with_password'));
// 원본 lib.rs 의 코드 값과 같은지 (순서가 바뀌면 화면 글이 틀린다)
final rs = File('tool/rhwp_ffi/src/lib.rs').readAsStringSync();
const names = ['CANCELLED', 'READ', 'INVALID', 'PASSWORD_REQUIRED', 'PASSWORD_WRONG', 'RANGE', 'RENDER', 'WRITE', 'INTERNAL'];
for (final (i, n) in names.indexed) {
expect(rs, contains('pub const JJ_RHWP_E_$n: c_int = ${i + 1};'), reason: n);
}
});
group('QueuedHwpDoc (28 지적: 시간 제한 · 멈춤은 요청마다)', () {
test('앞 묶음이 오래 돌아도, 줄에서 기다린 뒤 묶음의 시간 제한은 제 시작부터 잰다', () async {
final n = _Gated();
final d = QueuedHwpDoc(n);
final a = d.exportPdf(0, 5, 'a', timeout: const Duration(seconds: 30)); // 오래 도는 앞 묶음
// 흔들림 (10/21 전체 실행): 제한이 300ms 면 무거울 때 [release] 까지의 틈이 그것을 넘었다 - 제한 1.5초 · 줄에서 2초 기다림
final b = d.exportPdf(5, 10, 'b', timeout: const Duration(milliseconds: 1500));
await Future<void>.delayed(const Duration(milliseconds: 2000)); // 뒤 묶음의 제한보다 오래 줄에서 기다림
expect(n.started, [1]);
n.release(1);
await a;
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(n.started, [1, 2]);
n.release(2);
await b; // 시간 제한 없이 끝남
expect(n.cancelled, isEmpty);
});
test('한 요청이 시간을 넘기면 그 요청만 멈추고, 멈춘 것이 끝난 뒤 다음 것이 돈다', () async {
final n = _Gated();
final d = QueuedHwpDoc(n);
Object? err;
final a = d.exportPdf(0, 5, 'a', timeout: const Duration(milliseconds: 200)).catchError((Object e) => err = e);
final b = d.exportPdf(5, 10, 'b');
await a;
expect(err, isA<HwpPdfException>().having((e) => e.kind, 'kind', HwpErrorKind.timeout));
expect(n.cancelled, {1}, reason: '다른 요청 (2) 은 멈추지 않음');
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(n.started, [1, 2], reason: '멈춘 1 이 끝난 뒤에 2 가 시작');
n.release(2);
await b;
});
test('닫으면 도는 것은 멈추고 줄에 선 것은 시작하지 않는다 (닫기 요청이 지워지지 않음)', () async {
final n = _Gated();
final d = QueuedHwpDoc(n);
final a = d.exportPdf(0, 5, 'a').then<Object?>((_) => null, onError: (Object e) => e);
final b = d.exportPdf(5, 10, 'b').then<Object?>((_) => null, onError: (Object e) => e);
await Future<void>.delayed(const Duration(milliseconds: 50));
await d.close();
expect(n.all, isTrue);
expect(n.started, [1], reason: '줄에 선 2 는 시작하지 않음');
expect(n.closed, isTrue);
expect(await a, isA<HwpPdfException>().having((e) => e.kind, 'kind', HwpErrorKind.closed));
expect(await b, isA<HwpPdfException>().having((e) => e.kind, 'kind', HwpErrorKind.closed));
expect(() => d.exportPdf(0, 1, 'c'), throwsA(isA<HwpPdfException>()), reason: '닫은 뒤의 요청');
});
});
}
/// 요청마다 문이 있는 가짜 C 함수 - 멈춤 · 닫기를 받으면 문이 열리고 (cancelled) 로 끝난다
class _Gated implements HwpNative {
final started = <int>[];
final gates = <int, Completer<void>>{};
final cancelled = <int>{};
var all = false;
var closed = false;
@override
int get pageCount => 10;
Completer<void> _gate(int req) => gates.putIfAbsent(req, Completer<void>.new);
void release(int req) {
if (!_gate(req).isCompleted) _gate(req).complete();
}
@override
Future<(int, String?)> export(int req, int from, int to, String fontDirs, String out) async {
started.add(req);
await _gate(req).future;
return all || cancelled.contains(req) ? (1, 'cancelled') : (0, null);
}
@override
void cancel(int req) {
cancelled.add(req);
release(req);
}
@override
void cancelAll() {
all = true;
for (final r in [...gates.keys, ...started]) {
release(r);
}
}
@override
Future<void> close() async => closed = true;
}

View File

@ -165,4 +165,30 @@ void main() {
expect(a, contains('[패키지 라이선스]'));
expect(a, contains('GPLv3'));
});
// 관리자 지시 (10/20, 28 지적): Android HWP 보기 (libjj_rhwp.so) - 실제로 링크되는 Rust crate 마다 라이선스 · 원문
// (tool/licenses/rhwp_crates.py 를 tool/build_rhwp_android.sh 가 부름). Cargo.lock 이 바뀌었는데 다시 만들지 않으면 여기서 잡힌다
test('rhwp: Cargo.lock 의 모든 crate 가 "linked" 나 "not linked" 에 있고, 링크되는 crate 마다 원문 절', () {
final t = File('assets/licenses/RHWP_LICENSES.txt').readAsStringSync().replaceAll('\r\n', '\n');
final lock = File('tool/rhwp_ffi/Cargo.lock').readAsStringSync().replaceAll('\r\n', '\n');
final locked = {
for (final m in RegExp(r'\[\[package\]\]\s*\nname = "([^"]+)"\s*\nversion = "([^"]+)"').allMatches(lock)) '${m[1]} ${m[2]}'
};
final linked = {for (final m in RegExp(r'^linked: (\S+) (\S+) ', multiLine: true).allMatches(t)) '${m[1]} ${m[2]}'};
final notLinked = {for (final m in RegExp(r'^not linked: (\S+) (\S+)$', multiLine: true).allMatches(t)) '${m[1]} ${m[2]}'};
expect(linked, isNotEmpty);
expect(linked.intersection(notLinked), isEmpty);
expect({...linked, ...notLinked, 'jj_rhwp 0.1.0'}, locked, reason: 'Cargo.lock 이 바뀌면 sh tool/build_rhwp_android.sh 로 다시 만든다');
for (final c in linked) {
expect(t, contains('=== $c ('), reason: '$c 의 라이선스 원문 절');
}
// 28 이 짚은 것들 (Cargo.lock 에 있으면 어느 한쪽 목록에 - 링크되는지는 cargo tree 가 정함)
for (final n in ['tiny-skia', 'zlib-rs', 'curve25519-dalek', 'unicode-ident', 'rustybuzz', 'skrifa', 'miniz_oxide', 'fontdb']) {
for (final c in locked.where((c) => c.startsWith('$n '))) {
expect(linked.contains(c) || notLinked.contains(c), isTrue, reason: c);
}
}
expect(t, contains('rhwp v0.8.7'));
expect(t, contains('1a76570e833917d15817415a53c09ad61ab3203f'));
});
}

87
test/photo_info_test.dart Normal file
View File

@ -0,0 +1,87 @@
import 'dart:io';
import 'dart:typed_data';
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/core/photo_info.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;
/// 도 · 분 · 초 (image 패키지가 Rational 을 내보내지 않아 IfdValueRational 하나씩 만들어 잇는다)
img.IfdValueRational _dms(int d, int m, int s) =>
img.IfdValueRational(d, 1)..value.addAll([...img.IfdValueRational(m, 1).value, ...img.IfdValueRational(s, 1).value]);
/// Exif 가 든 JPEG (찍은 날 · 기기 · 위치)
Uint8List _jpeg({bool gps = true, bool south = false}) {
final im = img.Image(width: 30, height: 20);
im.exif.imageIfd['Make'] = 'Canon';
im.exif.imageIfd['Model'] = 'Canon EOS 5D';
im.exif.exifIfd['DateTimeOriginal'] = '2024:05:01 12:30:00';
if (gps) {
im.exif.gpsIfd[1] = img.IfdValueAscii(south ? 'S' : 'N');
im.exif.gpsIfd[2] = _dms(37, 30, 0);
im.exif.gpsIfd[3] = img.IfdValueAscii('E');
im.exif.gpsIfd[4] = _dms(127, 0, 36);
}
return Uint8List.fromList(img.encodeJpg(im));
}
/// X-plore 맞추기 (관리자 지시 10/20): 보기 화면 사진 정보 (Exif) - 찍은 날 · 기기 · 크기 · 위치 (누를 때만, 보내지 않음)
void main() {
test('Exif 읽기: 찍은 날 (2024-05-01) · 기기 (Make 가 Model 앞에 있으면 한 번만) · 크기 · 위치 (도 · 분 · 초 → 도, 남은 음수)', () {
final i = readPhotoInfo(_jpeg());
expect(i.width, 30);
expect(i.height, 20);
expect(i.taken, '2024-05-01 12:30:00');
expect(i.device, 'Canon EOS 5D');
expect(i.latitude, closeTo(37.5, 1e-6));
expect(i.longitude, closeTo(127.01, 1e-6));
expect(i.locationText, '37.500000, 127.010000');
expect(i.hasExif, isTrue);
expect(readPhotoInfo(_jpeg(south: true)).latitude, closeTo(-37.5, 1e-6));
expect(readPhotoInfo(_jpeg(gps: false)).hasLocation, isFalse);
});
test('Exif 가 없는 그림 (PNG) 은 크기만, 깨진 바이트는 아무것도 없이 (예외 없음)', () {
final png = readPhotoInfo(Uint8List.fromList(img.encodePng(img.Image(width: 7, height: 9))));
expect((png.width, png.height), (7, 9));
expect(png.hasExif, isFalse);
final bad = readPhotoInfo(Uint8List.fromList([1, 2, 3]));
expect(bad.width, isNull);
expect(bad.hasExif, isFalse);
});
testWidgets('보기 화면 [사진 정보 (Exif)]: 찍은 날 · 기기 · 크기, 위치는 [위치 보기] 를 눌러야 보임 (링크 없음)', (tester) async {
tester.view.physicalSize = const Size(1400, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final tmp = Directory.systemTemp.createTempSync('jj_photoinfo_');
addTearDown(() => tmp.deleteSync(recursive: true));
final f = File(p.join(tmp.path, 'trip.jpg'))..writeAsBytesSync(_jpeg());
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
await tester.pumpWidget(MaterialApp(home: ReaderPage(c: c, source: ImageFilesSource([f.path], tempDir: tmp.path, title: 'x'))));
await tester.pump();
await tester.tap(find.byTooltip('사진 정보 (Exif)'));
for (var k = 0; k < 10; k++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30)));
await tester.pump();
}
expect(find.text('2024-05-01 12:30:00'), findsOneWidget);
expect(find.text('Canon EOS 5D'), findsOneWidget);
expect(find.textContaining('30 × 20 픽셀'), findsOneWidget);
expect(find.textContaining('37.5'), findsNothing, reason: '위치는 누를 때만');
await tester.tap(find.text('위치 보기'));
await tester.pump();
expect(find.text('37.500000, 127.010000'), findsOneWidget);
expect(find.textContaining('http'), findsNothing, reason: '지도 · 인터넷 링크 없음');
await tester.tap(find.text('닫기'));
await tester.pumpAndSettle();
await tester.pumpWidget(const SizedBox());
});
}

View File

@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/ui/progress_dialog.dart';
/// 관리자 지시 (10/20, 28 지적): 진행 창이 ← · [취소] 로 먼저 닫혀도, 일이 끝난 뒤 close() 가 아래 화면을 닫지 않는다
void main() {
Future<BuildContext> pushPage(WidgetTester tester) async {
late BuildContext page;
await tester.pumpWidget(MaterialApp(home: Builder(builder: (ctx) {
return Scaffold(
body: TextButton(
onPressed: () => Navigator.of(ctx).push(MaterialPageRoute<void>(builder: (c) {
page = c;
return const Scaffold(body: Text('아래 화면'));
})),
child: const Text('열기'),
),
);
})));
await tester.tap(find.text('열기'));
await tester.pumpAndSettle();
return page;
}
testWidgets('← 로 닫으면 그만둔 것 - 끝난 뒤 close() 는 아래 화면을 닫지 않는다', (tester) async {
final page = await pushPage(tester);
final d = showProgressDialog(page, '여는 중: a.hwp');
await tester.pump();
expect(find.text('여는 중: a.hwp'), findsOneWidget);
expect(d.cancelled(), isFalse);
// Android ← (창을 닫음)
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('여는 중: a.hwp'), findsNothing);
expect(d.cancelled(), isTrue);
d.close(); // 일이 끝남 - 이미 닫혔으니 아무것도 닫지 않는다
await tester.pumpAndSettle();
expect(find.text('아래 화면'), findsOneWidget, reason: '예전에는 이 pop() 이 아래 화면을 닫았다');
});
testWidgets('[취소] 도 그만둔 것, 일이 끝나 close() 하면 창만 닫히고 그만둔 것이 아니다', (tester) async {
final page = await pushPage(tester);
final a = showProgressDialog(page, '바꾸는 중');
await tester.pump();
await tester.tap(find.text('취소'));
await tester.pumpAndSettle();
expect(a.cancelled(), isTrue);
expect(find.text('아래 화면'), findsOneWidget);
final b = showProgressDialog(page, '바꾸는 중');
await tester.pump();
b.close();
await tester.pumpAndSettle();
await b.done;
expect(b.cancelled(), isFalse);
expect(find.text('바꾸는 중'), findsNothing);
expect(find.text('아래 화면'), findsOneWidget);
});
}

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
@ -28,6 +29,18 @@ class _Sized extends ReaderSource {
}
}
/// 관리자 지시 10/20: WebDAV 처럼 느린 원본 - [hold] 의 장은 [release] 할 때까지 받는 중
class _Slow extends _Sized {
final int hold;
final release = Completer<void>();
_Slow(super.images, this.hold);
@override
Future<ReaderImage> load(int i, {required int maxWidth}) async {
if (i == hold) await release.future;
return super.load(i, maxWidth: maxWidth);
}
}
/// 223: 폴더의 그림들처럼 (자동 나누기는 다른 그림과 견주어 판단)
class _FolderSized extends ImageFilesSource {
final List<ui.Image> images;
@ -201,15 +214,49 @@ void main() {
expect(find.text('1 / 4'), findsOneWidget);
await t.pump(const Duration(milliseconds: 200));
expect(find.text('2 / 4'), findsOneWidget);
await t.pump(const Duration(seconds: 2));
// 관리자 지시 10/20: 시계는 그 장이 다 보인 뒤부터 - 넘긴 뒤 한 장면 (FutureBuilder 가 그림을 받아 그림) 이 지나야 잰다
await t.pump();
await t.pump(const Duration(milliseconds: 1900));
expect(find.text('2 / 4'), findsOneWidget);
await t.pump(const Duration(milliseconds: 200));
expect(find.text('3 / 4'), findsOneWidget);
await t.pump();
await t.pump(const Duration(seconds: 2));
expect(find.text('4 / 4'), findsOneWidget);
await t.pump();
await t.pump(const Duration(seconds: 2));
expect(find.byTooltip('계속 보기 (2초마다 다음 장)'), findsOneWidget, reason: '마지막 장에서 멈춤');
expect(screenOn, [true, false]);
await t.pumpAndSettle(const Duration(seconds: 3));
});
testWidgets('117 (관리자 지시 10/20): 느린 원본 - 받는 동안은 넘기지 않고, 다 보인 뒤부터 정한 초를 잰다', (t) async {
final c = controller();
final sized = await source(t, [(100, 200), (100, 200), (100, 200)]);
final src = _Slow(sized.images, 1);
await open(t, c, src);
await t.tap(find.byTooltip('계속 보기 (3초마다 다음 장)'));
await t.pump();
await t.pump(const Duration(seconds: 3));
expect(find.text('2 / 3'), findsOneWidget);
// 2 장은 받는 중 - 한참 지나도 넘기지 않는다
for (var k = 0; k < 10; k++) {
await t.pump(const Duration(seconds: 1));
}
expect(find.text('2 / 3'), findsOneWidget, reason: '받는 동안 시계가 흐르지 않음');
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// 다 받으면 그때부터 3초
src.release.complete();
await t.pump();
await t.pump();
await t.pump(const Duration(milliseconds: 2900));
expect(find.text('2 / 3'), findsOneWidget);
await t.pump(const Duration(milliseconds: 200));
expect(find.text('3 / 3'), findsOneWidget);
await t.tap(find.byTooltip('계속 보기 멈추기'));
await t.pumpAndSettle();
});
testWidgets('117: 화면을 누르면 멈추고 그 누름으로는 넘기지 않음 · 손으로 밀어도 멈춤', (t) async {
final c = controller();
final src = await source(t, [(100, 200), (100, 200), (100, 200), (100, 200)]);

View File

@ -1,8 +1,8 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:archive/archive_io.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:jj_mkvmaker/app/app_controller.dart';
@ -188,6 +188,94 @@ void main() {
expect(c.settings.readerSystemBars, isFalse);
expect(AppSettings.fromJson(c.settings.toJson()).readerSystemBars, isFalse);
});
testWidgets('X-plore 맞추기: 아래 갤러리 줄 - 처음은 끔, 켜면 썸네일 · 누르면 그 장으로, 위 막대에서 끄기', (tester) async {
tester.view.physicalSize = const Size(1400, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
expect(AppSettings().readerGallery, isFalse, reason: '처음 값은 지금 동작 그대로');
expect(AppSettings.fromJson({'readerGallery': true}).readerGallery, isTrue);
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()));
await tester.pumpWidget(MaterialApp(home: ReaderPage(c: c, source: ImageFilesSource(files, tempDir: tmp.path, title: 'imgs'))));
await tester.pump();
expect(find.byKey(const ValueKey('thumb0')), findsNothing, reason: '처음은 끔');
await tester.tap(find.byTooltip('아래 갤러리 줄 보이기'));
await tester.pumpAndSettle();
expect(c.settings.readerGallery, isTrue);
expect(find.byKey(const ValueKey('thumb2')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('thumb2')));
await tester.pumpAndSettle();
expect(find.text('3 / 3'), findsOneWidget, reason: '누른 장으로');
await tester.tap(find.byTooltip('아래 갤러리 줄 숨기기'));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('thumb0')), findsNothing);
await tester.pumpWidget(const SizedBox());
});
testWidgets('X-plore 맞추기: 볼륨 키로 넘기기 - 처음은 끔 (키를 가로채지 않음), 켜면 아래 = 다음 · 위 = 이전 (Android 만)', (tester) async {
tester.view.physicalSize = const Size(900, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final was = ReaderPage.volumeKeysSupported;
addTearDown(() => ReaderPage.volumeKeysSupported = was);
ReaderPage.volumeKeysSupported = () => true;
expect(AppSettings().readerVolumeKeys, isFalse);
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()));
await tester.pumpWidget(MaterialApp(home: ReaderPage(c: c, source: ImageFilesSource(files, tempDir: tmp.path, title: 'imgs'))));
await tester.pump();
// 끔: 볼륨 키를 가로채지 않는다 (시스템 볼륨이 바뀜)
expect(await tester.sendKeyEvent(LogicalKeyboardKey.audioVolumeDown), isFalse);
await tester.pumpAndSettle();
expect(find.text('1 / 3'), findsOneWidget);
// 켬
c.settings.readerVolumeKeys = true;
expect(await tester.sendKeyEvent(LogicalKeyboardKey.audioVolumeDown), isTrue, reason: '처리함 - 시스템 볼륨은 바뀌지 않음');
await tester.pumpAndSettle();
expect(find.text('2 / 3'), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.audioVolumeDown);
await tester.pumpAndSettle();
expect(find.text('3 / 3'), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.audioVolumeUp);
await tester.pumpAndSettle();
expect(find.text('2 / 3'), findsOneWidget);
// Android 가 아니면 쓰지 않음
ReaderPage.volumeKeysSupported = () => false;
expect(await tester.sendKeyEvent(LogicalKeyboardKey.audioVolumeDown), isFalse);
await tester.pumpWidget(const SizedBox());
});
testWidgets('그림이 깨졌으면 화면 글 + [자세히], 파일을 고친 뒤 [다시 시도] 하면 열린다 (28 지적: 같은 키 · 같은 그림이라 Image 가 다시 읽지 않았다 - 장의 키를 올리고 그림 캐시도 비움)', (tester) async {
tester.view.physicalSize = const Size(900, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final broken = File(p.join(tmp.path, 'broken.png'))..writeAsBytesSync([1, 2, 3, 4, 5]);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final src = ImageFilesSource([broken.path], tempDir: tmp.path, title: 'imgs');
Future<void> settle() async {
for (var k = 0; k < 20; k++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 50)));
await tester.pump();
}
}
await tester.pumpWidget(MaterialApp(home: ReaderPage(c: c, source: src)));
await settle();
expect(find.text('그림 파일을 읽을 수 없습니다 - 깨졌거나 지원하지 않는 형식'), findsOneWidget, reason: '원문 대신 화면 글');
expect(find.text('자세히'), findsOneWidget, reason: '원문은 [자세히] 에');
// 파일을 고치고 [다시 시도]
broken.writeAsBytesSync(_png(0, 200, 0));
await tester.tap(find.text('다시 시도'));
await settle();
expect(find.text('그림 파일을 읽을 수 없습니다 - 깨졌거나 지원하지 않는 형식'), findsNothing);
expect(find.byType(RawImage), findsWidgets, reason: '고친 그림이 보임');
await tester.pumpWidget(const SizedBox());
});
}
/// 읽은 너비를 기록 (확대하면 더 크게 다시 읽는지)
@ -209,4 +297,5 @@ class _CountingSource implements ReaderSource {
@override
Future<void> dispose() => inner.dispose();
}

View File

@ -20,6 +20,24 @@ class FakeDns {
int get port => _udp.port;
/// 그 번호로 UDP 를 연다 (못 열면 null). 전체 시험 (10/20) 에서 막힌 포트 (10013) 의 오류가 try 밖으로 (잡히지 않은 비동기 오류로)
/// 나와 시험이 실패한 적이 있어, 열기 하나를 따로 영역 (zone) 에서 돌려 어느 쪽으로 오든 "못 열었음" 으로 받는다
Future<RawDatagramSocket?> _tryUdp(int port) {
final done = Completer<RawDatagramSocket?>();
runZonedGuarded(() async {
final s = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, port);
if (done.isCompleted) {
s.close();
} else {
done.complete(s);
}
}, (e, _) {
events.add('udp bind $port failed: ${e is SocketException ? '${e.osError?.errorCode} ${e.message}' : e}');
if (!done.isCompleted) done.complete(null);
});
return done.future;
}
Future<void> start() async {
if (!truncateUdp) {
_udp = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0);
@ -28,17 +46,31 @@ class FakeDns {
// netsh int ipv4 show excludedportrange) 가 달라, TCP 를 먼저 OS 가 고른 번호로 열고 그 번호로 UDP 를 연다.
// 그 번호의 UDP 가 막혀 있거나 (10013) 쓰는 중이면 다른 번호로 다시
for (var i = 0;; i++) {
final tcp = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
try {
_udp = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, tcp.port);
_tcp = tcp;
events.add('bound tcp+udp ${tcp.port} (try $i)');
break;
} on SocketException catch (e) {
events.add('udp bind ${tcp.port} failed: ${e.osError?.errorCode} ${e.message}');
// 10/21 전체 실행: OS 가 TCP 번호를 차례로 주는데 (62345, 62346 …) 그 둘레가 통째로 UDP 막힌 범위라 50번 모두 실패했다 -
// 홀수 번째는 거꾸로 UDP 를 OS 가 고른 (열 수 있는) 번호로 먼저 열고 그 번호로 TCP 를 연다
if (i.isOdd) {
final udp = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0);
try {
_tcp = await ServerSocket.bind(InternetAddress.loopbackIPv4, udp.port);
_udp = udp;
events.add('bound udp+tcp ${udp.port} (try $i)');
break;
} on SocketException catch (e) {
events.add('tcp bind ${udp.port} failed: ${e.osError?.errorCode} ${e.message}');
udp.close();
}
} else {
final tcp = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
final udp = await _tryUdp(tcp.port);
if (udp != null) {
_udp = udp;
_tcp = tcp;
events.add('bound tcp+udp ${tcp.port} (try $i)');
break;
}
await tcp.close();
if (i > 50) rethrow;
}
if (i > 50) throw StateError('UDP · TCP 같은 포트를 열지 못함: $events');
}
}
_udp.listen((e) {

View File

@ -0,0 +1,99 @@
#!/bin/sh
# 관리자 지시 (10/20): Android (arm64) 에서 HWP · HWPX 를 PDF 로 바꿔 앱의 보기 화면으로 연다 - rhwp (Rust, MIT) 를
# tool/rhwp_ffi (얇은 C ABI) 와 함께 NDK 로 빌드해 third_party/rhwp/android/arm64-v8a/libjj_rhwp.so 로 둔다 (rsync · 7zz 와 같은 방식 -
# APK 의 jniLibs 로 들어가 앱이 dart:ffi 로 읽는다).
# 소스: https://github.com/edwardkim/rhwp v0.8.7 = 커밋 1a76570 (고정 - git 커밋 해시가 내용의 SHA-1 지문이다, 고치지 않음).
# 저장소가 크므로 (예제 문서 · PDF 로 4GB 넘음) 빌드에 쓰는 폴더만 받는다 (git sparse, 깊이 1).
# 라이선스: rhwp MIT (LICENSE → third_party/rhwp) · .so 에 실제로 링크되는 crate 마다 이름 · 판 · 라이선스 · 원문은
# tool/licenses/rhwp_crates.py 가 assets/licenses/RHWP_LICENSES.txt 로 만든다 (관리자 지시 10/20 - 저장소의 THIRD_PARTY_LICENSES.md 는
# npm 쪽이 섞여 있고 링크되는 crate 가 빠져 쓰지 않는다). Cargo.lock 이 바뀌면 test/licenses_test.dart 가 다시 만들라고 잡는다.
# 도구: Rust 1.93.1 (rhwp 의 rust-toolchain.toml 과 같음, tools/rust - RUSTUP_HOME · CARGO_HOME) + Android NDK clang (링커).
# Windows 에서는 호스트 쪽 (빌드 스크립트) 을 MSVC 로 링크하므로 tools/VSBuildTools 의 vcvars64 환경을 읽어 온다.
# 16KB 쪽 기기 (Android 15+) 에서도 읽히게 -z max-page-size=16384, 다시 만들어도 같은 파일이 되게 경로를 /jj 로 바꿔 적는다.
# 의존 crate 판은 tool/rhwp_ffi/Cargo.lock 으로 고정 (--locked).
# 사용법 (Git Bash): sh tool/build_rhwp_android.sh - Android NDK 가 필요 (ANDROID_NDK 또는 tools/android-sdk/ndk/*)
# 다시 만들어지는지 보기: JJ_RHWP_CHECK=1 sh tool/build_rhwp_android.sh (깨끗한 target 으로 두 번 빌드해 해시를 견줌)
set -e
APP=$(cd "$(dirname "$0")/.." && pwd)
TOOLS=$(cd "$APP/.." && pwd)/tools
VER=0.8.7
REV=1a76570e833917d15817415a53c09ad61ab3203f
NDK_ROOT=${ANDROID_NDK:-$(ls -d "$TOOLS"/android-sdk/ndk/* | sort | tail -1)}
case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) HOST=windows-x86_64; EXE=.exe; CMD=.cmd ;; Darwin) HOST=darwin-x86_64; EXE=; CMD= ;; *) HOST=linux-x86_64; EXE=; CMD= ;; esac
TC=$NDK_ROOT/toolchains/llvm/prebuilt/$HOST/bin
export RUSTUP_HOME=${RUSTUP_HOME:-$TOOLS/rust/rustup}
export CARGO_HOME=${CARGO_HOME:-$TOOLS/rust/cargo}
export PATH="$CARGO_HOME/bin:$PATH"
# Windows: 호스트 링커 (MSVC) 환경
if [ "$HOST" = windows-x86_64 ] && [ -z "$VCINSTALLDIR" ]; then
VC=$(cygpath -w "$TOOLS/VSBuildTools/VC/Auxiliary/Build/vcvars64.bat")
ENVF=$(mktemp)
# cmd 에 따옴표를 넘기면 Git Bash 가 바꿔 버려, 부를 것을 .cmd 파일로 적어 그것을 돌린다
BAT=$(mktemp --suffix=.cmd)
printf '@call "%s" >nul\r\n@set\r\n' "$VC" > "$BAT"
cmd //c "$(cygpath -w "$BAT")" | tr -d '\r' > "$ENVF"
rm -f "$BAT"
while IFS='=' read -r k v; do
case "$k" in
Path|PATH) PATH="$(cygpath -u -p "$v"):$PATH" ;;
LIB|LIBPATH|INCLUDE|VCINSTALLDIR|VCToolsInstallDir|WindowsSdkDir|WindowsSDKVersion|UCRTVersion|UniversalCRTSdkDir) export "$k=$v" ;;
esac
done < "$ENVF"
rm -f "$ENVF"
export PATH
[ -n "$VCINSTALLDIR" ] || { echo "vcvars64 환경을 읽지 못함: $VC"; exit 1; }
fi
# 작업 폴더: tools 아래 (빌드 캐시가 수 GB - 시스템 임시 폴더 (C:) 가 모자라 시험이 실패했다, 10/20). 다시 빌드할 때 이어 쓴다
WORK=${JJ_RHWP_WORK:-$TOOLS/src/rhwp/build}
mkdir -p "$WORK"
SRC=$WORK/rhwp
if [ "$(git -C "$SRC" rev-parse HEAD 2>/dev/null)" != "$REV" ]; then
rm -rf "$SRC"
git init -q "$SRC"
git -C "$SRC" remote add origin https://github.com/edwardkim/rhwp.git
git -C "$SRC" sparse-checkout init --no-cone
printf '%s\n' '/*' '!/samples/' '!/pdf/' '!/mydocs/' '!/saved/' '!/tests/' '!/gym/' '!/rhwp-*/' '!/npm/' '!/typescript/' \
'!/tools/' '!/fuzz/' '!/scripts/' '/saved/blank2010.hwp' '/mydocs/manual/' '/mydocs/tech/' > "$SRC/.git/info/sparse-checkout"
git -C "$SRC" fetch -q --depth 1 --filter=blob:none origin "$REV"
git -C "$SRC" checkout -q FETCH_HEAD
fi
[ "$(git -C "$SRC" rev-parse HEAD)" = "$REV" ] || { echo "rhwp 커밋이 다름"; exit 1; }
[ -z "$(git -C "$SRC" status --porcelain)" ] || { echo "rhwp 소스가 바뀌어 있음"; exit 1; }
rm -rf "$WORK/ffi"
cp -r "$APP/tool/rhwp_ffi" "$WORK/ffi"
# 처음 한 번: 의존 판을 rhwp 의 Cargo.lock 그대로 이어받아 tool/rhwp_ffi/Cargo.lock 을 만든다 (그 뒤로는 --locked)
LOCKED=--locked
if [ ! -f "$APP/tool/rhwp_ffi/Cargo.lock" ]; then
cp "$SRC/Cargo.lock" "$WORK/ffi/Cargo.lock"
LOCKED=
fi
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$TC/aarch64-linux-android24-clang$CMD"
export CC_aarch64_linux_android="$TC/aarch64-linux-android24-clang$CMD"
export AR_aarch64_linux_android="$TC/llvm-ar$EXE"
W=$WORK
[ "$HOST" = windows-x86_64 ] && W=$(cygpath -m "$WORK")
export RUSTFLAGS="-C link-arg=-Wl,-z,max-page-size=16384 -C link-arg=-Wl,--build-id=none --remap-path-prefix=$W=/jj --remap-path-prefix=$(cygpath -m "$CARGO_HOME" 2>/dev/null || echo "$CARGO_HOME")=/cargo"
export JJ_RHWP_VERSION=$VER
build() {
cargo build -q --release $LOCKED --target aarch64-linux-android --manifest-path "$WORK/ffi/Cargo.toml" --target-dir "$1"
"$TC/llvm-strip$EXE" -o "$2" "$1/aarch64-linux-android/release/libjj_rhwp.so"
}
OUT="$APP/third_party/rhwp/android/arm64-v8a"
mkdir -p "$OUT"
build "$WORK/target" "$OUT/libjj_rhwp.so"
[ -n "$LOCKED" ] || cp "$WORK/ffi/Cargo.lock" "$APP/tool/rhwp_ffi/Cargo.lock"
cp "$SRC/LICENSE" "$OUT/../LICENSE.txt"
rm -f "$OUT/../THIRD_PARTY_LICENSES.md"
# 링크되는 crate 의 라이선스 · 원문 (cargo tree · metadata - 의존은 위 빌드가 받아 둠)
python "$APP/tool/licenses/rhwp_crates.py" "$WORK/ffi/Cargo.toml" "$SRC" "$VER" "$REV" "$APP/assets/licenses/RHWP_LICENSES.txt"
if [ -n "$JJ_RHWP_CHECK" ]; then
rm -rf "$WORK/target2"
build "$WORK/target2" "$WORK/again.so"
A=$(sha256sum < "$OUT/libjj_rhwp.so"); B=$(sha256sum < "$WORK/again.so")
[ "$A" = "$B" ] && echo "다시 만들어도 같음" || { echo "다시 만들면 다름: $A / $B"; exit 1; }
fi
ls -l "$OUT/libjj_rhwp.so"
sha256sum "$OUT/libjj_rhwp.so"

View File

@ -8,6 +8,8 @@
# 새 버전 확인도 "최신과 같음" 이 된다. 시험판은 flutter build apk --build-number 9xx 로 빌드한다. 정식 판 APK 는 -Release.
# - 순번 900 이상의 v 태그 (시험판 Pre-release, 예: v2026.10.18_977) 는 정식 판으로 보지 않는다 (관리자 지시 10/19, 28 지적:
# 시험판에 태그를 단 뒤 같은 APK 를 다시 검사하면 "정식 판 번호와 같음" 으로 거짓 실패). 정식 판 (-Release) 인데 순번이 900 이상이면 실패.
# - HWP 보기 (관리자 지시 10/20, 28 지적): APK 안 libjj_rhwp.so 가 앱의 Dart 와 같은 C ABI 인지 - 내보낸 기호 (NDK llvm-nm) 에
# jj_rhwp_cancel · jj_rhwp_abi_version 등이 모두 있어야 한다 (옛 .so 는 이름 · 목록만으로는 못 가린다).
# 사용: powershell -ExecutionPolicy Bypass -File tool\check_apk.ps1 -Apk build\app\outputs\flutter-apk\app-release.apk [-Release]
param(
[Parameter(Mandatory = $true)][string]$Apk,
@ -41,7 +43,7 @@ if (-not $Libs) { $Libs = Join-Path $app 'android\app\third_party_libs.txt' }
$listed = @(Get-Content $Libs | ForEach-Object { $_.Trim() } | Where-Object { $_ -and -not $_.StartsWith('#') })
# third_party 에 있는 파일 (목록 밖이면 APK 에 없어야 함)
$tp = @()
foreach ($d in 'third_party\rsync\android', 'third_party\sdcpp\android', 'third_party\tor\android', 'third_party\7zip\android') {
foreach ($d in 'third_party\rsync\android', 'third_party\sdcpp\android', 'third_party\tor\android', 'third_party\7zip\android', 'third_party\rhwp\android') {
$dir = Join-Path $app $d
if (Test-Path $dir) { $tp += @(Get-ChildItem $dir -Recurse -Filter *.so | ForEach-Object { $_.Name }) }
}
@ -50,6 +52,13 @@ try {
$libsIn = @($zip.Entries | Where-Object { $_.FullName -like 'lib/arm64-v8a/*.so' } | ForEach-Object { $_.Name })
$dexes = @($zip.Entries | Where-Object { $_.FullName -match '^classes\d*\.dex$' })
if ($dexes.Count -eq 0) { throw "dex 가 없습니다: $Apk" }
# HWP 보기 라이브러리는 꺼내 두고 아래에서 기호를 본다
$rhwpSo = $null
$rhwpEntry = $zip.Entries | Where-Object { $_.FullName -eq 'lib/arm64-v8a/libjj_rhwp.so' } | Select-Object -First 1
if ($rhwpEntry) {
$rhwpSo = Join-Path ([System.IO.Path]::GetTempPath()) ("jj_check_rhwp_{0}.so" -f [guid]::NewGuid().ToString('N'))
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($rhwpEntry, $rhwpSo)
}
$found = $null
foreach ($e in $dexes) {
$s = $e.Open()
@ -61,6 +70,21 @@ try {
$zip.Dispose()
}
$bad = @()
# HWP 보기: libjj_rhwp.so 의 내보낸 기호 (lib/platform/android/hwp_pdf_ffi.dart 의 requiredSymbols 와 같게)
$rhwpNote = ''
if ($rhwpSo) {
try {
$nm = Get-ChildItem (Join-Path $app '..\tools\android-sdk\ndk') -Recurse -Filter llvm-nm.exe -ErrorAction SilentlyContinue | Select-Object -Last 1
if (-not $nm) { throw 'llvm-nm.exe 를 찾지 못했습니다 (tools\android-sdk\ndk)' }
$syms = @(& $nm.FullName -D --defined-only $rhwpSo 2>$null | ForEach-Object { ($_ -split '\s+')[-1] })
$need = 'jj_rhwp_abi_version', 'jj_rhwp_open', 'jj_rhwp_page_count', 'jj_rhwp_export_pdf', 'jj_rhwp_cancel', 'jj_rhwp_cancel_all', 'jj_rhwp_close', 'jj_rhwp_free'
$miss = @($need | Where-Object { $syms -notcontains $_ })
if ($miss) { $bad += "libjj_rhwp.so 가 옛 C ABI 입니다 - 없는 함수: $($miss -join ', ') (sh tool/build_rhwp_android.sh 로 다시 빌드)" }
else { $rhwpNote = " · libjj_rhwp.so 함수 $($need.Count)개 있음" }
} finally {
Remove-Item $rhwpSo -Force -ErrorAction SilentlyContinue
}
}
# 시험판 번호: aapt2 로 versionCode · versionName 을 읽어 정식 판 태그의 번호와 견준다 (build.gradle.kts 와 같은 셈: 년%100 · 월 · 일 · 번호)
$aapt = Get-ChildItem (Join-Path $app '..\tools\android-sdk\build-tools') -Recurse -Filter aapt2.exe -ErrorAction SilentlyContinue | Select-Object -Last 1
if (-not $aapt -and $env:ANDROID_HOME) { $aapt = Get-ChildItem (Join-Path $env:ANDROID_HOME 'build-tools') -Recurse -Filter aapt2.exe -ErrorAction SilentlyContinue | Select-Object -Last 1 }
@ -90,4 +114,4 @@ if (-not $found) {
Write-Host "실패: GeneratedPluginRegistrant 가 dex 에 없습니다 (켜자마자 꺼지는 APK). flutter pub get 뒤 --no-pub 없이 다시 빌드하세요." -ForegroundColor Red
exit 1
}
Write-Host "통과: GeneratedPluginRegistrant 있음 ($found) · third_party 실행 파일 $($listed.Count)개 목록과 같음 · versionCode $code ($vname)$(if ($Release) { ' (정식 판)' } else { ' (시험판 - 정식 판 번호와 다름)' })"
Write-Host "통과: GeneratedPluginRegistrant 있음 ($found) · third_party 실행 파일 $($listed.Count)개 목록과 같음$rhwpNote · versionCode $code ($vname)$(if ($Release) { ' (정식 판)' } else { ' (시험판 - 정식 판 번호와 다름)' })"

View File

@ -0,0 +1,140 @@
"""관리자 지시 (10/20, 28 지적): libjj_rhwp.so 에 실제로 링크되는 Rust crate 마다 이름 · 판 · 라이선스와 원문 · 저작권 고지를 모아
assets/licenses/RHWP_LICENSES.txt 를 만든다 (tool/build_rhwp_android.sh 가 빌드 뒤에 부른다).
- 링크되는 것: cargo tree --target aarch64-linux-android -e normal,no-proc-macro (빌드 전용 · 개발 · proc-macro 는 .so 에 들어가지 않음)
- 원문: crate 폴더 맨 위의 LICENSE* · LICENCE* · COPYING* · NOTICE* · COPYRIGHT* · UNLICENSE* (cargo metadata 의 manifest_path)
원문 파일이 없는 crate 는 같은 라이선스의 원문을 맨 뒤 "라이선스 원문" 에 한 번씩 넣는다.
- Cargo.lock 의 나머지는 "링크되지 않음" 목록에 적는다 - test/licenses_test.dart 가 두 목록의 합이 Cargo.lock 과 같은지 본다
(Cargo.lock 이 바뀌었는데 이 파일을 다시 만들지 않으면 시험이 잡는다).
- rhwp 저장소의 THIRD_PARTY_LICENSES.md (npm 쪽 포함) 는 쓰지 않는다.
사용: python tool/licenses/rhwp_crates.py <ffi Cargo.toml> <rhwp 소스 폴더> <rhwp 판> <rhwp 커밋> <출력 파일>
(cargo 는 PATH · CARGO_HOME 의 것, 의존은 이미 받아 둔 것 - --offline --locked)
"""
import hashlib
import json
import os
import re
import subprocess
import sys
manifest, rhwp_src, version, commit, out = sys.argv[1:6]
TARGET = 'aarch64-linux-android'
def cargo(*args):
r = subprocess.run(['cargo', *args, '--manifest-path', manifest, '--locked', '--offline'],
capture_output=True, text=True, encoding='utf-8')
if r.returncode != 0:
sys.exit('cargo %s 실패:\n%s' % (args[0], r.stderr))
return r.stdout
# 1. 링크되는 crate (이름, 판)
linked = set()
for line in cargo('tree', '--target', TARGET, '-e', 'normal,no-proc-macro', '--prefix', 'none', '--format', '{p}').splitlines():
m = re.match(r'^(\S+) v(\S+)', line.strip())
if m:
linked.add((m.group(1), m.group(2)))
# 2. 모든 crate 의 폴더 · 라이선스
meta = json.loads(cargo('metadata', '--format-version', '1', '--filter-platform', TARGET))
pkgs = {(p['name'], p['version']): p for p in meta['packages']}
# 3. Cargo.lock 의 모든 crate
lock = open(os.path.join(os.path.dirname(manifest), 'Cargo.lock'), encoding='utf-8').read()
locked = set(re.findall(r'\[\[package\]\]\s*\nname = "([^"]+)"\s*\nversion = "([^"]+)"', lock))
missing = linked - locked
if missing:
sys.exit('Cargo.lock 에 없는 crate: %s' % sorted(missing))
ours = {'jj_rhwp'}
NAMES = re.compile(r'^(LICEN[CS]E|COPYING|NOTICE|COPYRIGHT|UNLICENSE)', re.I)
def license_files(pkg):
d = os.path.dirname(pkg['manifest_path'])
files = []
for n in sorted(os.listdir(d)):
p = os.path.join(d, n)
if os.path.isfile(p) and NAMES.match(n):
files.append((n, open(p, encoding='utf-8', errors='replace').read().strip()))
if pkg.get('license_file'):
p = os.path.join(d, pkg['license_file'])
if os.path.isfile(p) and os.path.basename(p) not in [f for f, _ in files]:
files.append((os.path.basename(p), open(p, encoding='utf-8', errors='replace').read().strip()))
return files
# 원문이 없는 crate 를 위해 라이선스 이름 → 원문 하나 (같은 이름의 원문 파일 하나만 가진 crate 에서)
SPDX_FILE = {'MIT': r'LICEN[CS]E-?MIT', 'Apache-2.0': r'LICEN[CS]E-?APACHE', 'BSD-3-Clause': r'LICEN[CS]E-?BSD',
'Zlib': r'LICEN[CS]E-?ZLIB', 'BSL-1.0': r'LICEN[CS]E-?BOOST', 'Unicode-3.0': r'LICEN[CS]E-?UNICODE'}
spdx_text = {}
sections = []
seen_text = {}
no_file = []
for key in sorted(linked, key=lambda k: (k[0].lower(), k[1])):
name, ver = key
pkg = pkgs[key]
lic = pkg.get('license') or ('see ' + pkg['license_file'] if pkg.get('license_file') else '?')
if name in ours:
continue
files = license_files(pkg)
for fname, text in files:
for spdx, pat in SPDX_FILE.items():
if re.fullmatch(pat + r'(\.(txt|md))?', fname, re.I) and spdx not in spdx_text:
spdx_text[spdx] = (text, '%s %s' % (name, ver))
body = []
for fname, text in files:
h = hashlib.sha256(text.encode()).hexdigest()
if h in seen_text:
body.append('[%s] (%s 의 %s 와 같은 글)' % (fname, *seen_text[h]))
else:
seen_text[h] = ('%s %s' % (name, ver), fname)
body.append('[%s]\n%s' % (fname, text))
if not files:
no_file.append((name, ver, lic))
body.append('(crate 에 원문 파일이 없음 - 아래 "라이선스 원문" 의 %s)' % lic)
authors = ', '.join(pkg.get('authors') or [])
sections.append('=== %s %s (%s) ===\n%s%s\n%s' % (
name, ver, lic, 'Authors: %s\n' % authors if authors else '', pkg.get('repository') or '', '\n\n'.join(body)))
not_linked = sorted(locked - linked, key=lambda k: (k[0].lower(), k[1]))
lines = [
'rhwp v%s (https://github.com/edwardkim/rhwp, commit %s) - MIT' % (version, commit),
'Android: libjj_rhwp.so (HWP / HWPX -> PDF), built by tool/build_rhwp_android.sh with tool/rhwp_ffi (MIT)',
'This file lists every Rust crate linked into libjj_rhwp.so (target %s, normal dependencies,' % TARGET,
'build-only / dev / proc-macro crates excluded) with its license and the license / notice files it ships.',
'',
'---------------------------------------------------------------------',
'rhwp LICENSE',
'---------------------------------------------------------------------',
open(os.path.join(rhwp_src, 'LICENSE'), encoding='utf-8').read().strip(),
'',
'---------------------------------------------------------------------',
'Linked crates (%d)' % len(linked - {(n, v) for n, v in linked if n in ours}),
'---------------------------------------------------------------------',
]
for name, ver in sorted(linked, key=lambda k: (k[0].lower(), k[1])):
if name not in ours:
lines.append('linked: %s %s (%s)' % (name, ver, pkgs[(name, ver)].get('license') or '?'))
lines += ['', '---------------------------------------------------------------------',
'Not linked into libjj_rhwp.so (build tools, proc-macros, other platforms) - listed for completeness',
'---------------------------------------------------------------------']
lines += ['not linked: %s %s' % k for k in not_linked]
lines += ['', '---------------------------------------------------------------------', 'License and notice files',
'---------------------------------------------------------------------', '']
lines += [s + '\n' for s in sections]
need = sorted({l for _, _, l in no_file})
if need:
lines += ['---------------------------------------------------------------------', '라이선스 원문 (원문 파일이 없는 crate 용)',
'---------------------------------------------------------------------', '']
for lic in need:
for spdx in re.findall(r'[A-Za-z0-9.\-]+', lic):
if spdx in spdx_text:
text, src = spdx_text.pop(spdx)
lines += ['=== %s (원문: %s 의 파일) ===' % (spdx, src), text, '']
text = '\n'.join(lines).rstrip() + '\n'
open(out, 'wb').write(text.replace('\n', '\r\n').encode('utf-8'))
print('linked %d · not linked %d · 원문 파일 없음 %d %s' % (len(linked) - 1, len(not_linked), len(no_file),
[f'{n} {v}' for n, v, _ in no_file]))

1928
tool/rhwp_ffi/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

20
tool/rhwp_ffi/Cargo.toml Normal file
View File

@ -0,0 +1,20 @@
# JJ_MKVMaker: HWP · HWPX → PDF (rhwp) 의 C ABI - tool/build_rhwp_android.sh 가 rhwp 소스 (커밋 고정) 옆에 두고 빌드한다
[package]
name = "jj_rhwp"
version = "0.1.0"
edition = "2021"
license = "MIT"
publish = false
[lib]
crate-type = ["cdylib"]
[dependencies]
# 빌드 스크립트가 ../rhwp 에 rhwp v0.8.7 (커밋 1a76570) 을 받아 둔다
rhwp = { path = "../rhwp", default-features = false }
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
strip = true

View File

@ -0,0 +1,5 @@
# rhwp v0.8.7 과 같은 판 (rhwp 의 rust-toolchain.toml)
[toolchain]
channel = "1.93.1"
targets = ["aarch64-linux-android"]
profile = "minimal"

269
tool/rhwp_ffi/src/lib.rs Normal file
View File

@ -0,0 +1,269 @@
//! JJ_MKVMaker: HWP · HWPX 를 PDF 로 바꾸는 얇은 C ABI (rhwp, MIT - https://github.com/edwardkim/rhwp).
//! 관리자 지시 (10/20): Android 에서 HWP · HWPX 를 앱의 보기 화면 (PDF) 으로 연다 - 문서를 한 번 열고, 쪽 범위마다 PDF 를 만든다
//! (큰 문서는 앞쪽부터 몇 쪽씩 보이면서 뒤를 이어 만든다). Dart 는 dart:ffi 로 부른다 (lib/platform/android/hwp_pdf_ffi.dart).
//! - 오류는 종류 코드 (JJ_RHWP_E_*) 로 돌려주고, 원문 (rhwp 의 글) 은 err 로 (화면은 코드로 고른 번역 글, 원문은 [자세히] · 작업 기록).
//! - jj_rhwp_cancel(요청 번호): 다른 스레드에서 그 요청만 멈춘다 (쪽마다 확인 - 관리자 지시 10/20, 시간 제한 뒤에도 계속 돌지 않게).
//! jj_rhwp_cancel_all: 닫을 때 - 도는 것 · 앞으로 올 것 모두 멈춘다 (ABI 3: 예전에는 문서 하나의 깃발이라 한 요청의 시간 제한이
//! 앞 묶음까지 멈췄고, 바꾸기를 시작할 때 깃발을 지워 닫기 요청이 사라졌다 - 28 지적).
//! 오류 글 · 돌려준 글은 jj_rhwp_free 로 푼다. 어느 함수도 panic 을 C 쪽으로 넘기지 않는다.
use std::ffi::{c_char, c_int, CStr, CString};
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::path::PathBuf;
use std::ptr::null_mut;
use std::collections::HashSet;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use rhwp::document_core::DocumentCore;
use rhwp::paint::RenderProfile;
use rhwp::renderer::pdf::{svgs_to_pdf_with_options, PdfExportOptions};
/// 성공
pub const JJ_RHWP_OK: c_int = 0;
/// jj_rhwp_cancel 로 멈춤
pub const JJ_RHWP_E_CANCELLED: c_int = 1;
/// 파일을 읽지 못함
pub const JJ_RHWP_E_READ: c_int = 2;
/// HWP · HWPX 로 읽지 못함 (깨짐 · 지원하지 않는 형식)
pub const JJ_RHWP_E_INVALID: c_int = 3;
/// 비밀번호가 필요한 문서
pub const JJ_RHWP_E_PASSWORD_REQUIRED: c_int = 4;
/// 비밀번호가 틀림
pub const JJ_RHWP_E_PASSWORD_WRONG: c_int = 5;
/// 쪽 범위가 맞지 않음 (앱 잘못)
pub const JJ_RHWP_E_RANGE: c_int = 6;
/// 쪽을 그리지 못함
pub const JJ_RHWP_E_RENDER: c_int = 7;
/// PDF 를 저장하지 못함
pub const JJ_RHWP_E_WRITE: c_int = 8;
/// 변환기 안의 예기치 않은 오류 (panic)
pub const JJ_RHWP_E_INTERNAL: c_int = 9;
/// 연 문서
pub struct JjRhwpDoc {
core: DocumentCore,
/// 멈추라고 한 요청 번호 (끝나면 지운다)
cancelled: Mutex<HashSet<u64>>,
/// 닫는 중 - 모든 요청을 멈춘다 (되돌리지 않는다)
closing: AtomicBool,
}
impl JjRhwpDoc {
fn stopped(&self, req: u64) -> bool {
self.closing.load(Ordering::SeqCst) || self.cancelled.lock().map(|s| s.contains(&req)).unwrap_or(false)
}
}
struct Fail(c_int, String);
fn text(p: *const c_char) -> Option<String> {
if p.is_null() {
return None;
}
Some(unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned())
}
fn set_out(code: *mut c_int, err: *mut *mut c_char, c: c_int, msg: &str) {
if !code.is_null() {
unsafe { *code = c };
}
if !err.is_null() {
let s = CString::new(msg.replace('\0', " ")).unwrap_or_default();
unsafe { *err = s.into_raw() };
}
}
fn guarded<T>(code: *mut c_int, err: *mut *mut c_char, fail: T, f: impl FnOnce() -> Result<T, Fail>) -> T {
match catch_unwind(AssertUnwindSafe(f)) {
Ok(Ok(v)) => {
if !code.is_null() {
unsafe { *code = JJ_RHWP_OK };
}
v
}
Ok(Err(Fail(c, msg))) => {
set_out(code, err, c, &msg);
fail
}
Err(p) => {
let msg = p
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| p.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "panic".to_string());
set_out(code, err, JJ_RHWP_E_INTERNAL, &format!("rhwp panic: {msg}"));
fail
}
}
}
/// 여는 오류의 종류: rhwp v0.8.7 의 오류 글 (ParseError 의 Display) 로 암호 문서를 가린다
fn open_kind(msg: &str, with_password: bool) -> c_int {
if msg.contains("비밀번호가 필요한 암호 문서") {
JJ_RHWP_E_PASSWORD_REQUIRED
} else if msg.contains("암호 오류") {
if with_password {
JJ_RHWP_E_PASSWORD_WRONG
} else {
JJ_RHWP_E_PASSWORD_REQUIRED
}
} else {
JJ_RHWP_E_INVALID
}
}
/// 이 C ABI 의 판 - Dart (lib/platform/android/hwp_pdf_ffi.dart 의 RhwpEngine.abiVersion) 와 같아야 앱 안 보기를 쓴다
/// (관리자 지시 10/20, 28 지적: 옛 .so (판 1 - code 칸 · cancel 없음) 에 새 인수를 넘기면 힙이 망가진다).
/// 함수의 인수 · 오류 코드를 바꾸면 올린다
pub const JJ_RHWP_ABI_VERSION: c_int = 3;
#[no_mangle]
pub extern "C" fn jj_rhwp_abi_version() -> c_int {
JJ_RHWP_ABI_VERSION
}
/// 판 (예: "0.8.7") - 라이선스 화면 · 작업 기록용. jj_rhwp_free 로 푼다
#[no_mangle]
pub extern "C" fn jj_rhwp_version() -> *mut c_char {
CString::new(option_env!("JJ_RHWP_VERSION").unwrap_or("?")).unwrap_or_default().into_raw()
}
/// 문서 열기 (password 는 null 이나 "" 이면 없음). 실패하면 null 과 code · err
#[no_mangle]
pub extern "C" fn jj_rhwp_open(
path: *const c_char,
password: *const c_char,
code: *mut c_int,
err: *mut *mut c_char,
) -> *mut JjRhwpDoc {
guarded(code, err, null_mut(), || {
let path = text(path).ok_or_else(|| Fail(JJ_RHWP_E_READ, "no path".into()))?;
let data = std::fs::read(&path).map_err(|e| Fail(JJ_RHWP_E_READ, e.to_string()))?;
let pw = text(password).filter(|p| !p.is_empty());
let core = match &pw {
Some(p) => DocumentCore::from_bytes_with_password(&data, p.as_bytes()),
None => DocumentCore::from_bytes(&data),
}
.map_err(|e| {
let msg = e.to_string();
Fail(open_kind(&msg, pw.is_some()), msg)
})?;
Ok(Box::into_raw(Box::new(JjRhwpDoc { core, cancelled: Mutex::new(HashSet::new()), closing: AtomicBool::new(false) })))
})
}
/// 쪽 수 (doc 이 null 이면 -1)
#[no_mangle]
pub extern "C" fn jj_rhwp_page_count(doc: *const JjRhwpDoc) -> c_int {
if doc.is_null() {
return -1;
}
let doc = unsafe { &*doc };
catch_unwind(AssertUnwindSafe(|| doc.core.page_count() as c_int)).unwrap_or(-1)
}
/// 요청 [req] 만 멈춘다 (다른 스레드에서 불러도 됨 - 도는 중이면 다음 쪽에서, 아직 시작 전이면 시작하자마자
/// JJ_RHWP_E_CANCELLED 로 끝난다). 다른 요청은 그대로
#[no_mangle]
pub extern "C" fn jj_rhwp_cancel(doc: *const JjRhwpDoc, req: u64) {
if !doc.is_null() {
if let Ok(mut s) = unsafe { &*doc }.cancelled.lock() {
s.insert(req);
}
}
}
/// 닫기 전에: 도는 요청 · 앞으로 올 요청 모두 멈춘다 (되돌리지 않음)
#[no_mangle]
pub extern "C" fn jj_rhwp_cancel_all(doc: *const JjRhwpDoc) {
if !doc.is_null() {
unsafe { &*doc }.closing.store(true, Ordering::SeqCst);
}
}
/// 요청 [req] (부르는 쪽이 정한 번호 - jj_rhwp_cancel 로 멈출 때 씀): [from, to) 쪽 (0 부터) 을 PDF 파일 out 으로. font_dirs 는 줄바꿈 (\n) 으로 나눈 글꼴 폴더들 (Android 는 시스템 글꼴을
/// 스스로 찾지 않으므로 /system/fonts 와 앱이 받은 한글 글꼴 폴더를 넘긴다). 성공 0, 실패 -1 과 code · err.
/// rhwp 의 render_pages_pdf_native_with_options 와 같게 (Print 로 쪽마다 SVG → 한 PDF) 하되, 쪽마다 멈춤 표시를 본다
/// (하이퍼링크는 넣지 않는다 - 보기용)
#[no_mangle]
pub extern "C" fn jj_rhwp_export_pdf(
doc: *const JjRhwpDoc,
req: u64,
from: c_int,
to: c_int,
font_dirs: *const c_char,
out: *const c_char,
code: *mut c_int,
err: *mut *mut c_char,
) -> c_int {
guarded(code, err, -1, || {
if doc.is_null() {
return Err(Fail(JJ_RHWP_E_RANGE, "no document".into()));
}
let doc = unsafe { &*doc };
// 끝나면 (어떻게 끝나든) 이 요청의 멈춤 표시를 지운다 - 시작할 때는 지우지 않는다 (먼저 온 멈춤 · 닫기를 지키려고)
struct Forget<'a>(&'a JjRhwpDoc, u64);
impl Drop for Forget<'_> {
fn drop(&mut self) {
if let Ok(mut s) = self.0.cancelled.lock() {
s.remove(&self.1);
}
}
}
let _forget = Forget(doc, req);
let count = doc.core.page_count() as c_int;
if from < 0 || to > count || from >= to {
return Err(Fail(JJ_RHWP_E_RANGE, format!("pages {from}..{to} / {count}")));
}
let out = text(out).ok_or_else(|| Fail(JJ_RHWP_E_WRITE, "no output path".into()))?;
let mut options = PdfExportOptions::default();
options.font_paths = text(font_dirs)
.unwrap_or_default()
.split('\n')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.collect();
let cancelled = || doc.stopped(req);
let mut svgs = Vec::with_capacity((to - from) as usize);
for page in from as u32..to as u32 {
if cancelled() {
return Err(Fail(JJ_RHWP_E_CANCELLED, "cancelled".into()));
}
svgs.push(
doc.core
.render_page_svg_layer_with_profile_native(page, RenderProfile::Print)
.map_err(|e| Fail(JJ_RHWP_E_RENDER, format!("page {}: {e}", page + 1)))?,
);
}
if cancelled() {
return Err(Fail(JJ_RHWP_E_CANCELLED, "cancelled".into()));
}
let bytes = svgs_to_pdf_with_options(&svgs, &options).map_err(|e| Fail(JJ_RHWP_E_RENDER, e))?;
if cancelled() {
return Err(Fail(JJ_RHWP_E_CANCELLED, "cancelled".into()));
}
// 다 쓴 뒤에 이름을 바꾼다 (보기 화면이 반쪽 파일을 읽지 않게)
let part = format!("{out}.part");
std::fs::write(&part, &bytes).map_err(|e| Fail(JJ_RHWP_E_WRITE, e.to_string()))?;
std::fs::rename(&part, &out).map_err(|e| Fail(JJ_RHWP_E_WRITE, e.to_string()))?;
Ok(0)
})
}
/// 문서 닫기
#[no_mangle]
pub extern "C" fn jj_rhwp_close(doc: *mut JjRhwpDoc) {
if !doc.is_null() {
let _ = catch_unwind(AssertUnwindSafe(|| drop(unsafe { Box::from_raw(doc) })));
}
}
/// 이 라이브러리가 돌려준 글 (err · 판) 풀기
#[no_mangle]
pub extern "C" fn jj_rhwp_free(s: *mut c_char) {
if !s.is_null() {
drop(unsafe { CString::from_raw(s) });
}
}