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
12 changes: 10 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,18 @@ module github.com/go-pdfkit/render
go 1.26.4

require (
github.com/go-gfx/gfx v0.10.0
github.com/go-gfx/gfx v0.12.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
)

require github.com/go-pdfkit/pdffont v0.3.0
require (
github.com/ajroetker/go-jpeg2000 v0.0.2
github.com/go-pdfkit/pdffont v0.3.0
)

require (
github.com/ajroetker/go-highway v0.0.4 // indirect
golang.org/x/sys v0.47.0 // indirect
)
10 changes: 8 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
github.com/go-gfx/gfx v0.10.0 h1:3AqOO8TZph6/U8+ejJxYkCZ+wzddxAbZ7fxi6TvGop4=
github.com/go-gfx/gfx v0.10.0/go.mod h1:bFt/MWyYWRU3Ic9IaB8XOC9KLMMHRRmahMk4FaIGK7g=
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.12.0 h1:zBaYHahRwM6yYr86ifRelX132ZjhN5hZNr/uW9dpIzQ=
github.com/go-gfx/gfx v0.12.0/go.mod h1:DpRUcQrlLZH02CSB23J0iEk1F7mQH8w9WMF60xQBxDU=
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 @@ -8,3 +12,5 @@ github.com/go-pdfkit/pdffont v0.3.0 h1:G5DKcAmsZJ0e17QhSrcUaL7PKEXKjUx4P/iGMl7bA
github.com/go-pdfkit/pdffont v0.3.0/go.mod h1:bfmNLna1l1CljNX/Utg55YzFylovfmI7sJnvgA3bzKI=
github.com/go-pdfkit/reader v0.6.0 h1:KAabNOYUcTlZlNBTbG9bEhWP1NiZhjuhzUdavdTdfes=
github.com/go-pdfkit/reader v0.6.0/go.mod h1:fQFOVfCMUui1AdvD4qhimdyvvNr9KvvJ1S7IuKZjyV8=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
34 changes: 34 additions & 0 deletions image.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
_ "image/jpeg" // the one image format a PDF may carry undecoded
"math"

