diff --git a/images/images.go b/images/images.go index cdd8b5a..f19eaad 100644 --- a/images/images.go +++ b/images/images.go @@ -266,6 +266,12 @@ type Result struct { // samples as stored, so this picture and theirs are not the same question // and are counted apart rather than as a disagreement. Decoded bool + // PairedBy says how this picture was matched with the judge's: "object" + // when both sides named the same object number, "size" when it fell back + // to the first unclaimed picture of the same size. Empty when nothing was + // paired. A run whose size share is large is a run whose numbers are worth + // less, and that has to be visible. + PairedBy string // Space is the colour space pdfimages reports for the judge's picture: // gray, rgb, cmyk, lab, icc, index, sep, devn, or "-" for a mask. It is // empty when no row could be read for it. @@ -418,23 +424,35 @@ func judgePage(d *reader.Document, path string, p int) []Result { // own; see the package comment and conformance#20. This is the other // half, read out of the document rather than out of the listing. own := calibratedNames(d, p) - // The two do not agree on an order, and neither has a name the other - // knows, so a picture is matched to a picture of the same size. Where - // several share a size the first unclaimed one is taken, which is right - // as often as it is wrong and is reported either way. + // A picture is paired by OBJECT NUMBER, which both sides publish: theirs + // in the object column of pdfimages -list, ours by resolving the resource + // name render.Images hands back. Where that cannot be done the old rule + // stands -- the first unclaimed picture of the same size -- and the result + // says which was used, because the two are not equally trustworthy. + // + // Size and order alone is wrong often enough to matter. Page 1 of + // cerfa_10074.pdf draws 211 distinct 2x2 pictures whose stream bytes are + // all "00 00": uniform swatches, all ink or all paper, stretched under an + // /SMask that carries the glyphs. Walking two orders over 211 same-size + // swatches paired black with white and reported 84 "inversions" on that + // page alone. Over the eight documents that do this, pairing by object + // takes agreement from 2906 to 3414 of 3449, complements from 173 to 29, + // and other differences from 370 to 6. See conformance#13. + objects := objectsByName(d, p) claimed := make([]bool, len(theirs)) out := make([]Result, 0, len(ours)) for _, im := range ours { r := Result{Path: path, Page: p, Name: im.Name, Filter: im.Filter, Stencil: im.Stencil, Decoded: im.Decoded, Calibrated: own[im.Name], W: im.Pic.W, H: im.Pic.H, Difference: unjudged()} - j := match(theirs, claimed, im.Pic) + j, how := match(theirs, claimed, im.Pic, objects[im.Name], im.Stencil) if j < 0 { r.Note = "they took out nothing this size" out = append(out, r) continue } claimed[j] = true + r.PairedBy = how r.Space = theirs[j].space r.Converted = converted(theirs[j].space) || r.Calibrated r.Difference = difference(im.Pic, theirs[j].pic, im.Stencil) @@ -443,16 +461,42 @@ func judgePage(d *reader.Document, path string, p int) []Result { return out } -// match finds an unclaimed picture of the same size. -func match(theirs []shot, claimed []bool, ours *raster.Image) int { +// match finds the judge's picture that is ours, and says how it decided. +// +// By OBJECT first: the number is the one identity both sides publish, and it +// is exact. A picture and its soft mask are listed under the same object +// number, so the row's type has to agree too -- ours is a mask or it is not. +// +// By SIZE second, the rule this had before: the first unclaimed picture of the +// same size. It is right as often as it is wrong, which is why what it decided +// is now written down instead of only being said to be. +func match(theirs []shot, claimed []bool, ours *raster.Image, object int, mask bool) (int, string) { + if object > 0 { + for j, t := range theirs { + if claimed[j] || t.object != object || isMask(t.kind) != mask { + continue + } + return j, PairedByObject + } + } for j, t := range theirs { if !claimed[j] && t.pic.W == ours.W && t.pic.H == ours.H { - return j + return j, PairedBySize } } - return -1 + return -1, "" } +// isMask says whether a listing row is one of the two kinds that stand for a +// mask rather than for a picture. +func isMask(kind string) bool { return kind == "smask" || kind == "stencil" } + +// How a picture was paired with the judge's. +const ( + PairedByObject = "object" + PairedBySize = "size" +) + // difference compares two pictures channel by channel. // // It walks the pixels once and carries five things out of that walk: the @@ -566,6 +610,82 @@ var cieSpaces = map[reader.Name]bool{ // the pictures render.Images returns and no others. const maxFormDepth = 8 +// objectsByName maps each picture resource name a page reaches to the object +// number of the picture it names. +// +// It is the other half of pairing by identity. pdfimages publishes an object +// number for every row it lists; render.Images hands back a resource NAME. +// This walks the same resource graph calibratedIn does and joins the two. +// +// A name that reaches two different objects is dropped rather than guessed at. +// A name is unique within one resource dictionary and not across the several a +// page reaches through its forms, so a page whose two forms each name their own +// Im1 has one ambiguous name and everything else still paired by identity -- +// the conservative direction, since a wrong identity is worse than none. +func objectsByName(d *reader.Document, page int) map[string]int { + out, ambiguous := map[string]int{}, map[string]bool{} + pg, err := d.Page(page) + if err != nil { + return out + } + res, _ := d.Resolve(pg["Resources"]) + objectsIn(d, res, out, ambiguous, map[reader.Ref]bool{}, 0) + for name := range ambiguous { + delete(out, name) + } + return out +} + +// objectsIn adds one resource dictionary's picture names, and follows the +// forms it reaches. +// +// The visited set is on FORMS only, unlike calibratedIn's: a picture drawn +// under two names has to be recorded under both, and skipping the second would +// leave it paired by size. +func objectsIn(d *reader.Document, res reader.Object, out map[string]int, + ambiguous map[string]bool, seen map[reader.Ref]bool, depth int) { + if depth > maxFormDepth { + return + } + rd, ok := reader.ToDict(res) + if !ok { + return + } + xo, _ := d.Resolve(rd["XObject"]) + xd, ok := reader.ToDict(xo) + if !ok { + return + } + for name, entry := range xd { + ref, isRef := entry.(reader.Ref) + o, _ := d.Resolve(entry) + st, ok := reader.ToStream(o) + if !ok { + continue + } + switch sub, _ := reader.ToName(st.Dict["Subtype"]); sub { + case "Image": + if !isRef { + continue // an inline picture has no object number to pair on + } + if was, seenBefore := out[string(name)]; seenBefore && was != ref.Num { + ambiguous[string(name)] = true + continue + } + out[string(name)] = ref.Num + case "Form": + if isRef { + if seen[ref] { + continue + } + seen[ref] = true + } + inner, _ := d.Resolve(st.Dict["Resources"]) + objectsIn(d, inner, out, ambiguous, seen, depth+1) + } + } +} + // calibratedNames is the resource names on one page whose picture declares a // CIE-based colour space. // @@ -739,6 +859,14 @@ type shot struct { num int // space is the colour space of that row, empty when none was read. space string + // object is the PDF object number of the picture, from the row's own + // column. It is the one thing BOTH sides know a picture by, and pairing on + // it is what this package does before falling back to size. + object int + // kind is the row's type column: "image", "smask" or "stencil". A picture + // and its soft mask are listed under the SAME object number, so the object + // alone does not identify a row. + kind string } // judgeShots takes the pictures out of one page with pdfimages, and asks it @@ -784,7 +912,9 @@ func judgeShots(path string, page int) ([]shot, string, error) { continue } n := number(name) - out = append(out, shot{pic: im, num: n, space: spaces[n]}) + row := spaces[n] + out = append(out, shot{pic: im, num: n, space: row.space, + object: row.object, kind: row.kind}) } // Glob's order is the filesystem's, and a lexical sort is not pdfimages's // either: a page with more than a thousand pictures numbers one of them @@ -819,7 +949,7 @@ func number(name string) int { // header and rule lines fail to parse as a number and are skipped. A listing // that could not be taken at all leaves every picture unclassified, which the // package comment explains is deliberately loud. -func listing(path string, page int) (map[int]string, bool) { +func listing(path string, page int) (map[int]listRow, bool) { out, hung, err := listCommand("-list", "-f", fmt.Sprint(page), "-l", fmt.Sprint(page), path) if hung { return nil, true @@ -827,7 +957,7 @@ func listing(path string, page int) (map[int]string, bool) { if err != nil { return nil, false } - spaces := map[int]string{} + rows := map[int]listRow{} for _, line := range strings.Split(string(out), "\n") { f := strings.Fields(line) if len(f) < 6 { @@ -837,9 +967,25 @@ func listing(path string, page int) (map[int]string, bool) { if err != nil { continue } - spaces[num] = f[5] + r := listRow{space: f[5], kind: f[2]} + // The object column is the eleventh, and a row too short to hold it is + // a row from a poppler that lists fewer: the space is still read and + // the pairing falls back to size, which is where it was before. + if len(f) > 10 { + if obj, err := strconv.Atoi(f[10]); err == nil { + r.object = obj + } + } + rows[num] = r } - return spaces, false + return rows, false +} + +// A listRow is what one row of pdfimages -list says about a picture. +type listRow struct { + space string + kind string + object int } // readPNG reads one of the files pdfimages wrote. diff --git a/images/images_test.go b/images/images_test.go index e312a30..f14dd7c 100644 --- a/images/images_test.go +++ b/images/images_test.go @@ -373,9 +373,14 @@ func TestTheListingIsReadPastItsHeader(t *testing.T) { if hung { t.Fatal("a listing that came back was called a hang") } - if len(got) != 1 || got[0] != "index" { + if len(got) != 1 || got[0].space != "index" { t.Errorf("the listing read as %v", got) } + // A row too short to hold the object column is a row from a poppler that + // lists fewer: the space is still read, and pairing falls back to size. + if got[0].object != 0 { + t.Errorf("an object was read out of a row that has no object column: %v", got[0]) + } } func TestPicturesAreOrderedByTheirNumberAndNotTheirName(t *testing.T) { @@ -1170,3 +1175,250 @@ func TestTheReportSaysHowManyPicturesTheirOwnColourSpaceMoved(t *testing.T) { t.Errorf("the report says %q", got) } } + +func TestTheObjectColumnIsReadWhereThereIsOne(t *testing.T) { + // The object number is the one identity both sides publish, and pairing on + // it is what conformance#13 is about. + was := listCommand + defer func() { listCommand = was }() + listCommand = func(...string) ([]byte, bool, error) { + return []byte(strings.Join([]string{ + "page num type width height color comp bpc enc interp object ID x-ppi y-ppi size ratio", + "----------------------------------------------------------------------------------------", + " 1 0 image 2 2 index 1 1 image no 6 0 1 15 2B - ", + " 1 1 smask 843 82 gray 1 1 image no 6 0 599 600 1255B 15%", + " 1 2 image 2 2 index 1 1 image no nope 0 17 15 2B - ", + }, "\n")), false, nil + } + got, _ := listing("whatever.pdf", 1) + if len(got) != 3 { + t.Fatalf("%d rows read", len(got)) + } + // A picture and its soft mask share an object number and differ by type, + // which is why the object alone does not identify a row. + if got[0].object != 6 || got[0].kind != "image" { + t.Errorf("row 0 read as %+v", got[0]) + } + if got[1].object != 6 || got[1].kind != "smask" { + t.Errorf("row 1 read as %+v", got[1]) + } + // A column that is not a number leaves the row without an identity rather + // than failing the listing: the space is still worth having. + if got[2].object != 0 || got[2].space != "index" { + t.Errorf("row 2 read as %+v", got[2]) + } +} + +// pic is a one-pixel picture, for the tests that only care which one was +// chosen. +func pic(w, h int) *raster.Image { return raster.New(w, h) } + +func TestAPictureIsPairedByItsObjectNumber(t *testing.T) { + // The object is the one identity both sides publish. Two same-size + // pictures used to be paired by order, which on a page of 211 uniform + // swatches paired a black one with a white one. + theirs := []shot{ + {pic: pic(2, 2), num: 0, object: 6, kind: "image"}, + {pic: pic(2, 2), num: 1, object: 9, kind: "image"}, + } + claimed := make([]bool, len(theirs)) + j, how := match(theirs, claimed, pic(2, 2), 9, false) + if j != 1 || how != PairedByObject { + t.Errorf("object 9 matched row %d by %q", j, how) + } +} + +func TestAPictureAndItsSoftMaskShareAnObject(t *testing.T) { + // pdfimages lists a picture's soft mask under the picture's own object + // number, so the object alone does not identify a row: the type has to + // agree as well. + theirs := []shot{ + {pic: pic(2, 2), num: 0, object: 6, kind: "image"}, + {pic: pic(9, 9), num: 1, object: 6, kind: "smask"}, + } + claimed := make([]bool, len(theirs)) + if j, how := match(theirs, claimed, pic(2, 2), 6, false); j != 0 || how != PairedByObject { + t.Errorf("the picture matched row %d by %q", j, how) + } + if j, how := match(theirs, claimed, pic(9, 9), 6, true); j != 1 || how != PairedByObject { + t.Errorf("the mask matched row %d by %q", j, how) + } +} + +func TestWithoutAnObjectItFallsBackToSize(t *testing.T) { + // A poppler that lists no object column, or a name that reached two + // objects, leaves the old rule standing -- and the result says so, because + // the two are not equally trustworthy. + theirs := []shot{ + {pic: pic(4, 4), num: 0, object: 6, kind: "image"}, + {pic: pic(2, 2), num: 1, object: 9, kind: "image"}, + } + claimed := make([]bool, len(theirs)) + j, how := match(theirs, claimed, pic(2, 2), 0, false) + if j != 1 || how != PairedBySize { + t.Errorf("matched row %d by %q, want row 1 by size", j, how) + } + // An object nobody listed also falls back rather than refusing. + if j, how := match(theirs, claimed, pic(4, 4), 404, false); j != 0 || how != PairedBySize { + t.Errorf("an unlisted object matched row %d by %q", j, how) + } + // And a size nobody has is no match at all. + claimed[0], claimed[1] = true, true + if j, how := match(theirs, claimed, pic(2, 2), 9, false); j != -1 || how != "" { + t.Errorf("everything claimed still matched row %d by %q", j, how) + } +} + +func TestWhichListingRowsStandForMasks(t *testing.T) { + for kind, want := range map[string]bool{ + "image": false, "smask": true, "stencil": true, "": false, + } { + if got := isMask(kind); got != want { + t.Errorf("isMask(%q) = %v", kind, got) + } + } +} + +// imageRef is a picture stored as its own object, so it has a number to pair on. +func imageRef(w *reader.Writer) reader.Object { + return 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, 0xff}}) +} + +func TestEachNameIsResolvedToTheObjectItNames(t *testing.T) { + var im reader.Object + path := pageWithResources(t, func(w *reader.Writer) reader.Dict { + im = imageRef(w) + return reader.Dict{"XObject": reader.Dict{"I": im, "Also": im}} + }) + got := objectsByName(opened(t, path), 1) + ref, _ := im.(reader.Ref) + // Two names for ONE object are both recorded: a picture drawn under two + // names has to be paired under both. + if got["I"] != ref.Num || got["Also"] != ref.Num { + t.Errorf("names resolved to %v, want both %d", got, ref.Num) + } +} + +func TestANameThatReachesTwoObjectsIsDroppedRatherThanGuessedAt(t *testing.T) { + // A name is unique within one resource dictionary and not across the + // several a page reaches through its forms. A wrong identity is worse than + // none, so the ambiguous name falls back to size. + path := pageWithResources(t, func(w *reader.Writer) reader.Dict { + inner := w.Add(&reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Form"), + "Resources": reader.Dict{"XObject": reader.Dict{"I": imageRef(w)}}, + }, Raw: []byte("")}) + return reader.Dict{"XObject": reader.Dict{"I": imageRef(w), "F": inner}} + }) + if got := objectsByName(opened(t, path), 1); len(got) != 0 { + t.Errorf("an ambiguous name was resolved: %v", got) + } +} + +func TestAPictureInsideAFormIsResolvedToItsObject(t *testing.T) { + path := pageWithResources(t, func(w *reader.Writer) reader.Dict { + inner := w.Add(&reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Form"), + "Resources": reader.Dict{"XObject": reader.Dict{"Deep": imageRef(w)}}, + }, Raw: []byte("")}) + return reader.Dict{"XObject": reader.Dict{"F": inner}} + }) + if got := objectsByName(opened(t, path), 1); got["Deep"] == 0 { + t.Errorf("a picture inside a form was not reached: %v", got) + } +} + +func TestWhatHasNoObjectToPairOn(t *testing.T) { + // A picture written inline in the resource dictionary has no object + // number, and an entry that is not a stream at all is not a picture. + path := pageWithResources(t, func(w *reader.Writer) reader.Dict { + return reader.Dict{"XObject": reader.Dict{ + "Inline": &reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Image"), + "Width": reader.Integer(1), "Height": reader.Integer(1), + }, Raw: []byte{0}}, + "NotAStream": reader.Integer(7), + }} + }) + if got := objectsByName(opened(t, path), 1); len(got) != 0 { + t.Errorf("something without an object number was resolved: %v", got) + } +} + +func TestAStructureThatCannotBeReadResolvesNothing(t *testing.T) { + // A page that is not there, resources that are not a dictionary, and a + // resource dictionary with no XObject entry: each yields nothing rather + // than an error, and the pairing falls back to size. + path := pageWithResources(t, func(w *reader.Writer) reader.Dict { + return reader.Dict{"XObject": reader.Integer(3)} + }) + d := opened(t, path) + if got := objectsByName(d, 99); len(got) != 0 { + t.Errorf("a page that is not there resolved %v", got) + } + if got := objectsByName(d, 1); len(got) != 0 { + t.Errorf("an XObject that is not a dictionary resolved %v", got) + } + out := map[string]int{} + objectsIn(d, reader.Integer(3), out, map[string]bool{}, map[reader.Ref]bool{}, 0) + if len(out) != 0 { + t.Errorf("resources that are not a dictionary resolved %v", out) + } + objectsIn(d, reader.Dict{}, out, map[string]bool{}, map[reader.Ref]bool{}, 0) + if len(out) != 0 { + t.Errorf("a resource dictionary with no XObject resolved %v", out) + } +} + +func TestFormsAreNotFollowedForEver(t *testing.T) { + // render stops at maxFormDepth, so this reaches the pictures it returns + // and no others. A form that names itself would otherwise not terminate. + path := pageWithResources(t, func(w *reader.Writer) reader.Dict { + ref := w.Reserve() + w.Put(ref, &reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Form"), + "Resources": reader.Dict{"XObject": reader.Dict{"Self": ref, "I": imageRef(w)}}, + }, Raw: []byte("")}) + return reader.Dict{"XObject": reader.Dict{"F": ref}} + }) + got := objectsByName(opened(t, path), 1) + if got["I"] == 0 { + t.Errorf("the picture inside the self-naming form was not reached: %v", got) + } +} + +func TestTheThreeThingsAResourceEntryCanBeThatIsNotAPicture(t *testing.T) { + // Asked of objectsIn directly: what a document actually stores depends on + // the writer, and these three have to be answered whatever it stores. + path := pageWithResources(t, func(w *reader.Writer) reader.Dict { return reader.Dict{} }) + d := opened(t, path) + + // Too deep. render stops at maxFormDepth, so this reaches the pictures it + // returns and no others. + out := map[string]int{} + objectsIn(d, reader.Dict{"XObject": reader.Dict{"I": reader.Integer(1)}}, + out, map[string]bool{}, map[reader.Ref]bool{}, maxFormDepth+1) + if len(out) != 0 { + t.Errorf("a form past the depth limit was walked: %v", out) + } + + // Not a stream at all. + objectsIn(d, reader.Dict{"XObject": reader.Dict{"I": reader.Integer(7)}}, + out, map[string]bool{}, map[reader.Ref]bool{}, 0) + if len(out) != 0 { + t.Errorf("an entry that is not a stream resolved: %v", out) + } + + // A picture written INLINE in the dictionary has no object number to pair + // on, so it is left to the size rule rather than given somebody else's. + objectsIn(d, reader.Dict{"XObject": reader.Dict{"I": &reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Image"), + }, Raw: []byte{0}}}}, out, map[string]bool{}, map[reader.Ref]bool{}, 0) + if len(out) != 0 { + t.Errorf("an inline picture was given an object number: %v", out) + } +}