From 9450783f2fa7fcd25abd02befb9d3073b313543d Mon Sep 17 00:00:00 2001 From: tannevaled Date: Wed, 26 Aug 2026 14:28:30 +0200 Subject: [PATCH] Read the soft masks, and draw a transparency group as one thing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A soft mask is how a PDF says that what is drawn from here on shows through by an amount that varies from one place on the page to another. It is not a number but a whole little drawing of its own — a form, on paper of its own — and what is read off that drawing is the mask: how light each pixel came out, or how much of it was painted at all. It is how nearly every drop shadow, every soft edge and every faded surface in an illustration is written. The corpus was asked first: 1 286 of its pages name one, seven times as many as carry a mesh shading. 871 read the light in the drawing and 415 how much of it was painted; 337 name a backdrop colour and 58 a transfer function. All four are read here. The second half of the change is what the first half made visible. A form that says it is a transparency group has to be drawn as one thing: everything inside it goes on at full strength against a copy of the page, and then the alpha and the mask in force where it was used are applied to the whole of the result. Drawn one mark at a time instead, a figure's own graphics state — set inside the form, where it starts again at one — throws the page's away, and every overlap inside the group doubles up. That is the difference between a shadow that fades and a slab. A mask is a byte a pixel rather than a float, since it is read off an eight-bit picture and holding it wider than it was measured would only cost memory; and only so many are kept, because a page may name a great many. Measured on the 1 286 pages the corpus says this can reach: 1 276 drawn each way with no panics, 1 217 changed. Put beside macOS's own renderer, a circular zoom-inset that was an opaque rectangle over half a figure cuts to its circle; panel backgrounds that were solid colour become the faint tints they were meant to be; a code panel that came out blank draws in full; and an isometric diagram that was a green slab becomes the wash it is. --- colour.go | 5 + image.go | 4 + pattern.go | 4 + render.go | 2 +- softmask.go | 213 +++++++++++++++++ softmask_test.go | 582 +++++++++++++++++++++++++++++++++++++++++++++++ state.go | 50 +++- xobject.go | 100 +++++++- 8 files changed, 956 insertions(+), 4 deletions(-) create mode 100644 softmask.go create mode 100644 softmask_test.go diff --git a/colour.go b/colour.go index f132a80..83f9271 100644 --- a/colour.go +++ b/colour.go @@ -137,4 +137,9 @@ func (r *renderer) applyExtGState(g *gstate, operands []reader.Object, resources if arr, ok := reader.ToArray(resolve(r.doc, params.Get("D"))); ok && len(arr) == 2 { g.dash, g.dashPhase = dashPattern([]reader.Object{resolve(r.doc, arr[0]), resolve(r.doc, arr[1])}) } + // A graphics state that does not mention a soft mask leaves the one in + // force alone, and Get cannot tell an absent entry from a null one. + if entry, named := params["SMask"]; named { + g.softMask = r.readSoftMask(entry, g, resources) + } } diff --git a/image.go b/image.go index 8a4a3a4..741403a 100644 --- a/image.go +++ b/image.go @@ -54,10 +54,14 @@ func (r *renderer) drawImage(g *gstate, s *sampled) { if g.clip != nil { alpha *= float64(g.clip[y*r.img.W+x]) } + if g.softMask != nil { + alpha *= maskLevel(g.softMask[y*r.img.W+x]) + } if alpha <= 0 { continue } r.img.Set(x, y, blend(r.img.At(x, y), c, alpha)) + r.markPixel(x, y, alpha) } } } diff --git a/pattern.go b/pattern.go index 37aec45..a00596a 100644 --- a/pattern.go +++ b/pattern.go @@ -108,6 +108,9 @@ func (r *renderer) paintShading(g *gstate, sh *shading, m geometry.Matrix, cov [ if g.clip != nil { a *= float64(g.clip[(oy+y)*r.img.W+(ox+x)]) } + if g.softMask != nil { + a *= maskLevel(g.softMask[(oy+y)*r.img.W+(ox+x)]) + } if a <= 0 { continue } @@ -125,6 +128,7 @@ func (r *renderer) paintShading(g *gstate, sh *shading, m geometry.Matrix, cov [ continue } r.img.Set(ox+x, oy+y, blend(r.img.At(ox+x, oy+y), c, a)) + r.markPixel(ox+x, oy+y, a) } } } diff --git a/render.go b/render.go index 247eebc..e6d72c0 100644 --- a/render.go +++ b/render.go @@ -90,7 +90,7 @@ func Page(d *reader.Document, i int, opt Options) (*raster.Image, error) { return nil, err } resources, _ := d.GetDict(page, "Resources") - r := &renderer{doc: d, img: img, fonts: map[int]*pdfFont{}} + r := &renderer{doc: d, img: img, fonts: map[int]*pdfFont{}, softMasks: map[softMaskKey][]uint8{}} start := r.initialState(box, rotation, s) r.base = start.ctm r.run(content, resources, start) diff --git a/softmask.go b/softmask.go new file mode 100644 index 0000000..111d6e3 --- /dev/null +++ b/softmask.go @@ -0,0 +1,213 @@ +package render + +import ( + "image/color" + + "github.com/go-gfx/gfx/geometry" + "github.com/go-gfx/gfx/raster" + "github.com/go-pdfkit/reader" +) + +// A soft mask says that what is drawn from here on shows through by an amount +// that varies from one place on the page to another. It is not a number but a +// whole little drawing of its own — a form, drawn on its own paper — and what +// is read off that drawing is the mask: either how light each pixel came out, +// or how much of it was painted at all. +// +// It is what a figure uses to fade a surface away, and it is how nearly every +// drop shadow and every soft edge in an illustration is written. A page that +// uses one and does not get it comes out with that part at full strength, +// which is worse than it sounds: a shadow meant to be a hint becomes a slab. + +// maxCachedMasks bounds how many masks a page may keep drawn, since each one +// is a byte for every pixel of the page and a file may name a great many. Past +// it they are still drawn, just not kept. +const maxCachedMasks = 64 + +// softMaskKey is what makes two uses of a mask the same picture: the same +// mask dictionary — which is what says the form, the kind, the backdrop and +// the transfer function all at once — drawn the same way round. A file names +// one graphics state and uses it over and over, so this saves drawing the +// same mask again for each use. +type softMaskKey struct { + mask reader.Ref + ctm geometry.Matrix +} + +// readSoftMask builds the mask an ExtGState's /SMask names, or nil for /None +// and for anything it cannot read — in which case nothing is masked, which is +// what this package did before it could read them at all. +func (r *renderer) readSoftMask(entry reader.Object, g *gstate, resources reader.Dict) []uint8 { + if name, ok := reader.ToName(resolve(r.doc, entry)); ok && name == "None" { + return nil + } + dict, ok := reader.ToDict(resolve(r.doc, entry)) + if !ok || r.depth >= maxFormDepth { + return nil + } + kind, _ := reader.ToName(resolve(r.doc, dict.Get("S"))) + if kind != "Luminosity" && kind != "Alpha" { + return nil + } + form, ok := reader.ToStream(resolve(r.doc, dict.Get("G"))) + if !ok { + return nil + } + // A mask written out once and pointed at is one this can recognise again; + // one written in place is not worth telling apart from another. + ref, cacheable := entry.(reader.Ref) + key := softMaskKey{mask: ref, ctm: g.ctm} + if cacheable { + if mask, ok := r.softMasks[key]; ok { + return mask + } + } + mask := r.drawSoftMask(dict, form, kind, g, resources) + if cacheable && len(r.softMasks) < maxCachedMasks { + r.softMasks[key] = mask + } + return mask +} + +// drawSoftMask draws the mask's form on paper of its own and reads the mask +// off what comes out. +func (r *renderer) drawSoftMask(dict reader.Dict, form *reader.Stream, kind reader.Name, g *gstate, resources reader.Dict) []uint8 { + content, img, err := r.doc.DecodeStream(form) + if err != nil || img != "" { + return nil + } + backdrop := r.maskBackdrop(dict, form, kind) + // The mask's own paper is the size of the page's, because a mask is read + // off the same pixels the page is drawn on. + paper := raster.New(r.img.W, r.img.H) + fill(paper, backdrop) + var painted []float32 + if kind == "Alpha" { + // How much was painted is not something an opaque picture remembers, + // so it is kept alongside. + painted = make([]float32, r.img.W*r.img.H) + } + + inner := gstate{ + ctm: g.ctm, + fill: black, + stroke: black, + fillSpace: deviceGray, + strokeSpace: deviceGray, + lineWidth: 1, + miterLimit: 10, + fillAlpha: 1, + strokeAlpha: 1, + } + if arr, ok := reader.ToArray(resolve(r.doc, form.Dict.Get("Matrix"))); ok && len(arr) == 6 { + n := make([]float64, 6) + for i := range n { + v, ok := reader.ToFloat(resolve(r.doc, arr[i])) + if !ok { + return nil + } + n[i] = v + } + inner.ctm = inner.ctm.Mul(matrix(n)) + } + // Everything outside the group's box is the backdrop, which the form's + // own bounding box is what says. + box, hasBox := rectangle(r.doc, form.Dict.Get("BBox")) + + formResources, ok := r.doc.GetDict(form.Dict, "Resources") + if !ok { + formResources = resources + } + + // The form is run against its own paper, and everything the renderer + // keeps that belongs to a page is put back afterwards. The text matrices + // are among them: a graphics state may be named in the middle of a run of + // text, and a mask that had any text of its own would otherwise leave the + // pen somewhere else than it found it. + wasImg, wasPainted, wasBase := r.img, r.painted, r.base + wasTm, wasTlm := r.tm, r.tlm + r.img, r.painted, r.base = paper, painted, inner.ctm + if hasBox { + r.clipToBox(&inner, box) + } + r.depth++ + r.run(content, formResources, inner) + r.depth-- + r.img, r.painted, r.base = wasImg, wasPainted, wasBase + r.tm, r.tlm = wasTm, wasTlm + + mask := make([]uint8, r.img.W*r.img.H) + if kind == "Alpha" { + for i, v := range painted { + mask[i] = byteOf(float64(v)) + } + } else { + for i := range mask { + mask[i] = byteOf(luminosity(paper.Pix[i*4], paper.Pix[i*4+1], paper.Pix[i*4+2])) + } + } + r.applyTransfer(mask, dict) + return mask +} + +// black is what a mask's paper starts as, and what masks everything the +// drawing on it does not reach. +var black = color.RGBA{A: 255} + +// maskBackdrop is what the mask's paper starts as. For a mask read from the +// light in a drawing that is the backdrop colour the file names, or black, +// which masks everything the drawing does not reach; for one read from how +// much was painted the paper does not matter, since nothing was. +func (r *renderer) maskBackdrop(dict reader.Dict, form *reader.Stream, kind reader.Name) color.RGBA { + if kind == "Alpha" { + return black + } + bc := r.floatArray(dict.Get("BC")) + if len(bc) == 0 { + return black + } + group, ok := r.doc.GetDict(form.Dict, "Group") + if !ok { + return black + } + // A backdrop colour is written in the group's own colour space, so a + // group that names none has nothing to write it in. + cs, named := group["CS"] + if !named { + return black + } + sp := r.colourSpace(cs, nil, 0) + if sp == nil || sp.pattern || len(bc) < sp.components { + return black + } + return sp.convert(bc) +} + +// luminosity is how light a colour is, weighted the way an eye weights it, +// which is what the format says a mask of this kind reads. +func luminosity(r8, g8, b8 uint8) float64 { + return (0.3*float64(r8) + 0.59*float64(g8) + 0.11*float64(b8)) / 255 +} + +// applyTransfer runs the mask through the function the file names, which is +// how a mask is made harder or softer than the drawing it came from. +func (r *renderer) applyTransfer(mask []uint8, dict reader.Dict) { + entry := resolve(r.doc, dict.Get("TR")) + if name, ok := reader.ToName(entry); ok && name == "Identity" { + return + } + fn := r.readFunction(dict.Get("TR"), 0) + if fn == nil || fn.outputs() < 1 { + return + } + // A transfer function takes one number and gives one back, so the whole + // of it is 256 values and every pixel is a lookup. + var table [256]uint8 + for i := range table { + out := fn.eval([]float64{float64(i) / 255}) + table[i] = byteOf(out[0]) + } + for i, v := range mask { + mask[i] = table[v] + } +} diff --git a/softmask_test.go b/softmask_test.go new file mode 100644 index 0000000..fd93793 --- /dev/null +++ b/softmask_test.go @@ -0,0 +1,582 @@ +package render + +import ( + "fmt" + "image/color" + "testing" + + "github.com/go-pdfkit/reader" +) + +// maskForm builds the little drawing a soft mask reads itself off. +func maskForm(w *reader.Writer, content string, resources reader.Dict, extra reader.Dict) reader.Object { + dict := reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Form"), + "BBox": nums(0, 0, 100, 100), + "Group": reader.Dict{"S": reader.Name("Transparency"), "CS": reader.Name("DeviceGray")}, + } + if resources != nil { + dict["Resources"] = resources + } + for k, v := range extra { + dict[k] = v + } + return w.Add(&reader.Stream{Dict: dict, Raw: []byte(content)}) +} + +// greyRamp is a shading from black to white across the page, which makes a +// mask whose strength can be read off wherever it is looked at. +func greyRamp(w *reader.Writer) reader.Object { + return w.Add(reader.Dict{ + "ShadingType": reader.Integer(2), "ColorSpace": reader.Name("DeviceGray"), + "Coords": nums(0, 0, 100, 0), + "Function": w.Add(reader.Dict{"FunctionType": reader.Integer(2), "Domain": nums(0, 1), + "C0": nums(0), "C1": nums(1), "N": reader.Integer(1)}), + "Extend": reader.Array{reader.Bool(true), reader.Bool(true)}, + }) +} + +// maskedPage paints a black rectangle over the whole page through whatever +// soft mask the state names. +func maskedPage(t *testing.T, mask func(w *reader.Writer) reader.Object, content string) *reader.Document { + t.Helper() + if content == "" { + content = "/GS0 gs 0 0 100 100 re f" + } + return shadedPage(t, content, func(w *reader.Writer) reader.Dict { + return reader.Dict{"ExtGState": reader.Dict{"GS0": w.Add(reader.Dict{ + "Type": reader.Name("ExtGState"), "SMask": mask(w), + })}} + }) +} + +func TestALuminosityMaskFadesWhatIsDrawnThroughIt(t *testing.T) { + // The mask is a drawing that goes from black to white across the page. + // Where it is black nothing shows, where it is white everything does, and + // in between a fill of black comes out grey. + d := maskedPage(t, func(w *reader.Writer) reader.Object { + form := maskForm(w, "/S1 sh", reader.Dict{"Shading": reader.Dict{"S1": greyRamp(w)}}, nil) + return reader.Dict{"S": reader.Name("Luminosity"), "G": form} + }, "") + img, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + wantColour(t, img, 2, 50, color.RGBA{R: 255, G: 255, B: 255, A: 255}, 10) + wantColour(t, img, 50, 50, color.RGBA{R: 128, G: 128, B: 128, A: 255}, 12) + wantColour(t, img, 97, 50, color.RGBA{A: 255}, 10) +} + +func TestAMaskDoesNotReachOutsideItsOwnBox(t *testing.T) { + // The mask's form covers the left half of the page, so the right half is + // the backdrop — black, and therefore hidden — however the drawing inside + // the box came out. + d := maskedPage(t, func(w *reader.Writer) reader.Object { + form := maskForm(w, "1 g 0 0 50 100 re f", nil, reader.Dict{"BBox": nums(0, 0, 50, 100)}) + return reader.Dict{"S": reader.Name("Luminosity"), "G": form} + }, "") + img, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + wantColour(t, img, 25, 50, color.RGBA{A: 255}, 6) + wantColour(t, img, 75, 50, color.RGBA{R: 255, G: 255, B: 255, A: 255}, 6) +} + +func TestABackdropColourSaysWhatIsOutsideTheMask(t *testing.T) { + // A white backdrop lets everything the mask does not cover through, which + // is the opposite of the default and what a file says when it wants a + // mask to hide something rather than to reveal it. + d := maskedPage(t, func(w *reader.Writer) reader.Object { + form := maskForm(w, "0 g 0 0 50 100 re f", nil, reader.Dict{"BBox": nums(0, 0, 50, 100)}) + return reader.Dict{"S": reader.Name("Luminosity"), "G": form, "BC": nums(1)} + }, "") + img, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + wantColour(t, img, 25, 50, color.RGBA{R: 255, G: 255, B: 255, A: 255}, 6) + wantColour(t, img, 75, 50, color.RGBA{A: 255}, 6) +} + +func TestABackdropThatDoesNotFitItsSpaceIsBlack(t *testing.T) { + for _, c := range []struct { + why string + group reader.Object + bc reader.Array + }{ + {"a backdrop with too few numbers for the space", + reader.Dict{"S": reader.Name("Transparency"), "CS": reader.Name("DeviceRGB")}, nums(1)}, + {"a group that names no space at all", + reader.Dict{"S": reader.Name("Transparency")}, nums(1)}, + {"a form with no group dictionary", nil, nums(1)}, + } { + d := maskedPage(t, func(w *reader.Writer) reader.Object { + extra := reader.Dict{"BBox": nums(0, 0, 50, 100)} + if c.group == nil { + extra["Group"] = reader.Null{} + } else { + extra["Group"] = c.group + } + form := maskForm(w, "1 g 0 0 50 100 re f", nil, extra) + return reader.Dict{"S": reader.Name("Luminosity"), "G": form, "BC": c.bc} + }, "") + img, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + wantColour(t, img, 75, 50, color.RGBA{R: 255, G: 255, B: 255, A: 255}, 6) + } +} + +func TestAnAlphaMaskAsksWhetherAnythingWasDrawnAtAll(t *testing.T) { + // This kind of mask does not care what colour came out, only how much of + // each pixel was covered. A white rectangle on white paper is invisible + // and still masks nothing away. + d := maskedPage(t, func(w *reader.Writer) reader.Object { + form := maskForm(w, "1 g 0 0 50 100 re f", nil, nil) + return reader.Dict{"S": reader.Name("Alpha"), "G": form} + }, "") + img, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + wantColour(t, img, 25, 50, color.RGBA{A: 255}, 6) + wantColour(t, img, 75, 50, color.RGBA{R: 255, G: 255, B: 255, A: 255}, 6) +} + +func TestAnAlphaMaskCountsEveryKindOfMark(t *testing.T) { + // Images and gradients cover a pixel as surely as a fill does, and a mask + // of this kind has to count them too. + for _, c := range []struct { + why string + content string + resources reader.Dict + }{ + {"a gradient painted on its own", "/S1 sh", nil}, + {"an image", "q 50 0 0 100 0 0 cm /I1 Do Q", nil}, + } { + d := shadedPage(t, "/GS0 gs 0 0 100 100 re f", func(w *reader.Writer) reader.Dict { + inner := reader.Dict{"Shading": reader.Dict{"S1": greyRamp(w)}, + "XObject": reader.Dict{"I1": w.Add(&reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Image"), + "Width": reader.Integer(1), "Height": reader.Integer(1), + "ColorSpace": reader.Name("DeviceGray"), "BitsPerComponent": reader.Integer(8), + }, Raw: []byte{255}})}} + form := maskForm(w, c.content, inner, reader.Dict{"BBox": nums(0, 0, 50, 100)}) + return reader.Dict{"ExtGState": reader.Dict{"GS0": w.Add(reader.Dict{ + "SMask": reader.Dict{"S": reader.Name("Alpha"), "G": form}, + })}} + }) + img, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + if img.At(25, 50) == (color.RGBA{R: 255, G: 255, B: 255, A: 255}) { + t.Errorf("%s: was not counted as having covered anything", c.why) + } + wantColour(t, img, 75, 50, color.RGBA{R: 255, G: 255, B: 255, A: 255}, 6) + } +} + +func TestAMaskIsPutAsideAgain(t *testing.T) { + // A state that names /None takes the mask off, and one that says nothing + // about masks at all leaves whatever is in force alone. + d := shadedPage(t, "/GS0 gs /GS1 gs 0 0 100 100 re f", func(w *reader.Writer) reader.Dict { + form := maskForm(w, "0 g 0 0 100 100 re f", nil, nil) + return reader.Dict{"ExtGState": reader.Dict{ + "GS0": w.Add(reader.Dict{"SMask": reader.Dict{"S": reader.Name("Luminosity"), "G": form}}), + "GS1": w.Add(reader.Dict{"SMask": reader.Name("None")}), + }} + }) + img, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + wantColour(t, img, 50, 50, color.RGBA{A: 255}, 6) + + d = shadedPage(t, "/GS0 gs /GS1 gs 0 0 100 100 re f", func(w *reader.Writer) reader.Dict { + form := maskForm(w, "0 g 0 0 100 100 re f", nil, nil) + return reader.Dict{"ExtGState": reader.Dict{ + "GS0": w.Add(reader.Dict{"SMask": reader.Dict{"S": reader.Name("Luminosity"), "G": form}}), + "GS1": w.Add(reader.Dict{"LW": reader.Integer(4)}), + }} + }) + img, err = Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + wantColour(t, img, 50, 50, color.RGBA{R: 255, G: 255, B: 255, A: 255}, 6) +} + +func TestAMaskIsDrawnOnceHoweverOftenItIsUsed(t *testing.T) { + // The same graphics state named twice is the same mask twice, and drawing + // it again would be drawing the same picture again. + d := shadedPage(t, "/GS0 gs 0 0 50 100 re f /GS0 gs 50 0 50 100 re f", + func(w *reader.Writer) reader.Dict { + form := maskForm(w, "/S1 sh", reader.Dict{"Shading": reader.Dict{"S1": greyRamp(w)}}, nil) + return reader.Dict{"ExtGState": reader.Dict{"GS0": w.Add(reader.Dict{ + "SMask": w.Add(reader.Dict{"S": reader.Name("Luminosity"), "G": form}), + })}} + }) + img, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + wantColour(t, img, 2, 50, color.RGBA{R: 255, G: 255, B: 255, A: 255}, 10) + wantColour(t, img, 97, 50, color.RGBA{A: 255}, 10) +} + +func TestATransferFunctionTurnsTheMaskRound(t *testing.T) { + // A transfer function is a second thought about the mask: this one gives + // back one minus what it was handed, so the fade runs the other way. + d := maskedPage(t, func(w *reader.Writer) reader.Object { + form := maskForm(w, "/S1 sh", reader.Dict{"Shading": reader.Dict{"S1": greyRamp(w)}}, nil) + return reader.Dict{"S": reader.Name("Luminosity"), "G": form, + "TR": w.Add(&reader.Stream{Dict: reader.Dict{ + "FunctionType": reader.Integer(4), "Domain": nums(0, 1), "Range": nums(0, 1), + }, Raw: []byte("{ 1 exch sub }")}), + } + }, "") + img, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + wantColour(t, img, 2, 50, color.RGBA{A: 255}, 10) + wantColour(t, img, 97, 50, color.RGBA{R: 255, G: 255, B: 255, A: 255}, 10) +} + +func TestATransferFunctionThatSaysNothingIsSkipped(t *testing.T) { + for _, tr := range []reader.Object{reader.Name("Identity"), reader.Name("Nonsense"), reader.Null{}} { + d := maskedPage(t, func(w *reader.Writer) reader.Object { + form := maskForm(w, "/S1 sh", reader.Dict{"Shading": reader.Dict{"S1": greyRamp(w)}}, nil) + return reader.Dict{"S": reader.Name("Luminosity"), "G": form, "TR": tr} + }, "") + img, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + wantColour(t, img, 2, 50, color.RGBA{R: 255, G: 255, B: 255, A: 255}, 10) + wantColour(t, img, 97, 50, color.RGBA{A: 255}, 10) + } +} + +func TestAMaskThatCannotBeReadMasksNothing(t *testing.T) { + // A file may say something about a mask that cannot be made sense of. The + // page is then drawn at full strength, which is what this package did + // before it could read masks at all, rather than blanked. + for _, c := range []struct { + why string + make func(w *reader.Writer) reader.Object + }{ + {"a mask of a kind that does not exist", func(w *reader.Writer) reader.Object { + return reader.Dict{"S": reader.Name("Sepia"), + "G": maskForm(w, "0 g 0 0 100 100 re f", nil, nil)} + }}, + {"a mask whose group is not a form", func(w *reader.Writer) reader.Object { + return reader.Dict{"S": reader.Name("Luminosity"), "G": reader.Integer(4)} + }}, + {"a mask that is neither a name nor a dictionary", func(w *reader.Writer) reader.Object { + return reader.Integer(7) + }}, + {"a mask whose form is a picture rather than a drawing", func(w *reader.Writer) reader.Object { + return reader.Dict{"S": reader.Name("Luminosity"), "G": w.Add(&reader.Stream{ + Dict: reader.Dict{"Subtype": reader.Name("Form"), "BBox": nums(0, 0, 100, 100), + "Filter": reader.Name("DCTDecode")}, Raw: []byte("not a jpeg")})} + }}, + {"a mask whose form has a matrix that is not numbers", func(w *reader.Writer) reader.Object { + return reader.Dict{"S": reader.Name("Luminosity"), + "G": maskForm(w, "0 g 0 0 100 100 re f", nil, reader.Dict{ + "Matrix": reader.Array{reader.Name("x"), reader.Integer(0), reader.Integer(0), + reader.Integer(1), reader.Integer(0), reader.Integer(0)}})} + }}, + } { + d := maskedPage(t, c.make, "") + img, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + if got := img.At(50, 50); got != (color.RGBA{A: 255}) { + t.Errorf("%s: middle of the page is %v, wanted it drawn at full strength", c.why, got) + } + } +} + +func TestAMaskFormMayHaveAMatrixOfItsOwn(t *testing.T) { + // The form's matrix moves the mask, so a mask drawn on the left half of + // its own space can end up over the right half of the page. + d := maskedPage(t, func(w *reader.Writer) reader.Object { + form := maskForm(w, "1 g 0 0 50 100 re f", nil, reader.Dict{ + "BBox": nums(0, 0, 50, 100), + "Matrix": nums(1, 0, 0, 1, 50, 0), + }) + return reader.Dict{"S": reader.Name("Luminosity"), "G": form} + }, "") + img, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + wantColour(t, img, 25, 50, color.RGBA{R: 255, G: 255, B: 255, A: 255}, 6) + wantColour(t, img, 75, 50, color.RGBA{A: 255}, 6) +} + +func TestATransparencyGroupGoesOnAsOneThing(t *testing.T) { + // Two shapes that overlap, drawn at half strength inside a group, come + // out the same shade where they overlap as where they do not: the group + // is drawn whole and then faded, not faded mark by mark. Drawing them one + // at a time would darken the overlap, which is what a shadow made of + // several shapes looks like when it is got wrong. + d := shadedPage(t, "/GS0 gs /F1 Do", func(w *reader.Writer) reader.Dict { + form := w.Add(&reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Form"), + "BBox": nums(0, 0, 100, 100), + "Group": reader.Dict{"S": reader.Name("Transparency")}, + }, Raw: []byte("0 g 10 10 50 50 re f 30 10 50 50 re f")}) + return reader.Dict{ + "XObject": reader.Dict{"F1": form}, + "ExtGState": reader.Dict{"GS0": w.Add(reader.Dict{"ca": nums(0.5)[0]})}, + } + }) + img, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + alone, overlap := img.At(20, 60), img.At(45, 60) + if alone != overlap { + t.Errorf("one shape is %v and the overlap %v; a group goes on as one thing", alone, overlap) + } + wantColour(t, img, 20, 60, color.RGBA{R: 128, G: 128, B: 128, A: 255}, 4) + wantColour(t, img, 90, 60, color.RGBA{R: 255, G: 255, B: 255, A: 255}, 4) +} + +func TestATransparencyGroupThatIsWhollyHiddenLeavesThePageAlone(t *testing.T) { + d := shadedPage(t, "/GS0 gs /F1 Do", func(w *reader.Writer) reader.Dict { + form := w.Add(&reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Form"), + "BBox": nums(0, 0, 100, 100), + "Group": reader.Dict{"S": reader.Name("Transparency")}, + }, Raw: []byte("0 g 10 10 50 50 re f")}) + return reader.Dict{ + "XObject": reader.Dict{"F1": form}, + "ExtGState": reader.Dict{"GS0": w.Add(reader.Dict{"ca": nums(0)[0]})}, + } + }) + img, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + wantColour(t, img, 20, 60, color.RGBA{R: 255, G: 255, B: 255, A: 255}, 2) +} + +func TestATransparencyGroupWithNoBoxOnThePageDrawsNothing(t *testing.T) { + d := shadedPage(t, "/GS0 gs /F1 Do", func(w *reader.Writer) reader.Dict { + form := w.Add(&reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Form"), + "BBox": nums(500, 500, 600, 600), + "Group": reader.Dict{"S": reader.Name("Transparency")}, + }, Raw: []byte("0 g 500 500 100 100 re f")}) + return reader.Dict{ + "XObject": reader.Dict{"F1": form}, + "ExtGState": reader.Dict{"GS0": w.Add(reader.Dict{"ca": nums(0.5)[0]})}, + } + }) + img, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + wantColour(t, img, 50, 50, color.RGBA{R: 255, G: 255, B: 255, A: 255}, 2) +} + +func TestAGroupInsideAMaskCountsForWhatItCovered(t *testing.T) { + // A mask that asks how much was painted, over a drawing that is itself a + // group faded to half: half of it was painted, so half of what the page + // draws through the mask shows. + d := shadedPage(t, "/GS0 gs 0 0 100 100 re f", func(w *reader.Writer) reader.Dict { + inner := w.Add(&reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Form"), + "BBox": nums(0, 0, 50, 100), + "Group": reader.Dict{"S": reader.Name("Transparency")}, + }, Raw: []byte("0 g 0 0 50 100 re f")}) + form := maskForm(w, "/GS1 gs /F1 Do", + reader.Dict{ + "XObject": reader.Dict{"F1": inner}, + "ExtGState": reader.Dict{"GS1": w.Add(reader.Dict{"ca": nums(0.5)[0]})}, + }, nil) + return reader.Dict{"ExtGState": reader.Dict{"GS0": w.Add(reader.Dict{ + "SMask": reader.Dict{"S": reader.Name("Alpha"), "G": form}, + })}} + }) + img, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + wantColour(t, img, 25, 50, color.RGBA{R: 128, G: 128, B: 128, A: 255}, 8) + wantColour(t, img, 75, 50, color.RGBA{R: 255, G: 255, B: 255, A: 255}, 4) +} + +func TestAMaskFormWithNoResourcesUsesThePagesOwn(t *testing.T) { + // A form need not carry resources; when it does not, what it names is + // looked for where it was used. + d := shadedPage(t, "/GS0 gs 0 0 100 100 re f", func(w *reader.Writer) reader.Dict { + form := maskForm(w, "/S1 sh", nil, nil) + return reader.Dict{ + "Shading": reader.Dict{"S1": greyRamp(w)}, + "ExtGState": reader.Dict{"GS0": w.Add(reader.Dict{ + "SMask": reader.Dict{"S": reader.Name("Luminosity"), "G": form}, + })}, + } + }) + img, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + wantColour(t, img, 2, 50, color.RGBA{R: 255, G: 255, B: 255, A: 255}, 10) + wantColour(t, img, 97, 50, color.RGBA{A: 255}, 10) +} + +func TestEverythingIsDrawnThroughTheMask(t *testing.T) { + // Not only a fill: an image and a gradient are marks like any other and + // the mask has to reach them too. + for _, c := range []struct { + why string + content string + }{ + {"an image", "/GS0 gs q 100 0 0 100 0 0 cm /I1 Do Q"}, + {"a gradient painted on its own", "/GS0 gs /S2 sh"}, + } { + d := shadedPage(t, c.content, func(w *reader.Writer) reader.Dict { + form := maskForm(w, "1 g 0 0 50 100 re f", nil, nil) + return reader.Dict{ + "XObject": reader.Dict{"I1": w.Add(&reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Image"), + "Width": reader.Integer(1), "Height": reader.Integer(1), + "ColorSpace": reader.Name("DeviceGray"), "BitsPerComponent": reader.Integer(8), + }, Raw: []byte{0}})}, + "Shading": reader.Dict{"S2": w.Add(reader.Dict{ + "ShadingType": reader.Integer(2), "ColorSpace": reader.Name("DeviceGray"), + "Coords": nums(0, 0, 100, 0), + "Function": w.Add(reader.Dict{"FunctionType": reader.Integer(2), + "Domain": nums(0, 1), "C0": nums(0), "C1": nums(0), "N": reader.Integer(1)}), + "Extend": reader.Array{reader.Bool(true), reader.Bool(true)}, + })}, + "ExtGState": reader.Dict{"GS0": w.Add(reader.Dict{ + "SMask": reader.Dict{"S": reader.Name("Luminosity"), "G": form}, + })}, + } + }) + img, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + if got := img.At(25, 50); got != (color.RGBA{A: 255}) { + t.Errorf("%s: where the mask is open the pixel is %v, wanted it drawn", c.why, got) + } + if got := img.At(75, 50); got != (color.RGBA{R: 255, G: 255, B: 255, A: 255}) { + t.Errorf("%s: where the mask is shut the pixel is %v, wanted paper", c.why, got) + } + } +} + +func TestAGroupIsFadedByTheMaskItIsDrawnUnder(t *testing.T) { + // A whole group behind a mask: where the mask is open the group goes on + // as it was drawn, and where it is shut the page is left as it was. + d := shadedPage(t, "/GS0 gs /F1 Do", func(w *reader.Writer) reader.Dict { + mask := maskForm(w, "1 g 0 0 50 100 re f", nil, nil) + form := w.Add(&reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Form"), + "BBox": nums(0, 0, 100, 100), + "Group": reader.Dict{"S": reader.Name("Transparency")}, + }, Raw: []byte("0 g 0 0 100 100 re f")}) + return reader.Dict{ + "XObject": reader.Dict{"F1": form}, + "ExtGState": reader.Dict{"GS0": w.Add(reader.Dict{ + "SMask": reader.Dict{"S": reader.Name("Luminosity"), "G": mask}, + })}, + } + }) + img, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + wantColour(t, img, 25, 50, color.RGBA{A: 255}, 4) + wantColour(t, img, 75, 50, color.RGBA{R: 255, G: 255, B: 255, A: 255}, 4) +} + +func TestAPageMayNameMoreMasksThanAreWorthKeeping(t *testing.T) { + // Each mask drawn is a byte for every pixel of the page, so only so many + // are kept. Past that they are drawn again each time they are used, which + // costs time and not correctness — the page has to come out the same + // either way. + const masks = maxCachedMasks + 4 + content := "" + for i := 0; i < masks; i++ { + content += fmt.Sprintf("/GS%d gs 0 %d 100 1 re f ", i, i) + } + d := shadedPage(t, content, func(w *reader.Writer) reader.Dict { + states := reader.Dict{} + for i := 0; i < masks; i++ { + // Each mask is its own object, so each is a different one: the + // left half open for the even ones and the right for the odd. + at := 0 + if i%2 == 1 { + at = 50 + } + form := maskForm(w, fmt.Sprintf("1 g %d 0 50 100 re f", at), nil, nil) + states[reader.Name(fmt.Sprintf("GS%d", i))] = w.Add(reader.Dict{ + "SMask": w.Add(reader.Dict{"S": reader.Name("Luminosity"), "G": form}), + }) + } + return reader.Dict{"ExtGState": states} + }) + img, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + // The stripe drawn under the last mask is an odd one, so it is on the + // right and not on the left. + y := 100 - masks + wantColour(t, img, 25, y, color.RGBA{R: 255, G: 255, B: 255, A: 255}, 4) + wantColour(t, img, 75, y, color.RGBA{A: 255}, 4) +} + +func TestAMaskNamedInTheMiddleOfSomeTextLeavesThePenWhereItWas(t *testing.T) { + // A graphics state may be named between one show operator and the next, + // and a mask with text of its own would move the pen if it were not put + // back. What follows the mask has to land where it would have anyway. + page := func(withMask bool) *reader.Document { + content := "BT /F1 12 Tf 10 50 Td (AB) Tj " + if withMask { + content += "/GS0 gs " + } + content += "(CD) Tj ET" + return shadedPage(t, content, func(w *reader.Writer) reader.Dict { + face := w.Add(reader.Dict{"Type": reader.Name("Font"), + "Subtype": reader.Name("Type1"), "BaseFont": reader.Name("Helvetica")}) + form := maskForm(w, "BT /F1 12 Tf 80 80 Td (XY) Tj ET 1 g 0 0 100 100 re f", + reader.Dict{"Font": reader.Dict{"F1": face}}, nil) + return reader.Dict{ + "Font": reader.Dict{"F1": face}, + "ExtGState": reader.Dict{"GS0": w.Add(reader.Dict{ + "SMask": reader.Dict{"S": reader.Name("Luminosity"), "G": form}, + })}, + } + }) + } + plain, err := Page(page(false), 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + masked, err := Page(page(true), 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + for y := 0; y < 100; y++ { + for x := 0; x < 100; x++ { + if plain.At(x, y) != masked.At(x, y) { + t.Fatalf("pixel (%d,%d) is %v without the mask and %v with it; a mask whose "+ + "own paper is white masks nothing and must move nothing", + x, y, plain.At(x, y), masked.At(x, y)) + } + } + } +} diff --git a/state.go b/state.go index e8bc20f..7790e35 100644 --- a/state.go +++ b/state.go @@ -46,6 +46,14 @@ type gstate struct { // 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 + + // 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 + // much of each pixel a mark is allowed to reach. It is a byte a pixel + // rather than the clip's float, because a mask is read off an eight-bit + // picture and holding it wider than it was measured would only cost + // memory — and a page may name a great many masks. + softMask []uint8 } // clone copies a state deeply enough that saving and restoring works: the clip @@ -77,6 +85,15 @@ type renderer struct { // fonts are the ones already read, by the object they were read from. fonts map[int]*pdfFont + // softMasks are the ones already drawn, since a file names one graphics + // state and uses it over and over. + softMasks map[softMaskKey][]uint8 + + // painted records how much of each pixel has been marked, and is set only + // while a soft mask of the second kind is being drawn — which is the one + // kind that asks not what came out but whether anything did. + painted []float32 + // ops counts what has been drawn, so a file cannot ask for an unbounded // amount of work. ops int @@ -93,10 +110,35 @@ func (r *renderer) paint(g *gstate, cov []float64, ox, oy, w, h int, c color.RGB if len(cov) == 0 || alpha <= 0 { return } - if alpha < 1 || g.clip != nil { + if alpha < 1 || g.clip != nil || g.softMask != nil { cov = r.mask(g, cov, ox, oy, w, h, alpha) } vector.Composite(r.img, cov, ox, oy, w, h, vector.SolidPaint{Color: c}) + r.record(cov, ox, oy, w, h) +} + +// record notes how much of each pixel a mark covered, for a soft mask that +// asks how much was painted rather than how it came out. +func (r *renderer) record(cov []float64, ox, oy, w, h int) { + if r.painted == nil { + return + } + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + r.markPixel(ox+x, oy+y, cov[y*w+x]) + } + } +} + +// markPixel adds one mark's coverage to what is already there, the way one +// coat of paint over another leaves less of the paper showing. +func (r *renderer) markPixel(x, y int, a float64) { + if r.painted == nil || a <= 0 { + return + } + i := y*r.img.W + x + was := float64(r.painted[i]) + r.painted[i] = float32(was + a*(1-was)) } // mask multiplies a coverage grid by the clip and the alpha, in place on a @@ -111,12 +153,18 @@ func (r *renderer) mask(g *gstate, cov []float64, ox, oy, w, h int, alpha float6 if g.clip != nil { v *= float64(g.clip[(oy+y)*r.img.W+(ox+x)]) } + if g.softMask != nil { + v *= maskLevel(g.softMask[(oy+y)*r.img.W+(ox+x)]) + } out[i] = v } } return out } +// maskLevel turns one of a mask's bytes into how much it lets through. +func maskLevel(v uint8) float64 { return float64(v) / 255 } + // 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) { diff --git a/xobject.go b/xobject.go index c50e21e..c260294 100644 --- a/xobject.go +++ b/xobject.go @@ -1,7 +1,9 @@ package render import ( + "image" "image/color" + "math" "github.com/go-gfx/gfx/geometry" "github.com/go-gfx/gfx/vector" @@ -54,9 +56,12 @@ func (r *renderer) drawForm(g *gstate, stream *reader.Stream, parent reader.Dict } inner.ctm = inner.ctm.Mul(matrix(n)) } - // A form's bounding box clips what it draws, which files rely on. + // A form's bounding box clips what it draws, which files rely on, and it + // is also as far as the form can reach. + region := image.Rect(0, 0, r.img.W, r.img.H) if box, ok := rectangle(r.doc, stream.Dict.Get("BBox")); ok { r.clipToBox(&inner, box) + region = boxBounds(inner.ctm, box, r.img.W, r.img.H) } resources, ok := r.doc.GetDict(stream.Dict, "Resources") if !ok { @@ -69,11 +74,102 @@ func (r *renderer) drawForm(g *gstate, stream *reader.Stream, parent reader.Dict was := r.base r.base = inner.ctm r.depth++ - r.run(content, resources, inner) + if r.isGroup(stream) && (g.fillAlpha < 1 || g.softMask != nil) { + r.runGroup(g, content, resources, inner, region) + } else { + r.run(content, resources, inner) + } r.depth-- r.base = was } +// isGroup reports whether a form says it is a transparency group, which is a +// file saying that what is inside it belongs together. +func (r *renderer) isGroup(stream *reader.Stream) bool { + group, ok := r.doc.GetDict(stream.Dict, "Group") + if !ok { + return false + } + kind, _ := reader.ToName(resolve(r.doc, group.Get("S"))) + return kind == "Transparency" +} + +// runGroup draws a transparency group as one thing. Everything inside it goes +// on at full strength against a copy of the page, and then the whole of it is +// laid over the page at the strength in force where it was used — the alpha, +// and the soft mask, and both together. +// +// The difference this makes is the difference between a shadow that fades and +// fifty overlapping shapes each fading on their own, which is what drawing the +// contents one mark at a time comes to: every overlap doubles up and the thing +// comes out darker at its seams than anywhere else. +func (r *renderer) runGroup(g *gstate, content []byte, resources reader.Dict, inner gstate, region image.Rectangle) { + // Inside a group the alpha starts again at one and the mask is set aside, + // since both are about to be applied to the result instead. + inner.fillAlpha, inner.strokeAlpha, inner.softMask = 1, 1, nil + if region.Empty() { + return + } + // Only the part of the page the form's own box can reach is kept and put + // back, since that is as far as anything inside it can draw. + w, h := region.Dx(), region.Dy() + before := make([]uint8, w*h*4) + for y := 0; y < h; y++ { + from := ((region.Min.Y+y)*r.img.W + region.Min.X) * 4 + copy(before[y*w*4:(y+1)*w*4], r.img.Pix[from:from+w*4]) + } + + // How much of the group reached each pixel, kept only when this group is + // itself inside a mask that asks that question. + var covered []float32 + wasPainted := r.painted + if wasPainted != nil { + covered = make([]float32, r.img.W*r.img.H) + } + r.painted = covered + r.run(content, resources, inner) + r.painted = wasPainted + + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + px, py := region.Min.X+x, region.Min.Y+y + i := py*r.img.W + px + a := g.fillAlpha + if g.softMask != nil { + a *= maskLevel(g.softMask[i]) + } + if wasPainted != nil { + r.markPixel(px, py, float64(covered[i])*a) + } + if a >= 1 { + continue + } + was := before[(y*w+x)*4:] + if a <= 0 { + copy(r.img.Pix[i*4:i*4+4], was[:4]) + continue + } + for c := 0; c < 3; c++ { + k := i*4 + c + r.img.Pix[k] = uint8(math.Round(float64(was[c])*(1-a) + float64(r.img.Pix[k])*a)) + } + } + } +} + +// boxBounds is the part of the image a rectangle in user space can reach. +func boxBounds(m geometry.Matrix, box [4]float64, w, h int) image.Rectangle { + minX, minY := math.Inf(1), math.Inf(1) + maxX, maxY := math.Inf(-1), math.Inf(-1) + for _, c := range [][2]float64{{box[0], box[1]}, {box[2], box[1]}, {box[2], box[3]}, {box[0], box[3]}} { + p := m.TransformPoint(geometry.Point{X: c[0], Y: c[1]}) + minX, minY = math.Min(minX, p.X), math.Min(minY, p.Y) + maxX, maxY = math.Max(maxX, p.X), math.Max(maxY, p.Y) + } + return image.Rect(int(math.Floor(minX)), int(math.Floor(minY)), + int(math.Ceil(maxX))+1, int(math.Ceil(maxY))+1).Intersect(image.Rect(0, 0, w, h)) +} + // clipToBox narrows the clip to a rectangle in the current user space. func (r *renderer) clipToBox(g *gstate, box [4]float64) { path := newRectPath(g.ctm, box)