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, "