Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "hanfix"
version = "0.2.7"
version = "0.2.8"
edition = "2021"
rust-version = "1.80"
description = "Fix commands and text typed in the wrong keyboard layout (Korean 2-set <-> QWERTY)"
Expand Down
93 changes: 90 additions & 3 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,12 +191,98 @@ hanfix --to en 한글 -> gksrmf
**방향은 자동으로 정합니다.** 한글이 하나라도 있으면 영문 키로, 없으면 한글로 바꿉니다.
자동 판단이 틀리면 `--to ko` 또는 `--to en`으로 방향을 정하세요.

### 이스케이프된 한글 되돌리기

파일 이름이나 URL에 한글이 `트리` 대신 `_ED_8A_B8_EB_A6_AC`나 `%ED%8A%B8%EB%A6%AC`처럼 UTF-8 바이트로
이스케이프되어 들어 있을 때가 있습니다. `hanfix inspect`는 무엇이 들어 있는지 설명하고, `hanfix decode`는
지정한 형식으로만 되돌립니다.

```console
$ hanfix inspect _ED_8A_B8_EB_A6_AC
Detected: underscore-hex
Status: valid
Encoding: UTF-8
Reason: 6 escaped bytes are valid UTF-8; other characters are kept

Original: _ED_8A_B8_EB_A6_AC
Candidate: 트리
$ hanfix decode --type underscore-hex _ED_8A_B8_EB_A6_AC
트리
$ hanfix decode --type percent %ED%8A%B8%EB%A6%AC
트리
$ hanfix decode --type underscore-hex report__ED_8A_B8.pdf
report_트.pdf
$ printf '%s' '_ED_8A_B8' | hanfix decode --type underscore-hex
트
```

- **underscore-hex** (`_XX`): `_` 뒤에 대문자 16진수 두 자리, 바이트 `80`-`FF`만 해당합니다. ASCII는 이스케이프할
필요가 없으므로 `backup_2026_10_29`, `hello_bed`, `report__ED_8A_B8`의 첫 `_`는 그대로 두고, `__`를 공백으로
바꾸지도 않습니다. `_ed_8a_b8`처럼 소문자로 된 것은 되돌리지 않으며, `inspect`가 이를 보면 안내 한 줄을 덧붙입니다.
- underscore-hex의 이어진 이스케이프는 UTF-8 글자를 시작할 수 없는 바이트(`80`-`BF`, `C0`, `C1`, `F5`-`FF`)에서도
끝나고, 그 바이트는 글자로 둡니다. 그래서 `IMG_8123.JPG`와 `test_99.txt`는 그대로 나오고,
`_ED_8A_B8_99.txt`는 `트_99.txt`가 됩니다. 글자를 시작하는 바이트는 나머지 바이트가 있어야 하므로,
`트리_DB_backup.sql`을 `_ED_8A_B8_EB_A6_AC_DB_backup.sql`로 쓴 이름은 실패합니다. `_DB`는 2바이트 글자의
시작인데 뒤가 없습니다.
- **percent** (`%XX`): URL 인코딩입니다. 모든 바이트, 대소문자 모두 받습니다(`%20`은 공백). 뒤에 16진수 두
자리가 없는 `%`는 글자로 둡니다(`100% done`). `+`는 그대로 `+`입니다. 공백을 뜻하는 것은 HTML 폼 데이터뿐입니다. 모든 `%XX`를
이스케이프로 보므로 `%ED%8A%B8%99`는 실패합니다.
- 두 형식에 나뉘어 들어간 바이트열(`%ED_8A_B8`)은 어느 쪽으로도 되돌리지 않습니다.
- 이어진 이스케이프는 하나의 바이트열로 보고, 완전하고 유효한 UTF-8이어야 합니다. 하나라도 아니면(`_ED_8A`는
3바이트 글자의 앞 2바이트) `decode`는 아무것도 출력하지 않고, 이유를 표준 오류에 쓰고, 종료 코드 1로 끝납니다.
`�`로 바꿔 넣지 않습니다. 표준 입력이 UTF-8이 아니면 `inspect`와 `decode` 모두 종료 코드 1로 끝납니다.
- `decode`는 결과만 출력합니다. 이스케이프가 없는 글자는 그대로 나오고 종료 코드는 0입니다. `inspect`는 UTF-8
글자에서 실패하지 않고 아무것도 바꾸지 않습니다.
- `inspect`와 `decode`는 첫 번째 인자일 때만 하위 명령입니다. `hanfix -- inspect`와 `hf`는 지금처럼 글자를 변환합니다.
- 유효한 UTF-8이라는 것은 바이트가 글자를 이룬다는 뜻일 뿐, 원래 뜻한 글이라는 보장은 아닙니다. 그래서 hanfix는
스스로 되돌리지 않습니다. 형식은 사용자가 정하고, `inspect`로 후보를 먼저 볼 수 있습니다.
- 모지바케(UTF-8을 다른 인코딩으로 읽은 글자, `안녕` 대신 `안녕`)는 아직 지원하지 않습니다.

