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
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ require (

require (
github.com/ajroetker/go-highway v0.0.4 // indirect
github.com/dkrisman/gobig2 v0.0.0-20260513123937-51e39052fde6
golang.org/x/sys v0.47.0 // indirect
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +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.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=
Expand Down
45 changes: 31 additions & 14 deletions image.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,18 +113,27 @@ func (r *renderer) decodeImage(dict reader.Dict, raw []byte, resources reader.Di
if len(data) == 0 {
return nil
}
if imageFilter == "JBIG2Decode" {
// Decoded here rather than in the switch below because a JBIG2 stream
// is far more often a stencil than an image, and a stencil never
// reaches that switch.
if data = r.decodeJBIG2(dict, data, w, h); data == nil {
return nil
}
imageFilter = ""
}
if mask, ok := reader.ToBool(resolve(r.doc, dict.Get("ImageMask"))); ok && mask {
// A stencil is one bit a pixel, so the bytes have to be samples. When
// the filter chain stopped at an image format nothing here decodes,
// they are not: they are still compressed, and drawing them paints
// noise through the shape of nothing.
//
// 273 of the image masks in the 1 633 real forms carry an encoded
// filter. 236 of them were faxes, which the reader now decodes; the
// nine that remain are JBIG2, and until something decodes those the
// honest answer is not to draw them. That is the rule the rest of this
// function already follows: "the image is not drawn rather than drawn
// wrong".
// filter. 236 of them were faxes and nine were JBIG2, both of which
// are decoded before this point. What is left is a format nothing
// here reads, and the honest answer is not to draw it. That is the
// rule the rest of this function already follows: "the image is not
// drawn rather than drawn wrong".
if imageFilter != "" {
return nil
}
Expand All @@ -138,11 +147,11 @@ func (r *renderer) decodeImage(dict reader.Dict, raw []byte, resources reader.Di
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.
return nil
}
// No arm ran, or the one that ran could not read its bytes: the image is
// not drawn rather than drawn wrong. Every filter the reader hands back
// unread has an arm above, so the first of those is a case this cannot
// reach today and the check is here for the second.
if out == nil {
return nil
}
Expand Down Expand Up @@ -415,8 +424,18 @@ func (r *renderer) applySoftMask(s *sampled, stream *reader.Stream, resources re
return true
}

// applyStencilMask reads a one-bit image whose set pixels are the ones to
// leave out.
// applyStencilMask reads a one-bit image that says which parts of this one are
// painted.
//
// A mask sample of 0 means PAINT. The bit and the coverage run opposite ways,
// which is the whole difficulty: decodeImage returns a stencil whose alpha is
// set where the sample is 0, so alpha here already means "painted" and what has
// to be cleared is everything else. Reading the alpha as though it were the
// sample shows the exact complement of the picture — a scanned page whose text
// is the only part hidden.
//
// Asked which half of a two-colour page a mask of eight 0 bits and eight 1 bits
// paints, poppler answers the 0 half.
func (r *renderer) applyStencilMask(s *sampled, stream *reader.Stream, resources reader.Dict) bool {
mask := r.decodeImage(stream.Dict, stream.Raw, resources)
if mask == nil {
Expand All @@ -425,9 +444,7 @@ func (r *renderer) applyStencilMask(s *sampled, stream *reader.Stream, resources
for y := 0; y < s.h; y++ {
for x := 0; x < s.w; x++ {
m := mask.at(x*mask.w/s.w, y*mask.h/s.h)
// A stencil mask marks what is hidden, so where it paints, the
// image does not.
if m.A > 127 {
if m.A <= 127 {
s.pix[(y*s.w+x)*4+3] = 0
}
}
Expand Down
22 changes: 15 additions & 7 deletions image_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,9 +220,15 @@ func TestASoftMaskMakesAnImageSeeThrough(t *testing.T) {
}

func TestAMaskHidesPartOfAnImage(t *testing.T) {
// A mask sample of 0 means PAINT, so the half whose bit is 0 is the half
// that shows. This test asserted the complement of that, and so did the
// code: every explicit mask in the corpus was showing exactly the parts it
// was meant to hide. Asked the same question — which half of a
// two-colour page a mask of one 0 bit and one 1 bit paints — poppler
// answers the 0 half.
w := reader.NewWriter("1.7")
pagesRef := w.Reserve()
// A stencil that covers the left half.
// A stencil whose left half is 0 and right half is 1.
mask := w.Add(&reader.Stream{Dict: reader.Dict{
"Type": reader.Name("XObject"), "Subtype": reader.Name("Image"),
"Width": reader.Integer(2), "Height": reader.Integer(1),
Expand Down Expand Up @@ -253,8 +259,8 @@ func TestAMaskHidesPartOfAnImage(t *testing.T) {
t.Fatal(err)
}
img := draw(t, d, Options{})
wantWhite(t, img, 5, 10) // hidden by the mask
wantBlack(t, img, 15, 10) // shown
wantBlack(t, img, 5, 10) // sample 0: painted
wantWhite(t, img, 15, 10) // sample 1: masked out
}

func TestAJPEGImage(t *testing.T) {
Expand Down Expand Up @@ -482,15 +488,17 @@ func TestAMaskWhoseBytesAreStillEncodedIsNotDrawn(t *testing.T) {
// nothing at all.
//
// 273 of the image masks in the 1 633 real forms carry an encoded filter.
// 236 were faxes, which the reader decodes as of go-pdfkit/reader#15; the
// nine that remain are JBIG2. Fifty-one first pages of real forms were
// showing this noise before either change.
// 236 were faxes and nine were JBIG2, and both are decoded before this
// point now. What can still arrive encoded is a mask a file gave a
// photographic filter, which no amount of decoding turns into one bit a
// pixel. Fifty-one first pages of real forms were showing this noise
// before any of it was decoded.
for _, tc := range []struct {
name string
filter reader.Name
drawn bool
}{
{"a format nothing here decodes", "JBIG2Decode", false},
{"a mask given a photographic filter", "DCTDecode", false},
{"no filter at all", "", true},
} {
t.Run(tc.name, func(t *testing.T) {
Expand Down
103 changes: 103 additions & 0 deletions jbig2.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright (c) 2026, the go-pdfkit/render authors
// All rights reserved.
//
// SPDX-License-Identifier: BSD-3-Clause

package render

import (
"bytes"
"image"

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

// decodeJBIG2 turns a JBIG2 stream into the packed one-bit rows the rest of
// this file already reads, so a JBIG2 image goes on to be drawn by the same
// code as any other one-bit image and a JBIG2 stencil by the same code as any
// other stencil.
//
// JBIG2 is what a scanned page's ink is stored in, and it is almost never the
// page's content: it is the /Mask that gives the high-resolution ink layer its
// shape. Counting filters by what they encode as content put it in twenty
// documents; counting the images it SHAPES put it in four thousand.
//
// The two conventions are opposite. JBIG2 sets a bit where there is ink; a
// one-bit DeviceGray sample of 0 is black, and an image mask paints where its
// sample is 0. One inversion here serves both.
func (r *renderer) decodeJBIG2(dict reader.Dict, data []byte, w, h int) []byte {
img, err := jbig2Decode(data, r.jbig2Globals(dict))
if err != nil || img == nil {
return nil
}
b := img.Bounds()
if b.Dx() != w || b.Dy() != h {
// The dictionary's size is the one the page's geometry was computed
// from. A stream that decodes to another size is not this image.
return nil
}
rowBytes := (w + 7) / 8
out := make([]byte, rowBytes*h)
for i := range out {
out[i] = 0xff
}
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
gr, gg, gb, _ := img.At(b.Min.X+x, b.Min.Y+y).RGBA()
if (gr*299+gg*587+gb*114)/1000 < 0x8000 {
out[y*rowBytes+x/8] &^= 0x80 >> uint(x%8)
}
}
}
return out
}

// jbig2Globals returns the shared segments the stream's decode parameters name,
// which is where an encoder puts the symbol dictionary several pages draw from.
// /DecodeParms runs parallel to /Filter, so it may be one dictionary or an
// array with one entry per filter in the chain.
func (r *renderer) jbig2Globals(dict reader.Dict) []byte {
parms := resolve(r.doc, dict.Get("DecodeParms"))
if arr, ok := reader.ToArray(parms); ok {
for _, v := range arr {
if g := r.globalsFrom(resolve(r.doc, v)); g != nil {
return g
}
}
return nil
}
return r.globalsFrom(parms)
}

// globalsFrom reads the globals stream one decode-parameters dictionary names.
func (r *renderer) globalsFrom(parms reader.Object) []byte {
d, ok := reader.ToDict(parms)
if !ok {
return nil
}
st, ok := reader.ToStream(resolve(r.doc, d.Get("JBIG2Globals")))
if !ok {
return nil
}
data, _, err := reader.DecodeStream(st, r.doc.Get)
if err != nil {
return nil
}
return data
}

// jbig2Decode is a variable so a test can watch what happens when a decoder
// refuses, which is the case that decides whether the image is drawn wrong or
// not drawn.
var jbig2Decode = func(data, globals []byte) (image.Image, error) {
d, err := gobig2.NewDecoderEmbedded(bytes.NewReader(data), globals)
if err != nil {
return nil, err
}
img, err := d.Decode()
if err != nil {
return nil, err
}
return img, nil
}
Loading
Loading