Compare commits

...

12 Commits

Author SHA1 Message Date
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
27 changed files with 15628 additions and 23 deletions

View File

@ -67,9 +67,12 @@ android {
sourceSets["main"].jniLibs.srcDir("../../third_party/tor/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 고정) // 86: 7-Zip 25.01 (lib7zz.so 라는 이름의 실행 파일 - 7z · RAR 풀기): tool/build_7zz_android.sh (공식 소스, SHA-256 고정)
sourceSets["main"].jniLibs.srcDir("../../third_party/7zip/android") 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 에 적은 것만 (공유 폴더에 다른 판이 있어도 섞이지 않게) // 195: third_party 의 실행 파일은 third_party_libs.txt 에 적은 것만 (공유 폴더에 다른 판이 있어도 섞이지 않게)
val thirdPartyLibs = file("third_party_libs.txt").readLines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") }.toSet() 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 -> fileTree(dir).matching { include("**/*.so") }.forEach { so ->
if (so.name !in thirdPartyLibs) packaging.jniLibs.excludes += "**/${so.name}" if (so.name !in thirdPartyLibs) packaging.jniLibs.excludes += "**/${so.name}"
} }

View File

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

View File

@ -213,6 +213,7 @@
"GitHub 의 최신 버전과 비교해, 새 버전이 있으면 받아서 설치합니다": "Compares with the latest version on GitHub and downloads and installs it if newer", "GitHub 의 최신 버전과 비교해, 새 버전이 있으면 받아서 설치합니다": "Compares with the latest version on GitHub and downloads and installs it if newer",
"HTML 파일로 내보내기": "Export to HTML file", "HTML 파일로 내보내기": "Export to HTML file",
"HTML 파일에서 가져오기": "Import from 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.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)", "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", "JJ_MKVMaker - 다운로드 {0}개": "JJ_MKVMaker - {0} downloads",
@ -547,6 +548,7 @@
"{0}: 작업 중이라 옮기지 않았습니다": "{0}: not moved because it is being processed", "{0}: 작업 중이라 옮기지 않았습니다": "{0}: not moved because it is being processed",
"{0}: 저장하지 않았습니다": "{0}: not saved", "{0}: 저장하지 않았습니다": "{0}: not saved",
"{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: moves the {1} checked videos to {2}", "{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}개": "{0}",
"{0}개 ZIP 으로 묶기": "Compress {0} items to ZIP", "{0}개 ZIP 으로 묶기": "Compress {0} items to ZIP",
"{0}개 · {1}": "{0} · {1}", "{0}개 · {1}": "{0} · {1}",
@ -798,6 +800,7 @@
"그림 · 만화 보기, PDF 보기 · 페이지 편집, ZIP 목록 · 만화 보기": "Images · comic reading, PDF view · page editing, ZIP list · comic reading", "그림 · 만화 보기, PDF 보기 · 페이지 편집, ZIP 목록 · 만화 보기": "Images · comic reading, PDF view · page editing, ZIP list · comic reading",
"그림 · 프롬프트가 기기 밖으로 나갑니다 ({0})": "Images and prompts leave this device ({0})", "그림 · 프롬프트가 기기 밖으로 나갑니다 ({0})": "Images and prompts leave this device ({0})",
"그림 보기 대상": "Image viewer file types", "그림 보기 대상": "Image viewer file types",
"그림 파일을 읽을 수 없습니다 - 깨졌거나 지원하지 않는 형식": "Can't read the image file - damaged or unsupported format",
"그림으로 바꾸는 중": "Turning into an image", "그림으로 바꾸는 중": "Turning into an image",
"그림을 만들지 못했습니다: {0}": "Couldn't make the image: {0}", "그림을 만들지 못했습니다: {0}": "Couldn't make the image: {0}",
"그림을 바꿔 만들기": "Make from this picture", "그림을 바꿔 만들기": "Make from this picture",
@ -807,6 +810,7 @@
"그림을 읽을 수 없습니다": "Cannot read the image", "그림을 읽을 수 없습니다": "Cannot read the image",
"그림을 페이지로 넣기 (이 페이지 뒤)": "Add images as pages (after this page)", "그림을 페이지로 넣기 (이 페이지 뒤)": "Add images as pages (after this page)",
"그림이 거의 같고 마지막 단계가 10배쯤 빠릅니다 (받아 두었을 때)": "Almost the same image, and the last step is about 10× faster (when downloaded)", "그림이 거의 같고 마지막 단계가 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}개는 받는 쪽이 더 새것입니다 - 더 새 파일을 예전 것으로 덮어씁니다": "{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", "그중 폴더 {0}개 - 안의 파일 · 폴더도 모두 함께": "Including {0} folders - everything inside them too",
"글 → 그림 · 그림 → 그림 (기기 안에서, 모델은 환경 설정에서 받기)": "Text → image · image → image (on this device; download models in Settings)", "글 → 그림 · 그림 → 그림 (기기 안에서, 모델은 환경 설정에서 받기)": "Text → image · image → image (on this device; download models in Settings)",
@ -1026,6 +1030,7 @@
"단축키 - 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", "단축키 - 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", "단축키 해제": "Unregister hotkey",
"닫기": "Close", "닫기": "Close",
"닫힌 문서입니다": "The document is closed",
"닫힘 (실행 안 함)": "Closed (not run)", "닫힘 (실행 안 함)": "Closed (not run)",
"담아 두고 붙여넣기 (기본)": "Hold and paste (default)", "담아 두고 붙여넣기 (기본)": "Hold and paste (default)",
"답이 없습니다": "No answer", "답이 없습니다": "No answer",
@ -1295,6 +1300,7 @@
"바꾸기 ({0}개)": "Rename ({0})", "바꾸기 ({0}개)": "Rename ({0})",
"바꾸는 정도 {0}%": "Change amount {0}%", "바꾸는 정도 {0}%": "Change amount {0}%",
"바꾸면 바로 저장됩니다": "Saved automatically", "바꾸면 바로 저장됩니다": "Saved automatically",
"바꾼 PDF 를 저장하지 못했습니다 (저장 공간을 확인해 주세요)": "Couldn't save the converted PDF (check storage space)",
"바꾼 것이 없습니다 (원본 그대로)": "Nothing changed (original as is)", "바꾼 것이 없습니다 (원본 그대로)": "Nothing changed (original as is)",
"바꿀 글": "Replace with", "바꿀 글": "Replace with",
"바꿀 글 (비우면 지움)": "Replace with (empty = remove)", "바꿀 글 (비우면 지움)": "Replace with (empty = remove)",
@ -1397,6 +1403,7 @@
"벵골어": "Bengali", "벵골어": "Bengali",
"변경 내용을 저장하지 않고 나갈까요?": "Leave without saving the changes?", "변경 내용을 저장하지 않고 나갈까요?": "Leave without saving the changes?",
"변환": "Convert", "변환": "Convert",
"변환기 안에서 오류가 났습니다": "An error occurred inside the converter",
"보관한 설정으로 되살리고 다시 시작": "Restore kept settings and restart", "보관한 설정으로 되살리고 다시 시작": "Restore kept settings and restart",
"보관해 둔 설정을 되살렸습니다: {0}": "Restored the kept settings: {0}", "보관해 둔 설정을 되살렸습니다: {0}": "Restored the kept settings: {0}",
"보기": "View", "보기": "View",
@ -1756,6 +1763,8 @@
"알리지 않음": "Don't notify", "알리지 않음": "Don't notify",
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "Notifications show as JJ_MKVMaker and open the app when clicked (current user only, no admin rights)", "알림이 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}", "암호 ZIP 으로 묶는 중 · {0}": "Making password ZIP · {0}",
"암호가 걸린 문서입니다": "This document is password-protected",
"암호가 맞지 않습니다": "The password is incorrect",
"암호로 묶기": "Make password ZIP", "암호로 묶기": "Make password ZIP",
"암호화": "Encryption", "암호화": "Encryption",
"암호화 (TLS) 연결을 맺지 못했습니다: {0}": "Couldn't establish an encrypted (TLS) connection: {0}", "암호화 (TLS) 연결을 맺지 못했습니다: {0}": "Couldn't establish an encrypted (TLS) connection: {0}",
@ -1788,6 +1797,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.", "앱 안 브라우저의 쿠키 (로그인) · 사이트 데이터 · 캐시 · 방문 기록과, 다운로드용으로 내보낸 쿠키 파일을 지웁니다. 사이트에 다시 로그인해야 합니다. 즐겨찾기는 그대로입니다.": "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 · 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)", "앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "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", "앱 지우기": "Uninstall app",
"앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "Kept in the app trash for {0} days, then deleted (restore from [Trash] in the explorer).", "앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "Kept in the app trash for {0} days, then deleted (restore from [Trash] in the explorer).",
"앱에 들어 있는 것 (기본)": "Built into the app (default)", "앱에 들어 있는 것 (기본)": "Built into the app (default)",
@ -1827,6 +1837,7 @@
"엔진": "Engine", "엔진": "Engine",
"여기까지 범위 고르기": "Select range up to here", "여기까지 범위 고르기": "Select range up to here",
"여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "Drop here to add to the Make MKV video list", "여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "Drop here to add to the Make MKV video list",
"여는 중: {0}": "Opening: {0}",
"여러 개를 골랐을 때": "When several are selected", "여러 개를 골랐을 때": "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)", "여러 동영상을 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", "여러 번 틀려 {0}초 뒤에 다시 넣을 수 있습니다": "Too many wrong tries. You can try again in {0} s",
@ -2071,6 +2082,7 @@
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "This location (network drive, network share, USB memory etc.) has no Recycle Bin, so items are deleted right away.", "이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "This location (network drive, network share, USB memory etc.) has no Recycle Bin, so items are deleted right away.",
"이 이동 버튼 지우기": "Delete this move button", "이 이동 버튼 지우기": "Delete this move button",
"이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "These options will be used the next time this move runs. Run it now with the new options?", "이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "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)", "이 파일에 맞는 자막 우선 (영상 해시)": "Prefer subtitles that match this file (video hash)",
"이 파일용": "For this file", "이 파일용": "For this file",
"이 파일을 열 수 있는 앱이 없습니다.": "No app can open this file.", "이 파일을 열 수 있는 앱이 없습니다.": "No app can open this file.",
@ -2450,6 +2462,7 @@
"지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "The last run did not end normally (last check {0}:{1}). It has been noted in the job log.", "지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {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}", "지난 업데이트가 남긴 임시 파일을 지움: {0}": "Removed temporary files left by the last update: {0}",
"지난 작업 기록": "Old job logs", "지난 작업 기록": "Old job logs",
"지난번에 남은 문서 보기 임시 폴더를 지움: {0}개": "Removed {0} leftover document-view temp folders",
"지문 확인하고 연결": "Check fingerprint and connect", "지문 확인하고 연결": "Check fingerprint and connect",
"지우고 맞추기": "Delete and sync", "지우고 맞추기": "Delete and sync",
"지우고 초기화": "Delete and reset", "지우고 초기화": "Delete and reset",
@ -2491,6 +2504,7 @@
"진행 패널 보기": "Show progress panel", "진행 패널 보기": "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].", "집 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.", "짧을수록 빨리 맞추지만 배터리를 더 씁니다.": "Shorter means faster syncing but more battery use.",
"쪽 범위가 맞지 않습니다": "The page range is invalid",
"차례 기다림": "Waiting in queue", "차례 기다림": "Waiting in queue",
"창": "Panes", "창": "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)", "창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "Icon of the window, taskbar, tray and the top-left button in the app (the program file and desktop shortcut icons stay the same)",
@ -2658,6 +2672,7 @@
"파일만 골랐을 때": "When only files are selected", "파일만 골랐을 때": "When only files are selected",
"파일을 복사하지 못했습니다": "Could not copy the file", "파일을 복사하지 못했습니다": "Could not copy the file",
"파일을 분석할 수 없습니다: {0}": "Cannot analyze the file: {0}", "파일을 분석할 수 없습니다: {0}": "Cannot analyze the file: {0}",
"파일을 읽을 수 없습니다": "Can't read the file",
"파일을 읽지 못했습니다": "Could not read the file", "파일을 읽지 못했습니다": "Could not read the file",
"파일이 깨졌습니다": "The file is damaged", "파일이 깨졌습니다": "The file is damaged",
"파일이 깨졌습니다 · [다시 받기] 를 눌러 주세요": "The file is damaged · tap [Download again]", "파일이 깨졌습니다 · [다시 받기] 를 눌러 주세요": "The file is damaged · tap [Download again]",

