Windows 系統上 Python 的文字輸出編碼

codemee

codemee

Posted on August 10, 2021

Windows 系統上 Python 的文字輸出編碼

與文字編碼有關的幾個函式

在 Python 中, 有幾個地方都與文字的編碼有關, 很容易搞混:

設定 說明
locale.getpreferredencoding() 這是根據使用者作業系統的地區設定而決定的編碼, 它會決定輸出入文字時預設採用的編碼, 包含終端機輸出入、檔案輸出入等等。
sys.getfilesystemencoding() 這是處理檔案路徑名稱時預設採用的文字編碼。
sys.getdefaultencoding() 處理字串時預設的文字編碼, 用在 str.encode()bytes.decode()bytearray.decode()

我們可以使用以下這個簡單的程式顯示以上各項設定:

# 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)
)
Enter fullscreen mode Exit fullscreen mode
  • Windows 執行結果:
  ❯ python .\print_encoding.py
  locale.getpreferredencoding():  cp950
  sys.getfilesystemencoding():    utf-8
  sys.getdefaultencoding():       utf-8
  sys.stduot.encoding:            utf-8
Enter fullscreen mode Exit fullscreen mode

可以看到在繁體中文 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
Enter fullscreen mode Exit fullscreen mode

完全都採用 UTF-8。

使用 print() 輸出文字

在預設的情況下, print() 會依照平台的設定輸出符合編碼的文字, 因此可以正常顯示輸出的文字, 例如以下的程式不論是在哪一種環境下輸出都是正確的:

# test_print.py
print('測試')
Enter fullscreen mode Exit fullscreen mode
  • 在 Windows 的 PowerShell 下:
  ❯ python test_print.py
  測試
Enter fullscreen mode Exit fullscreen mode
  • 在 Windows 的命令提示字元 (cmd.exe) 下:
  >python test_print.py
  測試
Enter fullscreen mode Exit fullscreen mode
  • 在 Linux 的 zsh 下:
  $ python3 test_print.py
  測試
Enter fullscreen mode Exit fullscreen mode

轉向儲存到文字

如果你將輸出結果轉向儲存到文字, 就會開始不一樣了, 我們分別將上述執行結果利用 > 轉向到文字檔案, 然後看看個別檔案的大小 (我們分別以 cmd、ps、zsh 代表在 Windows 下的 cmd.exe、PowerShell 以及 Linxu 下的 zsh):

❯ ls out*

    Directory: D:\code\test_ampy

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a---         2021/7/13 下午 01:59              6 out_cmd.txt
-a---         2021/7/13 下午 01:59              8 out_ps.txt
-a---         2021/7/13 下午 02:00              7 out_zsh.txt
Enter fullscreen mode Exit fullscreen mode

你會發現在這 3 個環境下轉存的檔案大小各不相同:

  • 在 cmd.exe 下中文 Windows 的預設編碼是 Big5, Big5 中單一中文字佔 2 個位元組, 所以檔案中儲存的是 2 個中文字外加結尾的 Windows 換行標示 \x0D\x0A 總共 6 個位元組, 使用 16 進位模式觀察就很清楚了:
  B4 FA B8 D5 0D 0A 
Enter fullscreen mode Exit fullscreen mode

其中 \xB4\xFA 是『』、\xB8\xD5 是『』。

個別字元的 Big5 編碼可在全字庫查詢。

  • 在 Linux 下預設的文字編碼是 UTF-8, 『測試』這 2 個中文字在 UTF--8 下各佔 3 個位元組, 而換行是 \x0A, 所以總共 7 個位元組, 16 進位的內容如下:
  E6 B8 AC E8 A9 A6 0A 
Enter fullscreen mode Exit fullscreen mode

其中 \xE6\xB8\xAC 是『』、\xE8\xA9\xA6 是『』。

單一中文字的 UFT-8 編碼可用Unihan Database 查詢;中文字串的 UFT-8 編碼則可使用 UTF-8 encoder/decoder 網頁查詢。

  • PowerShell 比較特別, > 其實是 Out-File 內建指令, 如果沒有特別使用 -encoding 指定文字編碼, 預設會將文字轉成 UTF-8 編碼 後存檔, 只是 Windows 下換行是 \x0D\x0A, 16 進位的內容如下:
  E6 B8 AC E8 A9 A6 0D 0A  
