Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/go-pdfkit/render
go 1.26.4

require (
github.com/go-gfx/gfx v0.14.0
github.com/go-gfx/gfx v0.15.0
github.com/go-opentype/fonts v0.9.0
github.com/go-opentype/opentype v0.10.0
github.com/go-pdfkit/reader v0.6.0
Expand All @@ -16,9 +16,9 @@ require (

require (
github.com/ajroetker/go-highway v0.0.4 // indirect
github.com/dkrisman/gobig2 v0.0.0-20260513123937-51e39052fde6 // indirect
github.com/sergeymakinen/go-bmp v1.0.0 // indirect
github.com/sergeymakinen/go-ico v1.0.0 // indirect
github.com/tannevaled/gobig2 v0.1.0 // indirect
golang.org/x/image v0.45.0 // indirect
golang.org/x/sys v0.47.0 // indirect
)
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@ github.com/ajroetker/go-highway v0.0.4 h1:RDQo+9OhTXI6BFctLo+5gYpHNbb92VYJ0ObnR4
github.com/ajroetker/go-highway v0.0.4/go.mod h1:C/zYPNSSpOaraejY89FUTZTyQNEhi5+rEbU0LjlqJeU=
github.com/ajroetker/go-jpeg2000 v0.0.2 h1:ni8brffZrci4Kacx3nM5d92ipmTDfak84KgHYi6IxFw=
github.com/ajroetker/go-jpeg2000 v0.0.2/go.mod h1:7ld88W47lZy0x8gRQesRGAonDPOpr6ev8rckjCAfbzE=
github.com/dkrisman/gobig2 v0.0.0-20260513123937-51e39052fde6 h1:hHPgbpPdcaSXtpLb9NMEUJPxsZ0cxGtGHwmvKGI36NA=
github.com/dkrisman/gobig2 v0.0.0-20260513123937-51e39052fde6/go.mod h1:2Ij0rpAVy1tZ+PQ9FM1jCpnVo/OOCMCD6rLtSp4JkEI=
github.com/go-gfx/gfx v0.14.0 h1:G6A0ZIirv9XjlxVFOl3HLscLRJOdhe9e1HqiCK5CHz0=
github.com/go-gfx/gfx v0.14.0/go.mod h1:8KcjwJmHW2ImXcjeMEIQ1bY+7Q2DNcMYoQCl270Isr8=
github.com/go-gfx/gfx v0.15.0 h1:ol+4uqUTq8s23cGSjfbEHciTxkrZ7kTrpUuAPPz4XuY=
github.com/go-gfx/gfx v0.15.0/go.mod h1:VAK6hgCgkhT3j3ek2K7G8zDfkCPD3RCF45UbcRlCV8Q=
github.com/go-opentype/fonts v0.9.0 h1:slB6OB3riLyUPrOxqXe0s6/AzdenF1TDvCN8N87hhQk=
github.com/go-opentype/fonts v0.9.0/go.mod h1:C6yQL2apHItfEZ5hztpsHF0S5mlX/hklLlq/Z5fRG/g=
github.com/go-opentype/opentype v0.10.0 h1:cZVMZ3RVkcijXmxlmpqyVkjlNc33aSB2ugKVWYvoLUg=
Expand All @@ -18,6 +16,8 @@ github.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNW
github.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY=
github.com/sergeymakinen/go-ico v1.0.0 h1:uL3khgvKkY6WfAetA+RqsguClBuu7HpvBB/nq/Jvr80=
github.com/sergeymakinen/go-ico v1.0.0/go.mod h1:wQ47mTczswBO5F0NoDt7O0IXgnV4Xy3ojrroMQzyhUk=
github.com/tannevaled/gobig2 v0.1.0 h1:9PdMvmnmYQURlUF40zt8t35Wnrz3KhZrwfxgCMvQc04=
github.com/tannevaled/gobig2 v0.1.0/go.mod h1:X0S1H+N35kg6zYgVYRqGMsGj3C35zs+iA2r1MXyPcV4=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
Expand Down
111 changes: 111 additions & 0 deletions images.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright (c) 2026, the go-pdfkit/render authors
// All rights reserved.
//
// SPDX-License-Identifier: BSD-3-Clause

package render

import (
"sort"

"github.com/go-gfx/gfx/raster"
"github.com/go-pdfkit/reader"
)

// Image is one picture a page draws, decoded.
type Image struct {
// Name is the resource name the page draws it by.
Name string
// Filter is the image format the picture was stored in — DCTDecode,
// JPXDecode, JBIG2Decode, CCITTFaxDecode — and is empty when the bytes
// were samples that no image codec had to read.
Filter string
// Stencil says the picture is a one-bit mask, which carries no colours of
// its own: it paints whatever colour is in force through its own shape.
// Its Pic has that shape in the alpha channel and black everywhere else,
// because the colour is not the image's to know.
Stencil bool
// Pic is the decoded picture.
Pic *raster.Image
}

// Images decodes the pictures the i'th page draws, counting from one, in the
// order of the names it draws them by.
//
// It exists because a page is the wrong unit for two jobs. Getting the
// pictures out of a document is one. Finding out whether a codec is right is
// the other: a page is a composition, so one image decoded wrongly can be
// averaged away by everything drawn over and around it, and a per-page
// difference of a few percent says nothing about which image was wrong. That
// mattered here — a release went out drawing scanned pages dark because "ink
// appeared" was measured instead of "the right ink".
//
// 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
// the stream that draws them rather than to the page's resources.
func Images(d *reader.Document, i int) ([]Image, error) {
page, err := d.Page(i)
if err != nil {
return nil, err
}
r := &renderer{doc: d, fonts: map[int]*pdfFont{}, softMasks: map[softMaskKey][]uint8{}}
res, _ := reader.ToDict(resolve(d, page.Get("Resources")))
return r.imagesIn(res, 0), nil
}

// maxImageDepth is how far a form XObject may nest before its pictures stop
// being counted. A form may hold a form, and a document may say it holds
// itself.
const maxImageDepth = 8

// imagesIn collects the pictures one resource dictionary reaches, following
// the forms it names.
func (r *renderer) imagesIn(res reader.Dict, depth int) []Image {
if depth > maxImageDepth {
return nil
}
xo, _ := reader.ToDict(resolve(r.doc, res.Get("XObject")))
names := make([]string, 0, len(xo))
for name := range xo {
names = append(names, string(name))
}
// A map hands its keys back in a different order every run, and a list of
// pictures that reorders itself is not a measurement.
sort.Strings(names)

var out []Image
for _, name := range names {
st, ok := reader.ToStream(resolve(r.doc, xo.Get(reader.Name(name))))
if !ok {
continue
}
sub, _ := reader.ToName(resolve(r.doc, st.Dict.Get("Subtype")))
if sub == "Form" {
inner, _ := reader.ToDict(resolve(r.doc, st.Dict.Get("Resources")))
out = append(out, r.imagesIn(inner, depth+1)...)
continue
}
if sub != "Image" {
continue
}
s := r.decodeImage(st.Dict, st.Raw, res)
if s == nil {
continue
}
stencil, _ := reader.ToBool(resolve(r.doc, st.Dict.Get("ImageMask")))
out = append(out, Image{
Name: name,
Filter: imageFilterOf(r.doc, st),
Stencil: bool(stencil),
Pic: &raster.Image{W: s.w, H: s.h, Pix: s.pix},
})
}
return out
}

// imageFilterOf names the image format a stream's filter chain stopped at, and
// is empty when the chain ran to samples.
func imageFilterOf(d *reader.Document, st *reader.Stream) string {
return string(reader.DecodeRecovering(st.Dict, st.Raw, d.Resolver()).Image)
}
222 changes: 222 additions & 0 deletions images_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
// Copyright (c) 2026, the go-pdfkit/render authors
// All rights reserved.
//
// SPDX-License-Identifier: BSD-3-Clause

package render

import (
"testing"

"github.com/go-pdfkit/reader"
)

// pageWithResources builds a page whose resource dictionary is whatever the
// caller gives, so a test can put forms, nonsense and pictures side by side.
func pageWithResources(t *testing.T, build func(w *reader.Writer) reader.Dict) *reader.Document {
t.Helper()
w := reader.NewWriter("1.7")
pagesRef := w.Reserve()
res := build(w)
page := w.Add(reader.Dict{
"Type": reader.Name("Page"), "Parent": pagesRef,
"MediaBox": reader.Array{reader.Integer(0), reader.Integer(0), reader.Integer(20), reader.Integer(20)},
"Contents": w.Add(&reader.Stream{Dict: reader.Dict{}, Raw: []byte("")}),
"Resources": res,
})
w.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"),
"Kids": reader.Array{page}, "Count": reader.Integer(1)})
out, err := w.Finish(reader.Dict{"Root": w.Add(reader.Dict{
"Type": reader.Name("Catalog"), "Pages": pagesRef})})
if err != nil {
t.Fatal(err)
}
d, err := reader.Open(out)
if err != nil {
t.Fatal(err)
}
return d
}

