From 4686c84bac41089b6fffd1e3e0fd3e3b52a8fd79 Mon Sep 17 00:00:00 2001 From: tianrking Date: Thu, 16 Jul 2026 23:38:11 +0800 Subject: [PATCH] fix: decode UTF-16 markdown with BOM --- main.go | 1 + ui/stash.go | 3 ++- ui/ui.go | 2 +- utils/encoding.go | 31 +++++++++++++++++++++++++++++++ utils/encoding_test.go | 40 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 utils/encoding.go create mode 100644 utils/encoding_test.go diff --git a/main.go b/main.go index b31ca15..664bbc1 100644 --- a/main.go +++ b/main.go @@ -278,6 +278,7 @@ func executeCLI(cmd *cobra.Command, src *source, w io.Writer) error { return fmt.Errorf("unable to read from reader: %w", err) } + b = utils.DecodeUTF16BOM(b) b = utils.RemoveFrontmatter(b) // render diff --git a/ui/stash.go b/ui/stash.go index c3739b2..c6fcb09 100644 --- a/ui/stash.go +++ b/ui/stash.go @@ -12,6 +12,7 @@ import ( "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glow/v2/utils" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/log" "github.com/muesli/reflow/ansi" @@ -860,7 +861,7 @@ func loadLocalMarkdown(md *markdown) tea.Cmd { log.Debug("error reading local file", "error", err) return errMsg{err} } - md.Body = string(data) + md.Body = string(utils.DecodeUTF16BOM(data)) return fetchedMarkdownMsg(md) } } diff --git a/ui/ui.go b/ui/ui.go index 3537d3f..06f61d6 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -195,7 +195,7 @@ func (m model) Init() tea.Cmd { log.Error("unable to read file", "file", m.common.cfg.Path, "error", err) return func() tea.Msg { return errMsg{err} } } - body := string(utils.RemoveFrontmatter(content)) + body := string(utils.RemoveFrontmatter(utils.DecodeUTF16BOM(content))) cmds = append(cmds, renderWithGlamour(m.pager, body)) } diff --git a/utils/encoding.go b/utils/encoding.go new file mode 100644 index 0000000..dd266da --- /dev/null +++ b/utils/encoding.go @@ -0,0 +1,31 @@ +package utils + +import ( + "encoding/binary" + "unicode/utf16" +) + +// DecodeUTF16BOM converts UTF-16 text with a byte-order mark to UTF-8. +// Data without a UTF-16 BOM, including malformed UTF-16 with an odd payload, +// is returned unchanged. +func DecodeUTF16BOM(data []byte) []byte { + if len(data) < 2 || (len(data)-2)%2 != 0 { + return data + } + + var order binary.ByteOrder + switch { + case data[0] == 0xff && data[1] == 0xfe: + order = binary.LittleEndian + case data[0] == 0xfe && data[1] == 0xff: + order = binary.BigEndian + default: + return data + } + + codeUnits := make([]uint16, (len(data)-2)/2) + for i := range codeUnits { + codeUnits[i] = order.Uint16(data[2+i*2:]) + } + return []byte(string(utf16.Decode(codeUnits))) +} diff --git a/utils/encoding_test.go b/utils/encoding_test.go new file mode 100644 index 0000000..8e24992 --- /dev/null +++ b/utils/encoding_test.go @@ -0,0 +1,40 @@ +package utils + +import "testing" + +func TestDecodeUTF16BOM(t *testing.T) { + tests := []struct { + name string + data []byte + want string + }{ + { + name: "little endian", + data: []byte{0xff, 0xfe, '#', 0, ' ', 0, 'T', 0, 'i', 0, 't', 0, 'l', 0, 'e', 0}, + want: "# Title", + }, + { + name: "big endian", + data: []byte{0xfe, 0xff, 0, '#', 0, ' ', 0, 'T', 0, 'i', 0, 't', 0, 'l', 0, 'e'}, + want: "# Title", + }, + { + name: "utf8 remains unchanged", + data: []byte("# Title"), + want: "# Title", + }, + { + name: "odd utf16 payload remains unchanged", + data: []byte{0xff, 0xfe, '#'}, + want: string([]byte{0xff, 0xfe, '#'}), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := string(DecodeUTF16BOM(tt.data)); got != tt.want { + t.Fatalf("DecodeUTF16BOM() = %q, want %q", got, tt.want) + } + }) + } +}