View File

@ -213,6 +213,7 @@
"GitHub 의 최신 버전과 비교해, 새 버전이 있으면 받아서 설치합니다": "GitHub の最新版と比べ、新しい版があればダウンロードしてインストールします", "GitHub 의 최신 버전과 비교해, 새 버전이 있으면 받아서 설치합니다": "GitHub の最新版と比べ、新しい版があればダウンロードしてインストールします",
"HTML 파일로 내보내기": "HTML ファイルに書き出す", "HTML 파일로 내보내기": "HTML ファイルに書き出す",
"HTML 파일에서 가져오기": "HTML ファイルから取り込む", "HTML 파일에서 가져오기": "HTML ファイルから取り込む",
"HWP · HWPX 문서로 읽을 수 없습니다 (깨졌거나 지원하지 않는 형식)": "HWP・HWPX 文書として読み込めません (破損しているか未対応の形式)",
"IPv4 · IPv6 주소를 넣으세요 (예: 8.8.4.4)": "IPv4・IPv6 アドレスを入力してください (例: 8.8.4.4)", "IPv4 · IPv6 주소를 넣으세요 (예: 8.8.4.4)": "IPv4・IPv6 アドレスを入力してください (例: 8.8.4.4)",
"IPv4 · IPv6 주소를 넣으세요 (예: 8.8.8.8)": "IPv4・IPv6 アドレスを入力してください (例: 8.8.8.8)", "IPv4 · IPv6 주소를 넣으세요 (예: 8.8.8.8)": "IPv4・IPv6 アドレスを入力してください (例: 8.8.8.8)",
"JJ_MKVMaker - 다운로드 {0}개": "JJ_MKVMaker - ダウンロード {0}個", "JJ_MKVMaker - 다운로드 {0}개": "JJ_MKVMaker - ダウンロード {0}個",
@ -547,6 +548,7 @@
"{0}: 작업 중이라 옮기지 않았습니다": "{0}: 作業中のため移動しませんでした", "{0}: 작업 중이라 옮기지 않았습니다": "{0}: 作業中のため移動しませんでした",
"{0}: 저장하지 않았습니다": "{0}: 保存しませんでした", "{0}: 저장하지 않았습니다": "{0}: 保存しませんでした",
"{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: チェックした動画 {1}個を {2} に移動します", "{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: チェックした動画 {1}個を {2} に移動します",
"{0}~{1}쪽을 {2}초 안에 바꾸지 못했습니다": "{0}~{1} ページを {2} 秒以内に変換できませんでした",
"{0}개": "{0}個", "{0}개": "{0}個",
"{0}개 ZIP 으로 묶기": "{0} 件を ZIP にまとめる", "{0}개 ZIP 으로 묶기": "{0} 件を ZIP にまとめる",
"{0}개 · {1}": "{0}個・{1}", "{0}개 · {1}": "{0}個・{1}",
@ -798,6 +800,7 @@
"그림 · 만화 보기, PDF 보기 · 페이지 편집, ZIP 목록 · 만화 보기": "画像・マンガ表示、PDF 表示・ページ編集、ZIP 一覧・マンガ表示", "그림 · 만화 보기, PDF 보기 · 페이지 편집, ZIP 목록 · 만화 보기": "画像・マンガ表示、PDF 表示・ページ編集、ZIP 一覧・マンガ表示",
"그림 · 프롬프트가 기기 밖으로 나갑니다 ({0})": "画像・プロンプトが端末の外に送られます ({0})", "그림 · 프롬프트가 기기 밖으로 나갑니다 ({0})": "画像・プロンプトが端末の外に送られます ({0})",
"그림 보기 대상": "画像表示の対象", "그림 보기 대상": "画像表示の対象",
"그림 파일을 읽을 수 없습니다 - 깨졌거나 지원하지 않는 형식": "画像ファイルを読み込めません - 破損しているか未対応の形式",
"그림으로 바꾸는 중": "画像に変換中", "그림으로 바꾸는 중": "画像に変換中",
"그림을 만들지 못했습니다: {0}": "画像を作成できませんでした: {0}", "그림을 만들지 못했습니다: {0}": "画像を作成できませんでした: {0}",
"그림을 바꿔 만들기": "画像を変えて作成", "그림을 바꿔 만들기": "画像を変えて作成",
@ -807,6 +810,7 @@
"그림을 읽을 수 없습니다": "画像を読み込めません", "그림을 읽을 수 없습니다": "画像を読み込めません",
"그림을 페이지로 넣기 (이 페이지 뒤)": "画像をページとして追加 (このページの後)", "그림을 페이지로 넣기 (이 페이지 뒤)": "画像をページとして追加 (このページの後)",
"그림이 거의 같고 마지막 단계가 10배쯤 빠릅니다 (받아 두었을 때)": "画像はほぼ同じで、最後の段階が約 10 倍速くなります (ダウンロード済みの場合)", "그림이 거의 같고 마지막 단계가 10배쯤 빠릅니다 (받아 두었을 때)": "画像はほぼ同じで、最後の段階が約 10 倍速くなります (ダウンロード済みの場合)",
"그만두었습니다": "中止しました",
"그중 {0}개는 받는 쪽이 더 새것입니다 - 더 새 파일을 예전 것으로 덮어씁니다": "そのうち {0} 件は受け取る側の方が新しいです - 新しいファイルを古いもので上書きします", "그중 {0}개는 받는 쪽이 더 새것입니다 - 더 새 파일을 예전 것으로 덮어씁니다": "そのうち {0} 件は受け取る側の方が新しいです - 新しいファイルを古いもので上書きします",
"그중 폴더 {0}개 - 안의 파일 · 폴더도 모두 함께": "うちフォルダー {0} 件 - 中のファイル・フォルダーもすべて一緒に", "그중 폴더 {0}개 - 안의 파일 · 폴더도 모두 함께": "うちフォルダー {0} 件 - 中のファイル・フォルダーもすべて一緒に",
"글 → 그림 · 그림 → 그림 (기기 안에서, 모델은 환경 설정에서 받기)": "テキスト → 画像 · 画像 → 画像 (端末内で。モデルは設定でダウンロード)", "글 → 그림 · 그림 → 그림 (기기 안에서, 모델은 환경 설정에서 받기)": "テキスト → 画像 · 画像 → 画像 (端末内で。モデルは設定でダウンロード)",
@ -1026,6 +1030,7 @@
"단축키 - 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 更新", "단축키 - 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 更新",
"단축키 해제": "ショートカット解除", "단축키 해제": "ショートカット解除",
"닫기": "閉じる", "닫기": "閉じる",
"닫힌 문서입니다": "文書は閉じられています",
"닫힘 (실행 안 함)": "閉じた (実行しない)", "닫힘 (실행 안 함)": "閉じた (実行しない)",
"담아 두고 붙여넣기 (기본)": "保持して貼り付け (既定)", "담아 두고 붙여넣기 (기본)": "保持して貼り付け (既定)",
"답이 없습니다": "応答がありません", "답이 없습니다": "応答がありません",
@ -1295,6 +1300,7 @@
"바꾸기 ({0}개)": "変更 ({0} 件)", "바꾸기 ({0}개)": "変更 ({0} 件)",
"바꾸는 정도 {0}%": "変える度合い {0}%", "바꾸는 정도 {0}%": "変える度合い {0}%",
"바꾸면 바로 저장됩니다": "変更するとすぐ保存されます", "바꾸면 바로 저장됩니다": "変更するとすぐ保存されます",
"바꾼 PDF 를 저장하지 못했습니다 (저장 공간을 확인해 주세요)": "変換した PDF を保存できませんでした (空き容量を確認してください)",
"바꾼 것이 없습니다 (원본 그대로)": "変更なし(元のまま)", "바꾼 것이 없습니다 (원본 그대로)": "変更なし(元のまま)",
"바꿀 글": "置き換える文字", "바꿀 글": "置き換える文字",
"바꿀 글 (비우면 지움)": "置き換える文字 (空なら削除)", "바꿀 글 (비우면 지움)": "置き換える文字 (空なら削除)",
@ -1397,6 +1403,7 @@
"벵골어": "ベンガル語", "벵골어": "ベンガル語",
"변경 내용을 저장하지 않고 나갈까요?": "変更を保存せずに終了しますか?", "변경 내용을 저장하지 않고 나갈까요?": "変更を保存せずに終了しますか?",
"변환": "変換", "변환": "変換",
"변환기 안에서 오류가 났습니다": "変換エンジン内でエラーが発生しました",
"보관한 설정으로 되살리고 다시 시작": "保管した設定で復元して再起動", "보관한 설정으로 되살리고 다시 시작": "保管した設定で復元して再起動",
"보관해 둔 설정을 되살렸습니다: {0}": "保管した設定を復元しました: {0}", "보관해 둔 설정을 되살렸습니다: {0}": "保管した設定を復元しました: {0}",
"보기": "表示", "보기": "表示",
@ -1756,6 +1763,8 @@
"알리지 않음": "知らせない", "알리지 않음": "知らせない",
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知が JJ_MKVMaker の名前で表示され、クリックするとアプリが開きます (管理者権限なしで現在のユーザーのみ)", "알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知が JJ_MKVMaker の名前で表示され、クリックするとアプリが開きます (管理者権限なしで現在のユーザーのみ)",
"암호 ZIP 으로 묶는 중 · {0}": "パスワード付き ZIP にまとめています · {0}", "암호 ZIP 으로 묶는 중 · {0}": "パスワード付き ZIP にまとめています · {0}",
"암호가 걸린 문서입니다": "パスワードで保護された文書です",
"암호가 맞지 않습니다": "パスワードが正しくありません",
"암호로 묶기": "パスワード付きでまとめる", "암호로 묶기": "パスワード付きでまとめる",
"암호화": "暗号化", "암호화": "暗号化",
"암호화 (TLS) 연결을 맺지 못했습니다: {0}": "暗号化 (TLS) 接続を確立できませんでした: {0}", "암호화 (TLS) 연결을 맺지 못했습니다: {0}": "暗号化 (TLS) 接続を確立できませんでした: {0}",
@ -1788,6 +1797,7 @@
"앱 안 브라우저의 쿠키 (로그인) · 사이트 데이터 · 캐시 · 방문 기록과, 다운로드용으로 내보낸 쿠키 파일을 지웁니다. 사이트에 다시 로그인해야 합니다. 즐겨찾기는 그대로입니다.": "アプリ内ブラウザの Cookie (ログイン)・サイトデータ・キャッシュ・閲覧履歴と、ダウンロード用に書き出した Cookie ファイルを削除します。サイトへの再ログインが必要です。お気に入りはそのままです。", "앱 안 브라우저의 쿠키 (로그인) · 사이트 데이터 · 캐시 · 방문 기록과, 다운로드용으로 내보낸 쿠키 파일을 지웁니다. 사이트에 다시 로그인해야 합니다. 즐겨찾기는 그대로입니다.": "アプリ内ブラウザの Cookie (ログイン)・サイトデータ・キャッシュ・閲覧履歴と、ダウンロード用に書き出した Cookie ファイルを削除します。サイトへの再ログインが必要です。お気に入りはそのままです。",
"앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "アプリ内 Web ブラウザー・ブックマーク・動画ダウンロード", "앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "アプリ内 Web ブラウザー・ブックマーク・動画ダウンロード",
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "アプリ内ウェブブラウザー:[スキップ]を代わりに押し、スキップできない広告は音を消して早送りします(スキップ中は画面上部に「広告をスキップ中…」)", "앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "アプリ内ウェブブラウザー:[スキップ]を代わりに押し、スキップできない広告は音を消して早送りします(スキップ中は画面上部に「広告をスキップ中…」)",
"앱 안에서 열지 못해 다른 앱으로 엽니다: {0}": "アプリ内で開けないため、他のアプリで開きます: {0}",
"앱 지우기": "アプリを削除", "앱 지우기": "アプリを削除",
"앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "アプリのゴミ箱に {0} 日間置いてから削除します (エクスプローラーの [ゴミ箱] から復元できます)。", "앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "アプリのゴミ箱に {0} 日間置いてから削除します (エクスプローラーの [ゴミ箱] から復元できます)。",
"앱에 들어 있는 것 (기본)": "アプリに内蔵 (既定)", "앱에 들어 있는 것 (기본)": "アプリに内蔵 (既定)",
@ -1827,6 +1837,7 @@
"엔진": "エンジン", "엔진": "エンジン",
"여기까지 범위 고르기": "ここまで範囲選択", "여기까지 범위 고르기": "ここまで範囲選択",
"여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "ここにドロップすると MKV 作成の動画リストに追加します", "여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "ここにドロップすると MKV 作成の動画リストに追加します",
"여는 중: {0}": "開いています: {0}",
"여러 개를 골랐을 때": "複数選んだとき", "여러 개를 골랐을 때": "複数選んだとき",
"여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "複数の動画を MKV にするとき同時に処理する数(再エンコードは CPU を多く使います)", "여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "複数の動画を MKV にするとき同時に処理する数(再エンコードは CPU を多く使います)",
"여러 번 틀려 {0}초 뒤에 다시 넣을 수 있습니다": "何度も間違えたため {0} 秒後に再入力できます", "여러 번 틀려 {0}초 뒤에 다시 넣을 수 있습니다": "何度も間違えたため {0} 秒後に再入力できます",
@ -2071,6 +2082,7 @@
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "この場所 (ネットワークドライブ・ネットワーク共有・USB メモリなど) にはごみ箱がないため、すぐに削除されます。", "이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "この場所 (ネットワークドライブ・ネットワーク共有・USB メモリなど) にはごみ箱がないため、すぐに削除されます。",
"이 이동 버튼 지우기": "この移動ボタンを削除", "이 이동 버튼 지우기": "この移動ボタンを削除",
"이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "この移動を次に行うときにこのオプションを使います。今すぐ新しいオプションで実行しますか?", "이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "この移動を次に行うときにこのオプションを使います。今すぐ新しいオプションで実行しますか?",
"이 쪽을 그리지 못했습니다": "このページを描画できませんでした",
"이 파일에 맞는 자막 우선 (영상 해시)": "このファイルに合う字幕を優先(動画ハッシュ)", "이 파일에 맞는 자막 우선 (영상 해시)": "このファイルに合う字幕を優先(動画ハッシュ)",
"이 파일용": "このファイル用", "이 파일용": "このファイル用",
"이 파일을 열 수 있는 앱이 없습니다.": "このファイルを開けるアプリがありません。", "이 파일을 열 수 있는 앱이 없습니다.": "このファイルを開けるアプリがありません。",
@ -2450,6 +2462,7 @@
"지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "前回の実行が正常に終了しませんでした(最終確認 {0}:{1})。作業記録に残しました。", "지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "前回の実行が正常に終了しませんでした(最終確認 {0}:{1})。作業記録に残しました。",
"지난 업데이트가 남긴 임시 파일을 지움: {0}": "前回のアップデートが残した一時ファイルを削除: {0}", "지난 업데이트가 남긴 임시 파일을 지움: {0}": "前回のアップデートが残した一時ファイルを削除: {0}",
"지난 작업 기록": "過去の作業記録", "지난 작업 기록": "過去の作業記録",
"지난번에 남은 문서 보기 임시 폴더를 지움: {0}개": "前回残った文書表示の一時フォルダーを削除: {0} 個",
"지문 확인하고 연결": "フィンガープリントを確認して接続", "지문 확인하고 연결": "フィンガープリントを確認して接続",
"지우고 맞추기": "削除して同期", "지우고 맞추기": "削除して同期",
"지우고 초기화": "削除して初期化", "지우고 초기화": "削除して初期化",
@ -2491,6 +2504,7 @@
"진행 패널 보기": "進行パネルを表示", "진행 패널 보기": "進行パネルを表示",
"집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 \"인증서 확인 안 함\" 을 켜세요.": "自宅 NAS のように自己署名証明書なら、[サーバー設定を修正] で「証明書を検証しない」をオンにしてください。", "집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 \"인증서 확인 안 함\" 을 켜세요.": "自宅 NAS のように自己署名証明書なら、[サーバー設定を修正] で「証明書を検証しない」をオンにしてください。",
"짧을수록 빨리 맞추지만 배터리를 더 씁니다.": "短いほど早く同期しますが、バッテリーをより使います。", "짧을수록 빨리 맞추지만 배터리를 더 씁니다.": "短いほど早く同期しますが、バッテリーをより使います。",
"쪽 범위가 맞지 않습니다": "ページ範囲が正しくありません",
"차례 기다림": "順番待ち", "차례 기다림": "順番待ち",
"창": "ペイン", "창": "ペイン",
"창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "ウィンドウ・タスクバー・トレイとアプリ内左上のボタンのアイコンです(実行ファイル・デスクトップのショートカットのアイコンはそのまま)", "창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "ウィンドウ・タスクバー・トレイとアプリ内左上のボタンのアイコンです(実行ファイル・デスクトップのショートカットのアイコンはそのまま)",
@ -2658,6 +2672,7 @@
"파일만 골랐을 때": "ファイルだけを選んだとき", "파일만 골랐을 때": "ファイルだけを選んだとき",
"파일을 복사하지 못했습니다": "ファイルをコピーできませんでした", "파일을 복사하지 못했습니다": "ファイルをコピーできませんでした",
"파일을 분석할 수 없습니다: {0}": "ファイルを分析できません: {0}", "파일을 분석할 수 없습니다: {0}": "ファイルを分析できません: {0}",
"파일을 읽을 수 없습니다": "ファイルを読み込めません",
"파일을 읽지 못했습니다": "ファイルを読み取れませんでした", "파일을 읽지 못했습니다": "ファイルを読み取れませんでした",
"파일이 깨졌습니다": "ファイルが壊れています", "파일이 깨졌습니다": "ファイルが壊れています",
"파일이 깨졌습니다 · [다시 받기] 를 눌러 주세요": "ファイルが壊れています・[再ダウンロード] を押してください", "파일이 깨졌습니다 · [다시 받기] 를 눌러 주세요": "ファイルが壊れています・[再ダウンロード] を押してください",