// greyImage is a picture stored as plain samples, dark then light.
func greyImage(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 TestThePicturesAPageDrawsComeBackDecoded(t *testing.T) {
d := pageWithResources(t, func(w *reader.Writer) reader.Dict {
return reader.Dict{"XObject": reader.Dict{"I": greyImage(w)}}
})
got, err := Images(d, 1)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("%d pictures, want 1", len(got))
}
im := got[0]
if im.Name != "I" || im.Filter != "" || im.Stencil {
t.Errorf("name %q, filter %q, stencil %v", im.Name, im.Filter, im.Stencil)
}
if im.Pic.W != 2 || im.Pic.H != 1 {
t.Fatalf("decoded %dx%d, want 2x1", im.Pic.W, im.Pic.H)
}
// Dark then light, so a decoder handing back a flat or mirrored picture is
// caught and not merely one handing back nothing.
if im.Pic.Pix[0] >= 128 {
t.Errorf("the dark pixel came back at %d", im.Pic.Pix[0])
}
if im.Pic.Pix[4] < 128 {
t.Errorf("the light pixel came back at %d", im.Pic.Pix[4])
}
}

func TestAPictureIsNamedByTheFilterItWasStoredIn(t *testing.T) {
// Which codec read a picture is the question a conformance run asks, and
// it cannot be answered from the pixels.
d := pageWithImage(t, reader.Dict{
"Width": reader.Integer(16), "Height": reader.Integer(8),
"ColorSpace": reader.Name("DeviceGray"), "BitsPerComponent": reader.Integer(1),
"Filter": reader.Name("JBIG2Decode"),
}, jbig2Ink, "")
got, err := Images(d, 1)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("%d pictures, want 1", len(got))
}
if got[0].Filter != "JBIG2Decode" {
t.Errorf("filter %q", got[0].Filter)
}
}

