Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions document.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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))
Expand Down
36 changes: 36 additions & 0 deletions objects.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <FEFF…> 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("<FEFF")
for _, r := range s {
if r > 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
Expand Down
51 changes: 51 additions & 0 deletions pdftextstring_test.go
Original file line number Diff line number Diff line change
@@ -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 <FEFF…> 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 != "<FEFF00630061006600E9>" {
t.Errorf("accented = %q, want UTF-16BE hex", got)
}
// 😀 = U+1F600 encodes as the surrogate pair D83D DE00.
if got := enc("😀"); got != "<FEFFD83DDE00>" {
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, "<FEFF") {
t.Error("accented metadata was not written as a UTF-16BE string")
}
if strings.Contains(out, "Résumé") || strings.Contains(out, "Méthode") {
t.Error("accented text leaked as raw UTF-8 bytes instead of UTF-16BE")
}
}