JBIG2 Encoder support for inserting binary images into PDF (#288)

* Added JBIG2 PDF support
* Added JBIG2 Encoder binary image requirements
* PR #288 revision r1 fixes
* PR #288 revision r2 fixes
This commit is contained in:
Jacek Kucharczyk 2020-04-03 22:54:59 +02:00 committed by GitHub
parent 64a43b38d2
commit 29efa30439
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 359 additions and 54 deletions

View File

@ -37,6 +37,7 @@ func NewInlineImageFromImage(img model.Image, encoder core.StreamEncoder) (*Cont
if encoder == nil {
encoder = core.NewRawEncoder()
}
encoder.UpdateParams(img.GetParamsDict())
inlineImage := ContentStreamInlineImage{}
if img.ColorComponents == 1 {

View File

@ -6,11 +6,11 @@
package core
import (
"bytes"
"image"
"image/color"
"github.com/unidoc/unipdf/v3/common"
"github.com/unidoc/unipdf/v3/internal/imageutil"
"github.com/unidoc/unipdf/v3/internal/jbig2"
"github.com/unidoc/unipdf/v3/internal/jbig2/bitmap"
@ -55,6 +55,17 @@ const JB2ImageAutoThreshold = -1.0
// The similarity is defined by the 'Threshold' variable (default: 0.95). The less the value is, the more components
// matches to single class, thus the compression is better, but the result might become lossy.
type JBIG2Encoder struct {
// These values are required to be set for the 'EncodeBytes' method.
// ColorComponents defines the number of color components for provided image.
ColorComponents int
// BitsPerComponent is the number of bits that stores per color component
BitsPerComponent int
// Width is the width of the image to encode
Width int
// Height is the height of the image to encode.
Height int
// Encode Page and Decode parameters
d *document.Document
// Globals are the JBIG2 global segments.
Globals jbig2.Globals
@ -69,7 +80,9 @@ type JBIG2Encoder struct {
// NewJBIG2Encoder creates a new JBIG2Encoder.
func NewJBIG2Encoder() *JBIG2Encoder {
return &JBIG2Encoder{}
return &JBIG2Encoder{
d: document.InitEncodeDocument(false),
}
}
// AddPageImage adds the page with the image 'img' to the encoder context in order to encode it jbig2 document.
@ -165,18 +178,31 @@ func (enc *JBIG2Encoder) DecodeStream(streamObj *PdfObjectStream) ([]byte, error
// to encode given image.
func (enc *JBIG2Encoder) EncodeBytes(data []byte) ([]byte, error) {
const processName = "JBIG2Encoder.EncodeBytes"
if len(data) == 0 {
return nil, errors.Errorf(processName, "input 'data' not defined")
if enc.ColorComponents != 1 || enc.BitsPerComponent != 1 {
return nil, errors.Errorf(processName, "provided invalid input image. JBIG2 Encoder requires binary images data")
}
i, _, err := image.Decode(bytes.NewReader(data))
b, err := bitmap.NewWithUnpaddedData(enc.Width, enc.Height, data)
if err != nil {
return nil, errors.Wrap(err, processName, "decode input image")
return nil, err
}
encoded, err := enc.encodeImage(i)
if err != nil {
settings := enc.DefaultPageSettings
if err = settings.Validate(); err != nil {
return nil, errors.Wrap(err, processName, "")
}
return encoded, nil
switch settings.Compression {
case JB2Generic:
if err = enc.d.AddGenericPage(b, settings.DuplicatedLinesRemoval); err != nil {
return nil, errors.Wrap(err, processName, "")
}
case JB2SymbolCorrelation:
return nil, errors.Error(processName, "symbol correlation encoding not implemented yet")
case JB2SymbolRankHaus:
return nil, errors.Error(processName, "symbol rank haus encoding not implemented yet")
default:
return nil, errors.Error(processName, "provided invalid compression")
}
return enc.Encode()
}
// EncodeImage encodes 'img' golang image.Image into jbig2 encoded bytes document using default encoder settings.
@ -184,6 +210,15 @@ func (enc *JBIG2Encoder) EncodeImage(img image.Image) ([]byte, error) {
return enc.encodeImage(img)
}
// EncodeJBIG2Image encodes 'img' into jbig2 encoded bytes stream, using default encoder settings.
func (enc *JBIG2Encoder) EncodeJBIG2Image(img *JBIG2Image) ([]byte, error) {
const processName = "core.EncodeJBIG2Image"
if err := enc.AddPageImage(img, &enc.DefaultPageSettings); err != nil {
return nil, errors.Wrap(err, processName, "")
}
return enc.Encode()
}
// Encode encodes previously prepare jbig2 document and stores it as the byte slice.
func (enc *JBIG2Encoder) Encode() (data []byte, err error) {
const processName = "JBIG2Document.Encode"
@ -217,8 +252,24 @@ func (enc *JBIG2Encoder) MakeStreamDict() *PdfObjectDictionary {
}
// UpdateParams updates the parameter values of the encoder.
// The body of this method is empty but required to implement StreamEncoder interface.
// Implements StreamEncoder interface.
func (enc *JBIG2Encoder) UpdateParams(params *PdfObjectDictionary) {
bpc, err := GetNumberAsInt64(params.Get("BitsPerComponent"))
if err == nil {
enc.BitsPerComponent = int(bpc)
}
width, err := GetNumberAsInt64(params.Get("Width"))
if err == nil {
enc.Width = int(width)
}
height, err := GetNumberAsInt64(params.Get("Height"))
if err == nil {
enc.Height = int(height)
}
colorComponents, err := GetNumberAsInt64(params.Get("ColorComponents"))
if err == nil {
enc.ColorComponents = int(colorComponents)
}
}
func (enc *JBIG2Encoder) encodeImage(i image.Image) ([]byte, error) {
@ -262,24 +313,29 @@ func newJBIG2DecoderFromStream(streamObj *PdfObjectStream, decodeParams *PdfObje
}
}
}
if decodeParams != nil {
if globals := decodeParams.Get("JBIG2Globals"); globals != nil {
var err error
globalsStream, ok := globals.(*PdfObjectStream)
if !ok {
err = errors.Error(processName, "jbig2.Globals stream should be an Object Stream")
common.Log.Debug("ERROR: %s", err.Error())
return nil, err
}
encoder.Globals, err = jbig2.DecodeGlobals(globalsStream.Stream)
if err != nil {
err = errors.Wrap(err, processName, "corrupted jbig2 encoded data")
common.Log.Debug("ERROR: %s", err)
return nil, err
}
}
// if no decode params provided - end fast.
if decodeParams == nil {
return encoder, nil
}
// set image parameters.
encoder.UpdateParams(decodeParams)
globals := decodeParams.Get("JBIG2Globals")
if globals == nil {
return encoder, nil
}
// decode and set JBIG2 Globals.
var err error
globalsStream, ok := globals.(*PdfObjectStream)
if !ok {
err = errors.Error(processName, "jbig2.Globals stream should be an Object Stream")
common.Log.Debug("ERROR: %v", err)
return nil, err
}
encoder.Globals, err = jbig2.DecodeGlobals(globalsStream.Stream)
if err != nil {
err = errors.Wrap(err, processName, "corrupted jbig2 encoded data")
common.Log.Debug("ERROR: %v", err)
return nil, err
}
return encoder, nil
}
@ -348,9 +404,9 @@ func GoImageToJBIG2(i image.Image, bwThreshold float64) (*JBIG2Image, error) {
var th uint8
if bwThreshold == JB2ImageAutoThreshold {
// autoThreshold using triangle method
gray := bitmap.ImgToGray(i)
histogram := bitmap.GrayImageHistogram(gray)
th = bitmap.AutoThresholdTriangle(histogram)
gray := imageutil.ImgToGray(i)
histogram := imageutil.GrayImageHistogram(gray)
th = imageutil.AutoThresholdTriangle(histogram)
i = gray
} else if bwThreshold > 1.0 || bwThreshold < 0.0 {
// check if bwThreshold is unknown - set to 0.0 is not in the allowed range.
@ -358,7 +414,7 @@ func GoImageToJBIG2(i image.Image, bwThreshold float64) (*JBIG2Image, error) {
} else {
th = uint8(255 * bwThreshold)
}
gray := bitmap.ImgToBinary(i, th)
gray := imageutil.ImgToBinary(i, th)
return bwToJBIG2Image(gray), nil
}

View File

@ -158,6 +158,13 @@ func (img *Image) GetMargins() (float64, float64, float64, float64) {
return img.margins.left, img.margins.right, img.margins.top, img.margins.bottom
}
// ConvertToBinary converts current image data into binary (Bi-level image) format.
// If provided image is RGB or GrayScale the function converts it into binary image
// using histogram auto threshold method.
func (img *Image) ConvertToBinary() error {
return img.img.ConvertToBinary()
}
// makeXObject makes the encoded XObject Image that will be used in the PDF.
func (img *Image) makeXObject() error {
encoder := img.encoder
@ -181,7 +188,10 @@ func (img *Image) makeXObject() error {
func (img *Image) GeneratePageBlocks(ctx DrawContext) ([]*Block, DrawContext, error) {
if img.xobj == nil {
// Build the XObject Image if not already prepared.
img.makeXObject()
if err := img.makeXObject(); err != nil {
return nil, ctx, err
}
}
var blocks []*Block

1
go.sum
View File

@ -8,6 +8,7 @@ github.com/boombuler/barcode v1.0.0 h1:s1TvRnXwL2xJRaccrdcBQMZxq6X7DvsMogtmJeHDd
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/gunnsth/pkcs7 v0.0.0-20181213175627-3cffc6fbfe83 h1:saj5dTV7eQ1wFg/gVZr1SfbkOmg8CYO9R8frHgQiyR4=
github.com/gunnsth/pkcs7 v0.0.0-20181213175627-3cffc6fbfe83/go.mod h1:xaGEIRenAiJcGgd9p62zbiP4993KaV3PdjczwGnP50I=

View File

@ -1,4 +1,4 @@
package bitmap
package imageutil
import (
"image"
@ -131,6 +131,11 @@ func ImgToGray(i image.Image) *image.Gray {
return g
}
// IsGrayImgBlackAndWhite checks if provided gray image is BlackAndWhite - Binary image.
func IsGrayImgBlackAndWhite(i *image.Gray) bool {
return isGrayBlackWhite(i)
}
func blackOrWhite(c, threshold uint8) uint8 {
if c < threshold {
return 255

View File

@ -545,7 +545,7 @@ func (b *Bitmap) addBorderGeneral(left, right, top, bot int, val int) (*Bitmap,
// addPadBits creates new data byte slice that contains extra padding on the last byte for each row.
func (b *Bitmap) addPadBits() (err error) {
const processName = "addPadBits"
const processName = "bitmap.addPadBits"
endbits := b.Width % 8
if endbits == 0 {
// no partial words
@ -559,18 +559,16 @@ func (b *Bitmap) addPadBits() (err error) {
w := writer.NewMSB(data)
temp := make([]byte, fullBytes)
var (
i, j int
i int
bits uint64
)
for i = 0; i < b.Height; i++ {
// iterate over full bytes
for j = 0; j < fullBytes; j++ {
if _, err = r.Read(temp); err != nil {
return errors.Wrap(err, processName, "full byte")
}
if _, err = w.Write(temp); err != nil {
return errors.Wrap(err, processName, "full bytes")
}
if _, err = r.Read(temp); err != nil {
return errors.Wrap(err, processName, "full byte")
}
if _, err = w.Write(temp); err != nil {
return errors.Wrap(err, processName, "full bytes")
}
// read unused bits
if bits, err = r.ReadBits(byte(endbits)); err != nil {

View File

@ -98,7 +98,7 @@ func (d *Document) AddGenericPage(bm *bitmap.Bitmap, duplicateLineRemoval bool)
const processName = "Document.AddGenericPage"
// check if this is PDFMode and there is already a page
if !d.FullHeaders && d.NumberOfPages != 0 {
return errors.Error(processName, "document already contains page. FileMode disallows addoing more than one page")
return errors.Error(processName, "document already contains page. FileMode disallows adding more than one page")
}
// initialize page
page := &Page{

View File

@ -3,3 +3,4 @@ jbig2files
.test
*.jbig2
.envrc
.env

View File

@ -0,0 +1,155 @@
/*
* This file is subject to the terms and conditions defined in
* file 'LICENSE.md', which is part of this source code package.
*/
package tests
import (
"bytes"
"crypto/md5"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/unidoc/unipdf/v3/common"
"github.com/unidoc/unipdf/v3/core"
"github.com/unidoc/unipdf/v3/creator"
)
// TestImageEncodeJBIG2PDF tests the encode process for the JBIG2 encoder into PDF file.
func TestImageEncodeJBIG2PDF(t *testing.T) {
dirName := os.Getenv(EnvImageDirectory)
if dirName == "" {
t.Skipf("no environment variable: '%s' provided", EnvImageDirectory)
}
// get the file names within given directory
fileNames, err := readFileNames(dirName, "jpg")
require.NoError(t, err)
if len(fileNames) == 0 {
t.Skipf("no files found in the '%s' directory", dirName)
}
// prepare temporary directory where the jbig2 files would be stored
tempDir := filepath.Join(os.TempDir(), "unipdf", "jbig2", "encoded-pdf")
err = os.MkdirAll(tempDir, 0700)
require.NoError(t, err)
var f *os.File
switch {
case logToFile:
fileName := filepath.Join(tempDir, fmt.Sprintf("log_%s.txt", time.Now().Format("20060102")))
f, err = os.Create(fileName)
require.NoError(t, err)
common.SetLogger(common.NewWriterLogger(common.LogLevelTrace, f))
case testing.Verbose():
common.SetLogger(common.NewConsoleLogger(common.LogLevelDebug))
}
// clear all the temporary files
defer func() {
if f != nil {
f.Close()
}
switch {
case !keepEncodedFile && !logToFile:
err = os.RemoveAll(filepath.Join(tempDir))
case !keepEncodedFile:
err = filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if strings.HasSuffix(info.Name(), "zip") {
return os.Remove(path)
}
return nil
})
}
if err != nil {
common.Log.Error(err.Error())
}
}()
defer func() {
if !keepEncodedFile {
os.RemoveAll(tempDir)
}
}()
buf := &bytes.Buffer{}
h := md5.New()
edp := []goldenValuePair{}
for _, fileName := range fileNames {
var duplicateLinesRemoval bool
duplicateLinesName := "NoDuplicateLinesRemoval"
for i := 0; i < 2; i++ {
if i == 1 {
duplicateLinesRemoval = true
duplicateLinesName = duplicateLinesName[2:]
}
t.Run(rawFileName(fileName)+duplicateLinesName, func(t *testing.T) {
// read the file
c := creator.New()
img, err := c.NewImageFromFile(filepath.Join(dirName, fileName))
require.NoError(t, err)
// conver an image to binary image
err = img.ConvertToBinary()
require.NoError(t, err)
img.ScaleToWidth(612.0)
e := core.NewJBIG2Encoder()
if duplicateLinesRemoval {
e.DefaultPageSettings.DuplicatedLinesRemoval = true
}
img.SetEncoder(e)
// Use page width of 612 points, and calculate the height proportionally based on the image.
// Standard PPI is 72 points per inch, thus a width of 8.5"
height := 612.0 * img.Height() / img.Width()
c.NewPage()
c.SetPageSize(creator.PageSize{612, height})
// c.SetPageSize(creator.PageSize{img.Width() * 1.2, img.Height() * 1.2})
img.SetPos(0, 0)
err = c.Draw(img)
require.NoError(t, err)
err = c.Write(buf)
require.NoError(t, err)
_, err = h.Write(buf.Bytes())
require.NoError(t, err)
if keepEncodedFile {
f, err := os.Create(filepath.Join(tempDir, rawFileName(fileName)+duplicateLinesName+".pdf"))
require.NoError(t, err)
defer f.Close()
_, err = f.Write(buf.Bytes())
require.NoError(t, err)
}
hashEncoded := h.Sum(nil)
buf.Reset()
edp = append(edp, goldenValuePair{
Filename: rawFileName(fileName) + duplicateLinesName,
Hash: hashEncoded,
})
})
}
}
const goldenFileName = "encoded-pdf"
checkGoldenValuePairs(t, dirName, goldenFileName, edp...)
}

View File

@ -11,14 +11,15 @@ import (
goimage "image"
gocolor "image/color"
"image/draw"
"io"
// Imported for initialization side effects.
_ "image/gif"
_ "image/png"
"io"
"github.com/unidoc/unipdf/v3/common"
"github.com/unidoc/unipdf/v3/core"
"github.com/unidoc/unipdf/v3/internal/imageutil"
"github.com/unidoc/unipdf/v3/internal/jbig2/bitmap"
"github.com/unidoc/unipdf/v3/internal/sampling"
)
@ -50,6 +51,59 @@ func (img *Image) AlphaMap(mapFunc AlphaMapFunc) {
}
}
// ConvertToBinary converts current image into binary (bi-level) format.
// Binary images are composed of single bits per pixel (only black or white).
// If provided image has more color components, then it would be converted into binary image using
// histogram auto threshold function.
func (img *Image) ConvertToBinary() error {
// check if given image is already a binary image (1 bit per component - 1 color component - the size of the data
// is equal to the multiplication of width and height.
if img.ColorComponents == 1 && img.BitsPerComponent == 1 {
return nil
}
i, err := img.ToGoImage()
if err != nil {
return err
}
gray := imageutil.ImgToGray(i)
// check if 'img' is already a binary image.
if !imageutil.IsGrayImgBlackAndWhite(gray) {
threshold := imageutil.AutoThresholdTriangle(imageutil.GrayImageHistogram(gray))
gray = imageutil.ImgToBinary(i, threshold)
}
// use JBIG2 bitmap as the temporary binary data converter - by default it uses
tmpBM := bitmap.New(int(img.Width), int(img.Height))
for y := 0; y < tmpBM.Height; y++ {
for x := 0; x < tmpBM.Width; x++ {
c := gray.GrayAt(x, y)
// set only the white pixel - c.Y != 0
if c.Y != 0 {
if err = tmpBM.SetPixel(x, y, 1); err != nil {
return err
}
}
}
}
unpaddedData, err := tmpBM.GetUnpaddedData()
if err != nil {
return err
}
img.BitsPerComponent = 1
img.ColorComponents = 1
img.Data = unpaddedData
return nil
}
// GetParamsDict returns *core.PdfObjectDictionary with a set of basic image parameters.
func (img *Image) GetParamsDict() *core.PdfObjectDictionary {
params := core.MakeDict()
params.Set("Width", core.MakeInteger(img.Width))
params.Set("Height", core.MakeInteger(img.Height))
params.Set("ColorComponents", core.MakeInteger(int64(img.ColorComponents)))
params.Set("BitsPerComponent", core.MakeInteger(img.BitsPerComponent))
return params
}
// GetSamples converts the raw byte slice into samples which are stored in a uint32 bit array.
// Each sample is represented by BitsPerComponent consecutive bits in the raw data.
// NOTE: The method resamples the image byte data before returning the result and
@ -294,6 +348,15 @@ func (img *Image) Resample(targetBitsPerComponent int64) {
img.BitsPerComponent = int64(targetBitsPerComponent)
}
// ToJBIG2Image converts current image to the core.JBIG2Image.
func (img *Image) ToJBIG2Image() (*core.JBIG2Image, error) {
goImg, err := img.ToGoImage()
if err != nil {
return nil, err
}
return core.GoImageToJBIG2(goImg, core.JB2ImageAutoThreshold)
}
// ToGoImage converts the unidoc Image to a golang Image structure.
func (img *Image) ToGoImage() (goimage.Image, error) {
common.Log.Trace("Converting to go image")

View File

@ -240,20 +240,17 @@ func NewXObjectImageFromImage(img *Image, cs PdfColorspace, encoder core.StreamE
// If `encoder` is nil, uses raw encoding (none).
func UpdateXObjectImageFromImage(xobjIn *XObjectImage, img *Image, cs PdfColorspace,
encoder core.StreamEncoder) (*XObjectImage, error) {
xobj := NewXObjectImage()
if encoder == nil {
encoder = core.NewRawEncoder()
}
encoder.UpdateParams(img.GetParamsDict())
encoded, err := encoder.EncodeBytes(img.Data)
if err != nil {
common.Log.Debug("Error with encoding: %v", err)
return nil, err
}
xobj.Filter = encoder
xobj.Stream = encoded
xobj := NewXObjectImage()
// Width and height.
imWidth := img.Width
@ -261,8 +258,12 @@ func UpdateXObjectImageFromImage(xobjIn *XObjectImage, img *Image, cs PdfColorsp
xobj.Width = &imWidth
xobj.Height = &imHeight
// Bits.
xobj.BitsPerComponent = &img.BitsPerComponent
// Bits per Component.
imBPC := img.BitsPerComponent
xobj.BitsPerComponent = &imBPC
xobj.Filter = encoder
xobj.Stream = encoded
// Guess colorspace if not explicitly set.
if cs == nil {
@ -284,6 +285,7 @@ func UpdateXObjectImageFromImage(xobjIn *XObjectImage, img *Image, cs PdfColorsp
// Has same width and height as original and stored in same
// bits per component (1 component, hence the DeviceGray channel).
smask := NewXObjectImage()
smask.Filter = encoder
encoded, err := encoder.EncodeBytes(img.alphaData)
if err != nil {
@ -291,7 +293,7 @@ func UpdateXObjectImageFromImage(xobjIn *XObjectImage, img *Image, cs PdfColorsp
return nil, err
}
smask.Stream = encoded
smask.BitsPerComponent = &img.BitsPerComponent
smask.BitsPerComponent = xobj.BitsPerComponent
smask.Width = &img.Width
smask.Height = &img.Height
smask.ColorSpace = NewPdfColorspaceDeviceGray()
@ -448,6 +450,8 @@ func NewXObjectImageFromStream(stream *core.PdfObjectStream) (*XObjectImage, err
// SetImage updates XObject Image with new image data.
func (ximg *XObjectImage) SetImage(img *Image, cs PdfColorspace) error {
// update image parameters of the filter encoder.
ximg.Filter.UpdateParams(img.GetParamsDict())
encoded, err := ximg.Filter.EncodeBytes(img.Data)
if err != nil {
return err
@ -493,6 +497,7 @@ func (ximg *XObjectImage) SetFilter(encoder core.StreamEncoder) error {
}
ximg.Filter = encoder
encoder.UpdateParams(ximg.getParamsDict())
encoded, err = encoder.EncodeBytes(decoded)
if err != nil {
return err
@ -596,3 +601,13 @@ func (ximg *XObjectImage) ToPdfObject() core.PdfObject {
return stream
}
// getParamsDict returns *core.PdfObjectDictionary with a set of basic image parameters.
func (ximg *XObjectImage) getParamsDict() *core.PdfObjectDictionary {
params := core.MakeDict()
params.Set("Width", core.MakeInteger(*ximg.Width))
params.Set("Height", core.MakeInteger(*ximg.Height))
params.Set("ColorComponents", core.MakeInteger(int64(ximg.ColorSpace.GetNumComponents())))
params.Set("BitsPerComponent", core.MakeInteger(*ximg.BitsPerComponent))
return params
}