func TestAStencilSaysSoAndCarriesOnlyItsShape(t *testing.T) {
// A stencil has no colours of its own: what comes back is the shape, and
// the colour belongs to whoever draws it.
d := pageWithImage(t, reader.Dict{
"Width": reader.Integer(2), "Height": reader.Integer(1),
"ImageMask": reader.Bool(true),
}, []byte{0b01000000}, "")
got, err := Images(d, 1)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || !got[0].Stencil {
t.Fatalf("%d pictures, stencil %v", len(got), len(got) == 1 && got[0].Stencil)
}
if got[0].Pic.Pix[3] != 255 {
t.Errorf("the painted pixel is transparent")
}
if got[0].Pic.Pix[7] != 0 {
t.Errorf("the unpainted pixel is opaque")
}
}

func TestThePicturesInsideAFormAreFound(t *testing.T) {
// A form is a page inside a page. pdfimages follows them, and a picture
// that is only reachable through one is still a picture the page draws.
d := 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": greyImage(w)}},
}, Raw: []byte("")})
return reader.Dict{"XObject": reader.Dict{"F": inner, "A": greyImage(w)}}
})
got, err := Images(d, 1)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("%d pictures, want 2", len(got))
}
// A map hands its keys back in a different order every run. The names are
// walked in order, so "A" comes before the form named "F" whose picture is
// "Deep" — and the answer is the same every time, which a measurement
// needs and a map does not give.
if got[0].Name != "A" || got[1].Name != "Deep" {
t.Errorf("came back as %q then %q", got[0].Name, got[1].Name)
}
}

