From d4b52efc64d7da85ddee51176491d54213276187 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sat, 29 Aug 2026 21:06:34 +0200 Subject: [PATCH] Make a page of a picture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. This package could take a document apart and put it together and could not begin one from an image. Picture takes every format go-gfx/gfx reads, which is more than a PDF can carry. A JPEG goes 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. Every format is decoded either way, including the one carried undecoded, because the page has to be the size of the picture and only the picture says what that is. Transparency becomes a soft mask. Dropping the alpha channel instead puts the colour that was UNDER the transparency on the page, so a PNG with a clear background comes out with black or with whatever happened to be in those bytes. dpi says how many of the image's pixels go into an inch of paper. A photograph from a telephone at 72 is a page the size of a wall, and at 300 it is a photograph. A picture of no size is refused. Writing the test for that found the hole it now closes: GIF, BMP and JPEG all decode a zero-by-zero image WITHOUT complaining — such a file is not malformed, it is empty — and the check was on the path that decodes rather than on the one that only measures, so a JPEG of no size would have become a page of no size. Judged by poppler rather than by ourselves: it reads the files, lists the picture at the right size, carries the JPEG as jpeg, and renders the halves the right way round — the transparent half over white rather than over black. 100% statement coverage, go vet and -race clean, nine cross-compile targets. --- compose.go | 5 +- doc.go | 3 + go.mod | 11 ++ go.sum | 16 +++ picture.go | 155 ++++++++++++++++++++++++++++ picture_test.go | 263 ++++++++++++++++++++++++++++++++++++++++++++++++ write.go | 2 +- 7 files changed, 453 insertions(+), 2 deletions(-) create mode 100644 picture.go create mode 100644 picture_test.go 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)