feat(web): GitHub Pages 기반 NFD→NFC 파일명 변환 웹앱 추가

설치 없이 브라우저에서 한글 파일명을 NFC 로 정규화할 수 있는 웹 도구.
파일/폴더 드롭 → 모든 path segment 를 독립적으로 NFC 변환 → fflate 스트리밍
ZIP 다운로드. UTF-8 flag(bit 11) 자동 설정으로 Windows Explorer 에서 정상 표시.

기존 src/core/filter.ts 의 shouldNormalize 를 그대로 재사용. 빌드는 web/
디렉토리의 별도 Vite 설정으로 격리되어 Electron 빌드에 영향 없음.

main 브랜치 push 시 .github/workflows/pages.yml 이
https://jung-geun.github.io/NFD2NFC/ 로 자동 배포.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 15:11:41 +09:00
parent aa772f7f97
commit e967a0fbdb
15 changed files with 1003 additions and 2 deletions

94
web/src/lib/buildZip.ts Normal file
View File

@@ -0,0 +1,94 @@
import { Zip, ZipPassThrough } from 'fflate';
import type { CollectedFile } from './collectFiles';
export interface BuildProgress {
/** 완료된 파일 수. */
done: number;
/** 전체 파일 수. */
total: number;
}
/**
* NFC 경로로 정규화된 파일 목록을 스트리밍 방식으로 ZIP 으로 묶는다.
*
* - fflate 의 Zip + ZipPassThrough 사용. ZipPassThrough 는 deflate 없이 STORE 모드로 묶기 때문에
* 사진/영상처럼 이미 압축된 파일이 많은 일반 케이스에서 CPU 를 낭비하지 않는다.
* - 파일명은 JS 문자열로 그대로 넘긴다. fflate 는 non-ASCII 가 포함되면 general purpose bit 11
* (UTF-8 flag)을 자동으로 켜기 때문에 Windows Explorer 가 CP949 로 오디코딩하지 않는다.
* - 한 번에 하나씩 파이프해서 다중 entry 가 동시에 push 되지 않도록 한다 (Zip 은 순차 처리 필요).
*/
export async function buildZip(
files: CollectedFile[],
onProgress?: (p: BuildProgress) => void,
): Promise<Blob> {
const chunks: Uint8Array[] = [];
const zipDone = new Promise<void>((resolve, reject) => {
const zip = new Zip((err, data, final) => {
if (err) {
reject(err);
return;
}
if (data && data.length) chunks.push(data);
if (final) resolve();
});
void (async () => {
try {
let done = 0;
for (const f of files) {
const entry = new ZipPassThrough(f.zipPath);
zip.add(entry);
await pipeFileToEntry(f.file, entry);
done++;
onProgress?.({ done, total: files.length });
}
zip.end();
} catch (e) {
reject(e);
}
})();
});
await zipDone;
// Uint8Array<ArrayBufferLike> 의 TypeScript 5.7+ 변경 때문에 BlobPart 직접 매칭이 실패할 수 있어 캐스팅.
return new Blob(chunks as BlobPart[], { type: 'application/zip' });
}
async function pipeFileToEntry(file: File, entry: ZipPassThrough): Promise<void> {
// 일부 브라우저(특히 구버전) 에서 Blob.stream() 이 없을 수 있어 fallback 으로 arrayBuffer 사용.
if (typeof file.stream !== 'function') {
const buf = new Uint8Array(await file.arrayBuffer());
entry.push(buf, true);
return;
}
const reader = file.stream().getReader();
try {
for (;;) {
const { done, value } = await reader.read();
if (done) {
entry.push(new Uint8Array(0), true);
return;
}
if (value && value.length) entry.push(value, false);
}
} finally {
reader.releaseLock();
}
}
/**
* 다운로드 트리거. URL.revokeObjectURL 로 메모리 해제까지 처리.
*/
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
// 같은 microtask 에서 revoke 하면 일부 브라우저에서 다운로드가 취소될 수 있어 약간 지연.
setTimeout(() => URL.revokeObjectURL(url), 1_000);
}