func TestAFormThatHoldsItselfStops(t *testing.T) {
// A document may say a form holds itself, and following that is a way of
// never coming back.
d := 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{
"Loop": ref, "Pic": greyImage(w)}},
}, Raw: []byte("")})
return reader.Dict{"XObject": reader.Dict{"F": ref}}
})
got, err := Images(d, 1)
if err != nil {
t.Fatal(err)
}
// One picture per level it went down, and then it stopped.
if len(got) == 0 {
t.Fatal("the picture inside the form was not found at all")
}
if len(got) > maxImageDepth+1 {
t.Errorf("it went down %d levels", len(got))
}
}

func TestWhatIsNotAPictureIsNotReturned(t *testing.T) {
for _, tc := range []struct {
name string
res func(w *reader.Writer) reader.Dict
}{
{"a page with no resources", func(*reader.Writer) reader.Dict { return nil }},
{"resources naming no XObjects", func(*reader.Writer) reader.Dict {
return reader.Dict{"Font": reader.Dict{}}
}},
{"an XObject entry that is not a dictionary", func(*reader.Writer) reader.Dict {
return reader.Dict{"XObject": reader.Integer(3)}
}},
{"an entry that is not a stream", func(*reader.Writer) reader.Dict {
return reader.Dict{"XObject": reader.Dict{"X": reader.Integer(3)}}
}},
{"a stream that is neither picture nor form", func(w *reader.Writer) reader.Dict {
return reader.Dict{"XObject": reader.Dict{"X": w.Add(&reader.Stream{
Dict: reader.Dict{"Subtype": reader.Name("PS")}, Raw: []byte("")})}}
}},
{"a picture nothing here can decode", func(w *reader.Writer) reader.Dict {
return reader.Dict{"XObject": reader.Dict{"X": w.Add(&reader.Stream{
Dict: reader.Dict{
"Type": reader.Name("XObject"), "Subtype": reader.Name("Image"),
"Width": reader.Integer(4), "Height": reader.Integer(4),
"Filter": reader.Name("JPXDecode"),
}, Raw: []byte{0xff, 0x4f, 0xff, 0x51, 0, 1}})}}
}},
{"a form with nothing in it", func(w *reader.Writer) reader.Dict {
return reader.Dict{"XObject": reader.Dict{"F": w.Add(&reader.Stream{
Dict: reader.Dict{"Type": reader.Name("XObject"),
"Subtype": reader.Name("Form")}, Raw: []byte("")})}}
}},
} {
t.Run(tc.name, func(t *testing.T) {
d := pageWithResources(t, tc.res)
got, err := Images(d, 1)
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Errorf("%d pictures came back", len(got))
}
})
}
}

func TestAPageThatIsNotThereHasNoPictures(t *testing.T) {
d := pageWithResources(t, func(w *reader.Writer) reader.Dict { return nil })
if _, err := Images(d, 9); err == nil {
t.Error("page nine of a one-page document came back without complaint")
}
}
Loading