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
101 changes: 101 additions & 0 deletions budget_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package render

import (
"errors"
"fmt"
"strings"
"testing"
"time"

"github.com/go-pdfkit/reader"
)

// slowPage builds a page that asks for a great deal of drawing: many thousands
// of filled rectangles, which is what a plotting tool writes and what takes a
// renderer a long time.
func slowPage(t *testing.T, rects int) *reader.Document {
t.Helper()
var content strings.Builder
for i := 0; i < rects; i++ {
fmt.Fprintf(&content, "%d %d 40 40 re f\n", i%60, (i*7)%60)
}
return shadedPage(t, content.String(), func(w *reader.Writer) reader.Dict {
return reader.Dict{}
})
}

func TestAPageMayBeGivenOnlySoLong(t *testing.T) {
// Some pages take a very long time, and a caller drawing somebody else's
// file cannot afford to wait for the worst of them. What comes back is
// how far it got, and an error saying so.
d := slowPage(t, 400000)
start := time.Now()
img, err := Page(d, 1, Options{Scale: 1, MaxDuration: 50 * time.Millisecond})
took := time.Since(start)
if !errors.Is(err, ErrTimedOut) {
t.Fatalf("a page given fifty milliseconds came back with %v after %s", err, took)
}
if img == nil {
t.Fatal("nothing came back at all; half a page is worth more than none")
}
if img.W == 0 || img.H == 0 {
t.Fatalf("what came back is %dx%d", img.W, img.H)
}
// It has to stop near when it was told to, not merely eventually.
if took > 5*time.Second {
t.Errorf("it took %s to give up on fifty milliseconds", took)
}
}

func TestAPageGivenNoLimitIsDrawnWhole(t *testing.T) {
// Zero means as long as it takes, which is what this did before there was
// anywhere to say otherwise.
d := slowPage(t, 200)
img, err := Page(d, 1, Options{Scale: 1})
if err != nil {
t.Fatal(err)
}
if isWhite(img, 20, 30) {
t.Error("the page came back blank")
}
}

func TestAPageThatFinishesInTimeSaysNothingAboutIt(t *testing.T) {
d := slowPage(t, 50)
img, err := Page(d, 1, Options{Scale: 1, MaxDuration: time.Minute})
if err != nil {
t.Fatalf("a page with a minute to draw in came back with %v", err)
}
if isWhite(img, 20, 30) {
t.Error("the page came back blank")
}
}

func TestLookingAtTheClock(t *testing.T) {
// The clock is looked at once every so many operations, because asking the
// machine the time is dear beside drawing a line. Once the time has gone,
// it is gone: the answer does not depend on being asked again.
r := &renderer{}
if r.overrun() {
t.Error("a page with no deadline said it had run out of time")
}
r.deadline = time.Now().Add(-time.Second)
for i := 0; i < timeCheckEvery-1; i++ {
if r.overrun() {
t.Fatalf("it looked at the clock after %d operations, not %d", i+1, timeCheckEvery)
}
}
if !r.overrun() {
t.Fatal("it never looked at the clock at all")
}
if !r.overrun() {
t.Error("having run out of time, it changed its mind")
}

fresh := &renderer{deadline: time.Now().Add(time.Hour)}
for i := 0; i < timeCheckEvery+2; i++ {
if fresh.overrun() {
t.Fatal("a page with an hour to draw in said its time had gone")
}
}
}
3 changes: 3 additions & 0 deletions exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ func (r *renderer) run(content []byte, resources reader.Dict, g gstate) {
if r.ops++; r.ops > maxOperations {
return
}
if r.overrun() {
return
}
n := numbers(op.Operands)
switch op.Operator {
case "q":
Expand Down
29 changes: 29 additions & 0 deletions render.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
package render

import (
"errors"
"fmt"
"math"
"time"

"github.com/go-gfx/gfx/geometry"
"github.com/go-gfx/gfx/raster"
Expand All @@ -36,8 +38,29 @@ type Options struct {
// drawn by default, because a filled-in form drawn without them is a form
// with nothing in it.
NoAnnotations bool
// MaxDuration is how long the page may be drawn for. Zero means as long as
// it takes, which is what this did before there was anywhere to say
// otherwise.
//
// Some pages take a very long time. Of 59 432 corpus pages drawn at half
// as much again as their own size, 1 131 were still going after twenty
// seconds, and one figure took two hundred and seventy-three. A caller
// drawing somebody else's file — a browser tab, a queue of them, a server
// — cannot afford to wait for the worst of those and has, until now, had
// no way to say so: MaxPixels bounds what comes out, and nothing bounded
// the work of making it.
//
// When the time runs out the page stops being drawn and comes back as far
// as it got, with ErrTimedOut. That is deliberately not a blank: half a
// page is worth more than nothing to somebody scrolling, and the error
// says plainly that it is half.
MaxDuration time.Duration
}

// ErrTimedOut says a page was still being drawn when its time ran out. The
// image returned with it holds what had been drawn by then.
var ErrTimedOut = errors.New("render: the page was still being drawn when its time ran out")

// defaultMaxPixels is a little more than A4 at 600 dots to the inch.
const defaultMaxPixels = 40 << 20

Expand Down Expand Up @@ -96,12 +119,18 @@ func Page(d *reader.Document, i int, opt Options) (*raster.Image, error) {
}
resources, _ := d.GetDict(page, "Resources")
r := &renderer{doc: d, img: img, fonts: map[int]*pdfFont{}, softMasks: map[softMaskKey][]uint8{}}
if opt.MaxDuration > 0 {
r.deadline = time.Now().Add(opt.MaxDuration)
}
start := r.initialState(box, rotation, s)
r.base = start.ctm
r.run(content, resources, start)
if !opt.NoAnnotations {
r.drawAnnotations(page, start)
}
if r.ranOut {
return img, ErrTimedOut
}
return img, nil
}

Expand Down
37 changes: 37 additions & 0 deletions state.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/go-pdfkit/reader"
"image"
"image/color"
"time"
)

// A gstate is everything the drawing operators read and write: where things
Expand Down Expand Up @@ -98,6 +99,42 @@ type renderer struct {
// ops counts what has been drawn, so a file cannot ask for an unbounded
// amount of work.
ops int

// deadline is when this page stops being drawn, or the zero time when it
// may take as long as it takes. checked is how many operations ago the
// clock was last looked at, since asking the machine the time is dear
// beside drawing a line.
deadline time.Time
checked int
// ranOut records that the time passed, so that what comes back can say
// the page is unfinished rather than pretend it is done.
ranOut bool
}

// timeCheckEvery is how many operations pass between looks at the clock. A
// page of a hundred thousand operations then asks the machine the time four
// hundred times, which costs nothing measurable; asking on every operation
// would cost more than some of the operations do.
const timeCheckEvery = 256

// overrun says whether the page has been drawing for longer than it was given.
// It is asked once an operation and looks at the clock far less often.
func (r *renderer) overrun() bool {
if r.deadline.IsZero() {
return false
}
if r.ranOut {
return true
}
if r.checked++; r.checked < timeCheckEvery {
return false
}
r.checked = 0
if time.Now().After(r.deadline) {
r.ranOut = true
return true
}
return false
}

// maxFormDepth is how deeply forms may nest before the page gives up.
Expand Down
Loading