Enter fullscreen mode Exit fullscreen mode

強制輸出 UTF-8 編碼的結果

如果你想強制程式輸出 UTF-8 編碼的文字, 可以有幾種作法。

使用 -X utf8 選項讓 Python 強制採用 UTF-8 編碼

執行 Python 環境時可以加上額外的 -X utf8 選項, 這會讓 Python 在輸出入時都採用 UTF-8 作為預設的文字編碼:

  • 在剛剛輸出 Big5 的 cmd.exe 下使用此選項:
  >python -X utf8 test_print.py
  測試
Enter fullscreen mode Exit fullscreen mode

看起來好像一樣, 但其實轉存到檔案就會發現不一樣了:

  >type out_cmd.txt
  測試

  >python -X utf8 test_print.py > out_cmd.txt

  >type out_cmd.txt
  皜祈岫
Enter fullscreen mode Exit fullscreen mode

原本用 type 指令可以顯示正確的檔案內容, 但加上 -X utf8 選項重新轉存檔案後用 type 看到的內容變得莫名其妙, 我們以 16 進位模式看一下實際檔案內容:

  E6 B8 AC E8 A9 A6 0D 0A
Enter fullscreen mode Exit fullscreen mode

原來檔案內容是用 UTF-8 編碼的『測試』加換行, 可是 cmd.exe 下的 type 指令把檔案內容用 Big5 編碼來解譯, 所以把 \xE6\xB8 當一個字, 變成『』;\xAC\xE8 當一個字, 變成『』;\xA9\xA6 也當一個字, 變成『』。

如果我們把字碼頁切換到代表 UTF-8 編碼的 65001, 再重新使用 type 指令檢視檔案內容:

  >chcp 65001
  Active code page: 65001

  D:\code\test_ampy>type out_cmd.txt
  測試

Enter fullscreen mode Exit fullscreen mode

就可以看到用 UTF-8 正確解譯檔案內容的結果了。為了後續實驗的正確性, 請記得將字元碼換回代表 Big5 的 950:

  >chcp 950
  Active code page: 950
Enter fullscreen mode Exit fullscreen mode
  • 在 PowerShell 下則為有類似的結果:
  ❯ python -X utf8 print.py > out_ps.txt
  ❯ type out_ps.txt
  皜祈岫
Enter fullscreen mode Exit fullscreen mode

看起來好像跟剛剛 cmd.exe 下的結果一樣, 可是如果觀察一下檔案大小:

  ❯ ls out_ps.txt

      Directory: D:\code\test_ampy

  Mode                 LastWriteTime         Length Name
  ----                 -------------         ------ ----
  -a---         2021/8/10 上午 09:33             11 out_ps.txt
Enter fullscreen mode Exit fullscreen mode

竟然是 11 個位元組, 單看文字看不出所以然, 用 16 進位模式看一下:

  E7 9A 9C E7 A5 88 E5 B2 AB 0D 0A
Enter fullscreen mode Exit fullscreen mode

這其實真的是 UTF-8 編碼的文字, 其中 \xE7\x9A\x9C 是『』、\xE7\xA5\x88 是『』、\xE5\xB2\xAB 是『』。但是我們明明輸出的是 UTF-8 編碼的『測試』, 為什麼會變成是 UTF-8 編碼的『皜祈岫』呢?

這是因為前面提過, PowerShell 的轉向存檔其實是 out-file 這個內部指令, 它會依據 \[Console\]::OutputEncoding 的設定來解讀輸入的文字:

  ❯ [console]::OutputEncoding

  EncodingName      : Chinese Traditional (Big5)
  WebName           : big5
  HeaderName        : big5
  BodyName          : big5
  Preamble          :
  WindowsCodePage   :
  IsBrowserDisplay  :
  IsBrowserSave     :
  IsMailNewsDisplay :
  IsMailNewsSave    :
  IsSingleByte      : False
  EncoderFallback   : System.Text.InternalEncoderBestFitFallback
  DecoderFallback   : System.Text.InternalDecoderBestFitFallback
  IsReadOnly        : False
  CodePage          : 950