jpeg2000 "github.com/ajroetker/go-jpeg2000"
"github.com/go-gfx/gfx/geometry"
"github.com/go-gfx/gfx/raster"
"github.com/go-pdfkit/reader"
Expand Down Expand Up @@ -135,6 +136,8 @@ func (r *renderer) decodeImage(dict reader.Dict, raw []byte, resources reader.Di
out = r.samples(dict, data, w, h, resources)
case "DCTDecode", "DCT":
out = decodeJPEG(data, w, h, r.decodeInverts(dict))
case "JPXDecode":
out = decodeJPX(data, w, h)
default:
// A format nothing here can decode: the image is not drawn rather than
// drawn wrong.
Expand Down Expand Up @@ -277,6 +280,37 @@ func decodeJPEG(data []byte, w, h int, inverted bool) *sampled {
return &sampled{w: w, h: h, pix: src.Pix}
}

// decodeJPX reads a JPEG 2000 image, which is what a scanned page is stored in.
//
// Measured over a corpus of a thousand scanned documents: all 250 biodiversity
// scans carry one, 248 of the 250 medical ones do, and 144 of the 222 readable
// scanned books — and between them 655 pages have nothing on them at all
// besides such an image. Those pages came out blank.
//
// The size is taken from the picture rather than from the dictionary, as it is
// for JPEG: a codestream carries its own, and where the two disagree the one
// the pixels are actually in is the one that can be drawn.
func decodeJPX(data []byte, w, h int) *sampled {
img, err := jpxDecode(data)
if err != nil || img == nil {
return nil
}
if img.W != w || img.H != h {
w, h = img.W, img.H
}
return &sampled{w: w, h: h, pix: img.Pix}
}

// jpxDecode is a variable so a test can watch what happens when a decoder
// refuses what it is given.
var jpxDecode = func(data []byte) (*raster.Image, error) {
img, err := jpeg2000.Decode(bytes.NewReader(data))
if err != nil {
return nil, err
}
return raster.FromImage(img), nil
}

// jpegDecode is a variable so a test can watch what happens when a decoder
// refuses what it is given.
var jpegDecode = func(data []byte) (image.Image, error) {
Expand Down
151 changes: 151 additions & 0 deletions jpx_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package render

import (
"bytes"
"image"
"image/color"
"testing"

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

// jpxImage encodes a small picture as a JPEG 2000 codestream, losslessly, so a
// test can check the colours that come back. It is built here rather than
// committed: nobody else's scan enters the repository.
func jpxImage(t *testing.T, w, h int) []byte {
t.Helper()
src := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
c := color.RGBA{R: 20, G: 20, B: 20, A: 255} // dark
if x >= w/2 {
c = color.RGBA{R: 240, G: 240, B: 240, A: 255} // light
}
src.Set(x, y, c)
}
}
var buf bytes.Buffer
if err := jpeg2000.Encode(&buf, src, &jpeg2000.EncodeOptions{Lossless: true}); err != nil {
t.Fatalf("encoding a JPEG 2000 to draw: %v", err)
}
return buf.Bytes()
}

// jpxPage builds a page whose only content is one JPEG 2000 image filling it —
// which is what a scanned page IS.
func jpxPage(t *testing.T, data []byte, w, h int) *reader.Document {
t.Helper()
wr := reader.NewWriter("1.7")
pagesRef := wr.Reserve()
img := wr.Add(&reader.Stream{Dict: reader.Dict{
"Type": reader.Name("XObject"), "Subtype": reader.Name("Image"),
"Width": reader.Integer(w), "Height": reader.Integer(h),
"ColorSpace": reader.Name("DeviceRGB"), "BitsPerComponent": reader.Integer(8),
"Filter": reader.Name("JPXDecode"),
}, Raw: data})
pageRef := wr.Add(reader.Dict{"Type": reader.Name("Page"), "Parent": pagesRef,
"MediaBox": nums(0, 0, 40, 40),
"Resources": reader.Dict{"XObject": reader.Dict{"S": img}},
"Contents": wr.Add(&reader.Stream{Dict: reader.Dict{},
Raw: []byte("q 40 0 0 40 0 0 cm /S Do Q")})})
wr.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"),
"Kids": reader.Array{pageRef}, "Count": reader.Integer(1)})
out, err := wr.Finish(reader.Dict{"Root": wr.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
}

func TestAScannedPageIsDrawn(t *testing.T) {
// A page whose only content is a JPEG 2000 image came out blank. Over a
// corpus of a thousand scanned documents that is 655 pages: every one of
// the 250 biodiversity scans carries such an image, and 248 of the 250
// medical ones.
d := jpxPage(t, jpxImage(t, 32, 32), 32, 32)
img, err := Page(d, 1, Options{Scale: 1})
if err != nil {
t.Fatal(err)
}
if inked(img) == 0 {
t.Fatal("a page whose only content is a JPEG 2000 image drew nothing")
}
// Dark on the left, light on the right — so a decoder that hands back a
// flat or a mirrored picture is caught, not merely one that hands back
// nothing.
if !isBlack(img, 8, 20) {
t.Errorf("the dark half is not dark: %s", pixel(img, 8, 20))
}
if !isWhite(img, 30, 20) {
t.Errorf("the light half is not light: %s", pixel(img, 30, 20))
}
}

func TestAPictureThatWillNotDecodeIsNotDrawn(t *testing.T) {
// The rule the rest of this file follows: not drawn rather than drawn
// wrong.
d := jpxPage(t, []byte{0xFF, 0x4F, 0xFF, 0x51, 0, 1, 2}, 32, 32)
img, err := Page(d, 1, Options{Scale: 1})
if err != nil {
t.Fatal(err)
}
if ink := inked(img); ink != 0 {
t.Errorf("%d pixels drawn from a codestream that is not one", ink)
}
}

func TestTheSizeComesFromThePictureNotTheDictionary(t *testing.T) {
// A codestream carries its own size, and where the two disagree the one the
// pixels are actually in is the one that can be drawn.
d := jpxPage(t, jpxImage(t, 32, 32), 999, 7)
img, err := Page(d, 1, Options{Scale: 1})
if err != nil {
t.Fatal(err)
}
if inked(img) == 0 {
t.Error("a dictionary that lies about the size stopped the page being drawn")
}
}

func TestAFormatNothingHereDecodesIsNotDrawn(t *testing.T) {
// JPEG 2000 used to be the example of this. What is left is JBIG2, which
// 12 of 250 court filings and 1 of 222 scanned books carry — a real gap,
// and one that draws nothing rather than drawing noise.
wr := reader.NewWriter("1.7")
pagesRef := wr.Reserve()
img := wr.Add(&reader.Stream{Dict: reader.Dict{
"Type": reader.Name("XObject"), "Subtype": reader.Name("Image"),
"Width": reader.Integer(8), "Height": reader.Integer(8),
"ColorSpace": reader.Name("DeviceGray"), "BitsPerComponent": reader.Integer(1),
"Filter": reader.Name("JBIG2Decode"),
}, Raw: make([]byte, 8)})
pageRef := wr.Add(reader.Dict{"Type": reader.Name("Page"), "Parent": pagesRef,
"MediaBox": nums(0, 0, 40, 40),
"Resources": reader.Dict{"XObject": reader.Dict{"S": img}},
"Contents": wr.Add(&reader.Stream{Dict: reader.Dict{},
Raw: []byte("q 40 0 0 40 0 0 cm /S Do Q")})})
wr.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"),
"Kids": reader.Array{pageRef}, "Count": reader.Integer(1)})
out, err := wr.Finish(reader.Dict{"Root": wr.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)
}
pic, err := Page(d, 1, Options{Scale: 1})
if err != nil {
t.Fatal(err)
}
if ink := inked(pic); ink != 0 {
t.Errorf("%d pixels drawn from a format nothing here decodes", ink)
}
}
Loading