From 307b75d3b124d54a2f8fa347e22ca9e23e92054d Mon Sep 17 00:00:00 2001 From: tannevaled Date: Wed, 26 Aug 2026 19:45:17 +0200 Subject: [PATCH] Keep a clip as the box it covers, not as a value for every pixel of the page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Narrowing the clip made a float for every pixel of the image, whatever the shape covered, and did it again for every narrowing. A page narrows its clip more often than one would think: a real French government form does it two thousand one hundred and forty times, and one arXiv figure six thousand seven hundred. Measured before this, drawing a single page: fr-cerfa/cerfa_11126.pdf 1 090 815 bytes 9 350 MB 16.9 s 2603.22026/Fig6New.pdf 566 578 bytes 218 762 MB 6.6 s A hundred and thirteen kilobytes of paper asking for two hundred and eighteen gigabytes is not a page anybody can draw twice at once. A clip is now the box it has narrowed itself down to and the coverage inside that box. Narrowing intersects the boxes first, so what is stored is never larger than the smaller of the two shapes, and a clip to a corner of the page costs a corner of the page. fr-cerfa/cerfa_11126.pdf 6 036 MB 6.8 s 2603.22026/Fig6New.pdf 719 MB 0.7 s Three hundred and four times less memory for the figure and nine times less time; the form is a third less and two and a half times faster, and what remains there is two thousand large clips that genuinely are large. Proved to change nothing: 3 040 corpus pages drawn before and after, every page's pixels hashed, every hash identical. I also tried keeping no coverage at all for a clip that lets everything through inside its box, which is what a clip to a rectangle ought to be. It never once fired on either of the files above — a rectangle's edge pixels are covered fractionally, so the grid is never all ones — so it is not here. An optimisation that does not fire is complexity with nothing to show for it. Found by the robustness campaign, which measured it across 33 393 render stages: median 34 MB, p99 5 618 MB, worst 97 333 MB. --- clip_test.go | 49 ++++++++++++++++++++++++++++++++++ image.go | 2 +- pattern.go | 2 +- state.go | 74 +++++++++++++++++++++++++++++++++++++++++++--------- 4 files changed, 112 insertions(+), 15 deletions(-) create mode 100644 clip_test.go diff --git a/clip_test.go b/clip_test.go new file mode 100644 index 0000000..c12694b --- /dev/null +++ b/clip_test.go @@ -0,0 +1,49 @@ +package render + +import ( + "image/color" + "testing" + + "github.com/go-pdfkit/reader" +) + +func TestAClipThatIsNotThereLetsEverythingThrough(t *testing.T) { + // Every place that asks the clip how much of a pixel it allows checks + // first whether there is one; the method answers for itself as well, so + // that adding a place cannot make a page vanish. + var none *clip + if got := none.at(0, 0); got != 1 { + t.Errorf("no clip at all let through %v", got) + } +} + +func TestTwoClipsThatDoNotOverlapLetNothingThrough(t *testing.T) { + // Clipping to one corner and then to another leaves nothing: what is left + // is what both allow, and they allow nothing in common. + d := shadedPage(t, "q 0 0 40 40 re W n 60 60 40 40 re W n 0 0 100 100 re f Q", + func(w *reader.Writer) reader.Dict { return reader.Dict{} }) + img, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + for _, at := range [][2]int{{20, 20}, {20, 80}, {80, 20}, {80, 80}, {50, 50}} { + wantWhite(t, img, at[0], at[1]) + } +} + +func TestAClipKeepsToItsOwnCorner(t *testing.T) { + // And when they do overlap, what is drawn is the overlap and nothing + // else — which is what says the box was narrowed rather than forgotten. + d := shadedPage(t, "q 0 0 60 60 re W n 40 40 60 60 re W n 0 0 100 100 re f Q", + func(w *reader.Writer) reader.Dict { return reader.Dict{} }) + img, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + // The overlap is x 40..60, y 40..60 in the page's own coordinates, which + // is the middle of the image with its y counted the other way. + wantColour(t, img, 50, 50, color.RGBA{A: 255}, 4) + for _, at := range [][2]int{{20, 80}, {80, 20}, {20, 20}, {80, 80}} { + wantWhite(t, img, at[0], at[1]) + } +} diff --git a/image.go b/image.go index 741403a..3921ee3 100644 --- a/image.go +++ b/image.go @@ -52,7 +52,7 @@ func (r *renderer) drawImage(g *gstate, s *sampled) { c := s.at(sx, sy) alpha := float64(c.A) / 255 * g.fillAlpha if g.clip != nil { - alpha *= float64(g.clip[y*r.img.W+x]) + alpha *= g.clip.at(x, y) } if g.softMask != nil { alpha *= maskLevel(g.softMask[y*r.img.W+x]) diff --git a/pattern.go b/pattern.go index a00596a..a5a9a9d 100644 --- a/pattern.go +++ b/pattern.go @@ -106,7 +106,7 @@ func (r *renderer) paintShading(g *gstate, sh *shading, m geometry.Matrix, cov [ for x := 0; x < w; x++ { a := cov[y*w+x] * alpha if g.clip != nil { - a *= float64(g.clip[(oy+y)*r.img.W+(ox+x)]) + a *= g.clip.at(ox+x, oy+y) } if g.softMask != nil { a *= maskLevel(g.softMask[(oy+y)*r.img.W+(ox+x)]) diff --git a/state.go b/state.go index 7790e35..4302443 100644 --- a/state.go +++ b/state.go @@ -5,6 +5,7 @@ import ( "github.com/go-gfx/gfx/raster" "github.com/go-gfx/gfx/vector" "github.com/go-pdfkit/reader" + "image" "image/color" ) @@ -43,9 +44,9 @@ type gstate struct { // text is everything the show operators read besides the string. text textState - // clip is the coverage every mark is multiplied by, one value per pixel of - // the image, or nil when nothing is clipped away. - clip []float32 + // clip is the shape everything is drawn through, or nil when nothing is + // clipped away. + clip *clip // softMask is a second such grid, and multiplies alongside the clip: a // clip is a shape and a soft mask is a picture, but both come down to how @@ -151,7 +152,7 @@ func (r *renderer) mask(g *gstate, cov []float64, ox, oy, w, h int, alpha float6 i := y*w + x v := out[i] * alpha if g.clip != nil { - v *= float64(g.clip[(oy+y)*r.img.W+(ox+x)]) + v *= g.clip.at(ox+x, oy+y) } if g.softMask != nil { v *= maskLevel(g.softMask[(oy+y)*r.img.W+(ox+x)]) @@ -165,20 +166,67 @@ func (r *renderer) mask(g *gstate, cov []float64, ox, oy, w, h int, alpha float6 // maskLevel turns one of a mask's bytes into how much it lets through. func maskLevel(v uint8) float64 { return float64(v) / 255 } +// A clip is what a page is allowed to draw on: the box it has narrowed itself +// down to, and how much of each pixel inside that box is allowed through. +// +// It is kept as a box rather than as a value for every pixel of the page +// because a page narrows its clip over and over — a real government form does +// it two thousand one hundred and forty times, and an arXiv figure six +// thousand seven hundred — and a full page of floats each time is four +// kilobytes per hundred pixels of paper, whether or not the shape covers any +// of it. Measured before this: that form allocated 4 226 MB, and one figure +// asked for 97 333 MB to draw a single page. +type clip struct { + ox, oy, w, h int + // cov is one value a pixel over the box, and nothing outside it. A clip + // whose box is empty lets nothing through at all, which is what a page + // asks for when it clips to a shape that covers no pixel. + cov []float32 +} + +// at is how much of one pixel of the image the clip lets through. Everything +// outside the box is outside the clip. +func (c *clip) at(x, y int) float64 { + if c == nil { + return 1 + } + if x < c.ox || y < c.oy || x >= c.ox+c.w || y >= c.oy+c.h { + return 0 + } + return float64(c.cov[(y-c.oy)*c.w+(x-c.ox)]) +} + // narrow intersects the clip with one coverage grid: what was already hidden // stays hidden, and everything outside the new shape joins it. func (r *renderer) narrow(g *gstate, cov []float64, ox, oy, w, h int, ok bool) { - next := make([]float32, r.img.W*r.img.H) - if ok { - for y := 0; y < h; y++ { - for x := 0; x < w; x++ { - next[(oy+y)*r.img.W+(ox+x)] = float32(cov[y*w+x]) - } - } + if !ok { + // The shape covered no pixel, so nothing may be drawn from here on. + g.clip = &clip{} + return } + // What is left is what both shapes allow, so the new box need be no + // larger than the smaller of the two — which is what keeps a page that + // narrows itself repeatedly from paying for the whole sheet each time. + box := image.Rect(ox, oy, ox+w, oy+h) if g.clip != nil { - for i := range next { - next[i] *= g.clip[i] + box = box.Intersect(image.Rect(g.clip.ox, g.clip.oy, + g.clip.ox+g.clip.w, g.clip.oy+g.clip.h)) + } + box = box.Intersect(image.Rect(0, 0, r.img.W, r.img.H)) + if box.Empty() { + g.clip = &clip{} + return + } + next := &clip{ox: box.Min.X, oy: box.Min.Y, w: box.Dx(), h: box.Dy()} + next.cov = make([]float32, next.w*next.h) + for y := 0; y < next.h; y++ { + for x := 0; x < next.w; x++ { + px, py := next.ox+x, next.oy+y + v := float32(cov[(py-oy)*w+(px-ox)]) + if g.clip != nil { + v *= float32(g.clip.at(px, py)) + } + next.cov[y*next.w+x] = v } } g.clip = next