From 55b77468d2bb504bcd803873563bd711e73f6d27 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 8 Nov 2026 14:35:08 +0100 Subject: [PATCH] Page transitions: /Trans and /Dur Page.SetTransition writes the transition dictionary of PDF 32000-1 12.4.4.1 (Table 162): style, /D, /Dm, /M and /Di, refused where the table says the style does not take the entry or the value (Di 180 and 90 are Wipe only, 315 Glitter only, Fly is not offered). Page.SetDisplayDuration writes the page's /Dur. A slide deck's presentation mode needs them: go-slides carries a slide's transition into its PDF. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 5 ++ document.go | 6 ++ page.go | 4 ++ transition.go | 134 +++++++++++++++++++++++++++++++++++++++++++++ transition_test.go | 97 ++++++++++++++++++++++++++++++++ 5 files changed, 246 insertions(+) create mode 100644 transition.go create mode 100644 transition_test.go diff --git a/README.md b/README.md index 01042c7..5dd9176 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/document.go b/document.go index 120ef89..4502e90 100644 --- a/document.go +++ b/document.go @@ -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 { diff --git a/page.go b/page.go index 14e32df..80f53bd 100644 --- a/page.go +++ b/page.go @@ -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 diff --git a/transition.go b/transition.go new file mode 100644 index 0000000..25ba0b3 --- /dev/null +++ b/transition.go @@ -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 +} diff --git a/transition_test.go b/transition_test.go new file mode 100644 index 0000000..0f36a5c --- /dev/null +++ b/transition_test.go @@ -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") + } +}