Enter fullscreen mode Exit fullscreen mode

由於預設是 Big5 編碼, 因此原本 UTF-8 編碼輸出的『測試』就被兩個位元組一對當成 Big5 編碼解譯, 變成『皜祈岫』, 然後再將這 3 個字用預設的 UTF-8 編碼寫入檔案, 最後就變成我們看到的樣子了。

如果修改設定, 就可以讓 Out-File 正確解譯輸入的文字:

  ❯ [Console]::OutputEncoding = [text.encoding]::UTF8
  ❯ python -X utf8 print.py > out_ps.txt
  ❯ type out_ps.txt
  測試
  ❯ ls .\out_ps.txt

      Directory: D:\code\test_ampy

  Mode                 LastWriteTime         Length Name
  ----                 -------------         ------ ----
  -a---         2021/8/10 上午 09:40              8 out_ps.txt
Enter fullscreen mode Exit fullscreen mode

測試完請記得改回預設值, 才能讓後續的實驗正確:

  ❯ [Console]::OutputEncoding = [text.encoding]::GetEncoding('big5')
Enter fullscreen mode Exit fullscreen mode
  • 在 Linux 下因為是全 UTF-8 環境, 所以有沒有加 -X utf8 選項都一樣。

使用 sys.stdout.buffer 輸出個別位元組

使用 -X utf8 選項會讓所有的文字輸出入都採用 UTF-8, 如果只是希望某次輸出文字時強制輸出 UTF-8 編碼, 可以使用底層的 sys.stdout.buffer, 例如:

# test_buf_write.py
import sys

sys.stdout.buffer.write('測試\n'.encode('UTF-8'))
Enter fullscreen mode Exit fullscreen mode

我們先將字串轉成以 UTF-8 編碼的位元組串, 然後再利用 write() 一個個位元組輸出, 執行結果如下:

❯ python test_buf_write.py
測試
Enter fullscreen mode Exit fullscreen mode

看起來很正常, 不過魔鬼藏在細節中, 如果我們一樣將輸出結果轉向到檔案中, 再觀察一下個別檔案的長度:

❯ ls out*

    Directory: D:\code\test_ampy

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a---         2021/7/13 下午 02:37              7 out_cmd.txt
-a---         2021/7/13 下午 02:37             11 out_ps.txt
-a---         2021/7/13 下午 02:37              7 out_zsh.txt
Enter fullscreen mode Exit fullscreen mode
  • 在 cmd.exe 和 zsh 下檔案都是 UTF-8 編碼的『測試』加上用 \x0A 表示的換行, 所以總共是 7 位元組。

  • 在 PowerShell 下的檔案卻是奇怪的 11 個位元組?如果使用 Get-Content 指令看看檔案內容:

  ❯ Get-Content out_ps.txt
  皜祈岫
Enter fullscreen mode Exit fullscreen mode

這就跟剛剛使用 -X utf8 選項時一樣, PowerShell 的 out-file 把原本 UTF-8 編碼輸出的『測試』兩兩一對當成 Big5 編碼解譯, 變成『皜祈岫』, 然後再將這 3 個字用預設的 UTF-8 編碼寫入檔案。另外, 換行的 \0x0A 也是因為 Out-File 的關係, 幫我們轉成 Windows 系統的 \0x0D\0x0A 了

除了修改 [Consoel]::OutputEncoding 設定外, 你也可以做個實驗, 幫 out-file 加上選項, 讓它存檔時不要使用 UTF-8, 改用 Big5, 這會讓它以為輸入以及輸出的編碼都是 Big5, 因而原封不動將輸入的內容轉存到檔案中:

  ❯ python test_buf_write.py | out-file -Encoding Big5 out_ps.txt
  D:\code\test_ampy
  ❯ Get-Content .\out_ps.txt
  測試
Enter fullscreen mode Exit fullscreen mode

Windows 下 Python 對終端機的特別處理