**이스케이프된 이름의 파일 여러 개를 한꺼번에 바꾸기 (PowerShell).** 함수를 정의한 뒤 폴더에서
`Rename-Decoded`를 실행하면 `old -> new` 목록만 보여 줍니다. 실제로 바꾸려면 `-Apply`를 붙입니다. 되돌릴 수
없는 이름, 바뀌지 않는 이름, 파일 이름이 될 수 없는 결과(제어 문자, 끝의 점이나 공백, `NUL` 같은 Windows 장치 이름), 이미 있는 파일이나 같은
실행에서 먼저 바꾸기로 한 이름과 겹치는 경우(대소문자 무시)는 건너뜁니다.

```powershell
function Rename-Decoded {
param([string]$Path = '.', [ValidateSet('underscore-hex', 'percent')][string]$Type = 'underscore-hex', [switch]$Apply)
# hanfix 0.2.7 and older read "decode ..." as text to convert and exit 0, which would rename every file.
$probe = (hanfix decode --type percent -- '%41' 2>$null) -join "`n"
if ($LASTEXITCODE -ne 0 -or $probe -ne 'A') { throw 'hanfix too old: update with hanfix --update' }
# Every name in the folder and every new name planned so far, ignoring case, so the preview lists
# exactly what -Apply does.
$taken = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
Get-ChildItem -LiteralPath $Path -Force -Name | ForEach-Object { [void]$taken.Add($_) }
$enc = [Console]::OutputEncoding
[Console]::OutputEncoding = [Text.Encoding]::UTF8
try {
foreach ($f in Get-ChildItem -LiteralPath $Path -File) {
$out = @(hanfix decode --type $Type -- $f.Name 2>$null)
if ($LASTEXITCODE -ne 0) { Write-Warning "skip $($f.Name): does not decode"; continue }
$new = $out[0]
if ($new -ceq $f.Name) { continue }
# One line, no control characters, no trailing dot or space, which Windows drops ("a." -> "a"),
# and no Windows device name.
if ($out.Count -ne 1 -or $new -match '[\x00-\x1F\x7F]|[. ]$|^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)' -or
$new.IndexOfAny([IO.Path]::GetInvalidFileNameChars()) -ge 0) {
Write-Warning "skip $($f.Name): '$new' is not a file name"; continue
}
if (-not $taken.Add($new)) { Write-Warning "skip $($f.Name): $new exists or is planned"; continue }
"$($f.Name) -> $new"
if ($Apply) { Rename-Item -LiteralPath $f.FullName -NewName $new }
}
} finally { [Console]::OutputEncoding = $enc }
}
```

### 옵션

```
usage: hanfix [-h] [-V] [--to {ko,en}] [--] [TEXT ...]
hanfix --init {bash,zsh,fish,powershell} [--alias NAME]
hanfix --update
hanfix inspect [--] [TEXT ...]
hanfix decode --type {underscore-hex,percent} [--] [TEXT ...]
```

| 옵션 | 설명 |
Expand All @@ -208,10 +294,11 @@ usage: hanfix [-h] [-V] [--to {ko,en}] [--] [TEXT ...]
| `--init SHELL` | `hf` 함수를 정의하는 셸 코드를 출력합니다. |
| `--alias NAME` | `--init`이 만드는 함수 이름입니다. 기본값은 `hf`입니다. |
| `--` | 옵션의 끝을 표시합니다. 이후는 모두 글자로 봅니다. |
| `--type TYPE` | `decode` 전용: `underscore-hex` 또는 `percent`. 꼭 지정해야 합니다. |

- 옵션은 맨 앞에서만 읽습니다. 나머지는 전부 변환할 글자로 봅니다. 그래서 `hanfix ㅣㄴ -ㅁ`의 `-ㅁ`은 옵션이 아니라 글자(`-a`)입니다.
- `-`로 시작하는 영문 글자를 바꾸려면 앞에 `--`를 붙이세요: `hanfix -- -rk` → `-가`.
- 종료 코드는 성공 0, 잘못된 사용 2입니다.
- 종료 코드는 성공 0, `decode` 실패와 `inspect`·`decode`의 입력이 UTF-8이 아닐 때 1, 잘못된 사용 2입니다.

### 입력 중인 줄 바로 고치기 (선택)

Expand Down Expand Up @@ -260,9 +347,9 @@ bind -x '"\eh": READLINE_LINE=$(hanfix -- "$READLINE_LINE"); READLINE_POINT=${#R
## 개발

```sh
cargo test # 조합 사례, 한글 음절 11,172자 전체의 왕복 변환, 셸 코드
cargo test # 조합 사례, 한글 음절 11,172자 전체의 왕복 변환, 셸 코드, decode 사례, CLI
sh tests/shells.sh # bash, zsh, fish에서 hf가 실제로 고친 명령을 실행하는지 (설치된 hanfix 사용)
pwsh tests/shells.ps1
pwsh tests/shells.ps1 # PowerShell에서 같은 확인과 위의 Rename-Decoded 예시
```

빠른 설치의 세팅 단계도 테스트합니다. 코드 블록 앞의 `<!-- test -->` 주석이 대상을 표시하고, CI가 공통
Expand Down
98 changes: 95 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,12 +195,102 @@ hanfix --to en 한글 -> gksrmf
**The direction is automatic.** Text with any Hangul goes back to English keys; anything else is composed
into Hangul. If the guess is wrong, use `--to ko` or `--to en`.

### Decode escaped Korean text

File names and URLs sometimes carry Korean as escaped UTF-8 bytes: `_ED_8A_B8_EB_A6_AC` or
`%ED%8A%B8%EB%A6%AC` instead of `트리`. `hanfix inspect` explains what it finds; `hanfix decode` turns it back,
using only the type you name.

```console
$ hanfix inspect _ED_8A_B8_EB_A6_AC
Detected: underscore-hex
Status: valid
Encoding: UTF-8
Reason: 6 escaped bytes are valid UTF-8; other characters are kept

