diff --git a/budget_test.go b/budget_test.go new file mode 100644 index 0000000..5fc9399 --- /dev/null +++ b/budget_test.go @@ -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") + } + } +} diff --git a/exec.go b/exec.go index 9310afc..1fcfad6 100644 --- a/exec.go +++ b/exec.go @@ -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": diff --git a/render.go b/render.go index 59b29f0..a78b560 100644 --- a/render.go +++ b/render.go @@ -8,8 +8,10 @@ package render import ( + "errors" "fmt" "math" + "time" "github.com/go-gfx/gfx/geometry" "github.com/go-gfx/gfx/raster" @@ -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 @@ -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 } diff --git a/state.go b/state.go index 4302443..6713e39 100644 --- a/state.go +++ b/state.go @@ -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 @@ -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.