你可能會想說 Windows 終端機預設使用的是 Big5 編碼, 那如果使用 sys.stdout.buffer 直接送出 Big5 編碼後的位元組資料, 是不是就剛剛好呢?我們把剛剛使用過的 test_buf_write.py 修改成這樣, 讓我們可以從指令行透過參數指定編碼:

import sys
enc = 'UTF-8'
if len(sys.argv) > 1:
    enc = sys.argv[1]

sys.stdout.buffer.write('測試\n'.encode(enc))
Enter fullscreen mode Exit fullscreen mode

Python 可用的編碼可參考這裡

若不加參數, 預設使用 UTF-8, 以下是指定 big5 的結果:

❯ python test_buf_write.py big5
����
Enter fullscreen mode Exit fullscreen mode

咦?Windows 終端機預設使用 Big5 編碼, 為什麼直接送出 Big5 編碼不行呢?如果將程式輸出結果轉存到檔案呢?

❯ python test_buf_write.py big5 > big5.txt
D:\code\test_ampy
❯ type big5.txt
測試
Enter fullscreen mode Exit fullscreen mode

轉存到檔案是正確的, 那為什麼輸出到螢幕上是錯的呢?這就是 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'>
>>>
Enter fullscreen mode Exit fullscreen mode

但若是 Linux 下:

>>> import sys
>>> sys.platform
'linux'
>>> type(sys.stdout.buffer.raw)
<class '_io.FileIO'>
>>>
Enter fullscreen mode Exit fullscreen mode

Windows 上專用的這個 _io._WindowsConsoleIO 類別, 在實作上使用 Win32 API 中的 MultiByteToWideChar() 函式 轉換要送給終端機的文字, 這個函式只能接受 UTF-8 編碼的文字, 如果送非 UTF-8 編碼的文字, 就會轉成 '\uFFFD', 代表不合法的文字, 會顯示為�。轉換好的文字會再透過 WriteConsoleW() 送給終端機顯示。

因此, 當我們直接透過 sys.stdout.buffer 送出 Big5 編碼的文字時, 因為不符合 UTF-8 的編碼, 所以 2 個中文字共 4 個位元組就被個別當成 4 個不合法的字元, 送到終端機上就顯示成 ���� 了。

Python 在 Windows 上終端機特別處理的相關細節可參考官網上的說明

轉存到檔案時的不同處理

你可能會想到, 剛剛轉存到檔案時不是正常嗎?這是因為 Python 會依據實際輸出目的地是終端機還是檔案, 讓 sys.stdout.buffer.raw 採用不同的類別, 我們以底下的程式觀察:

#print_raw_type.py
import sys
print(type(sys.stdout.buffer.raw))
Enter fullscreen mode Exit fullscreen mode

直接執行的結果如同前面所提到, 是 _io._WinodwsConsoleIO 類別的物件:

❯ python .\print_raw_type.py
<class '_io._WindowsConsoleIO'>
Enter fullscreen mode Exit fullscreen mode

但若是轉向將輸出存檔, 就變成跟在 Linux 下一樣是 _io.FileIO 類別的物件了:

❯ python .\print_raw_type.py > type.txt
❯ type type.txt
<class '_io.FileIO'>
Enter fullscreen mode Exit fullscreen mode

這時即使輸出以 Big5 編碼過的文字, 也不會因為 MultiByteToWideChar() 函式的限制而變成不合法的文字, 送什麼就是什麼。

不要啟用 Windows 上對終端機的特別處理

我們可以透過一個環境變數 PYTHONLEGACYWINDOWSSTDIO 來讓 Python 不要啟用特別的處理, 只要設定此環境變數為任意字串即可。以下以 cmd.exe 為例:

>set PYTHONLEGACYWINDOWSSTDIO=NO

>python test_buf_write.py big5
測試

>python test_buf_write.py
皜祈岫
Enter fullscreen mode Exit fullscreen mode

你可以看到, 設定環境變數後, 送出 Big5 編碼的文字可以正常顯示, 但是送出 UTF-8 編碼的文字反而會被當成 Big5 解譯成 3 個字了。

