Windows 批次檔令人崩潰的特殊字元處理

有時我們需要對大量的檔案、資料夾分別做壓縮處理。壓縮很簡單,但如果要壓的檔案多達數十數百,一個一個做就太沒效率了。身為專業的程序猿,當然要想點法子偷懶。

我們利用優秀開源綠色軟體 7-Zip 搭配 Windows 內建的批次指令檔來達成偷懶的草根目的。

不過,由於 Windows 的 CMD 對特殊字元超凡入聖的處理能力,我們可以在過程中深切體會到 Windows 批次檔有多麼反人類……。

將多個檔案、資料夾分別壓縮成 7z

我們先從簡單的範例開始:

@echo off
chcp 65001
set "ZIP=%ProgramFiles%\7-Zip\7z.exe"
for %%F in (%*) do (
  "%ZIP%" a -t7z "%%~F.7z" "%%~F" -mx9 -xr!desktop.ini -xr!Thumb.db
)

假設把以上檔案儲存成 Compress7zMax.cmd,之後只要用滑鼠選取多個檔案和資料夾,拖放到這個 Compress7zMax.cmd 批次檔上面,就會分別以最大固實模式壓縮成 7z 檔案。

我們還可以到 %AppData%\Microsoft\Windows\SendTo 資料夾為這個批次檔建立捷徑,取個好聽的名字,例如 最大固實壓縮為 7z,以後選擇檔案與資料夾後就能用右鍵選單的「傳送到」子選單操作。

箇中原理都差不多:把多個檔案或資料夾拖放到或「傳送到」可執行檔或批次檔,就會執行可執行檔或批次檔,並且把每個選取的檔案或資料夾當做參數傳遞進去。

咦?這樣就完成了?不是很簡單嗎?

嗯……你可以試試用這個批次檔壓縮名為 G:\Users\IamGod\Downloads\我是欸漫(神人出品) 的資料夾試試,保證出錯。

這當然不是因為 Windows CMD 內建偵測及阻擋成人內容的功能,而是因為 for...in 會先把輸入的變數展開,變數展開是在執行前完成,因此原程式跑到第 3 行時會執行:

for %%F in (G:\Users\IamGod\Downloads\我是欸漫(神人出品)) do (...)

由於括號對 CMD 有特殊語義,因此程式會找不到真正的資料夾而出錯。

要解決以上問題,一個做法是延遲變數展開,如下:

@echo off
chcp 65001
set "ZIP=%ProgramFiles%\7-Zip\7z.exe"
set ARGS=%*
setlocal EnableDelayedExpansion
for %%F in (!ARGS!) do (
  "%ZIP%" a -t7z "%%~F.7z" "%%~F" -mx9 -xr!desktop.ini -xr!Thumb.db
)
endlocal

這樣程式就會等執行到 !ARGS! 再取出變數值,不會造成 for 迴圈辨識上的混亂。於是我們解決了這問題。

BUT...如果真的這樣改,你會發現程式還是不能跑,而且這次連處理一般檔案都會出錯了!

這是因為 EnableDelayedExpansion 模式下會把 ! 視為特殊字元,而很不幸地我們的 for...in 迴圈在執行到 do 那行時會把裡面的驚歎號當做變數解析,於是在 ...!desktop.ini -xr!... 這個地方出錯。

就算把 -xr!desktop.ini -xr!Thumb.db 刪掉,程式可以正常執行了。但是當要處理的檔案或資料夾含有驚歎號,比如 超刺激神人出品!,還是會出錯,因為執行到 do 時先展開變數的結果是像這樣:

"C:\Program Files\7-Zip\7z.exe" a -t7z "G:\Users\IamGod\Downloads\超刺激神人出品!.7z" "G:\Users\IamGod\Downloads\超刺激神人出品!" -mx9

於是執行時程式就在驚歎號的地方出錯給你看惹!

要解決這問題,我們只好在執行含有驚歎號的程式碼時關掉 EnableDelayedExpansion 模式。於是成為以下的華麗版本:

@echo off
chcp 65001
set "ZIP=%ProgramFiles%\7-Zip\7z.exe"
set ARGS=%*
setlocal EnableDelayedExpansion
for %%F in (!ARGS!) do (
  endlocal
  "%ZIP%" a -t7z "%%~F.7z" "%%~F" -mx9 -xr!desktop.ini -xr!Thumb.db
  setlocal
)
endlocal

如果執行一次 setlocal 卻執行多次 endlocal,可能會在某些情境下發生不可預期的後果,因此我們在每次迴圈後再加上對應的 setlocal。

這次終於解決了吧?

嗯……你可以試試看處理另一個神作:G:\Users\IamGod\Downloads\我是欸漫^神作^,大概會發現結果又怪怪的了……。

這是因為 CMD 在展開 %1%2%* 之類的參數後會先解析特殊字元再執行指令,而 ^ 是有特殊意義的字元(為下一個字元脫義,本身則消失),因此執行到第 4 行 set ARGS=%* 時的內容是這樣:

set ARGS=G:\Users\IamGod\Downloads\我是欸漫神作

於是程式又找不到檔案了。

把第 4 行改成 set "ARGS=%*" 也無濟於事,因為 CMD 是先展開變數、解析特殊字元、再執行指令,因此這麼做只是讓執行時變成看到這樣的內容:

