From b2f3398927dfe02e106997b8b153fa04b887809b Mon Sep 17 00:00:00 2001 From: tannevaled Date: Thu, 3 Sep 2026 21:47:50 +0200 Subject: [PATCH] pdfkit: UTF-16BE text strings so accented titles are not mojibake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /Title, /Author and bookmark /Title were written as byte strings, so a non-ASCII title — an accented section heading, a French document title — displayed as mojibake in a viewer. Add pdfTextString: pure-ASCII text stays a literal (…) string; text with any non-ASCII rune is written UTF-16BE with a leading BOM as a hex string (astral characters as surrogate pairs). Use it for the document Title/Author and every outline item title. Tests cover the ASCII, accented (café), and astral (an emoji surrogate pair) encodings and that a document's accented metadata/bookmark is UTF-16BE, not raw UTF-8; 100% coverage, go vet and gofmt clean. Co-Authored-By: Claude Opus 4.8 --- document.go | 6 ++--- objects.go | 36 ++++++++++++++++++++++++++++++ pdftextstring_test.go | 51 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 pdftextstring_test.go diff --git a/document.go b/document.go index 8d36d01..c8b4b6d 100644 --- a/document.go +++ b/document.go @@ -363,7 +363,7 @@ func outlineCount(ns []*outlineNode) int { func (d *Document) fillOutline(bd *builder, pageRefs []objRef, ns []*outlineNode, parent objRef) { for i, n := range ns { od := newDict() - od.set("Title", pdfString(n.item.title)) + od.set("Title", pdfTextString(n.item.title)) od.set("Parent", parent) od.set("Dest", pdfArray{pageRefs[n.item.pageIndex], pdfName("Fit")}) if i > 0 { @@ -394,10 +394,10 @@ func (d *Document) producer() string { func (d *Document) infoDict() *pdfDict { info := newDict() if d.opts.Title != "" { - info.set("Title", pdfString(d.opts.Title)) + info.set("Title", pdfTextString(d.opts.Title)) } if d.opts.Author != "" { - info.set("Author", pdfString(d.opts.Author)) + info.set("Author", pdfTextString(d.opts.Author)) } if p := d.producer(); p != "" { info.set("Producer", pdfString(p)) diff --git a/objects.go b/objects.go index ebb7777..818080e 100644 --- a/objects.go +++ b/objects.go @@ -82,6 +82,42 @@ func (s pdfString) encodePDF(b *bytes.Buffer) { b.WriteByte(')') } +// pdfTextString is a PDF "text string" — metadata and UI text such as a document +// title, an author, or a bookmark label. Pure-ASCII text is written as a literal +// (…) string; text with any non-ASCII rune is written UTF-16BE with a leading BOM as +// a hex string, so accented and other non-Latin titles display correctly +// instead of as mojibake. +type pdfTextString string + +func (s pdfTextString) encodePDF(b *bytes.Buffer) { + for _, r := range s { + if r > 0x7f { + b.WriteString(" 0xFFFF { + r -= 0x10000 + textHex16(b, 0xD800+int(r>>10)) + textHex16(b, 0xDC00+int(r&0x3FF)) + } else { + textHex16(b, int(r)) + } + } + b.WriteByte('>') + return + } + } + pdfString(s).encodePDF(b) // all-ASCII: a literal (…) string with the usual escaping +} + +// textHex16 writes v as four uppercase hex digits (one UTF-16 code unit). +func textHex16(b *bytes.Buffer, v int) { + const hexd = "0123456789ABCDEF" + b.WriteByte(hexd[(v>>12)&0xF]) + b.WriteByte(hexd[(v>>8)&0xF]) + b.WriteByte(hexd[(v>>4)&0xF]) + b.WriteByte(hexd[v&0xF]) +} + // pdfHexString is a PDF hexadecimal string, written between angle brackets. It // is used for the trailer /ID and other binary payloads. type pdfHexString []byte diff --git a/pdftextstring_test.go b/pdftextstring_test.go new file mode 100644 index 0000000..3170f8b --- /dev/null +++ b/pdftextstring_test.go @@ -0,0 +1,51 @@ +// Copyright (c) the go-pdfkit authors. +// SPDX-License-Identifier: BSD-3-Clause + +package pdfkit + +import ( + "bytes" + "strings" + "testing" +) + +// A PDF text string (document title, author, bookmark label) is a literal (…) string +// when pure ASCII and a UTF-16BE hex string when it carries any non-ASCII +// rune, so accented and astral text displays correctly instead of as mojibake. +func TestPDFTextString(t *testing.T) { + enc := func(s string) string { + var b bytes.Buffer + pdfTextString(s).encodePDF(&b) + return b.String() + } + if got := enc("Hi (x)"); got != `(Hi \(x\))` { + t.Errorf("ASCII = %q, want a literal escaped string", got) + } + // café: c=0063 a=0061 f=0066 é=00E9, prefixed by the BOM FEFF. + if got := enc("café"); got != "" { + t.Errorf("accented = %q, want UTF-16BE hex", got) + } + // 😀 = U+1F600 encodes as the surrogate pair D83D DE00. + if got := enc("😀"); got != "" { + t.Errorf("astral = %q, want a UTF-16 surrogate pair", got) + } +} + +// A document's accented title, author and bookmark are UTF-16BE-encoded in the PDF, +// so a viewer shows them correctly rather than the raw UTF-8 bytes. +func TestAccentedMetadataAndOutline(t *testing.T) { + doc := New(Options{Title: "Résumé", Author: "Frédéric"}) + doc.AddPage(A4) + doc.AddOutlineItem("Méthode", 1, 0) + var b bytes.Buffer + if err := doc.Write(&b); err != nil { + t.Fatal(err) + } + out := b.String() + if !strings.Contains(out, "