>set PYTHONLEGACYWINDOWSSTDIO=

>python test_buf_write.py big5
����
Enter fullscreen mode Exit fullscreen mode

一旦移除該環境變數, 就又改回特別處理, Big5 送出編碼的文字就無法正常顯示了。

在 PowerShell 上也可以進行相同的實驗:

❯ Set-Item Env:\PYTHONLEGACYWINDOWSSTDIO "NO"
❯ python .\test_buf_write.py big5
測試
❯ python .\test_buf_write.py
皜祈岫
❯ Remove-Item Env:\PYTHONLEGACYWINDOWSSTDIO
❯ python .\test_buf_write.py big5
����
Enter fullscreen mode Exit fullscreen mode

讀寫檔案

前面提過, locale.getpreferredencoding() 除了控制終端機的文字編碼外, 也控制檔案讀寫時的編碼, 在 Windows 上一樣預設是 Big5。

寫檔

為了能夠控制寫檔時採用的文字編碼, open() 現在多了一個 encoding 參數, 以底下的程式為例:

# 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()
Enter fullscreen mode Exit fullscreen mode

若沒有指定參數, 在建立檔案時就不加入 encoding 參數, 採用 locale.getpreferredencoding() 的設定, 例如:

❯ python .\test_file_write.py
D:\code\test_ampy
❯ Get-Content out_file.txt
���
D:\code\test_ampy
❯ Get-Content -Encoding big5 out_file.txt
測試
Enter fullscreen mode Exit fullscreen mode

由於預設是 Big5 編碼, 所以當我們在 PowerSehll 中用 Get-Content 讀取內容時, 會嘗試以 UTF-8 解譯錯檔案內容。但是若指定以 UTF-8 解譯, 就可以看到正確的檔案內容了。如果建檔的時候指定 encoding 參數, 就可以用特定的編碼存檔:

❯ python .\test_file_write.py utf8
❯ Get-Content out_file.txt
測試
Enter fullscreen mode Exit fullscreen mode

讀取檔案

讀檔時也是一樣, 以底下的程式為例:

# 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()
Enter fullscreen mode Exit fullscreen mode

先用之前的程式建立一個以 UTF-8 編碼的檔案:

❯ python .\test_file_write.py utf8
Enter fullscreen mode Exit fullscreen mode

如果以預設的 Big5 編碼讀檔, 就會解譯錯誤, 把 2 字共 6 個位元組的內容解譯成 3 個各 2 個位元組的 Big5 編碼文字:

❯ python .\test_file_read.py big5
皜祈岫
Enter fullscreen mode Exit fullscreen mode

但若是以 UTF-8 編碼讀檔, 就一切正常了:

❯ python .\test_file_read.py utf8
測試
Enter fullscreen mode Exit fullscreen mode

互動介面的歷史檔

如果你有安裝 pyreadline 模組, 在啟動 Python 互動介面時會改用 pyreadline 模組讀取操作過程記錄的歷史檔, 這個檔案位在使用者資料夾下的 .histoty_file, 不過它有個問題, pyreadline 預設會採用 sys.stdout.encoding(在 Windows 上預設是 UTF-8) 為文字編碼, 寫檔實是採用先編碼後再以二進位模式寫入, 但是讀檔時卻沒有指定編碼, 導致歷史檔中若含有中文, 就可能會遇到類似這樣的錯誤訊息

❯ 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
Enter fullscreen mode Exit fullscreen mode

這是因為在我的歷史檔中有這樣一行:

ans = input("姓名:")
Enter fullscreen mode Exit fullscreen mode

其中『』的 UTF-8 編碼是 \0xE5\0xA7\0x93, 但因為中文 Windows 下預設讀檔是採用 Big5 編碼, 所以前面的 \0xE5\0xA7 被當成 1 個中文字, 而 \0x93 並不符合 Big5 編碼高位元組只能使用 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.
>>>
Enter fullscreen mode Exit fullscreen mode

或者如果你其實不會用到 pyreadline, 也可以將之移除。

💖 💪 🙅 🚩
codemee
codemee

Posted on August 10, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related