View File

@ -213,6 +213,7 @@
"GitHub 의 최신 버전과 비교해, 새 버전이 있으면 받아서 설치합니다": "与 GitHub 上的最新版本比较,如有新版本则下载并安装", "GitHub 의 최신 버전과 비교해, 새 버전이 있으면 받아서 설치합니다": "与 GitHub 上的最新版本比较,如有新版本则下载并安装",
"HTML 파일로 내보내기": "导出为 HTML 文件", "HTML 파일로 내보내기": "导出为 HTML 文件",
"HTML 파일에서 가져오기": "从 HTML 文件导入", "HTML 파일에서 가져오기": "从 HTML 文件导入",
"HWP · HWPX 문서로 읽을 수 없습니다 (깨졌거나 지원하지 않는 형식)": "无法作为 HWP · HWPX 文档读取 (已损坏或格式不受支持)",
"IPv4 · IPv6 주소를 넣으세요 (예: 8.8.4.4)": "请输入 IPv4 · IPv6 地址 (例: 8.8.4.4)", "IPv4 · IPv6 주소를 넣으세요 (예: 8.8.4.4)": "请输入 IPv4 · IPv6 地址 (例: 8.8.4.4)",
"IPv4 · IPv6 주소를 넣으세요 (예: 8.8.8.8)": "请输入 IPv4 · IPv6 地址 (例: 8.8.8.8)", "IPv4 · IPv6 주소를 넣으세요 (예: 8.8.8.8)": "请输入 IPv4 · IPv6 地址 (例: 8.8.8.8)",
"JJ_MKVMaker - 다운로드 {0}개": "JJ_MKVMaker - 下载 {0} 个", "JJ_MKVMaker - 다운로드 {0}개": "JJ_MKVMaker - 下载 {0} 个",
@ -547,6 +548,7 @@
"{0}: 작업 중이라 옮기지 않았습니다": "{0}: 正在处理,未移动", "{0}: 작업 중이라 옮기지 않았습니다": "{0}: 正在处理,未移动",
"{0}: 저장하지 않았습니다": "{0}:未保存", "{0}: 저장하지 않았습니다": "{0}:未保存",
"{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: 将勾选的 {1} 个视频移到 {2}", "{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: 将勾选的 {1} 个视频移到 {2}",
"{0}~{1}쪽을 {2}초 안에 바꾸지 못했습니다": "无法在 {2} 秒内转换第 {0}~{1} 页",
"{0}개": "{0} 个", "{0}개": "{0} 个",
"{0}개 ZIP 으로 묶기": "将 {0} 项压缩为 ZIP", "{0}개 ZIP 으로 묶기": "将 {0} 项压缩为 ZIP",
"{0}개 · {1}": "{0} 个 · {1}", "{0}개 · {1}": "{0} 个 · {1}",
@ -798,6 +800,7 @@
"그림 · 만화 보기, PDF 보기 · 페이지 편집, ZIP 목록 · 만화 보기": "图片 · 漫画阅读,PDF 查看 · 页面编辑,ZIP 列表 · 漫画阅读", "그림 · 만화 보기, PDF 보기 · 페이지 편집, ZIP 목록 · 만화 보기": "图片 · 漫画阅读,PDF 查看 · 页面编辑,ZIP 列表 · 漫画阅读",
"그림 · 프롬프트가 기기 밖으로 나갑니다 ({0})": "图片和提示词将离开本设备 ({0})", "그림 · 프롬프트가 기기 밖으로 나갑니다 ({0})": "图片和提示词将离开本设备 ({0})",
"그림 보기 대상": "图片查看的文件类型", "그림 보기 대상": "图片查看的文件类型",
"그림 파일을 읽을 수 없습니다 - 깨졌거나 지원하지 않는 형식": "无法读取图片文件 - 已损坏或格式不受支持",
"그림으로 바꾸는 중": "正在生成图像", "그림으로 바꾸는 중": "正在生成图像",
"그림을 만들지 못했습니다: {0}": "无法生成图片: {0}", "그림을 만들지 못했습니다: {0}": "无法生成图片: {0}",
"그림을 바꿔 만들기": "基于图片生成", "그림을 바꿔 만들기": "基于图片生成",
@ -807,6 +810,7 @@
"그림을 읽을 수 없습니다": "无法读取图片", "그림을 읽을 수 없습니다": "无法读取图片",
"그림을 페이지로 넣기 (이 페이지 뒤)": "将图片作为页面加入 (本页之后)", "그림을 페이지로 넣기 (이 페이지 뒤)": "将图片作为页面加入 (本页之后)",
"그림이 거의 같고 마지막 단계가 10배쯤 빠릅니다 (받아 두었을 때)": "图片几乎相同,最后一步快约 10 倍 (已下载时)", "그림이 거의 같고 마지막 단계가 10배쯤 빠릅니다 (받아 두었을 때)": "图片几乎相同,最后一步快约 10 倍 (已下载时)",
"그만두었습니다": "已停止",
"그중 {0}개는 받는 쪽이 더 새것입니다 - 더 새 파일을 예전 것으로 덮어씁니다": "其中 {0} 个在接收端较新 - 将用旧文件覆盖较新的文件", "그중 {0}개는 받는 쪽이 더 새것입니다 - 더 새 파일을 예전 것으로 덮어씁니다": "其中 {0} 个在接收端较新 - 将用旧文件覆盖较新的文件",
"그중 폴더 {0}개 - 안의 파일 · 폴더도 모두 함께": "其中 {0} 个文件夹 - 其中的文件和文件夹也一并处理", "그중 폴더 {0}개 - 안의 파일 · 폴더도 모두 함께": "其中 {0} 个文件夹 - 其中的文件和文件夹也一并处理",
"글 → 그림 · 그림 → 그림 (기기 안에서, 모델은 환경 설정에서 받기)": "文字 → 图片 · 图片 → 图片(在本设备上;模型在设置中下载)", "글 → 그림 · 그림 → 그림 (기기 안에서, 모델은 환경 설정에서 받기)": "文字 → 图片 · 图片 → 图片(在本设备上;模型在设置中下载)",
@ -1026,6 +1030,7 @@
"단축키 - 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 刷新", "단축키 - 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 刷新",
"단축키 해제": "注销快捷键", "단축키 해제": "注销快捷键",
"닫기": "关闭", "닫기": "关闭",
"닫힌 문서입니다": "文档已关闭",
"닫힘 (실행 안 함)": "已关闭 (未执行)", "닫힘 (실행 안 함)": "已关闭 (未执行)",
"담아 두고 붙여넣기 (기본)": "暂存后粘贴 (默认)", "담아 두고 붙여넣기 (기본)": "暂存后粘贴 (默认)",
"답이 없습니다": "没有应答", "답이 없습니다": "没有应答",
@ -1295,6 +1300,7 @@
"바꾸기 ({0}개)": "重命名 ({0} 个)", "바꾸기 ({0}개)": "重命名 ({0} 个)",
"바꾸는 정도 {0}%": "改变程度 {0}%", "바꾸는 정도 {0}%": "改变程度 {0}%",
"바꾸면 바로 저장됩니다": "更改后立即保存", "바꾸면 바로 저장됩니다": "更改后立即保存",
"바꾼 PDF 를 저장하지 못했습니다 (저장 공간을 확인해 주세요)": "无法保存转换后的 PDF (请检查存储空间)",
"바꾼 것이 없습니다 (원본 그대로)": "未做更改(保持原样)", "바꾼 것이 없습니다 (원본 그대로)": "未做更改(保持原样)",
"바꿀 글": "替换为", "바꿀 글": "替换为",
"바꿀 글 (비우면 지움)": "替换为 (留空则删除)", "바꿀 글 (비우면 지움)": "替换为 (留空则删除)",
@ -1397,6 +1403,7 @@
"벵골어": "孟加拉语", "벵골어": "孟加拉语",
"변경 내용을 저장하지 않고 나갈까요?": "不保存更改就离开吗?", "변경 내용을 저장하지 않고 나갈까요?": "不保存更改就离开吗?",
"변환": "转换", "변환": "转换",
"변환기 안에서 오류가 났습니다": "转换器内部出错",
"보관한 설정으로 되살리고 다시 시작": "用保存的设置恢复并重新启动", "보관한 설정으로 되살리고 다시 시작": "用保存的设置恢复并重新启动",
"보관해 둔 설정을 되살렸습니다: {0}": "已恢复保存的设置: {0}", "보관해 둔 설정을 되살렸습니다: {0}": "已恢复保存的设置: {0}",
"보기": "查看", "보기": "查看",
@ -1756,6 +1763,8 @@
"알리지 않음": "不提醒", "알리지 않음": "不提醒",
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知以 JJ_MKVMaker 名义显示,点击即可打开应用 (仅当前用户,无需管理员权限)", "알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知以 JJ_MKVMaker 名义显示,点击即可打开应用 (仅当前用户,无需管理员权限)",
"암호 ZIP 으로 묶는 중 · {0}": "正在创建加密 ZIP · {0}", "암호 ZIP 으로 묶는 중 · {0}": "正在创建加密 ZIP · {0}",
"암호가 걸린 문서입니다": "这是受密码保护的文档",
"암호가 맞지 않습니다": "密码不正确",
"암호로 묶기": "加密压缩", "암호로 묶기": "加密压缩",
"암호화": "加密", "암호화": "加密",
"암호화 (TLS) 연결을 맺지 못했습니다: {0}": "无法建立加密 (TLS) 连接:{0}", "암호화 (TLS) 연결을 맺지 못했습니다: {0}": "无法建立加密 (TLS) 连接:{0}",
@ -1788,6 +1797,7 @@
"앱 안 브라우저의 쿠키 (로그인) · 사이트 데이터 · 캐시 · 방문 기록과, 다운로드용으로 내보낸 쿠키 파일을 지웁니다. 사이트에 다시 로그인해야 합니다. 즐겨찾기는 그대로입니다.": "删除应用内浏览器的 Cookie (登录) · 网站数据 · 缓存 · 浏览记录,以及为下载导出的 Cookie 文件。需要重新登录网站。收藏保持不变。", "앱 안 브라우저의 쿠키 (로그인) · 사이트 데이터 · 캐시 · 방문 기록과, 다운로드용으로 내보낸 쿠키 파일을 지웁니다. 사이트에 다시 로그인해야 합니다. 즐겨찾기는 그대로입니다.": "删除应用内浏览器的 Cookie (登录) · 网站数据 · 缓存 · 浏览记录,以及为下载导出的 Cookie 文件。需要重新登录网站。收藏保持不变。",
"앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "应用内浏览器 · 书签 · 视频下载", "앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "应用内浏览器 · 书签 · 视频下载",
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "应用内网页浏览器: 代为点击[跳过],无法跳过的广告会静音并快进(跳过时屏幕顶部显示“正在跳过广告…”)", "앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "应用内网页浏览器: 代为点击[跳过],无法跳过的广告会静音并快进(跳过时屏幕顶部显示“正在跳过广告…”)",
"앱 안에서 열지 못해 다른 앱으로 엽니다: {0}": "无法在应用内打开,改用其他应用打开: {0}",
"앱 지우기": "卸载应用", "앱 지우기": "卸载应用",
"앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "在应用回收站保留 {0} 天后删除 (可在资源管理器的 [回收站] 中还原)。", "앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "在应用回收站保留 {0} 天后删除 (可在资源管理器的 [回收站] 中还原)。",
"앱에 들어 있는 것 (기본)": "应用内置 (默认)", "앱에 들어 있는 것 (기본)": "应用内置 (默认)",
@ -1827,6 +1837,7 @@
"엔진": "引擎", "엔진": "引擎",
"여기까지 범위 고르기": "选择到此处的范围", "여기까지 범위 고르기": "选择到此处的范围",
"여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "拖放到此处即可加入制作 MKV 的视频列表", "여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "拖放到此处即可加入制作 MKV 的视频列表",
"여는 중: {0}": "正在打开: {0}",
"여러 개를 골랐을 때": "选择多个时", "여러 개를 골랐을 때": "选择多个时",
"여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "将多个视频制作成 MKV 时同时处理的数量(重新编码会占用大量 CPU)", "여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "将多个视频制作成 MKV 时同时处理的数量(重新编码会占用大量 CPU)",
"여러 번 틀려 {0}초 뒤에 다시 넣을 수 있습니다": "错误次数过多,{0} 秒后可再次输入", "여러 번 틀려 {0}초 뒤에 다시 넣을 수 있습니다": "错误次数过多,{0} 秒后可再次输入",
@ -2071,6 +2082,7 @@
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "此位置 (网络驱动器 · 网络共享 · U 盘等) 没有回收站,会直接删除。", "이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "此位置 (网络驱动器 · 网络共享 · U 盘等) 没有回收站,会直接删除。",
"이 이동 버튼 지우기": "删除此移动按钮", "이 이동 버튼 지우기": "删除此移动按钮",
"이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "下次执行此移动时将使用这些选项。现在用新选项执行吗?", "이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "下次执行此移动时将使用这些选项。现在用新选项执行吗?",
"이 쪽을 그리지 못했습니다": "无法渲染此页",
"이 파일에 맞는 자막 우선 (영상 해시)": "优先匹配此文件的字幕(视频哈希)", "이 파일에 맞는 자막 우선 (영상 해시)": "优先匹配此文件的字幕(视频哈希)",
"이 파일용": "适用于此文件", "이 파일용": "适用于此文件",
"이 파일을 열 수 있는 앱이 없습니다.": "没有可以打开此文件的应用。", "이 파일을 열 수 있는 앱이 없습니다.": "没有可以打开此文件的应用。",
@ -2450,6 +2462,7 @@
"지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "上次运行未正常结束(最后检查 {0}:{1})。已记录在任务记录中。", "지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "上次运行未正常结束(最后检查 {0}:{1})。已记录在任务记录中。",
"지난 업데이트가 남긴 임시 파일을 지움: {0}": "已删除上次更新留下的临时文件: {0}", "지난 업데이트가 남긴 임시 파일을 지움: {0}": "已删除上次更新留下的临时文件: {0}",
"지난 작업 기록": "旧任务记录", "지난 작업 기록": "旧任务记录",
"지난번에 남은 문서 보기 임시 폴더를 지움: {0}개": "已删除上次留下的文档查看临时文件夹: {0} 个",
"지문 확인하고 연결": "确认指纹并连接", "지문 확인하고 연결": "确认指纹并连接",
"지우고 맞추기": "删除并同步", "지우고 맞추기": "删除并同步",
"지우고 초기화": "删除并重置", "지우고 초기화": "删除并重置",
@ -2491,6 +2504,7 @@
"진행 패널 보기": "显示进度面板", "진행 패널 보기": "显示进度面板",
"집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 \"인증서 확인 안 함\" 을 켜세요.": "如果是自签名证书 (如家用 NAS),请在 [修改服务器设置] 中开启“不验证证书”。", "집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 \"인증서 확인 안 함\" 을 켜세요.": "如果是自签名证书 (如家用 NAS),请在 [修改服务器设置] 中开启“不验证证书”。",
"짧을수록 빨리 맞추지만 배터리를 더 씁니다.": "间隔越短同步越快,但更耗电。", "짧을수록 빨리 맞추지만 배터리를 더 씁니다.": "间隔越短同步越快,但更耗电。",
"쪽 범위가 맞지 않습니다": "页面范围无效",
"차례 기다림": "排队等待", "차례 기다림": "排队等待",
"창": "窗格", "창": "窗格",
"창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "窗口、任务栏、托盘以及应用内左上角按钮的图标(程序文件和桌面快捷方式的图标不变)", "창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "窗口、任务栏、托盘以及应用内左上角按钮的图标(程序文件和桌面快捷方式的图标不变)",
@ -2658,6 +2672,7 @@
"파일만 골랐을 때": "只选择文件时", "파일만 골랐을 때": "只选择文件时",
"파일을 복사하지 못했습니다": "无法复制文件", "파일을 복사하지 못했습니다": "无法复制文件",
"파일을 분석할 수 없습니다: {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 로 빌드 (이 앱은 RAR 을 만들지 않습니다). 공식 소스 (https://www.7-zip.org/a/7z2501-src.7z) 를 고치지 않고 Android NDK 로 빌드
(빌드 방법: 소스의 tool/build_7zz_android.sh). 따로 실행하는 프로그램이며, 대응 소스는 위 주소와 이 앱 판의 태그에 있습니다. (빌드 방법: 소스의 tool/build_7zz_android.sh). 따로 실행하는 프로그램이며, 대응 소스는 위 주소와 이 앱 판의 태그에 있습니다.
원문: 앱 안 "오픈 소스 라이선스" 의 7-Zip (7ZIP_LICENSE.txt) · GNU LGPL 2.1 원문: 앱 안 "오픈 소스 라이선스" 의 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 - stable-diffusion.cpp · ggml (libsdcli.so 라는 이름의 실행 파일) MIT https://github.com/leejet/stable-diffusion.cpp
커밋 228c707 을 고치지 않고 Android NDK 로 빌드 (AI 그림 · 해상도 올리기, 빌드 방법: 소스의 tool/build_sdcpp_android.sh) 커밋 228c707 을 고치지 않고 Android NDK 로 빌드 (AI 그림 · 해상도 올리기, 빌드 방법: 소스의 tool/build_sdcpp_android.sh)
함께 묶인 구성 요소: ggml (MIT) · libwebp · libwebm (BSD-3) · Oniguruma · Darts-clone (BSD-2) · utf8proc · nlohmann/json · 함께 묶인 구성 요소: ggml (MIT) · libwebp · libwebm (BSD-3) · Oniguruma · Darts-clone (BSD-2) · utf8proc · nlohmann/json ·

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

