diff --git a/colour.go b/colour.go index 83f9271..334f685 100644 --- a/colour.go +++ b/colour.go @@ -108,11 +108,11 @@ func (r *renderer) applyExtGState(g *gstate, operands []reader.Object, resources if !ok { return } - states, ok := r.doc.GetDict(resources, "ExtGState") - if !ok { + entry := r.named(resources, "ExtGState", name) + if entry == nil { return } - params, ok := r.doc.GetDict(states, name) + params, ok := reader.ToDict(resolve(r.doc, entry)) if !ok { return } diff --git a/exec.go b/exec.go index 7015372..d5e32ab 100644 --- a/exec.go +++ b/exec.go @@ -20,6 +20,10 @@ const ( // run executes a content stream against a graphics state. func (r *renderer) run(content []byte, resources reader.Dict, g gstate) { + // These are the innermost resources from here on, and a name they do not + // provide is resolved outward -- see renderer.resChain and named(). + r.resChain = append(r.resChain, resources) + defer func() { r.resChain = r.resChain[:len(r.resChain)-1] }() // Marked content does not reach across streams: a form's BDC has nothing // to do with the page's, and a stream that opens one and never closes it // must not leave the next stream hidden. diff --git a/formresources_test.go b/formresources_test.go new file mode 100644 index 0000000..0774eff --- /dev/null +++ b/formresources_test.go @@ -0,0 +1,133 @@ +package render + +import ( + "testing" + + "github.com/go-pdfkit/reader" +) + +// pageWhoseFormHasSomeResources builds a page that draws a form whose own +// /Resources is PRESENT and INCOMPLETE: it holds one entry of the category and +// not the one the form's content uses. +// +// That is the shape of qpdf's form-xobjects-some-resources2.pdf, which is in +// the conformance corpus and which this renderer drew two of six images of. +// See go-pdfkit/render#102. +func pageWhoseFormHasSomeResources(t *testing.T, formContent string, formRes, pageRes reader.Dict) *reader.Document { + t.Helper() + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + inner := w.Add(&reader.Stream{ + Dict: reader.Dict{"Type": reader.Name("XObject"), "Subtype": reader.Name("Form")}, + Raw: []byte("0 g 0 0 10 10 re f"), + }) + form := reader.Dict{"Type": reader.Name("XObject"), "Subtype": reader.Name("Form")} + if formRes != nil { + form["Resources"] = formRes + } + outer := w.Add(&reader.Stream{Dict: form, Raw: []byte(formContent)}) + res := reader.Dict{"XObject": reader.Dict{"F": outer, "G": inner}} + for k, v := range pageRes { + res[k] = v + } + page := w.Add(reader.Dict{ + "Type": reader.Name("Page"), "Parent": pagesRef, + "MediaBox": reader.Array{reader.Integer(0), reader.Integer(0), reader.Integer(40), reader.Integer(40)}, + "Contents": w.Add(&reader.Stream{Dict: reader.Dict{}, Raw: []byte("/F Do")}), + "Resources": res, + }) + w.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"), + "Kids": reader.Array{page}, "Count": reader.Integer(1)}) + root := w.Add(reader.Dict{"Type": reader.Name("Catalog"), "Pages": pagesRef}) + out, err := w.Finish(reader.Dict{"Root": root}) + if err != nil { + t.Fatal(err) + } + d, err := reader.Open(out) + if err != nil { + t.Fatal(err) + } + return d +} + +func TestAFormWithSomeResourcesInheritsTheRest(t *testing.T) { + // The form lists an /XObject of its own -- so the dictionary is there -- + // and does NOT list /G, which it draws. Before, a present /Resources + // replaced the page's wholesale and /G resolved to nothing: the renderer + // returned early and drew paper. + // + // The control is the SAME document with the form's own /XObject holding + // the name. If both drew ink the test would pass with the chain removed. + some := reader.Dict{"XObject": reader.Dict{"Unused": reader.Name("nothing")}} + d := pageWhoseFormHasSomeResources(t, "/G Do", some, nil) + wantBlack(t, draw(t, d, Options{}), 5, 35) + + // And the extraction path, which walks the content stream separately. + ims, err := Images(d, 1) + if err != nil { + t.Fatal(err) + } + if len(ims) != 0 { + t.Errorf("a form of forms yielded %d pictures", len(ims)) + } +} + +func TestANameNoDictionaryInForceProvidesDrawsNothing(t *testing.T) { + // The other direction, and the one that keeps the chain from making + // everything resolve to something: a name nowhere. + some := reader.Dict{"XObject": reader.Dict{"Unused": reader.Name("nothing")}} + d := pageWhoseFormHasSomeResources(t, "/Nowhere Do", some, nil) + wantWhite(t, draw(t, d, Options{}), 5, 35) + if ims, err := Images(d, 1); err != nil || len(ims) != 0 { + t.Errorf("got %d pictures, %v", len(ims), err) + } +} + +func TestAGraphicsStateNameNoDictionaryProvidesIsLeftAlone(t *testing.T) { + // gs names an /ExtGState, and the lookup for it went through the same + // change. A name nothing provides must leave the state as it was rather + // than take a nil for a dictionary. + some := reader.Dict{"XObject": reader.Dict{"Unused": reader.Name("nothing")}} + d := pageWhoseFormHasSomeResources(t, "/Nowhere gs 0 g 0 0 10 10 re f", some, nil) + wantBlack(t, draw(t, d, Options{}), 5, 35) +} + +func TestAGraphicsStateIsInheritedByAFormThatDoesNotListIt(t *testing.T) { + // The inheritance has to hold for every category the change touched, and + // /ExtGState is the one whose effect is visible in one pixel: half alpha + // over white paper is grey, not black. + page := reader.Dict{"ExtGState": reader.Dict{"Half": reader.Dict{"ca": reader.Real(0.5)}}} + some := reader.Dict{"XObject": reader.Dict{"Unused": reader.Name("nothing")}} + d := pageWhoseFormHasSomeResources(t, "/Half gs 0 g 0 0 10 10 re f", some, page) + img := draw(t, d, Options{}) + if isBlack(img, 5, 35) || isWhite(img, 5, 35) { + t.Errorf("the form did not inherit the page's /ExtGState: %s", pixel(img, 5, 35)) + } +} + +func TestADictionaryInForceWithoutTheCategoryIsSteppedOver(t *testing.T) { + // A form whose /Resources holds no /XObject AT ALL is not the absent case + // -- the dictionary is there, the category is not -- and the walk has to + // step over it rather than stop. + some := reader.Dict{"Font": reader.Dict{"Unused": reader.Name("nothing")}} + d := pageWhoseFormHasSomeResources(t, "/G Do", some, nil) + wantBlack(t, draw(t, d, Options{}), 5, 35) +} + +func TestAResourceThatIsThereAndIsNotWhatItShouldBe(t *testing.T) { + // Both lookups now answer "not there" with a nil and leave "there but + // wrong" to the type assertion after it. Before the change a missing name + // came back as /Null and took that same path, so these two branches were + // reached by the missing case and never by the wrong-type one. + some := reader.Dict{"XObject": reader.Dict{"Unused": reader.Name("nothing")}} + + // An /XObject entry that is not a stream. + d := pageWhoseFormHasSomeResources(t, "/Unused Do", some, nil) + wantWhite(t, draw(t, d, Options{}), 5, 35) + + // An /ExtGState entry that is not a dictionary, with ink after it so the + // page says whether the operator took the state down with it. + page := reader.Dict{"ExtGState": reader.Dict{"Bad": reader.Integer(1)}} + d = pageWhoseFormHasSomeResources(t, "/Bad gs 0 g 0 0 10 10 re f", some, page) + wantBlack(t, draw(t, d, Options{}), 5, 35) +} diff --git a/images.go b/images.go index d373434..a54eb98 100644 --- a/images.go +++ b/images.go @@ -292,7 +292,12 @@ func (r *renderer) imagesDrawn(content []byte, res reader.Dict, depth int) []Ima if depth > maxImageDepth { return nil } - xo, _ := reader.ToDict(resolve(r.doc, res.Get("XObject"))) + // The extraction path does not go through run(), so it pushes its own. + // Without this a form whose /Resources is present but INCOMPLETE loses + // every name it does not list: six images against two on qpdf's + // form-xobjects-some-resources2.pdf. See renderer.resChain. + r.resChain = append(r.resChain, res) + defer func() { r.resChain = r.resChain[:len(r.resChain)-1] }() // A stream that stops decoding part way is walked as far as it got, which // is what this package draws; the error says nothing the operators do not. ops, _ := reader.Operations(content) @@ -305,7 +310,10 @@ func (r *renderer) imagesDrawn(content []byte, res reader.Dict, depth int) []Ima if !ok { continue } - entry := xo.Get(name) + entry := r.named(res, "XObject", name) + if entry == nil { + continue + } st, ok := reader.ToStream(resolve(r.doc, entry)) if !ok { continue diff --git a/optional.go b/optional.go index b99cc5e..428d264 100644 --- a/optional.go +++ b/optional.go @@ -170,11 +170,11 @@ func (r *renderer) hiddenProperty(operands []reader.Object, resources reader.Dic return false } if name, ok := reader.ToName(operands[1]); ok { - props, ok := r.doc.GetDict(resources, "Properties") - if !ok { + entry := r.named(resources, "Properties", name) + if entry == nil { return false } - return r.oc.hidden(r.doc, props.Get(name)) + return r.oc.hidden(r.doc, entry) } return r.oc.hidden(r.doc, operands[1]) } diff --git a/pattern.go b/pattern.go index d333d49..2684bb9 100644 --- a/pattern.go +++ b/pattern.go @@ -32,11 +32,11 @@ type pattern struct { // readPattern reads a pattern out of the page's resources. func (r *renderer) readPattern(name reader.Name, resources reader.Dict) *pattern { - pats, ok := r.doc.GetDict(resources, "Pattern") - if !ok { + e := r.named(resources, "Pattern", name) + if e == nil { return nil } - entry := resolve(r.doc, pats.Get(name)) + entry := resolve(r.doc, e) dict, ok := reader.ToDict(entry) stream, isStream := reader.ToStream(entry) if isStream { @@ -153,11 +153,11 @@ func (r *renderer) drawShading(g *gstate, operands []reader.Object, resources re if !ok { return } - shadings, ok := r.doc.GetDict(resources, "Shading") - if !ok { + e := r.named(resources, "Shading", name) + if e == nil { return } - sh := r.readShading(shadings.Get(name), resources) + sh := r.readShading(e, resources) if sh == nil { return } diff --git a/space.go b/space.go index aa825b7..91f7998 100644 --- a/space.go +++ b/space.go @@ -106,12 +106,8 @@ func (r *renderer) colourSpace(o reader.Object, resources reader.Dict, depth int return s } // A name that is not a device space names one in the resources. - spaces, ok := r.doc.GetDict(resources, "ColorSpace") - if !ok { - return deviceGray - } - entry := spaces.Get(name) - if entry.Kind() == reader.KindNull { + entry := r.named(resources, "ColorSpace", name) + if entry == nil || entry.Kind() == reader.KindNull { return deviceGray } return r.colourSpace(entry, resources, depth+1) diff --git a/state.go b/state.go index f59fdc2..5a78611 100644 --- a/state.go +++ b/state.go @@ -75,6 +75,26 @@ type renderer struct { // depth bounds how far one form may draw another. depth int + // resChain is the resource dictionaries in force, outermost first. + // + // A form XObject's /Resources is not required to be COMPLETE, and a name + // it does not provide is resolved in the resources that were in force + // where the form was painted. This renderer had half of that rule: an + // ABSENT /Resources fell back to the parent, a present one replaced it + // wholesale, so a name the form did not list resolved to nothing and was + // silently not drawn. + // + // qpdf ships a fixture for exactly this, and it is in the conformance + // corpus: form-xobjects-some-resources2.pdf draws six 15x15 images and we + // drew TWO, 8.54% of the page's pixels away from poppler at a worst + // square mean of 130 levels. See go-pdfkit/render#102. + // + // Each stream that brings its own resources pushes them -- a form, a Type + // 3 glyph procedure, a pattern cell, an annotation's appearance, a soft + // mask -- and named() walks this innermost-first. maxFormDepth bounds how + // deep it goes. + resChain []reader.Dict + // drawingProcs holds the object numbers of the Type 3 glyph procedures // currently being drawn, so that one cannot draw itself. // @@ -490,3 +510,43 @@ func (r *renderer) salvaged(stream *reader.Stream) ([]byte, reader.Name, error) } return dec.Data, dec.Image, nil } + +// named resolves one name in one resource category, in the resources in force: +// the innermost dictionary first, then outward. +// +// It replaces seven lookups that each read the category out of ONE dictionary. +// Each of them is a place where a form with incomplete resources lost a name: +// XObject, Font, ColorSpace, Pattern, Shading, ExtGState and Properties. +// +// A nil comes back for a name no dictionary in force provides, which is what +// the callers already handled: the entry does not resolve and nothing is drawn. +// The dictionary the caller holds is tried FIRST and the chain behind it. It +// is passed rather than read off the chain so that the parameter every one of +// these functions already takes keeps meaning what it says -- a lookup whose +// first dictionary is implicit is a lookup nobody can call directly, and the +// tests do call them directly. +func (r *renderer) named(resources reader.Dict, category, name reader.Name) reader.Object { + if v := in(r.doc, resources, category, name); v != nil { + return v + } + for i := len(r.resChain) - 1; i >= 0; i-- { + if v := in(r.doc, r.resChain[i], category, name); v != nil { + return v + } + } + return nil +} + +// in looks one name up in one dictionary's category, and answers nil for +// "not there" -- which a present /Null is not. +func in(doc *reader.Document, resources reader.Dict, category, name reader.Name) reader.Object { + d, ok := doc.GetDict(resources, category) + if !ok { + return nil + } + v, present := d[name] + if !present { + return nil + } + return v +} diff --git a/text.go b/text.go index 0e82faf..cc37953 100644 --- a/text.go +++ b/text.go @@ -303,11 +303,10 @@ func (r *renderer) font(o reader.Object, resources reader.Dict) *pdfFont { if !ok { return nil } - fonts, ok := r.doc.GetDict(resources, "Font") - if !ok { + entry := r.named(resources, "Font", name) + if entry == nil { return nil } - entry := fonts.Get(name) if ref, ok := entry.(reader.Ref); ok { if f, ok := r.fonts[ref.Num]; ok { return f diff --git a/xobject.go b/xobject.go index 3c0cc2d..1392882 100644 --- a/xobject.go +++ b/xobject.go @@ -21,11 +21,11 @@ func (r *renderer) drawXObject(g *gstate, operands []reader.Object, resources re if !ok { return } - xobjects, ok := r.doc.GetDict(resources, "XObject") - if !ok { + entry := r.named(resources, "XObject", name) + if entry == nil { return } - stream, ok := reader.ToStream(resolve(r.doc, xobjects.Get(name))) + stream, ok := reader.ToStream(resolve(r.doc, entry)) if !ok { return }