diff --git a/image.go b/image.go index db6a870..4c799ec 100644 --- a/image.go +++ b/image.go @@ -98,8 +98,34 @@ func unitSquareBounds(m geometry.Matrix, w, h int) image.Rectangle { // name a grid it would take the whole machine to hold. const maxImagePixels = 64 << 20 -// decodeImage turns an image XObject into a grid of colours. +// decodeImage turns an image XObject into the grid of colours a page draws: +// the picture the codec read, with whatever mask it names applied to it. func (r *renderer) decodeImage(dict reader.Dict, raw []byte, resources reader.Dict) *sampled { + out := r.decodeBase(dict, raw, resources) + if out == nil { + return nil + } + if !r.applyTransparency(out, dict, resources) { + // A mask was named and could not be read, so how much of this image + // shows is unknown. Drawing it whole is the worst of the three + // answers: it is how a scanned page's high-resolution ink layer, which + // is meant to show through a stencil, ends up painted over the page as + // a solid dark rectangle. + return nil + } + return out +} + +// decodeBase is the picture the codec read, with no mask applied. +// +// It is separate because the two questions are different. What a page draws +// is the composited picture; what a codec produced is this one, and comparing +// a codec against another implementation means comparing THIS, since the other +// implementation hands its masks back separately too. Measured the wrong way +// round, 21 of 22 JPEG 2000 pictures in a corpus of scanned pages looked +// wrong, and 11 of them differed only by an /SMask that had been applied to +// one side and not the other. +func (r *renderer) decodeBase(dict reader.Dict, raw []byte, resources reader.Dict) *sampled { w := int(intOr(resolve(r.doc, dict.Get("Width")), 0)) h := int(intOr(resolve(r.doc, dict.Get("Height")), 0)) if w <= 0 || h <= 0 || w*h > maxImagePixels { @@ -152,17 +178,6 @@ func (r *renderer) decodeImage(dict reader.Dict, raw []byte, resources reader.Di // not drawn rather than drawn wrong. Every filter the reader hands back // unread has an arm above, so the first of those is a case this cannot // reach today and the check is here for the second. - if out == nil { - return nil - } - if !r.applyTransparency(out, dict, resources) { - // A mask was named and could not be read, so how much of this image - // shows is unknown. Drawing it whole is the worst of the three - // answers: it is how a scanned page's high-resolution ink layer, which - // is meant to show through a stencil, ends up painted over the page as - // a solid dark rectangle. - return nil - } return out } diff --git a/images.go b/images.go index 982c464..7ce21ed 100644 --- a/images.go +++ b/images.go @@ -40,6 +40,18 @@ type Image struct { // mattered here — a release went out drawing scanned pages dark because "ink // appeared" was measured instead of "the right ink". // +// What comes back is what each CODEC produced, not what the page draws. A +// picture that names a mask is returned unmasked, and the mask is returned +// beside it as its own entry, named for the key that named it. That is what +// pdfimages does, and it is the only way the two can be compared: applying a +// mask to one side and not the other made 21 of 22 JPEG 2000 pictures in a +// corpus of scanned pages look wrong, when 11 of them differed by nothing but +// the /SMask. +// +// It also means a picture whose mask cannot be read still comes back. [Page] +// declines to draw that one, because how much of it shows is unknown — but the +// codec read it, and this is about the codec. +// // A picture nothing here can decode is left out rather than returned empty, // which is the same answer [Page] gives by not drawing it. Inline images — // the ones written into the content stream — are not returned: they belong to @@ -89,10 +101,15 @@ func (r *renderer) imagesIn(res reader.Dict, depth int) []Image { if sub != "Image" { continue } - s := r.decodeImage(st.Dict, st.Raw, res) - if s == nil { - continue - } + out = append(out, r.decoded(name, st, res)...) + } + return out +} + +// decoded reads one image XObject and the mask it names, if any. +func (r *renderer) decoded(name string, st *reader.Stream, res reader.Dict) []Image { + var out []Image + if s := r.decodeBase(st.Dict, st.Raw, res); s != nil { stencil, _ := reader.ToBool(resolve(r.doc, st.Dict.Get("ImageMask"))) out = append(out, Image{ Name: name, @@ -101,6 +118,25 @@ func (r *renderer) imagesIn(res reader.Dict, depth int) []Image { Pic: &raster.Image{W: s.w, H: s.h, Pix: s.pix}, }) } + // A mask is a picture in its own right, stored in its own filter, and it + // is very often the one that is wrong: JBIG2 is almost never a page's + // content and almost always its mask. + for _, key := range []reader.Name{"SMask", "Mask"} { + ms, ok := reader.ToStream(resolve(r.doc, st.Dict.Get(key))) + if !ok { + continue + } + s := r.decodeBase(ms.Dict, ms.Raw, res) + if s == nil { + continue + } + out = append(out, Image{ + Name: name + "/" + string(key), + Filter: imageFilterOf(r.doc, ms), + Stencil: true, + Pic: &raster.Image{W: s.w, H: s.h, Pix: s.pix}, + }) + } return out } diff --git a/images_test.go b/images_test.go index 7901ac5..62167b1 100644 --- a/images_test.go +++ b/images_test.go @@ -220,3 +220,114 @@ func TestAPageThatIsNotThereHasNoPictures(t *testing.T) { t.Error("page nine of a one-page document came back without complaint") } } + +func TestAMaskComesBackBesideThePictureItShapes(t *testing.T) { + // A picture that names a mask is returned unmasked, with the mask beside + // it. Applying it to one side and not the other made 21 of 22 JPEG 2000 + // pictures in a corpus of scanned pages look wrong, when 11 of them + // differed by nothing but the /SMask. + d := pageWithResources(t, func(w *reader.Writer) reader.Dict { + mask := w.Add(&reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Image"), + "Width": reader.Integer(2), "Height": reader.Integer(1), + "ColorSpace": reader.Name("DeviceGray"), "BitsPerComponent": reader.Integer(8), + }, Raw: []byte{0x00, 0x00}}) + return reader.Dict{"XObject": reader.Dict{"I": w.Add(&reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Image"), + "Width": reader.Integer(2), "Height": reader.Integer(1), + "ColorSpace": reader.Name("DeviceGray"), "BitsPerComponent": reader.Integer(8), + "SMask": mask, + }, Raw: []byte{0x00, 0xff}})}} + }) + got, err := Images(d, 1) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("%d pictures, want the picture and its mask", len(got)) + } + // The mask says nothing shows. The picture must come back anyway, opaque: + // that is the codec's answer, and the codec is what this is about. + if got[0].Name != "I" || got[0].Pic.Pix[3] != 255 { + t.Errorf("the picture came back masked: %+v alpha %d", got[0], got[0].Pic.Pix[3]) + } + if got[1].Name != "I/SMask" || !got[1].Stencil { + t.Errorf("the mask came back as %+v", got[1]) + } +} + +func TestAPictureWhoseMaskCannotBeReadStillComesBack(t *testing.T) { + // Page declines to draw this one, because how much of it shows is + // unknown. The codec read it, and this is about the codec. + d := pageWithResources(t, func(w *reader.Writer) reader.Dict { + bad := w.Add(&reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Image"), + "Width": reader.Integer(2), "Height": reader.Integer(1), + "Filter": reader.Name("JPXDecode"), + }, Raw: []byte{0xff, 0x4f, 0xff, 0x51, 0, 1}}) + return reader.Dict{"XObject": reader.Dict{"I": w.Add(&reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Image"), + "Width": reader.Integer(2), "Height": reader.Integer(1), + "ColorSpace": reader.Name("DeviceGray"), "BitsPerComponent": reader.Integer(8), + "SMask": bad, + }, Raw: []byte{0x00, 0xff}})}} + }) + got, err := Images(d, 1) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Name != "I" { + t.Fatalf("got %+v", got) + } + // And the page still refuses to draw it, which is the other half of the + // rule and must not have moved. + img, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + if ink := inked(img); ink != 0 { + t.Errorf("%d pixels drawn from an image whose mask cannot be read", ink) + } +} + +func TestAMaskThatIsNotAPictureIsNotOne(t *testing.T) { + // /Mask may be an array of colour ranges rather than a stream, which is a + // different mechanism and not a picture to hand back. + d := pageWithResources(t, func(w *reader.Writer) reader.Dict { + return reader.Dict{"XObject": reader.Dict{"I": w.Add(&reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Image"), + "Width": reader.Integer(2), "Height": reader.Integer(1), + "ColorSpace": reader.Name("DeviceGray"), "BitsPerComponent": reader.Integer(8), + "Mask": reader.Array{reader.Integer(0), reader.Integer(0)}, + }, Raw: []byte{0x00, 0xff}})}} + }) + got, err := Images(d, 1) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 { + t.Errorf("a colour-key mask came back as a picture: %+v", got) + } +} + +func TestAMaskNothingCanDecodeIsLeftOut(t *testing.T) { + d := pageWithResources(t, func(w *reader.Writer) reader.Dict { + bad := w.Add(&reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Image"), + "Width": reader.Integer(0), "Height": reader.Integer(0), + }, Raw: []byte{}}) + return reader.Dict{"XObject": reader.Dict{"I": w.Add(&reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Image"), + "Width": reader.Integer(2), "Height": reader.Integer(1), + "ColorSpace": reader.Name("DeviceGray"), "BitsPerComponent": reader.Integer(8), + "Mask": bad, + }, Raw: []byte{0x00, 0xff}})}} + }) + got, err := Images(d, 1) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 { + t.Errorf("a mask of no size came back as a picture: %+v", got) + } +}