Original: _ED_8A_B8_EB_A6_AC
Candidate: 트리
$ hanfix decode --type underscore-hex _ED_8A_B8_EB_A6_AC
트리
$ hanfix decode --type percent %ED%8A%B8%EB%A6%AC
트리
$ hanfix decode --type underscore-hex report__ED_8A_B8.pdf
report_트.pdf
$ printf '%s' '_ED_8A_B8' | hanfix decode --type underscore-hex
트
```

- **underscore-hex** (`_XX`): `_` and two upper-case hex digits, for bytes `80`-`FF` only. ASCII never needs
escaping, so `backup_2026_10_29`, `hello_bed` and the first `_` of `report__ED_8A_B8` stay as they are, and
`__` never becomes a space. Lower-case runs such as `_ed_8a_b8` are not decoded; `inspect` adds a note
when it sees one.
- A run of underscore-hex escapes also ends at a byte that cannot start a UTF-8 character (`80`-`BF`, `C0`,
`C1`, `F5`-`FF`), which stays text: `IMG_8123.JPG` and `test_99.txt` come back unchanged, and
`_ED_8A_B8_99.txt` becomes `트_99.txt`. A byte that does start a character still needs the rest of it, so
a name like `트리_DB_backup.sql` written as `_ED_8A_B8_EB_A6_AC_DB_backup.sql` fails: `_DB` starts a
two-byte character and nothing follows.
- **percent** (`%XX`): URL encoding, any byte, either case (`%20` is a space). A `%` without two hex digits
after it is text (`100% done`). `+` stays `+`; it means a space only in HTML form data. Every `%XX` is an
escape, so `%ED%8A%B8%99` fails.
- A byte sequence split between the two types (`%ED_8A_B8`) decodes with neither.
- Consecutive escapes form one byte sequence that must be complete, valid UTF-8. If any sequence in the
text is not (`_ED_8A` is two bytes of a three-byte character), `decode` prints nothing, writes the reason to
standard error and exits with 1. It never substitutes `�`. Input on standard input that is not UTF-8
makes `inspect` and `decode` exit with 1 too.
- `decode` prints only the result; text with no escapes comes back unchanged with exit 0. `inspect` never
fails on UTF-8 text and changes nothing.
- `inspect` and `decode` are subcommands only as the first argument. `hanfix -- inspect` and `hf` still convert
the word as typed text.
- Valid UTF-8 only means the bytes form characters, not that they are the text someone meant. That is why
hanfix never decodes on its own: you name the type, and `inspect` shows the candidate first.
- Mojibake (UTF-8 read as another encoding, `안녕` for `안녕`) is not handled yet.

**Rename many files with escaped names (PowerShell).** Define the function, then run `Rename-Decoded` in a
folder: it only lists `old -> new`. Add `-Apply` to rename. It skips a name that does not decode, does not
change, would not be a valid file name (control characters, a trailing dot or space, a Windows device name such as `NUL`), or would take the name
of an existing file or of another file renamed in the same run (ignoring case).

<!-- rename-example -->
```powershell
function Rename-Decoded {
param([string]$Path = '.', [ValidateSet('underscore-hex', 'percent')][string]$Type = 'underscore-hex', [switch]$Apply)
# hanfix 0.2.7 and older read "decode ..." as text to convert and exit 0, which would rename every file.
$probe = (hanfix decode --type percent -- '%41' 2>$null) -join "`n"
if ($LASTEXITCODE -ne 0 -or $probe -ne 'A') { throw 'hanfix too old: update with hanfix --update' }
# Every name in the folder and every new name planned so far, ignoring case, so the preview lists
# exactly what -Apply does.
$taken = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
Get-ChildItem -LiteralPath $Path -Force -Name | ForEach-Object { [void]$taken.Add($_) }
$enc = [Console]::OutputEncoding
[Console]::OutputEncoding = [Text.Encoding]::UTF8
try {
foreach ($f in Get-ChildItem -LiteralPath $Path -File) {
$out = @(hanfix decode --type $Type -- $f.Name 2>$null)
if ($LASTEXITCODE -ne 0) { Write-Warning "skip $($f.Name): does not decode"; continue }
$new = $out[0]
if ($new -ceq $f.Name) { continue }
# One line, no control characters, no trailing dot or space, which Windows drops ("a." -> "a"),
# and no Windows device name.
if ($out.Count -ne 1 -or $new -match '[\x00-\x1F\x7F]|[. ]$|^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)' -or
$new.IndexOfAny([IO.Path]::GetInvalidFileNameChars()) -ge 0) {
Write-Warning "skip $($f.Name): '$new' is not a file name"; continue
}
if (-not $taken.Add($new)) { Write-Warning "skip $($f.Name): $new exists or is planned"; continue }
"$($f.Name) -> $new"
if ($Apply) { Rename-Item -LiteralPath $f.FullName -NewName $new }
}
} finally { [Console]::OutputEncoding = $enc }
}
```

### Options

```
usage: hanfix [-h] [-V] [--to {ko,en}] [--] [TEXT ...]
hanfix --init {bash,zsh,fish,powershell} [--alias NAME]
hanfix --update
hanfix inspect [--] [TEXT ...]
hanfix decode --type {underscore-hex,percent} [--] [TEXT ...]
```

| Option | Description |
Expand All @@ -212,11 +302,13 @@ usage: hanfix [-h] [-V] [--to {ko,en}] [--] [TEXT ...]
| `--init SHELL` | Print the shell code that defines `hf`. |
| `--alias NAME` | Name of the function `--init` defines. Default `hf`. |
| `--` | End of options. Everything after is text. |
| `--type TYPE` | `decode` only: `underscore-hex` or `percent`. Required. |

- Options are read only at the start; the rest is text to convert. So the `-ㅁ` in `hanfix ㅣㄴ -ㅁ` is
text (`-a`), not an option.
- To convert English text that starts with `-`, put `--` first: `hanfix -- -rk` → `-가`.
- Exit status: 0 on success, 2 on a usage error.
- Exit status: 0 on success, 1 when `decode` fails or the input of `inspect` or `decode` is not UTF-8, 2 on
a usage error.

### Fix the line you are typing (optional)

Expand Down Expand Up @@ -268,9 +360,9 @@ Characters that are not on the layout (digits, spaces, symbols) are left as they
## Development

```sh
cargo test # composition cases, round trip of all 11,172 Hangul syllables, shell code
cargo test # composition cases, round trip of all 11,172 Hangul syllables, shell code, decode cases, the CLI
sh tests/shells.sh # in bash, zsh and fish, hf actually runs the fixed command (uses the installed hanfix)
pwsh tests/shells.ps1
pwsh tests/shells.ps1 # the same in PowerShell, and the Rename-Decoded example above
```

The setup steps under Quick install are tested too. A `<!-- test -->` comment before a code block marks it, and CI runs
Expand Down
Loading
Loading