只改一個設定值,差異檢視卻顯示整份檔案都變了,通常不是版本控制工具故障,而是讀寫流程偷偷改變了檔案的表示方式。Python UTF-8 編修要分開處理文字內容、BOM 與行尾;能讀出中文,不代表能原樣寫回。

本文解決的搜尋意圖是「如何安全修改既有文字檔而不產生格式污染」,適合維護建置設定、範例資料與多語系文字資產的開發者。核心不是猜測編碼,而是定義可檢查的輸入契約,產生獨立候選檔,再比較實際位元組。本地筆記提供問題背景,Python 官方文件界定行為,隔離測試提供可重現證據;三者不能互相替代。

先決定要保留文字,還是保留位元組

保留文字意義與保留檔案位元組是兩種不同需求。若只允許修改一個指定片段,就應把其餘內容、開頭標記與行尾視為不可變條件,先讀取原始位元組,再決定是否需要解碼與重新編碼。

文字工具通常關心字串是否相同,版本控制與檔案消費端卻可能關心更細的差異。同樣顯示為換行,檔案裡可能是 LF、CRLF 或單獨 CR;同樣顯示為第一個字,前面可能還有不顯眼的 UTF-8 簽章。格式規範若未寫清楚,維護腳本就會把自己的預設值當成原檔規範。

**先寫下三個驗收條件:**允許變動的片段、必須保留的格式、不得改動的來源檔。本文範例只處理已知 UTF-8 的小型測試資料,不承諾自動辨識任意編碼,也不處理檔案屬性或多行程同步。讀寫成功只證明操作完成;完整位元組比對才支持「範例中的其餘內容未改變」。

判斷是否需要格式正規化,是獨立的編輯決策。若專案確實要統一換行,應另立變更並明確審查,而不是把它藏在功能修正裡。這樣既能讓差異更容易閱讀,也讓後續回歸測試知道究竟要驗證哪一種契約。

UTF-8 與 utf-8-sig 的 BOM 行為不同

一般 UTF-8 解碼會保留開頭標記對應的字元,utf-8-sig 則會略過開頭的簽章位元組。後者在編碼時又會加入簽章,因此能讀取有無標記的檔案,不代表重新寫出時會保留原本的標記狀態。

Python 的 codecs 文件說明,UTF-8 不需要用 BOM 判斷位元組順序;這裡的標記是簽章。utf-8-sig 解碼只會在開頭符合簽章時跳過該序列,編碼則會輸出簽章。因此,使用相同編碼名稱讀寫,並不足以證明位元組往返完全一致。

測試應同時包含「原先有 BOM」與「原先沒有 BOM」兩種輸入。前者以 utf-8-sig 解碼再編碼可以在本例中還原;後者經同樣流程則會多出開頭標記。這不是隨機錯誤,而是編碼器定義的行為。維護者若只拿單一樣本驗證,很容易把特定輸入下的成功誤認為通用保證。

不要把刪除所有不可見字元當成修復。 開頭簽章與正文裡的字元必須分開討論。若任務只是替換已知 ASCII 設定片段,直接保留原始位元組可以避免不必要的重編碼;若必須分析文字,則另外記錄原始標記狀態,讓輸出策略明確表達需求。

newline 參數才是行尾轉換的控制點

指定 UTF-8 只控制字元與位元組的轉換,不會關閉文字模式的行尾處理。讀取時使用預設 newline 會把多種行尾轉成換行字元;需要保留原始行尾時,必須在讀取與寫入兩端都明確設定策略。

Python 3.11 的 open() 文件把讀取與寫入分開定義。讀取時,newline=None 啟用通用行尾並轉為 \n;newline='' 仍辨識通用行尾,但不翻譯回傳的行尾。寫入時,newline=None 會將 \n 轉為系統預設行分隔符;newline='' 則不做這個轉換。

因此,「文字模式一定把 CRLF 變成 LF」並不精確。讀取後的字串與寫入後的實體位元組是不同階段,寫入行為還取決於參數與平台。本文使用混合行尾的合成樣本,直接比對輸出位元組,避免從終端顯示推論磁碟內容。

