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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ Go-idiomatic rather than a gem port.
optional **shaped-text** API (GSUB/GPOS via go-opentype) for Arabic/Indic/CJK.
- **Images** — JPEG embedded directly (DCTDecode); PNG and any `image.Image`
rasterised as XObjects (FlateDecode) with an `/SMask` for alpha.
**Pixel-identical bitmaps are shared**: each image is content-addressed by its
*uncompressed* samples, so a repeat is embedded — and compressed — once per
document and every placement, on any page, references the one XObject.
- **Pages** — standard sizes (A3/A4/A5/Letter/Legal/Tabloid), portrait/landscape,
custom sizes; `Pt`/`Mm`/`In` unit helpers.
- **Widget bridge** — `Page.AddWidget` and `Page.AddWidgetVector` "print" a
Expand Down
20 changes: 19 additions & 1 deletion doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,29 @@
// charstring-subsetted FontFile3 / CIDFontType0 whose glyph numbering is
// preserved, so an Identity /CIDToGIDMap suffices.
//
// # Images
//
// DrawJPEG stores the original JPEG bytes as a DCTDecode stream, so nothing is
// re-encoded. DrawPNG and DrawImage walk any image.Image into 8-bit DeviceRGB
// samples compressed with FlateDecode, and an image that is not fully opaque
// gets its alpha channel as a DeviceGray /SMask.
//
// An image is content-addressed by its uncompressed samples together with the
// width, height, colour space, bits per component and the alpha samples — the
// raw JPEG bytes in the DCTDecode case. Pixel-identical bitmaps therefore share
// a single XObject across the whole document, whichever page paints them and
// whichever entry point embedded them; the samples are hashed before they are
// compressed, so a repeat never pays for compression twice. This matters for
// pages that reuse one icon many times: an HTML renderer feeding pdfkit 166
// rasterised copies of a handful of icon SVGs now emits a handful of streams.
//
// # Determinism
//
// With the zero Options the output contains no timestamps and a content-derived
// /ID, so identical inputs produce byte-identical documents. Set Options.Now to
// stamp creation and modification dates.
// stamp creation and modification dates. The image cache is consulted but never
// iterated: object order and /Im<i> numbering follow first-sighting order, so
// deduplication does not put map iteration on the output path.
//
// # Widget bridge
//
Expand Down
28 changes: 25 additions & 3 deletions document.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ type Document struct {
use map[*Font]*fontUse // per-document glyph usage, keyed by font
images []*imageXObject // registration order; index drives the /Im<i> name
outline []outlineItem // document outline (bookmarks), in document order

// imgCache maps an image's content address (see imageKey) to the XObject
// already registered for it, so a bitmap drawn many times is embedded
// once. It is consulted, never iterated: the emitted order and the /Im<i>
// numbering come from the images slice, so output stays deterministic.
imgCache map[string]*imageXObject
}

