###### tags: `Python` `PowerShell` `Linux` # Windows 系統上 Python 的文字輸出編碼 ## 與文字編碼有關的幾個函式 在 Python 中, 有幾個地方都與文字的編碼有關, 很容易搞混: |設定|說明| |----|----| |[locale.getpreferredencoding()](https://docs.python.org/3/library/locale.html#locale.getpreferredencoding)|這是根據使用者作業系統的地區設定而決定的編碼, 它會決定輸出入文字時預設採用的編碼, 包含終端機輸出入、檔案輸出入等等。| |[sys.getfilesystemencoding()](https://docs.python.org/3/library/sys.html#sys.getfilesystemencoding)|這是處理檔案路徑名稱時預設採用的文字編碼。| |[sys.getdefaultencoding()](https://docs.python.org/3/library/sys.html#sys.getdefaultencoding)| 處理字串時預設的文字編碼, 用在 [str.encode()](https://docs.python.org/3/library/stdtypes.html?highlight=bytearray#str.encode)、[bytes.decode()](https://docs.python.org/3/library/stdtypes.html?highlight=bytearray#bytes.decode)、[bytearray.decode()](https://docs.python.org/3/library/stdtypes.html?highlight=bytearray#bytearray.decode)。| 我們可以使用以下這個簡單的程式顯示以上各項設定: ```python= # print_encoding.py import sys import locale print('locale.getpreferredencoding():\t{}'.format( locale.getpreferredencoding()) ) print('sys.getfilesystemencoding():\t{}'.format( sys.getfilesystemencoding()) ) print('sys.getdefaultencoding():\t{}'.format( sys.getdefaultencoding()) ) print('sys.stduot.encoding:\t\t{}'.format( sys.stdout.encoding) ) ``` - Windows 執行結果: ``` # python .\print_encoding.py locale.getpreferredencoding(): cp950 sys.getfilesystemencoding(): utf-8 sys.getdefaultencoding(): utf-8 sys.stduot.encoding: utf-8 ``` 可以看到在繁體中文 Windows 上, 除了終端機、檔案輸出入預設使用 Big5 外, 其餘都採用 UTF-8。 - Linux 上結果如下: ``` $ python3 print_encoding.py locale.getpreferredencoding(): UTF-8 sys.getfilesystemencoding(): utf-8 sys.getdefaultencoding(): utf-8 sys.stduot.encoding: UTF-8 ``` 完全都採用 UTF-8。 ## 使用 print() 輸出文字 在預設的情況下, print() 會依照平台的設定輸出符合編碼的文字, 因此可以正常顯示輸出的文字, 例如以下的程式不論是在哪一種環境下輸出都是正確的: ```python= # test_print.py print('測試') ``` - 在 Windows 的 PowerShell 下: ``` # python .\print.py 測試 ``` - 在 Windows 的命令提示字元 (cmd.exe) 下: ``` >python test_print.py 測試 ``` - 在 Linux 的 zsh 下: ``` $ python3 test_print.py 測試 ``` ## 轉向儲存到文字 如果你將輸出結果轉向儲存到文字, 就會開始不一樣了, 我們分別將上述執行結果利用 > 轉向到文字檔案, 然後看看個別檔案的大小 (我們分別以 cmd、ps、zsh 代表在 Windows 下的 cmd.exe、PowerShell 7.4 以及 Linux 下的 zsh): ``` >dir out* 磁碟區 C 中的磁碟是 Book 13 磁碟區序號: 8482-E7D5 C:\Users\meebo\code\python\py312 的目錄 2024/03/31 下午 04:04 6 out_cmd.txt 2024/03/31 下午 03:58 6 out_ps.txt 2024/03/31 下午 03:57 7 out_zsh.txt 3 個檔案 19 位元組 0 個目錄 288,017,121,280 位元組可用 ``` 你會發現在這 3 個環境下轉存的檔案大小各不相同: - 在 cmd.exe 下中文 Windows 的預設編碼是 Big5, Big5 中單一中文字佔 2 個位元組, 所以檔案中儲存的是 2 個中文字外加結尾的 Windows 換行標示 \x0D\x0A 總共 6 個位元組, 使用 16 進位模式觀察就很清楚了: ``` B4 FA B8 D5 0D 0A ``` 其中 \xB4\xFA 是『[測](https://www.cns11643.gov.tw/wordView.jsp?ID=90177)』、\xB8\xD5 是『[試](https://www.cns11643.gov.tw/wordView.jsp?ID=91740)』。 :::info 個別字元的 Big5 編碼可在[全字庫](https://www.cns11643.gov.tw/index.jsp)查詢。 ::: - 在 Linux 下預設的文字編碼是 UTF-8, 『測試』這 2 個中文字在 UTF--8 下各佔 3 個位元組, 而換行是 \x0A, 所以總共 7 個位元組, 16 進位的內容如下: ``` E6 B8 AC E8 A9 A6 0A ``` 其中 \xE6\xB8\xAC 是『[測](https://www.unicode.org/cgi-bin/GetUnihanData.pl?codepoint=%E6%B8%AC)』、\xE8\xA9\xA6 是『[試](https://www.unicode.org/cgi-bin/GetUnihanData.pl?codepoint=%E8%A9%A6)』。 :::info 單一中文字的 UFT-8 編碼可用 [Unihan Database](http://www.unicode.org/charts/unihan.html) 查詢;中文字串的 UFT-8 編碼則可使用 [UTF-8 encoder/decoder 網頁](https://mothereff.in/utf-8)查詢。 ::: - PowerShell 下的結果和 cmd 下是一樣的, 都是 big5 編碼的 6 個位元組, 但是因為 PowerShell 預設的編碼是 utf-8, 所以如果直接透過指令顯示檔案內容, 就會變成亂碼: ``` # type .\out_ps.txt ���� ``` 只要指定正確的編碼, 就可以顯示內容了: ``` # type -encoding big5 .\out_ps.txt 測試 ``` :::danger 如果你使用 PowerShell 7.4 之前的版本, 結果會不一樣。這是因為舊版的 PowerShell中, `>` 的效果等同於沒有任何選項的 [`Out-File`](https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/out-file?view=powershell-7.1) 內建指令, `Out-File` 會依照 `-encoding` 選項指定的編碼轉換後寫入到檔案, 沒有指定時預設就是 [UTF-8 編碼](https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/out-file?view=powershell-7.1#parameters), 所以內容會跟 Linux 下一樣, 只是 Windows 下換行是 "\x0D\x0A", 多了一個位元組。16 進位的內容如下: ``` E6 B8 AC E8 A9 A6 0D 0A ``` 從 PowerShell 7.4 開始, `>` 在轉向標準輸出的內容時, 會[保留原始內容](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_redirection?view=powershell-7.4#redirecting-output-from-native-commands), 不會像是 `Out-File` 額外進行編碼轉譯。本文以下均以 PowerShell 7.4 測試。 ::: ## 強制輸出 UTF-8 編碼的結果 如果你想強制程式輸出 UTF-8 編碼的文字, 可以有幾種作法。 ### 使用 -X utf8 選項讓 Python 強制採用 UTF-8 編碼 執行 Python 環境時可以加上額外的 `-X utf8` 選項, 這會讓 Python 在輸出入時都採用 UTF-8 作為預設的文字編碼: - 在剛剛輸出 Big5 的 cmd.exe 下使用此選項: ``` >python -X utf8 print.py 測試 ``` 看起來好像一樣, 但其實轉存到檔案就會發現不一樣了: ``` >type out_cmd.txt 測試 >python -X utf8 print.py > out_cmd.txt >type out_cmd.txt 皜祈岫 ``` 原本用 type 指令可以顯示正確的檔案內容, 但加上 -X utf8 選項重新轉向存檔後用 type 看到的內容變得莫名其妙, 我們以 16 進位模式看一下實際檔案內容: ``` E6 B8 AC E8 A9 A6 0D 0A ``` 原來檔案內容是用 UTF-8 編碼的『測試』加換行, 可是 cmd.exe 下的 type 指令把檔案內容用 Big5 編碼來解譯, 所以把 \xE6\xB8 當一個字, 變成『[皜](https://www.cns11643.gov.tw/wordView.jsp?ID=152128)』;\xAC\xE8 當一個字, 變成『[祈](https://www.cns11643.gov.tw/wordView.jsp?ID=86635)』;\xA9\xA6 也當一個字, 變成『[岫](https://www.cns11643.gov.tw/wordView.jsp?ID=85288)』。 如果我們把字碼頁切換到代表 UTF-8 編碼的 65001, 再重新使用 type 指令檢視檔案內容: ``` >chcp 65001 Active code page: 65001 >type out_cmd.txt 測試 ``` 就可以看到用 UTF-8 正確解譯檔案內容的結果了。為了後續實驗的正確性, 請記得將字元碼換回代表 Big5 的 950: ``` >chcp 950 Active code page: 950 ``` - 在 PowerShell 下則會有正確的結果: ``` # python -X utf8 .\print.py > out_ps.txt # type .\out_ps.txt 測試 ``` 檔案內容就和剛剛 cmd.exe 下的結果一樣 - 在 Linux 下因為是全 UTF-8 環境, 所以有沒有加 `-X utf8` 選項都一樣。 ### 使用環境變數強制使用特定的編碼 你也可以透過環境變數 [`PYTHONIOENCODING`](https://docs.python.org/3/using/cmdline.html#envvar-PYTHONIOENCODING) 在執行 Python 直譯器前強制設定編碼, 例如以下是在 cmd 下的測試: ``` >set PYTHONIOENCODING=utf8 >python print.py > out_cmd.txt >dir out_cmd.txt 磁碟區 C 中的磁碟是 Book 13 磁碟區序號: 8482-E7D5 C:\code\python\py312的目錄 2024/03/31 下午 02:15 8 out_cmd.txt 1 個檔案 8 位元組 0 個目錄 290,536,869,888 位元組可用 ``` 這就和剛剛透過 `-X utf8` 命令列選項設定編碼一樣, 會把轉向的文字以 utf-8 編碼儲存, 所以是 8 個位元組。 ### 使用 sys.stdout.buffer 輸出個別位元組 使用 `-X utf8` 選項會讓所有的文字輸出入都採用 UTF-8, 如果只是希望某次輸出文字時強制輸出 UTF-8 編碼, 可以使用底層的 `sys.stdout.buffer`, 例如: ```python= # test_buf_write.py import sys sys.stdout.buffer.write('測試\n'.encode('UTF-8')) ``` 我們先將字串轉成以 UTF-8 編碼的位元組串, 然後再利用 write() 一個個位元組輸出, 執行結果如下: ``` >python test_buf_write.py 測試 ``` 看起來很正常, 再觀察一下個別檔案的長度: ``` >dir out* 磁碟區 C 中的磁碟是 Book 13 磁碟區序號: 8482-E7D5 C:\code\python\py312 的目錄 2024/03/31 下午 06:19 7 out_cmd.txt 2024/03/31 下午 06:15 7 out_ps.txt 2024/03/31 下午 06:16 7 out_zsh.txt 3 個檔案 21 位元組 0 個目錄 288,028,950,528 位元組可用 ``` 就會看到結果都是 7 位元組, 也就是兩個 3 位元組的中文字加上換行的 '\x0A'。 ## Windows 下 Python 對終端機的特別處理 你可能會想說 Windows 終端機預設使用的是 Big5 編碼, 那如果使用 sys.stdout.buffer 直接送出 Big5 編碼後的位元組資料, 是不是就剛剛好呢?我們把剛剛使用過的 test_buf_write.py 修改成這樣, 讓我們可以從指令行透過參數指定編碼: ```python= import sys enc = 'UTF-8' if len(sys.argv) > 1: enc = sys.argv[1] sys.stdout.buffer.write('測試\n'.encode(enc)) ``` :::info Python 可用的編碼可參考[這裡](https://docs.python.org/3/library/codecs.html#standard-encodings)。 ::: 若不加參數, 預設使用 UTF-8, 以下是在 cmd 下指定 big5 的結果: ``` >python test_buf_write.py big5 ���� ``` 咦?Windows 終端機預設使用 Big5 編碼, 為什麼直接送出 Big5 編碼不行呢?如果轉向存檔的話, 內容是什麼呢? ``` >python test_buf_write.py big5 > out_cmd.txt >type out_cmd.txt 測試 ``` 咦?明明內容是正確, 為什麼輸出到螢幕上會是亂嗎? 想要探究這裡的差別, 就要瞭解 Python 在 Windows 上實作時的特別處理。 ### Windows 專用的 \_io.\_WindowsConsoleIO 類別 sys.stdout.buffer 實際上是依靠底層的 sys.stdout.buffer.raw 跟終端機溝通, 這個 raw 在 Window 與 Linux 上是不同類別的物件: ``` >>> import sys >>> sys.platform 'win32' >>> type(sys.stdout.buffer.raw) <class '_io._WindowsConsoleIO'> >>> ``` 但若是 Linux 下: ``` >>> import sys >>> sys.platform 'linux' >>> type(sys.stdout.buffer.raw) <class '_io.FileIO'> >>> ``` Windows 上專用的這個 [\_io.\_WindowsConsoleIO 類別](https://www.python.org/dev/peps/pep-0528/#id7), 在[實作](https://github.com/python/cpython/blob/2b496e79293a8b80e8ba0e514e186b3b1467b64b/Modules/_io/winconsoleio.c#L954)上使用 Win32 API 中的 [MultiByteToWideChar() 函式](https://docs.microsoft.com/en-us/windows/win32/api/stringapiset/nf-stringapiset-multibytetowidechar)將要送給終端機的文字轉成 UTF-16 編碼, 這個函式只能接受 UTF-8 編碼的文字, 如果送非 UTF-8 編碼的文字, 就會轉成 '\uFFFD', 代表不合法的文字, 會顯示為�。 轉換好的 UTF-16 編碼文字會再透過 [WriteConsoleW()](https://docs.microsoft.com/en-us/windows/console/writeconsole) 送給終端機顯示, 因此即使 `locale.getpreferredencoding()` 是 CP950(比Big5) 編碼, 也可以正常顯示不屬於 Big5 字集內的文字。 因此, 當我們直接透過 sys.stdout.buffer 送出 Big5 編碼的文字時, 因為不符合 UTF-8 的編碼, 所以 2 個中文字的 4 個位元組就被個別當成 4 個不合法的字元, 送到終端機上就顯示成 ���� 了。 :::info Python 在 Windows 上終端機特別處理的相關細節可參考[官網上的說明](https://www.python.org/dev/peps/pep-0528/)。 ::: ### 轉存到檔案時的不同處理 你可能會想到, 剛剛轉存到檔案時不是正常嗎?這是因為 Python 會依據實際輸出目的地是終端機還是檔案, 讓 sys.stdout.buffer.raw 採用不同的類別, 我們以底下的程式觀察: ```python= #print_raw_type.py import sys print(type(sys.stdout.buffer.raw)) ``` 直接執行的結果如同前面所提到, 是 \_io.\_WinodwsConsoleIO 類別的物件: ``` >python print_raw_type.py <class '_io._WindowsConsoleIO'> ``` 但若是轉向將輸出存檔, 就變成跟在 Linux 下一樣是 \_io.FileIO 類別的物件了: ``` >python print_raw_type.py > out_cmd.txt >type out_cmd.txt <class '_io.FileIO'> ``` 這時即使輸出以 Big5 編碼過的文字, 也不會因為 MultiByteToWideChar() 函式的限制而變成不合法的文字, 送什麼就是什麼。 ### 顯示正常但轉向存檔時出錯 由於上述 Windows 的特別處理, 所以可能會遇到顯示到終端機時很正常, 但是要轉存到檔案時卻會出錯的情況, 例如以下的程式檔: ```python= print("超圖解資料科學 ✕ 機器學習實戰探索") ``` 若直接在終端機執行, 可以正常顯示: ``` # python .\test.py 超圖解資料科學 ✕ 機器學習實戰探索 ``` 但要轉向存到檔案時, 就會出現錯誤訊息: ``` # python .\test.py > out_ps.txt Traceback (most recent call last): File "C:\Users\meebo\code\python\py312\test.py", line 1, in <module> print("超圖解資料科學 ✕ 機器學習實戰探索") UnicodeEncodeError: 'cp950' codec can't encode character '\u2715' in position 8: illegal multibyte sequence ``` 問題就出在轉向存檔時如同本文一開始看到的, Windows 平台預設的編碼是 big5, 所以會將輸出內容轉成 Big5 編碼, 但是 unicode 的 ['✕'](https://www.compart.com/en/unicode/U+2715) 字元沒有對應的 Big5 字元, 因此錯誤訊息告訴你 UTF-8 編碼 \u2715 無法轉成 CP950 編碼的字元。 ### 不要啟用 Windows 上對終端機的特別處理 我們可以透過一個環境變數 [PYTHONLEGACYWINDOWSSTDIO](https://docs.python.org/3/using/cmdline.html#envvar-PYTHONLEGACYWINDOWSSTDIO) 來讓 Python 不要啟用特別的處理, 只要設定此環境變數為任意字串即可。以下以 cmd.exe 為例: ``` >set PYTHONLEGACYWINDOWSSTDIO=YES >python test_buf_write.py big5 測試 >python test_buf_write.py 皜祈岫 ``` 你可以看到, 設定環境變數後, 送出 Big5 編碼的文字可以正常顯示, 但是送出 UTF-8 編碼的文字反而會被當成 Big5 解譯成 3 個字了。 ``` >set PYTHONLEGACYWINDOWSSTDIO= >python test_buf_write.py big5 ���� ``` 一旦移除該環境變數, 就又改回特別處理, Big5 送出編碼的文字就無法正常顯示了。 在 PowerShell 上也可以進行相同的實驗: ``` # $ENV:PYTHONLEGACYWINDOWSSTDIO='YES' # python .\test_buf_write.py big5 測試 # python .\test_buf_write.py 皜祈岫 # $ENV:PYTHONLEGACYWINDOWSSTDIO='' # python .\test_buf_write.py big5 ���� ``` ## 讀寫檔案 前面提過, [locale.getpreferredencoding()](https://docs.python.org/3/library/locale.html#locale.getpreferredencoding) 除了控制終端機的文字編碼外, 也控制檔案讀寫時的編碼, 在 Windows 上一樣預設是 Big5。 :::warning 之前看過的 [`PYTHONIOENCODING`](https://docs.python.org/3/using/cmdline.html#envvar-PYTHONIOENCODING) 環境變數只會影響 stdin/stdout/stderr 這些標準串流, 不會影響檔案讀寫的編碼。 ::: ### 寫檔 為了能夠控制寫檔時採用的文字編碼, [open()](https://docs.python.org/3/library/functions.html#open) 現在多了一個 encoding 參數, 以底下的程式為例: ```python= # test_file_write.py import sys if len(sys.argv) > 1: f = open('out_file.txt', 'w', encoding=sys.argv[1]) else: f = open('out_file.txt', 'w') f.write('測試') f.close() ``` 若沒有指定參數, 在建立檔案時就不加入 encoding 參數, 採用 locale.getpreferredencoding() 的設定, 例如以下是在 PowerShell 的測試: ``` # python .\test_file_write.py # type .\out_file.txt ���� # type -encoding big5 .\out_file.txt 測試 ``` 由於預設是 Big5 編碼, 所以當我們在 PowerSehll 中用 顯示內容時, 會嘗試以 UTF-8 解譯錯檔案內容。但是若指定以 Big5 解譯, 就可以看到正確的檔案內容了。如果建檔的時候指定 encoding 參數, 就可以用特定的編碼存檔: ``` # python .\test_file_write.py utf8 # type .\out_file.txt 測試 ``` 如果是在 cmd 中測試, 由於 cmd 預設的檔案編碼是 big5, 所以就可以正常運作: ``` >python test_file_write.py >type out_file.txt 測試 ``` ### 讀取檔案 讀檔時也是一樣, 以底下的程式為例: ```python= # test_file_read.py import sys if len(sys.argv) > 1: f = open('out_file.txt', 'r', encoding=sys.argv[1]) else: f = open('out_file.txt', 'r') print(f.readline()) f.close() ``` 先用之前的程式建立一個以 UTF-8 編碼的檔案: ``` # python .\test_file_write.py utf8 ``` 如果以預設的 Big5 編碼讀檔, 就會解譯錯誤, 把 2 字共 6 個位元組的內容解譯成 3 個各 2 個位元組的 Big5 編碼文字: ``` # python .\test_file_read.py big5 皜祈岫 ``` 但若是以 UTF-8 編碼讀檔, 就一切正常了: ``` # python .\test_file_read.py utf8 測試 ``` ### 互動介面的歷史檔 如果你有安裝 [pyreadline](https://github.com/pyreadline/pyreadline) 模組, 在啟動 Python 互動介面時會改用 pyreadline 模組讀取操作過程記錄的歷史檔, 這個檔案位在使用者資料夾下的 .histoty_file, 不過它有個問題, pyreadline 預設會採用 sys.stdout.encoding(在 Windows 上預設是 UTF-8) 為文字編碼, 寫檔時是採用[先編碼後再以二進位模式寫入](https://github.com/pyreadline/pyreadline/blob/4e4b7b468cfdf2dfef8e2c8c695a64e96c514213/pyreadline/lineeditor/history.py#L92), 但是[讀檔時卻沒有指定編碼](https://github.com/pyreadline/pyreadline/blob/4e4b7b468cfdf2dfef8e2c8c695a64e96c514213/pyreadline/lineeditor/history.py#L82), 導致歷史檔中若含有中文, 就可能會遇到[類似這樣的錯誤訊息](https://github.com/pyreadline/pyreadline/issues/55): ``` ❯ python Python 3.9.4 (tags/v3.9.4:1f2e308, Apr 6 2021, 13:40:21) [MSC v.1928 64 bit (AMD64)] on win32 Type "help", "copyright", "credits" or "license" for more informaTraceback (most recent call last): File "D:\Program Files\Python39\lib\site.py", line 449, in register_readline main.py", line 165, in read_history_file self.mode._history.read_history_file(filename) File "D:\Program Files\Python39\lib\site-packages\pyreadline\lineeditor\history.py", line 82, in read_history_file for line in open(filename, 'r'): UnicodeDecodeError: 'cp950' codec can't decode byte 0x93 in position 278: illegal multibyte sequence ``` 這是因為在我的歷史檔中有這樣一行: ```python ans = input("姓名:") ``` 其中『[姓](https://www.unicode.org/cgi-bin/GetUnihanData.pl?codepoint=%E5%A7%93)』的 UTF-8 編碼是 \0xE5\0xA7\0x93, 但因為中文 Windows 下預設讀檔是採用 Big5 編碼, 所以前面的 \0xE5\0xA7 被當成 1 個中文字, 而 \0x93 並不符合 [Big5 編碼](http://idv.sinica.edu.tw/bear/charcodes/Section09.htm)高位元組只能使用 0xA1~0xFE 的規範, 所以在解碼時就發生錯誤。 如果改用 -X utf8 強制使用 UTF-8 模式, 就可以正常讀取不會出錯: ``` ❯ python -X utf8 Python 3.9.4 (tags/v3.9.4:1f2e308, Apr 6 2021, 13:40:21) [MSC v.1928 64 bit (AMD64)] on win32 Type "help", "copyright", "credits" or "license" for more information. >>> ``` 或者如果你其實不會用到 pyreadline, 也可以將之移除。