diff --git a/compose.go b/compose.go index f83b750..9627373 100644 --- a/compose.go +++ b/compose.go @@ -353,8 +353,11 @@ func contentStream(data []byte, extra reader.Dict) *reader.Stream { func (d *Doc) madeContent(w *reader.Writer, p Page, area [4]float64) ([]byte, reader.Dict) { var content []byte resources := reader.Dict{} - if p.tiles != nil { + switch { + case p.tiles != nil: content, resources = d.composeContent(w, p) + case p.picture != nil: + content, resources = d.pictureContent(w, p, area) } if len(p.marks) > 0 { stamp, fonts, alpha := d.stampContent(p, area) diff --git a/doc.go b/doc.go index dd12120..0794bdc 100644 --- a/doc.go +++ b/doc.go @@ -53,6 +53,9 @@ type Page struct { blank bool size [2]float64 + // picture is set when the page is one image made into a page. + picture *picture + // marks is the text drawn on top of whatever the page already shows. marks []stampInstance } diff --git a/go.mod b/go.mod index 43c1663..c243273 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,17 @@ require ( ) require ( + github.com/ajroetker/go-highway v0.0.4 // indirect + github.com/ajroetker/go-jpeg2000 v0.0.2 // 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 +) + +require ( + github.com/go-gfx/gfx v0.16.0 github.com/go-opentype/fonts v0.9.0 // indirect github.com/go-opentype/opentype v0.12.0 // indirect github.com/go-pdfkit/pdffont v0.3.1 // indirect diff --git a/go.sum b/go.sum index dfe64cd..59693f0 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,9 @@ +github.com/ajroetker/go-highway v0.0.4 h1:RDQo+9OhTXI6BFctLo+5gYpHNbb92VYJ0ObnR4l6xAQ= +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/go-gfx/gfx v0.16.0 h1:HYlQrz2kHm+Vg1aBotnO6O9yfadNqWVrPK+b0uqxtKg= +github.com/go-gfx/gfx v0.16.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.12.0 h1:wBlcDi+3ZaNZXEt5z+Ixr11/cYYwi5W+jX6yTl/qr1I= @@ -10,3 +16,13 @@ github.com/go-pdfkit/pdffont v0.3.1 h1:+K7opHGRERDZ5EMkA12s1GT48t8GpncArhnyzyc5b github.com/go-pdfkit/pdffont v0.3.1/go.mod h1:eFJ/7t9AvcX76KY5qNJMMKetz8hUUUdVM1qSWLi3S1g= github.com/go-pdfkit/reader v0.6.0 h1:KAabNOYUcTlZlNBTbG9bEhWP1NiZhjuhzUdavdTdfes= github.com/go-pdfkit/reader v0.6.0/go.mod h1:fQFOVfCMUui1AdvD4qhimdyvvNr9KvvJ1S7IuKZjyV8= +github.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNWq0R+M= +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= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/picture.go b/picture.go new file mode 100644 index 0000000..a8569d6 --- /dev/null +++ b/picture.go @@ -0,0 +1,155 @@ +// Copyright (c) 2026, the go-pdfkit/ops authors +// All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package ops + +import ( + "bytes" + "compress/zlib" + "fmt" + + "github.com/go-gfx/gfx/codec" + "github.com/go-gfx/gfx/raster" + "github.com/go-pdfkit/reader" +) + +// picture is one image made into a page. +type picture struct { + // stored is the image as the file held it, when that is a format a PDF + // can carry as it stands. filter names it. + stored []byte + filter reader.Name + // pix is the decoded image, for everything else. + pix *raster.Image + width, height int +} + +// Picture adds a page holding one image, the size of the image. +// +// This is the other half of drawing a page: a scanner, a camera and a +// screenshot all produce a picture, and what people want of a PDF toolkit is +// to be handed one back with the picture in it. +// +// Every format go-gfx/gfx reads is accepted, which is more than a PDF can +// carry. A JPEG is put in as it stands, because a PDF carries JPEG and +// re-encoding one loses a little of it for nothing; everything else is decoded +// and written as samples, compressed. An image with any transparency in it +// gets a soft mask, so a PNG drawn over the page's white does not come out +// with black behind it. +// +// dpi says how many of the image's pixels go into an inch of paper; 0 means +// 72, one point per pixel. A photograph from a telephone at 72 is a page the +// size of a wall, and at 300 it is a photograph. +func (d *Doc) Picture(data []byte, dpi float64) error { + if dpi <= 0 { + dpi = 72 + } + p, err := readPicture(data) + if err != nil { + return err + } + scale := 72 / dpi + d.pages = append(d.pages, Page{ + picture: p, + size: [2]float64{float64(p.width) * scale, float64(p.height) * scale}, + }) + return nil +} + +// readPicture works out how an image will be carried. +// +// Every format is decoded, even the one that is then carried undecoded, because +// the page has to be the size of the picture and only the picture says what +// that is. +func readPicture(data []byte) (*picture, error) { + format := codec.Sniff(data) + if format == codec.Unknown { + return nil, fmt.Errorf("ops: these bytes are not a picture in any format that can be read") + } + img, err := codec.Decode(data) + if err != nil { + return nil, fmt.Errorf("ops: this picture cannot be read: %w", err) + } + // A picture of no size is a page of no size. GIF, BMP and JPEG all decode + // one without complaining — they are not malformed files, they are empty + // ones — so the check is here rather than left to the decoders. + if img.W <= 0 || img.H <= 0 { + return nil, fmt.Errorf("ops: a picture of %d by %d is not one", img.W, img.H) + } + if format == codec.JPEG { + // A PDF carries JPEG itself, and re-encoding one would lose a little + // of it for nothing. + return &picture{stored: data, filter: "DCTDecode", + width: img.W, height: img.H}, nil + } + return &picture{pix: img, width: img.W, height: img.H}, nil +} + +// pictureContent writes the image and returns the operators that draw it over +// the whole page, with the resources they need. +func (d *Doc) pictureContent(w *reader.Writer, p Page, area [4]float64) ([]byte, reader.Dict) { + pic := p.picture + dict := reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Image"), + "Width": reader.Integer(pic.width), "Height": reader.Integer(pic.height), + "BitsPerComponent": reader.Integer(8), + "ColorSpace": reader.Name("DeviceRGB"), + } + var ref reader.Object + if pic.stored != nil { + dict["Filter"] = pic.filter + ref = w.Add(&reader.Stream{Dict: dict, Raw: pic.stored}) + } else { + rgb, alpha := split(pic.pix) + if alpha != nil { + ref = w.Add(&reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Image"), + "Width": reader.Integer(pic.width), "Height": reader.Integer(pic.height), + "BitsPerComponent": reader.Integer(8), + "ColorSpace": reader.Name("DeviceGray"), + "Filter": reader.Name("FlateDecode"), + }, Raw: deflate(alpha)}) + dict["SMask"] = ref + } + dict["Filter"] = reader.Name("FlateDecode") + ref = w.Add(&reader.Stream{Dict: dict, Raw: deflate(rgb)}) + } + content := fmt.Sprintf("q %g 0 0 %g %g %g cm /Pic Do Q", + area[2]-area[0], area[3]-area[1], area[0], area[1]) + return []byte(content), reader.Dict{"XObject": reader.Dict{"Pic": ref}} +} + +// split separates an image into its colours and its transparency. The alpha is +// nil when every pixel is opaque, which is most pictures and saves carrying a +// mask that says nothing. +func split(img *raster.Image) (rgb, alpha []byte) { + n := img.W * img.H + rgb = make([]byte, n*3) + opaque := true + for i := 0; i < n; i++ { + rgb[i*3], rgb[i*3+1], rgb[i*3+2] = img.Pix[i*4], img.Pix[i*4+1], img.Pix[i*4+2] + if img.Pix[i*4+3] != 255 { + opaque = false + } + } + if opaque { + return rgb, nil + } + alpha = make([]byte, n) + for i := 0; i < n; i++ { + alpha[i] = img.Pix[i*4+3] + } + return rgb, alpha +} + +// deflate compresses samples the way a PDF carries them. +func deflate(data []byte) []byte { + var buf bytes.Buffer + zw := zlib.NewWriter(&buf) + // A bytes.Buffer never fails to take bytes, and Close only flushes. + zw.Write(data) + zw.Close() + return buf.Bytes() +} diff --git a/picture_test.go b/picture_test.go new file mode 100644 index 0000000..fe84491 --- /dev/null +++ b/picture_test.go @@ -0,0 +1,263 @@ +// Copyright (c) 2026, the go-pdfkit/ops authors +// All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package ops + +import ( + "bytes" + "strings" + "testing" + + "github.com/go-gfx/gfx/codec" + "github.com/go-gfx/gfx/raster" + "github.com/go-pdfkit/reader" +) + +// picBytes writes a picture in the given format: the left half red and opaque, +// the right half whatever alpha is asked for. +func picBytes(t *testing.T, f codec.Format, alpha uint8) []byte { + t.Helper() + const w, h = 32, 16 + img := raster.New(w, h) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + i := (y*w + x) * 4 + if x < w/2 { + img.Pix[i], img.Pix[i+3] = 255, 255 + } else { + img.Pix[i+1], img.Pix[i+3] = 255, alpha + } + } + } + var buf bytes.Buffer + if err := codec.Encode(&buf, img, f); err != nil { + t.Fatalf("writing a %s to make a page of: %v", f, err) + } + return buf.Bytes() +} + +// emptyPicture writes a picture with no pixels in it. GIF, BMP and JPEG all +// accept one and hand it back without complaining: it is not a malformed file, +// it is an empty one, and a page cannot be made of it. +func emptyPicture(t *testing.T, f codec.Format) []byte { + t.Helper() + var buf bytes.Buffer + if err := codec.Encode(&buf, raster.New(0, 0), f); err != nil { + t.Fatalf("writing an empty %s: %v", f, err) + } + return buf.Bytes() +} + +// resolved is the object a reference names, or Null. +func resolved(d *reader.Document, v reader.Object) reader.Object { + o, _ := d.Resolve(v) + return o +} + +// pageImage finds the one image a document's first page draws, decoded. +func pageImage(t *testing.T, out []byte) (reader.Dict, *reader.Stream, *reader.Document) { + t.Helper() + d, err := reader.Open(out) + if err != nil { + t.Fatalf("what was written cannot be read back: %v", err) + } + page, err := d.Page(1) + if err != nil { + t.Fatal(err) + } + res, _ := d.Resolve(page.Get("Resources")) + rd, ok := reader.ToDict(res) + if !ok { + t.Fatal("the page names no resources") + } + xo, _ := d.Resolve(rd.Get("XObject")) + xd, ok := reader.ToDict(xo) + if !ok || len(xd) != 1 { + t.Fatalf("the page names %d XObjects", len(xd)) + } + for _, v := range xd { + o, _ := d.Resolve(v) + st, ok := reader.ToStream(o) + if !ok { + t.Fatal("what it names is not a stream") + } + return page, st, d + } + return nil, nil, nil +} + +func TestAPictureBecomesAPage(t *testing.T) { + // Every format the fleet reads is accepted, which is more than a PDF can + // carry: what a PDF cannot hold is decoded and written as samples. + for _, f := range []codec.Format{codec.PNG, codec.JPEG, codec.GIF, codec.TIFF, codec.BMP} { + t.Run(f.String(), func(t *testing.T) { + d := New() + if err := d.Picture(picBytes(t, f, 255), 0); err != nil { + t.Fatal(err) + } + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + page, st, doc := pageImage(t, out) + // The page is the size of the picture at 72 to the inch. + box, _ := reader.ToArray(resolved(doc, page.Get("MediaBox"))) + if len(box) != 4 { + t.Fatalf("the page has no box") + } + w, _ := reader.ToFloat(resolved(doc, box[2])) + h, _ := reader.ToFloat(resolved(doc, box[3])) + if w != 32 || h != 16 { + t.Errorf("the page came out %g by %g points", w, h) + } + wid, _ := reader.ToInt(resolved(doc, st.Dict.Get("Width"))) + hei, _ := reader.ToInt(resolved(doc, st.Dict.Get("Height"))) + if wid != 32 || hei != 16 { + t.Errorf("the picture came out %d by %d", wid, hei) + } + // A JPEG goes in as it stands, because a PDF carries JPEG and + // re-encoding one loses a little of it for nothing. + filter, _ := reader.ToName(resolved(doc, st.Dict.Get("Filter"))) + want := reader.Name("FlateDecode") + if f == codec.JPEG { + want = "DCTDecode" + } + if filter != want { + t.Errorf("it was carried as %s, want %s", filter, want) + } + }) + } +} + +func TestThePictureIsTheOneThatWasGiven(t *testing.T) { + // Not merely that a picture arrived: the left half is red and the right + // half is green, so a page built from a mirrored or a flat picture is + // caught. + d := New() + if err := d.Picture(picBytes(t, codec.PNG, 255), 0); err != nil { + t.Fatal(err) + } + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + _, st, doc := pageImage(t, out) + data, _, err := reader.DecodeStream(st, doc.Get) + if err != nil { + t.Fatal(err) + } + if len(data) != 32*16*3 { + t.Fatalf("%d bytes of samples for a 32 by 16 picture in three components", len(data)) + } + // Row 8, well inside each half. + left := data[(8*32+4)*3 : (8*32+4)*3+3] + right := data[(8*32+28)*3 : (8*32+28)*3+3] + if left[0] < 200 || left[1] > 60 { + t.Errorf("the left half came out %v", left) + } + if right[1] < 200 || right[0] > 60 { + t.Errorf("the right half came out %v", right) + } +} + +func TestTransparencyBecomesASoftMask(t *testing.T) { + // A PNG drawn over the page's white must not come out with black behind + // it, which is what dropping the alpha channel does. + d := New() + if err := d.Picture(picBytes(t, codec.PNG, 0), 0); err != nil { + t.Fatal(err) + } + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + _, st, doc := pageImage(t, out) + ms, ok := reader.ToStream(resolved(doc, st.Dict.Get("SMask"))) + if !ok { + t.Fatal("a picture with transparency in it carries no soft mask") + } + alpha, _, err := reader.DecodeStream(ms, doc.Get) + if err != nil { + t.Fatal(err) + } + if len(alpha) != 32*16 { + t.Fatalf("%d bytes of mask for a 32 by 16 picture", len(alpha)) + } + if alpha[8*32+4] != 255 { + t.Errorf("the opaque half has alpha %d", alpha[8*32+4]) + } + if alpha[8*32+28] != 0 { + t.Errorf("the transparent half has alpha %d", alpha[8*32+28]) + } +} + +func TestAPictureWithNoTransparencyCarriesNoMask(t *testing.T) { + // Most pictures are opaque, and a mask that says nothing is bytes for + // nothing. + d := New() + if err := d.Picture(picBytes(t, codec.PNG, 255), 0); err != nil { + t.Fatal(err) + } + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + _, st, doc := pageImage(t, out) + if _, ok := reader.ToStream(resolved(doc, st.Dict.Get("SMask"))); ok { + t.Error("an opaque picture was given a soft mask") + } +} + +func TestHowManyPixelsGoIntoAnInch(t *testing.T) { + // A photograph from a telephone at 72 to the inch is a page the size of a + // wall, and at 300 it is a photograph. + for _, tc := range []struct{ dpi, want float64 }{ + {0, 32}, {72, 32}, {144, 16}, {300, 7.68}, + } { + d := New() + if err := d.Picture(picBytes(t, codec.PNG, 255), tc.dpi); err != nil { + t.Fatal(err) + } + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + page, _, doc := pageImage(t, out) + box, _ := reader.ToArray(resolved(doc, page.Get("MediaBox"))) + w, _ := reader.ToFloat(resolved(doc, box[2])) + if w != tc.want { + t.Errorf("at %g to the inch the page is %g points wide, want %g", tc.dpi, w, tc.want) + } + } +} + +func TestWhatIsNotAPicture(t *testing.T) { + for _, tc := range []struct { + name string + data []byte + want string + }{ + {"nothing at all", nil, "not a picture"}, + {"bytes of no format", []byte("hello, this is not an image"), "not a picture"}, + {"a PNG that stops part way", picBytes(t, codec.PNG, 255)[:20], "cannot be read"}, + {"a JPEG that stops part way", picBytes(t, codec.JPEG, 255)[:20], "cannot be read"}, + {"a picture of no size", emptyPicture(t, codec.GIF), "is not one"}, + {"a JPEG of no size", emptyPicture(t, codec.JPEG), "is not one"}, + } { + t.Run(tc.name, func(t *testing.T) { + d := New() + err := d.Picture(tc.data, 0) + if err == nil { + t.Fatal("it was made into a page anyway") + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("it said %q", err) + } + if d.PageCount() != 0 { + t.Errorf("%d pages came of it", d.PageCount()) + } + }) + } +} diff --git a/write.go b/write.go index db772bb..c0964c3 100644 --- a/write.go +++ b/write.go @@ -99,7 +99,7 @@ func (d *Doc) Bytes() ([]byte, error) { // buildPage assembles one page's dictionary without writing it. func (d *Doc) buildPage(w *reader.Writer, p Page, parent reader.Ref, where destinations, kept *keptAnnots) reader.Dict { - if p.blank || p.tiles != nil { + if p.blank || p.tiles != nil || p.picture != nil { return d.buildMadePage(w, p, parent) } return d.buildBorrowedPage(w, p, parent, where, kept)