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") + } +}