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)