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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,11 @@ divergences in the first second of fuzzing, both of them files
icon, colour and opacity on a page — go-slides carries a slide's speaker
notes in them. No other annotation type is written, and nothing here draws
an appearance stream — `go-pdfkit/render` does that when reading.
- **Page transitions** (`Page.SetTransition`, `Page.SetDisplayDuration`): the
`/Trans` dictionary of PDF 32000-1 12.4.4.1 (Table 162) — the style (Split,
Blinds, Box, Wipe, Dissolve, Glitter, R, Push, Cover, Uncover, Fade), `/D`,
`/Dm`, `/M` and `/Di`, refused where the table says the style does not take
the entry or the value — and the page's `/Dur`. Fly is not written.
- **Bookmarks** (`AddOutlineItem`) nest by level; since v0.16.0
`AddOutlineItemWith` styles a title bold or italic and points it at a place on
the page (`[page /XYZ x y null]`) instead of fitting the whole page.
Expand Down
6 changes: 6 additions & 0 deletions document.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,12 @@ func (d *Document) Write(w io.Writer) error {
node.set("MediaBox", pdfArray{pdfReal(0), pdfReal(0), pdfReal(p.width), pdfReal(p.height)})
node.set("Contents", cstream)
node.set("Resources", p.resources(fontRefs, imageRefs, formRefs))
if p.transition != nil {
node.set("Trans", p.transition.dict())
}
if p.dur > 0 {
node.set("Dur", pdfReal(p.dur))
}
var keys []int
key := func(int) int { return -1 }
if sw != nil {
Expand Down
4 changes: 4 additions & 0 deletions page.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ type Page struct {
// dests are the named destinations anchored on this page; each becomes an entry
// in the document's /Dests name tree, jumped to by an internal GoTo link.
dests []namedDest

// transition is the page's /Trans and dur its /Dur (transition.go).
transition *Transition
dur float64
}

// linkAnnot is a clickable rectangle. When dest is empty it opens uri (an external
Expand Down
134 changes: 134 additions & 0 deletions transition.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Copyright (c) 2026 the go-pdfkit/pdfkit authors. All rights reserved.
// Use of this source code is governed by a BSD-3-Clause license that can be
// found in the LICENSE file at the root of this repository.

package pdfkit

import (
"fmt"
"math"
)

// TransitionStyle is the /S entry of a transition dictionary: how a
// presentation moves to a page from another (PDF 32000-1 12.4.4.1,
// Table 162).
type TransitionStyle string

// The transition styles, with the PDF version that introduced each.
const (
TransitionSplit TransitionStyle = "Split"
TransitionBlinds TransitionStyle = "Blinds"
TransitionBox TransitionStyle = "Box"
TransitionWipe TransitionStyle = "Wipe"
TransitionDissolve TransitionStyle = "Dissolve"
TransitionGlitter TransitionStyle = "Glitter"
// TransitionReplace is /R, the default: the new page replaces the old
// one, with no effect.
TransitionReplace TransitionStyle = "R"
// PDF 1.5.
TransitionPush TransitionStyle = "Push"
TransitionCover TransitionStyle = "Cover"
TransitionUncover TransitionStyle = "Uncover"
TransitionFade TransitionStyle = "Fade"
)

// Transition is the transition dictionary of a page: the effect of moving to
// the page from the one before it. The zero value of every field but Style is
// the entry's default, and writes nothing.
//
// The Fly style, which needs /SS and /B as well, is not offered.
type Transition struct {
Style TransitionStyle
// Duration is /D, the effect's length in seconds; 0 leaves it to the
// viewer's default, which is 1 second.
Duration float64
// Vertical is /Dm /V, the dimension of Split and Blinds (default
// horizontal).
Vertical bool
// Outward is /M /O, the motion of Split and Box: from the centre rather
// than inward from the edges.
Outward bool
// Direction is /Di, in degrees counterclockwise from left-to-right, for
// Wipe, Glitter, Cover, Uncover and Push: 0 left to right (the default),
// 90 bottom to top and 180 right to left (Wipe only), 270 top to bottom,
// 315 top-left to bottom-right (Glitter only).
Direction int
}

// SetTransition gives the page a transition dictionary (/Trans). It returns
// an error, and changes nothing, for a style this package does not write, a
// duration that is negative or not a number, or an entry the style does not
// take: Table 162 lists which style each of Dm, M and Di is for, and which
// values of Di each admits.
func (p *Page) SetTransition(t Transition) error {
if err := t.validate(); err != nil {
return err
}
p.transition = &t
return nil
}

// SetDisplayDuration sets the page's /Dur: the longest the page is shown, in
// seconds, before a presentation advances to the next page by itself (PDF
// 32000-1 12.4.4.1). 0 removes it, and the page does not advance.
func (p *Page) SetDisplayDuration(seconds float64) error {
if seconds < 0 || math.IsNaN(seconds) || math.IsInf(seconds, 0) {
return fmt.Errorf("pdfkit: display duration %v is not a duration", seconds)
}
p.dur = seconds
return nil
}

func (t Transition) validate() error {
var di []int // the values of Di the style admits; nil: it takes no Di
switch t.Style {
case TransitionSplit, TransitionBlinds, TransitionBox, TransitionDissolve, TransitionReplace, TransitionFade:
case TransitionWipe:
di = []int{0, 90, 180, 270}
case TransitionGlitter:
di = []int{0, 270, 315}
case TransitionPush, TransitionCover, TransitionUncover:
di = []int{0, 270}
default:
return fmt.Errorf("pdfkit: transition style %q is not one this package writes", t.Style)
}
if t.Duration < 0 || math.IsNaN(t.Duration) || math.IsInf(t.Duration, 0) {
return fmt.Errorf("pdfkit: transition duration %v is not a duration", t.Duration)
}
if t.Vertical && t.Style != TransitionSplit && t.Style != TransitionBlinds {
return fmt.Errorf("pdfkit: /Dm is for Split and Blinds, not %s", t.Style)
}
if t.Outward && t.Style != TransitionSplit && t.Style != TransitionBox {
return fmt.Errorf("pdfkit: /M is for Split and Box, not %s", t.Style)
}
if t.Direction != 0 {
ok := false
for _, d := range di {
ok = ok || d == t.Direction
}
if !ok {
return fmt.Errorf("pdfkit: /Di %d is not one %s takes", t.Direction, t.Style)
}
}
return nil
}

// dict is the transition dictionary, with only what is not a default.
func (t Transition) dict() *pdfDict {
d := newDict()
d.set("Type", pdfName("Trans"))
d.set("S", pdfName(t.Style))
if t.Duration > 0 {
d.set("D", pdfReal(t.Duration))
}
if t.Vertical {
d.set("Dm", pdfName("V"))
}
if t.Outward {
d.set("M", pdfName("O"))
}
if t.Direction != 0 {
d.set("Di", pdfInt(t.Direction))
}
return d
}
97 changes: 97 additions & 0 deletions transition_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Copyright (c) 2026 the go-pdfkit/pdfkit authors. All rights reserved.
// Use of this source code is governed by a BSD-3-Clause license that can be
// found in the LICENSE file at the root of this repository.

package pdfkit

import (
"math"
"testing"

"rsc.io/pdf"
)

// A page's transition and display duration read back through the independent
// reader as PDF 32000-1 Table 162 writes them, classic or packed; a page
// without one has neither entry.
func TestTransition(t *testing.T) {
for _, packed := range []bool{false, true} {
doc := New(Options{ObjectStreams: packed, Compress: packed})
p := doc.AddPage(A4)
if err := p.SetTransition(Transition{Style: TransitionPush, Duration: 0.5, Direction: 270}); err != nil {
t.Fatal(err)
}
if err := p.SetDisplayDuration(5); err != nil {
t.Fatal(err)
}
q := doc.AddPage(A4)
if err := q.SetTransition(Transition{Style: TransitionSplit, Vertical: true, Outward: true}); err != nil {
t.Fatal(err)
}
doc.AddPage(A4)

r := reopenBytes(t, writeDoc(t, doc))
tr := r.Page(1).V.Key("Trans")
if tr.Key("Type").Name() != "Trans" || tr.Key("S").Name() != "Push" || tr.Key("D").Float64() != 0.5 || int(tr.Key("Di").Float64()) != 270 {
t.Errorf("packed=%v: page 1 /Trans = %v", packed, tr)
}
if tr.Key("Dm").Kind() != pdf.Null || tr.Key("M").Kind() != pdf.Null {
t.Errorf("packed=%v: a Push wrote Dm or M: %v", packed, tr)
}
if got := r.Page(1).V.Key("Dur").Float64(); got != 5 {
t.Errorf("packed=%v: /Dur = %v", packed, got)
}
tr = r.Page(2).V.Key("Trans")
if tr.Key("S").Name() != "Split" || tr.Key("Dm").Name() != "V" || tr.Key("M").Name() != "O" || tr.Key("D").Kind() != pdf.Null {
t.Errorf("packed=%v: page 2 /Trans = %v", packed, tr)
}
if r.Page(3).V.Key("Trans").Kind() != pdf.Null || r.Page(3).V.Key("Dur").Kind() != pdf.Null {
t.Errorf("packed=%v: page 3 has presentation entries", packed)
}
}
}

// What Table 162 says a style does not take is refused, and nothing is set.
func TestTransitionRefusals(t *testing.T) {
bad := []Transition{
{Style: "Fly"}, // not written here
{Style: ""}, // no style
{Style: TransitionFade, Duration: -1}, //
{Style: TransitionFade, Duration: math.NaN()},
{Style: TransitionFade, Vertical: true}, // Dm: Split and Blinds
{Style: TransitionWipe, Outward: true}, // M: Split, Box and Fly
{Style: TransitionPush, Direction: 180}, // 180 is Wipe only
{Style: TransitionGlitter, Direction: 90}, // 90 is Wipe only
{Style: TransitionDissolve, Direction: 270}, // Di: Wipe, Glitter, Cover, Uncover, Push
{Style: TransitionWipe, Direction: 315}, // 315 is Glitter only
{Style: TransitionCover, Direction: 45},
}
for _, tr := range bad {
p := New(Options{}).AddPage(A4)
if err := p.SetTransition(tr); err == nil {
t.Errorf("%+v was accepted", tr)
}
if p.transition != nil {
t.Errorf("%+v changed the page", tr)
}
}
good := []Transition{
{Style: TransitionWipe, Direction: 180}, {Style: TransitionGlitter, Direction: 315},
{Style: TransitionBlinds, Vertical: true}, {Style: TransitionBox, Outward: true},
{Style: TransitionUncover, Direction: 270}, {Style: TransitionReplace}, {Style: TransitionDissolve},
}
for _, tr := range good {
if err := New(Options{}).AddPage(A4).SetTransition(tr); err != nil {
t.Errorf("%+v: %v", tr, err)
}
}
p := New(Options{}).AddPage(A4)
for _, d := range []float64{-1, math.NaN(), math.Inf(1)} {
if p.SetDisplayDuration(d) == nil {
t.Errorf("display duration %v was accepted", d)
}
}
if p.SetDisplayDuration(0) != nil {
t.Error("0 removes the duration")
}
}
Loading