// outlineItem is one entry in the document outline (the viewer's bookmark tree):
Expand All @@ -85,9 +91,10 @@ func (d *Document) AddOutlineItem(title string, level, pageIndex int) {
// New returns a new, empty Document configured by opts.
func New(opts Options) *Document {
return &Document{
opts: opts,
fontIx: map[*Font]int{},
use: map[*Font]*fontUse{},
opts: opts,
fontIx: map[*Font]int{},
use: map[*Font]*fontUse{},
imgCache: map[string]*imageXObject{},
}
}

Expand Down Expand Up @@ -124,6 +131,21 @@ func (d *Document) registerImage(x *imageXObject) string {
return name
}

// imageFor returns the XObject registered for the content address key, calling
// build only the first time that content is seen. Every later draw of a
// pixel-identical bitmap — on this page or any other, since the cache belongs
// to the document — reuses the same object and the same /Im<i> name, so it
// costs one stream in the file instead of one per placement.
func (d *Document) imageFor(key string, build func() *imageXObject) *imageXObject {
if x, ok := d.imgCache[key]; ok {
return x
}
x := build()
x.name = d.registerImage(x)
d.imgCache[key] = x
return x
}

// builder assembles the flat list of indirect objects and assigns their
// numbers. Object number n lives at objs[n-1].
type builder struct {
Expand Down
75 changes: 53 additions & 22 deletions image.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package pdfkit

import (
"bytes"
"crypto/sha256"
"fmt"
"image"
"image/png"
Expand All @@ -25,9 +26,29 @@ type imageXObject struct {
smask *imageXObject
}

// imageKey content-addresses an image by the bytes a PDF consumer would see
// once the filter is undone: the *uncompressed* samples plus the geometry and
// colour parameters that give them meaning, and the alpha samples that would
// become a soft mask. The lengths go into the digest ahead of the payloads so
// no shift of bytes between the two slices can forge a match.
//
// Hashing the raw samples (rather than the filtered stream) is what lets a
// duplicate be recognised *before* it pays for compression.
func imageKey(filter, colorSpace string, w, h, bpc int, samples, alpha []byte) string {
sum := sha256.New()
fmt.Fprintf(sum, "%s|%s|%d|%d|%d|%d|%d|", filter, colorSpace, w, h, bpc, len(samples), len(alpha))
sum.Write(samples)
sum.Write(alpha)
return string(sum.Sum(nil))
}

// DrawImage embeds img and paints it into the rectangle r (in points). Any
// alpha channel becomes a soft mask, so partially transparent images composite
// correctly. Sample data is FlateDecode-compressed.
//
// Pixel-identical images share one XObject document-wide: the samples are
// hashed before they are compressed, so redrawing the same bitmap costs a hash
// and nothing else.
func (p *Page) DrawImage(img image.Image, r Rect) {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
Expand All @@ -44,24 +65,31 @@ func (p *Page) DrawImage(img image.Image, r Rect) {
}
}
}
x := &imageXObject{
width: w,
height: h,
colorSpace: "DeviceRGB",
bpc: 8,
filter: "FlateDecode",
data: flateCompress(rgb),
if !hasAlpha {
alpha = nil // a fully opaque image gets no soft mask, and keys as such
}
if hasAlpha {
x.smask = &imageXObject{
key := imageKey("FlateDecode", "DeviceRGB", w, h, 8, rgb, alpha)
x := p.doc.imageFor(key, func() *imageXObject {
x := &imageXObject{
width: w,
height: h,
colorSpace: "DeviceGray",
colorSpace: "DeviceRGB",
bpc: 8,
filter: "FlateDecode",
data: flateCompress(alpha),
data: flateCompress(rgb),
}
}
if alpha != nil {
x.smask = &imageXObject{
width: w,
height: h,
colorSpace: "DeviceGray",
bpc: 8,
filter: "FlateDecode",
data: flateCompress(alpha),
}
}
return x
})
p.placeImage(x, r)
}

Expand Down Expand Up @@ -95,21 +123,24 @@ func (p *Page) DrawJPEG(data []byte, r Rect) error {
default:
return fmt.Errorf("pdfkit: unsupported JPEG component count %d", comps)
}
x := &imageXObject{
width: w,
height: h,
colorSpace: cs,
bpc: 8,
filter: "DCTDecode",
data: data,
}
key := imageKey("DCTDecode", cs, w, h, 8, data, nil)
x := p.doc.imageFor(key, func() *imageXObject {
return &imageXObject{
width: w,
height: h,
colorSpace: cs,
bpc: 8,
filter: "DCTDecode",
data: data,
}
})
p.placeImage(x, r)
return nil
}

// placeImage registers the XObject and emits the operators to paint it into r.
// placeImage emits the operators that paint an already-registered XObject
// into r, and records it as a resource this page uses.
func (p *Page) placeImage(x *imageXObject, r Rect) {
x.name = p.doc.registerImage(x)
p.usedImages[x] = true
p.Save()
p.Transform(r.Width, 0, 0, r.Height, r.X, r.Y)
Expand Down
Loading