set "ARGS=G:\Users\IamGod\Downloads\我是欸漫神作"

結果一樣是找不到檔案。而且 set "ARGS=%*" 有其他副作用──如果參數本來就含有雙引號,加入的雙引號會導致本來的雙引號夾到錯誤的地方。

這問題不會發生在絕對路徑含有空白字元的檔案上,因為 Windows 會自動為含有空白的參數加上雙引號,而 ^ 字元在雙引號中不具有特殊意義。假設我們把上述檔案放到 G:\Users\IamGod\Downloads\my subfolder\我是欸漫^神作^,自動加上雙引號後,執行到第 4 行 set ARGS=%* 時的內容是這樣:

set ARGS="G:\Users\IamGod\Downloads\my subfolder\我是欸漫^神作^"

於是就安全通關,可喜可賀^^

^ 字元一樣在同一時機被處理的特殊字元還包括 "<LF>&|<>(),不過 Windows 不允許檔名含有 "<LF>|<>,而剩下的 &() 本身不像 ^ 會在解析時讓自己消失,set 後放到雙引號裡就能避掉特殊意義安全通關。

這問題除非微軟修,否則理論上是無解,因為一開始傳遞的參數就錯了。

小結

注意變數展開,以及在用批次檔搭配拖放處理大量檔案時,請確保檔名不含 ^ 字元或檔案的絕對路徑含有空白,以保安全。

參考資料

2017/12/02 後記:

感謝 Ranlempow 分享,此程式可利用 goto 搭配 shift 寫成更精簡的版本:

@echo off
chcp 65001
set ZIP="%ProgramFiles%\7-Zip\7z.exe"

:loop
%ZIP% a -t7z "%~1.7z" "%~1" -mx9 -xr!desktop.ini -xr!Thumb.db
shift
if not "%~1" == "" goto loop

留言

  1. 對於只有部分參數加上引號的引數列表,for迴圈真的無能為力。
    捨棄for迴圈而總是用像'"%~1"'這樣的型式來使用引數或許是最簡單又能符合windows檔案路徑規則的方式^^。
    閣下可以試試:

    :doWhile
    "%ZIP%" a -t7z "%~1.7z" "%~1" -mx9 -xr!desktop.ini -xr!Thumb.db
    shift
    if not "%~1" == "" goto doWhile

    回覆刪除
    回覆
    1. CMD 在第一階段展開變數或參數時就解析了其中的特殊字元,假設參數值是 `G:\Users\IamGod\Downloads\我是欸漫^神作^`,CMD 展開、解析完後代入 %~1 的內容就已經是 `G:\Users\IamGod\Downloads\我是欸漫神作`,此時 caret 已經消失,因此你的寫法沒有解決問題。

      不過用 goto 可以避免 for..do 先展開括號內變數的麻煩,倒是可以用於優化目前的程式碼,謝謝提供。

      刪除
    2. 你說的對,把`G:\Users\IamGod\Downloads\我是欸漫^神作^`這個檔案拖曳到批次檔來執行時,`%~1`就已經是`G:\Users\IamGod\Downloads\我是欸漫神作`。

      這卻不是cmd.exe的缺陷,而是因為windows的拖曳功能做壞了,它忘了替空白以外的特殊字元加上引號。你可以試試看把一個名為`^`的檔案拖到CMD視窗,它不會如預期的被加上引號。

      而檔案總管又很粗暴的把拖曳進入批次檔的動作,用於類似於執行system("cmd.exe /C call your.bat %*")的方式作處理。

      這是由於windows處理命令列參數不同於unix,在windows中「呼叫時為參數加入的任何引號,這些引號都會成為引數的一部分」。檔案總管與CMD都不敢貿然的替你加上引號,因為對於一些應用程式來說含有引號的參數可能有特別意義(例如start指令)。

      windows應該要修正它的拖曳功能的實作。

      真的希望能以拖曳來執行批次檔的話,也許用第三方檔案總管如FreeCommander之類的軟體再加以設定,能使其在呼叫系統的CreateProcess()之前總是替參數加上引號。

      刪除
    3. 主要還是 CMD 的問題,因為拖放執行的批次檔已經接到了正確的引數,只是由於 CMD 的語法限制導致無法在不重新解析其值下引用它。如果 CMD 提供用 DelayedExpension 的方式引用引數(!~1)就可以解決。

      拖放到 CMD 以外的程式都不會有這種問題,比如 Python 用 sys.argv 可正確取得所有引數;拖放 TXT、DOC、PPT 到記事本、MS Word、MS PowerPoint 也都正常。所以問題主要不是出在拖放功能。

      批次檔功能差又問題多,會想使用只是因為 Windows 內建,處理這種簡單小事還行,省得安裝其他東西。如果要用安裝軟體的方法解決,我傾向直接用其他腳本語言取代批次檔,比如 Python。一定要 Windows 內建的話,VBScript 和 Power Shell 應該可行,不過我還沒深入研究就是。

      刪除

張貼留言

1.本格歡迎任何留言,只有廣告和垃圾留言會刪。
2.希望您盡量留下代稱,以方便大家討論、回覆。
3.如果您打算長篇大論,建議在您自己的部落格貼文,然後留下連結和摘要。

這個網誌中的熱門文章

為什麼 Mercurial 沒有比 Git 更好

中文與英文的比較

用 git replace 改善 Git 本地版本庫的開發線圖