@ -32,6 +32,7 @@ import '../services/platform_services.dart';
import '../services/system_usage.dart'; import '../services/system_usage.dart';
import 'settings.dart'; import 'settings.dart';
import 'cleanup.dart' show removeUpdateLeftovers; import 'cleanup.dart' show removeUpdateLeftovers;
import '../core/hwp_source.dart' show removeStaleHwpWork;
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import '../core/remote_idle.dart'; import '../core/remote_idle.dart';
@ -213,6 +214,14 @@ class AppController extends ChangeNotifier {
final gone = removeUpdateLeftovers(Directory.systemTemp.path); final gone = removeUpdateLeftovers(Directory.systemTemp.path);
if (gone.isNotEmpty) _log(trf('지난 업데이트가 남긴 임시 파일을 지움: {0}', [gone.join(', ')])); 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(); ffmpegVersion = await _tool.version();
if (ffmpegVersion == null) { if (ffmpegVersion == null) {
_log(tr('⚠ FFmpeg 를 찾을 수 없습니다. MKV 만들기를 사용할 수 없습니다.')); _log(tr('⚠ FFmpeg 를 찾을 수 없습니다. MKV 만들기를 사용할 수 없습니다.'));

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 (_) {}
}
}

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

@ -25,6 +25,7 @@ import '../core/ftp.dart' show FtpCertException, FtpRegistry;
import '../core/smb.dart' show SmbRegistry; import '../core/smb.dart' show SmbRegistry;
import '../core/webdav.dart' show DavRegistry; import '../core/webdav.dart' show DavRegistry;
import '../platform/android/android_storage.dart'; import '../platform/android/android_storage.dart';
import '../platform/android/hwp_pdf_ffi.dart' show RhwpEngine;
import 'app_actions.dart'; import 'app_actions.dart';
import 'explorer_look.dart'; import 'explorer_look.dart';
import 'key_hint.dart'; import 'key_hint.dart';
@ -40,6 +41,7 @@ import '../platform/windows/recycle_bin.dart';
import '../core/rsync_filter.dart'; import '../core/rsync_filter.dart';
import '../core/sync_preview.dart'; import '../core/sync_preview.dart';
import 'path_label.dart'; import 'path_label.dart';
import 'progress_dialog.dart';
import 'sync_preview_view.dart'; import 'sync_preview_view.dart';
import 'sftp_settings.dart'; import 'sftp_settings.dart';
import 'ftp_settings.dart'; import 'ftp_settings.dart';
@ -50,6 +52,7 @@ import '../app/component_store.dart';
import '../app/components.dart'; import '../app/components.dart';
import 'zip_page.dart'; import 'zip_page.dart';
import '../core/reader_sources.dart'; import '../core/reader_sources.dart';
import '../core/hwp_source.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import 'explorer_bookmarks.dart'; import 'explorer_bookmarks.dart';
import 'explorer_search.dart'; import 'explorer_search.dart';
@ -1686,6 +1689,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
if (ext == 'pdf') return _viewPdf(path); if (ext == 'pdf') return _viewPdf(path);
if (zipExtensions.contains(ext)) return _openZip(path, pane); 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 로 바꿔 봄) // 문서 미리보기 (Windows: 설치한 변환기로 PDF 로 바꿔 봄)
if (c.settings.components.contains('docs') && Platform.isWindows && if (c.settings.components.contains('docs') && Platform.isWindows &&
AppComponent.docExtensions.contains(extOf(path))) { AppComponent.docExtensions.contains(extOf(path))) {
@ -1761,17 +1769,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
Future<void> _viewDoc(String path) async { Future<void> _viewDoc(String path) async {
final temp = await c.services.storage.tempDirectory(); final temp = await c.services.storage.tempDirectory();
if (!mounted) return; if (!mounted) return;
final dialog = showDialog<void>( final dialog = showProgressDialog(context, trf('PDF 로 바꾸는 중: {0}', [vBasename(path)]));
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
content: Row(children: [
const CircularProgressIndicator(),
const SizedBox(width: 16),
Expanded(child: Text(trf('PDF 로 바꾸는 중: {0}', [vBasename(path)]))),
]),
),
);
String? pdf; String? pdf;
Object? error; Object? error;
try { try {
@ -1780,8 +1778,10 @@ class _ExplorerPageState extends State<ExplorerPage> {
} catch (e) { } catch (e) {
error = e; error = e;
} }
if (mounted) Navigator.of(context).pop(); // 사용자가 [취소] · ← 로 그만두었으면 결과를 보이지 않는다 (바꾸기는 끝까지 돌았음)
await dialog; if (dialog.cancelled()) return;
dialog.close();
await dialog.done;
if (pdf == null) { if (pdf == null) {
_snack(trf('미리 볼 수 없습니다: {0}', [error])); _snack(trf('미리 볼 수 없습니다: {0}', [error]));
return; return;
@ -1797,6 +1797,37 @@ class _ExplorerPageState extends State<ExplorerPage> {
await openReader(context, c, src); 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 { Future<void> _viewPdf(String path) async {
final temp = await c.services.storage.tempDirectory(); final temp = await c.services.storage.tempDirectory();
PdfSource src; PdfSource src;

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'), (['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 브라우저: tor 와 함께 묶인 Libevent · OpenSSL · zlib · zstd (공식 번들의 docs)
(['Tor 0.4.9.13 (Libevent · OpenSSL · zlib · Zstandard)'], 'assets/licenses/TOR_LICENSES.txt'), (['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 쪽 패키지 라이선스와 따로) // 175: 함께 빌드한 네이티브 라이브러리 (Dart 쪽 패키지 라이선스와 따로)
([tr('whisper.cpp · ggml (whisper_ggml 안)')], 'assets/licenses/WHISPER_CPP_LICENSE.txt'), ([tr('whisper.cpp · ggml (whisper_ggml 안)')], 'assets/licenses/WHISPER_CPP_LICENSE.txt'),
(['ONNX Runtime 1.15.1 (Microsoft)'], 'assets/licenses/ONNXRUNTIME_LICENSES.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

@ -7,6 +7,7 @@ import 'package:wakelock_plus/wakelock_plus.dart';
import '../app/app_controller.dart'; import '../app/app_controller.dart';
import '../core/reader_sources.dart'; import '../core/reader_sources.dart';
import '../core/hwp_source.dart' show HwpPdfException, HwpSource;
import '../core/vfs.dart'; import '../core/vfs.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import 'ai_image_page.dart'; import 'ai_image_page.dart';
@ -67,6 +68,9 @@ class _ReaderPageState extends State<ReaderPage> {
/// PDF 는 페이지를 지우거나 넣으면 다시 그린다 /// PDF 는 페이지를 지우거나 넣으면 다시 그린다
int _version = 0; int _version = 0;
/// [다시 시도] 를 누른 횟수 (장마다) - 그 장의 화면을 새로 만든다 (28 지적: 같은 키면 실패한 그림을 그대로 다시 썼다)
final _retry = <int, int>{};
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -159,7 +163,8 @@ class _ReaderPageState extends State<ReaderPage> {
if (d == null) return false; if (d == null) return false;
Size turned(Size s) => _turns.isOdd ? Size(s.height, s.width) : s; 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)], 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: 나눠 본 장에서 바로 한 장으로 (설정을 "끄기" 로) /// 223: 나눠 본 장에서 바로 한 장으로 (설정을 "끄기" 로)
@ -439,13 +444,52 @@ class _ReaderPageState extends State<ReaderPage> {
final fit = c.settings.readerFit; final fit = c.settings.readerFit;
final slot = _v[i]; final slot = _v[i];
final half = slot.part >= 0; final half = slot.part >= 0;
Widget failed(Object? e) => SizedBox( // 관리자 지시 (10/20, HWP 2단계): 이유와 [다시 시도] - 그 장의 기억을 지우고 다시 부른다 (시간 제한을 넘긴 HWP 묶음 등)
width: 320, // [provider]: 그림 파일 · ZIP 의 그림을 읽다 (디코드) 실패한 것 - 화면 글은 종류에 맞게, 원문은 [자세히] (28 지적)
height: 120, Widget failed(Object? e, {ImageProvider? provider}) {
child: Center(child: Text(trf('열 수 없습니다: {0}', [e]), style: const TextStyle(color: Colors.white70)))); 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>( 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)), future: _sharp[i] ?? (half ? _loadHalf(slot.page) : _load(slot.page)),
builder: (context, snap) { builder: (context, snap) {
if (snap.hasError) return Center(child: failed(snap.error)); if (snap.hasError) return Center(child: failed(snap.error));
@ -454,7 +498,7 @@ class _ReaderPageState extends State<ReaderPage> {
Widget picture = r.image != null Widget picture = r.image != null
? RawImage(image: r.image, filterQuality: FilterQuality.medium) ? RawImage(image: r.image, filterQuality: FilterQuality.medium)
: Image(image: r.provider!, gaplessPlayback: true, filterQuality: FilterQuality.medium, : Image(image: r.provider!, gaplessPlayback: true, filterQuality: FilterQuality.medium,
errorBuilder: (_, e, _) => failed(e)); errorBuilder: (_, e, _) => failed(e, provider: r.provider));
// 120: 회전 · 상하 반전 (보기만, 파일은 그대로) // 120: 회전 · 상하 반전 (보기만, 파일은 그대로)
if (_turns != 0) picture = RotatedBox(quarterTurns: _turns, child: picture); if (_turns != 0) picture = RotatedBox(quarterTurns: _turns, child: picture);
if (_flipV) picture = Transform.flip(flipY: true, child: picture); if (_flipV) picture = Transform.flip(flipY: true, child: picture);

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,316 @@
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)); // 오래 도는 앞 묶음
final b = d.exportPdf(5, 10, 'b', timeout: const Duration(milliseconds: 300));
await Future<void>.delayed(const Duration(milliseconds: 700)); // 뒤 묶음의 제한보다 오래 줄에서 기다림
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('[패키지 라이선스]'));
expect(a, contains('GPLv3')); 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'));
});
} }

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

@ -188,6 +188,33 @@ void main() {
expect(c.settings.readerSystemBars, isFalse); expect(c.settings.readerSystemBars, isFalse);
expect(AppSettings.fromJson(c.settings.toJson()).readerSystemBars, isFalse); expect(AppSettings.fromJson(c.settings.toJson()).readerSystemBars, isFalse);
}); });
testWidgets('그림이 깨졌으면 화면 글 + [자세히], 파일을 고친 뒤 [다시 시도] 하면 열린다 (28 지적: 같은 키 · 같은 그림이라 Image 가 다시 읽지 않았다 - 장의 키를 올리고 그림 캐시도 비움)', (tester) async {
tester.view.physicalSize = const Size(900, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final broken = File(p.join(tmp.path, 'broken.png'))..writeAsBytesSync([1, 2, 3, 4, 5]);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final src = ImageFilesSource([broken.path], tempDir: tmp.path, title: 'imgs');
Future<void> settle() async {
for (var k = 0; k < 20; k++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 50)));
await tester.pump();
}
}
await tester.pumpWidget(MaterialApp(home: ReaderPage(c: c, source: src)));
await settle();
expect(find.text('그림 파일을 읽을 수 없습니다 - 깨졌거나 지원하지 않는 형식'), findsOneWidget, reason: '원문 대신 화면 글');
expect(find.text('자세히'), findsOneWidget, reason: '원문은 [자세히] 에');
// 파일을 고치고 [다시 시도]
broken.writeAsBytesSync(_png(0, 200, 0));
await tester.tap(find.text('다시 시도'));
await settle();
expect(find.text('그림 파일을 읽을 수 없습니다 - 깨졌거나 지원하지 않는 형식'), findsNothing);
expect(find.byType(RawImage), findsWidgets, reason: '고친 그림이 보임');
await tester.pumpWidget(const SizedBox());
});
} }
/// 읽은 너비를 기록 (확대하면 더 크게 다시 읽는지) /// 읽은 너비를 기록 (확대하면 더 크게 다시 읽는지)
@ -209,4 +236,5 @@ class _CountingSource implements ReaderSource {
@override @override
Future<void> dispose() => inner.dispose(); Future<void> dispose() => inner.dispose();
} }

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. # 새 버전 확인도 "최신과 같음" 이 된다. 시험판은 flutter build apk --build-number 9xx 로 빌드한다. 정식 판 APK 는 -Release.
# - 순번 900 이상의 v 태그 (시험판 Pre-release, 예: v2026.10.18_977) 는 정식 판으로 보지 않는다 (관리자 지시 10/19, 28 지적: # - 순번 900 이상의 v 태그 (시험판 Pre-release, 예: v2026.10.18_977) 는 정식 판으로 보지 않는다 (관리자 지시 10/19, 28 지적:
# 시험판에 태그를 단 뒤 같은 APK 를 다시 검사하면 "정식 판 번호와 같음" 으로 거짓 실패). 정식 판 (-Release) 인데 순번이 900 이상이면 실패. # 시험판에 태그를 단 뒤 같은 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] # 사용: powershell -ExecutionPolicy Bypass -File tool\check_apk.ps1 -Apk build\app\outputs\flutter-apk\app-release.apk [-Release]
param( param(
[Parameter(Mandatory = $true)][string]$Apk, [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('#') }) $listed = @(Get-Content $Libs | ForEach-Object { $_.Trim() } | Where-Object { $_ -and -not $_.StartsWith('#') })
# third_party 에 있는 파일 (목록 밖이면 APK 에 없어야 함) # third_party 에 있는 파일 (목록 밖이면 APK 에 없어야 함)
$tp = @() $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 $dir = Join-Path $app $d
if (Test-Path $dir) { $tp += @(Get-ChildItem $dir -Recurse -Filter *.so | ForEach-Object { $_.Name }) } 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 }) $libsIn = @($zip.Entries | Where-Object { $_.FullName -like 'lib/arm64-v8a/*.so' } | ForEach-Object { $_.Name })
$dexes = @($zip.Entries | Where-Object { $_.FullName -match '^classes\d*\.dex$' }) $dexes = @($zip.Entries | Where-Object { $_.FullName -match '^classes\d*\.dex$' })
if ($dexes.Count -eq 0) { throw "dex 가 없습니다: $Apk" } 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 $found = $null
foreach ($e in $dexes) { foreach ($e in $dexes) {
$s = $e.Open() $s = $e.Open()
@ -61,6 +70,21 @@ try {
$zip.Dispose() $zip.Dispose()
} }
$bad = @() $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 · 월 · 일 · 번호) # 시험판 번호: 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 $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 } 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 Write-Host "실패: GeneratedPluginRegistrant 가 dex 에 없습니다 (켜자마자 꺼지는 APK). flutter pub get 뒤 --no-pub 없이 다시 빌드하세요." -ForegroundColor Red
exit 1 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) });
}
}