如果讀取階段已經把不同的行尾合併為同一字元,寫出時再指定 CRLF 也無法恢復每一處原本的差異。保留資訊必須從讀取開始。 另一個常見問題是搜尋片段假設帶有 CRLF,但實際資料只有 LF,結果替換完全沒有發生。應先檢查匹配次數,再進行替換,不能把沒有例外視為已完成修改。

用匹配守門條件限制修改範圍

小型且格式已知的設定片段可以用位元組替換保留其餘資料,但必須先拒絕找不到或找到多次的情況。匹配數量、解碼有效性與候選檔回讀都應成為必要條件,而不是排錯時才臨時補上的檢查。

本例的 patch_one 要求舊片段非空且只出現一次,並在替換前後以嚴格 UTF-8 解碼確認資料符合輸入契約。這是刻意限縮用途的輔助函式,不是一般化的設定檔編輯器。它不理解 XML 節點、JSON 階層或註解,因此不應拿來處理可能有多個同名欄位的複雜文件。

匹配零次時,可能是行尾不同、版本不同或輸入檔不符預期;匹配多次則代表修改目標不夠明確。兩者都應停止產生候選結果,讓人先釐清輸入,而不是改用「全部替換」讓工作看似完成。範例另外拒絕不合法的 UTF-8,避免以忽略解碼錯誤的方式悄悄移除資料。

寫入也有自己的邊界:候選檔以 xb 排他建立,遇到既有同名檔就失敗;來源檔只讀回比較,不被覆蓋。這不等於完整的併發控制,也不提供斷電耐久性保證。它只讓這次本機練習的變動範圍清楚且可檢查,不需要碰觸既有專案。

本機驗證:BOM、混合行尾與受控替換

在固定的 Python 版本中執行三組隔離測試,可以分別觀察簽章行為、行尾轉換與替換守門條件。所有檔案都由測試建立於暫存目錄,檢查以位元組相等與明確例外為準,不需要外部服務或既有資料。

前置條件:CPython 3.11.15、標準庫,以及可建立暫存檔的本機測試環境。 將下列完整程式另存為 encoding_lab.py,不需安裝任何第三方套件。版本檢查會拒絕不同修補版本,目的是固定本次重現條件,不是聲稱其他版本不能使用這些 API。

  1. 執行 python --version 確認版本,再執行 python encoding_lab.py EncodingTests.test_bom。通過條件是有標記樣本的往返相等,以及無標記樣本重新編碼後不相等;任何斷言失敗都代表不通過。
  2. 執行 python encoding_lab.py EncodingTests.test_newlines。通過條件是預設讀取把混合行尾正規化,而明確關閉翻譯的讀寫可以還原測試位元組;不能只比較螢幕上的行數。
  3. 執行 python encoding_lab.py EncodingTests.test_guarded_patch,最後執行 python encoding_lab.py。通過條件包含候選檔符合預期、來源不變、零匹配與多匹配拋出指定例外,以及不合法 UTF-8 被拒絕。任一測試失敗或版本不符都會以非零結束碼結束。
import codecs
import sys
import tempfile
import unittest
from pathlib import Path


def patch_one(raw, old, new):
    raw.decode('utf-8', errors='strict')
    if not old or raw.count(old) != 1:
        raise ValueError('expected exactly one match')
    result = raw.replace(old, new, 1)
    result.decode('utf-8', errors='strict')
    return result


class EncodingTests(unittest.TestCase):
    def test_bom(self):
        raw = codecs.BOM_UTF8 + 'name=\u6e2c\u8a66\r\n'.encode('utf-8')
        self.assertTrue(raw.decode('utf-8').startswith('\ufeff'))
        self.assertFalse(raw.decode('utf-8-sig').startswith('\ufeff'))
        self.assertEqual(raw.decode('utf-8-sig').encode('utf-8-sig'), raw)
        plain = b'name=sample\n'
        self.assertNotEqual(plain.decode('utf-8-sig').encode('utf-8-sig'), plain)

    def test_newlines(self):
        raw = b'a\r\nb\nc\r'
        with tempfile.TemporaryDirectory() as folder:
            source = Path(folder) / 'source.txt'
            target = Path(folder) / 'copy.txt'
            source.write_bytes(raw)
            with open(source, encoding='utf-8', newline=None) as stream:
                self.assertEqual(stream.read(), 'a\nb\nc\n')
            with open(source, encoding='utf-8', newline='') as stream:
                text = stream.read()
            with open(target, 'w', encoding='utf-8', newline='') as stream:
                stream.write(text)
            self.assertEqual(target.read_bytes(), raw)

    def test_guarded_patch(self):
        old, new = b'mode=old', b'mode=new'
        raw = codecs.BOM_UTF8 + b'mode=old\r\nlabel=' + '\u6e2c\u8a66'.encode('utf-8') + b'\n'
        result = patch_one(raw, old, new)
        self.assertEqual(result, codecs.BOM_UTF8 + b'mode=new\r\nlabel=' + '\u6e2c\u8a66'.encode('utf-8') + b'\n')
        for bad in (b'missing\n', old + b'\n' + old):
            with self.assertRaises(ValueError):
                patch_one(bad, old, new)
        with self.assertRaises(UnicodeDecodeError):
            patch_one(b'\xffmode=old', old, new)
        with tempfile.TemporaryDirectory() as folder:
            source = Path(folder) / 'source.txt'
            target = Path(folder) / 'candidate.txt'
            source.write_bytes(raw)
            with open(target, 'xb') as stream:
                stream.write(result)
            self.assertEqual(source.read_bytes(), raw)
            self.assertEqual(target.read_bytes(), result)


if __name__ == '__main__':
    if sys.version_info[:3] != (3, 11, 15):
        raise SystemExit('Use CPython 3.11.15 for this fixed-version lab')
    unittest.main(verbosity=2)

**排程環境實測結果:**在 CPython 3.11.15 執行 python encoding_lab.py,三個測試均為 ok,測試框架回報 OK,程序結束碼為 0。這是上述合成案例的正確性驗證,不是大量檔案測試、跨平台實測或效能基準。文件屬於 Python 3.11 文件分支;本次執行版本則固定為 3.11.15,兩者應分開記錄。

將通過條件轉成日常編修清單

編修工具的完成條件不應停在程序正常結束,而應包含修改範圍正確、未修改部分保持一致,以及失敗案例確實被拒絕。把這些條件放進回歸測試,才能在換環境或調整工具時發現格式處理的退步。

  • **輸入界線:**是否確認檔案編碼與大小適合一次載入?不符合就停止,不要自動猜測後繼續寫入。
  • **格式證據:**是否記錄 BOM 狀態、行尾種類與來源位元組?終端字形正確只能當輔助訊號。
  • **修改界線:**是否只允許一個明確片段?若文件需要語法層級選取,就改用適合的解析方式並另驗格式保存能力。
  • **輸出證據:**是否回讀候選檔並確認來源不變?檔案存在與寫入函式成功都不能代替這項檢查。
  • **失敗路徑:**是否涵蓋零匹配、多匹配與不合法輸入?只測正常案例會錯過靜默未修改與過度修改兩端的風險。

完整位元組相等是此處最直接的驗收方式;摘要雜湊適合紀錄來源版本,但不能解釋哪裡改變。需要排錯時,應把差異定位與合成測試一起使用。本文不宣稱這個範例能縮短多少工時,也不承諾位元組替換比其他方案更快;若要處理大型檔案,須另設記憶體與耗時量測。

來源查核與結論

本篇只採用來源筆記中關於編碼、標記與行尾的可泛化問題,再以官方文件及合成測試限制結論。原筆記的特定環境細節與其他維運主題不納入範例,也不把個別觀察改寫為普遍成立的規則。

來源為 DevelopmentNote 下的 軟體開發/專案開發問題集/Windows環境陷阱/Windows環境陷阱.md,已完整閱讀;中英文版本共享同一來源摘要與測試。筆記提醒文字模式編修可能改變格式,本文進一步區分 BOM 的編碼器行為與行尾的讀寫參數,不直接沿用「一律如此」的簡化描述。

官方文件已實際開啟並核對:

結論:先保留證據,再修改內容。 對已知 UTF-8 的小型檔案,明確的格式契約、受控替換與候選檔回讀,比猜測終端亂碼或相信工具預設值更容易驗證。先在隔離樣本執行這三組測試,再把符合自身格式需求的條件加入編修流程。