Skip to content
Draft
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
52 changes: 49 additions & 3 deletions cmd/cera/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"bytes"
"encoding/binary"
"encoding/json"
"fmt"
"os"
Expand Down Expand Up @@ -249,7 +250,7 @@ func TestFormats(t *testing.T) {
for ext, magic := range map[string]string{"jpg": "\xff\xd8\xff", "tif": "II*\x00", "webp": "RIFF", "png": "\x89PNG"} {
for _, gray := range []bool{false, true} {
out := filepath.Join(dir, "p-%d."+ext)
if err := run(in, cera.OpenOptions{}, 72, 2, out, false, time.Minute, false, 0, cera.AnnotsView, cera.BoxCrop, cera.ImageNearest, nil, "encode", 80, gray); err != nil {
if err := run(in, cera.OpenOptions{}, 72, 2, out, false, time.Minute, false, 0, cera.AnnotsView, cera.BoxCrop, cera.ImageNearest, nil, "encode", 80, gray, false); err != nil {
t.Fatalf("%s, grey %v: %v", ext, gray, err)
}
b, err := os.ReadFile(fmt.Sprintf(out, 2))
Expand All @@ -258,10 +259,55 @@ func TestFormats(t *testing.T) {
}
}
}
if err := run(in, cera.OpenOptions{}, 72, 1, filepath.Join(dir, "p.jpg"), true, time.Minute, false, 0, cera.AnnotsView, cera.BoxCrop, cera.ImageNearest, nil, "encode", 0, false); err == nil {
if err := run(in, cera.OpenOptions{}, 72, 1, filepath.Join(dir, "p.jpg"), true, time.Minute, false, 0, cera.AnnotsView, cera.BoxCrop, cera.ImageNearest, nil, "encode", 0, false, false); err == nil {
t.Error("a transparent JPEG was written")
}
if err := run(in, cera.OpenOptions{}, 72, 1, filepath.Join(dir, "p.bmp"), false, time.Minute, false, 0, cera.AnnotsView, cera.BoxCrop, cera.ImageNearest, nil, "encode", 0, false); err == nil {
if err := run(in, cera.OpenOptions{}, 72, 1, filepath.Join(dir, "p.bmp"), false, time.Minute, false, 0, cera.AnnotsView, cera.BoxCrop, cera.ImageNearest, nil, "encode", 0, false, false); err == nil {
t.Error("a format nobody writes was written")
}
}

// -cmyk writes the inks into a TIFF with the profile they are for.
func TestCMYKTIFF(t *testing.T) {
dir := t.TempDir()
in := textPDF(t, dir, "in.pdf")
icc, err := os.ReadFile(filepath.Join("..", "..", "internal", "cmyk", "testdata", "toy-lut16.icc"))
if err != nil {
t.Fatal(err)
}
out := filepath.Join(dir, "p.tif")
if err := run(in, cera.OpenOptions{CMYKProfile: icc}, 72, 1, out, false, time.Minute, false, 0, cera.AnnotsView, cera.BoxCrop, cera.ImageNearest, nil, "encode", 0, false, true); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
// The IFD: PhotometricInterpretation 5 (separated), InkSet 1 (CMYK)
// and the profile (34675).
le := binary.LittleEndian
ifd := int(le.Uint32(b[4:]))
tags := map[uint16][]byte{}
for i := range int(le.Uint16(b[ifd:])) {
e := b[ifd+2+12*i:][:12]
tags[le.Uint16(e)] = e
}
if e := tags[262]; e == nil || le.Uint16(e[8:]) != 5 {
t.Error("not separated")
}
if e := tags[332]; e == nil || le.Uint16(e[8:]) != 1 {
t.Error("no CMYK ink set")
}
if e := tags[34675]; e == nil || !bytes.Equal(b[le.Uint32(e[8:]):][:le.Uint32(e[4:])], icc) {
t.Error("profile not embedded")
}
for _, c := range []struct {
out string
transparent bool
gray bool
}{{"p.png", false, false}, {"p.tif", true, false}, {"p.tif", false, true}} {
if err := run(in, cera.OpenOptions{}, 72, 1, filepath.Join(dir, c.out), c.transparent, time.Minute, false, 0, cera.AnnotsView, cera.BoxCrop, cera.ImageNearest, nil, "encode", 0, c.gray, true); err == nil {
t.Errorf("-cmyk %+v was written", c)
}
}
}
2 changes: 1 addition & 1 deletion cmd/cera/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/timzifer/cera/cmd/cera
go 1.27.1

require (
github.com/timzifer/calamus v0.2.0
github.com/timzifer/calamus v0.3.1-0.20261014111300-bd50c1615f30
github.com/timzifer/cera v0.0.0
)

Expand Down
4 changes: 2 additions & 2 deletions cmd/cera/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ github.com/go-opentype/opentype v0.15.0 h1:ioE3eCbNZavlk/w073J3uX5FKjqhgOHQw+wZZ
github.com/go-opentype/opentype v0.15.0/go.mod h1:ZB31jTJl2mhPESXYMuUW+p36UrOabw/2+6mtyh/vNnI=
github.com/tannevaled/gobig2 v0.2.0 h1:yx97/ltljALrIWDFVqHjPUIjAp3bfUUhH8Z8UROvNLc=
github.com/tannevaled/gobig2 v0.2.0/go.mod h1:X0S1H+N35kg6zYgVYRqGMsGj3C35zs+iA2r1MXyPcV4=
github.com/timzifer/calamus v0.2.0 h1:pOdCAVu75XASMhL5fiI3+qObJMCtiKZ1ehA4ZOOPWdA=
github.com/timzifer/calamus v0.2.0/go.mod h1:Z6fVRZRraaG0hgfvqynq9TM9vCq1ZGVm8AgDvnugCqw=
github.com/timzifer/calamus v0.3.1-0.20261014111300-bd50c1615f30 h1:vivSe451Wk0AyCPzVB2XcsPxK1lrLkForE477KaP/RE=
github.com/timzifer/calamus v0.3.1-0.20261014111300-bd50c1615f30/go.mod h1:Z6fVRZRraaG0hgfvqynq9TM9vCq1ZGVm8AgDvnugCqw=
github.com/timzifer/go-highway v0.0.13-timzifer.1 h1:+DREnc494whho5HlwAkjX1m09fxDLdG3n0hYRBm6AFY=
github.com/timzifer/go-highway v0.0.13-timzifer.1/go.mod h1:ZXPijqyP0TXlwJXJClrFCdp4q+zlQNAMZ3ncCROV648=
github.com/timzifer/stilus v0.11.0 h1:vCj79ga4NRr7oE/6YYxnly3UMJleTj9tNyPYmRVY/bE=
Expand Down
35 changes: 27 additions & 8 deletions cmd/cera/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// cera -dpi 150 -o 'out-%d.png' input.pdf # all pages
// cera -o 'out-%d.jpg' -quality 85 input.pdf # or .tif, .webp
// cera -gray -o 'fax-%d.tif' input.pdf
// cera -cmyk -o 'press-%d.tif' print.pdf # the inks, with the profile
// cera -field name=Ada -field agree=Yes -page 1 form.pdf
// cera -cmyk-profile CoatedFOGRA39.icc -page 1 print.pdf
//
Expand Down Expand Up @@ -62,6 +63,7 @@ func main() {
out := flag.String("o", "page-%d.png", "output file; %d is replaced by the page number, and its ending says what is written: .png, .jpg, .tif or .webp")
quality := flag.Int("quality", 0, "quality of a JPEG, 1 to 100 (default 75)")
gray := flag.Bool("gray", false, "draw in grey")
cmykOut := flag.Bool("cmyk", false, "draw in the CMYK of the document, as a press gets it, into a .tif that carries the profile of the output intent or of -cmyk-profile")
password := flag.String("password", "", "password of an encrypted file")
transparent := flag.Bool("transparent", false, "leave the background transparent")
timeout := flag.Duration("timeout", time.Minute, "deadline per page")
Expand Down Expand Up @@ -123,14 +125,24 @@ func main() {
}
opt.CMYKProfile = b
}
if err := run(flag.Arg(0), opt, *dpi, *page, *out, *transparent, *timeout, *verbose, *workers, mode, box, filter, fields, *pngMode, *quality, *gray); err != nil {
if err := run(flag.Arg(0), opt, *dpi, *page, *out, *transparent, *timeout, *verbose, *workers, mode, box, filter, fields, *pngMode, *quality, *gray, *cmykOut); err != nil {
fmt.Fprintln(os.Stderr, "cera:", err)
os.Exit(1)
}
}

func run(in string, opt cera.OpenOptions, dpi float64, page int, out string, transparent bool, timeout time.Duration, verbose bool, workers int, annots cera.AnnotMode, box cera.PageBox, filter cera.ImageFilter, fields []string, pngMode string, quality int, gray bool) error {
func run(in string, opt cera.OpenOptions, dpi float64, page int, out string, transparent bool, timeout time.Duration, verbose bool, workers int, annots cera.AnnotMode, box cera.PageBox, filter cera.ImageFilter, fields []string, pngMode string, quality int, gray, cmykOut bool) error {
format := strings.ToLower(filepath.Ext(out))
if cmykOut {
switch {
case gray:
return fmt.Errorf("-cmyk and -gray: draw in one or the other")
case transparent:
return fmt.Errorf("-cmyk: what is drawn on nothing is paper, no ink; -transparent has no say")
case format != ".tif" && format != ".tiff":
return fmt.Errorf("-o %q: -cmyk writes a TIFF, want a name that ends in .tif", out)
}
}
switch format {
case ".png", ".tif", ".tiff", ".webp":
case ".jpg", ".jpeg":
Expand Down Expand Up @@ -184,14 +196,20 @@ func run(in string, opt cera.OpenOptions, dpi float64, page int, out string, tra
}
var img image.Image
var sw *streamPNG
var icc []byte
t0 := time.Now()
if gray {
switch {
case cmykOut:
dst := image.NewCMYK(p.Bounds(dpi / 72))
img, icc = dst, p.CMYKProfile()
err = p.RenderCMYK(context.Background(), dst, ropt)
case gray:
// In grey the page is drawn a band at a time, without the
// page in colour.
dst := image.NewGray(p.Bounds(dpi / 72))
img = dst
err = p.RenderGray(context.Background(), dst, ropt)
} else {
default:
dst := image.NewRGBA(p.Bounds(dpi / 72))
img = dst
if pngMode == "stream" && format == ".png" {
Expand Down Expand Up @@ -222,7 +240,7 @@ func run(in string, opt cera.OpenOptions, dpi float64, page int, out string, tra
case format == ".png":
err = writePNG(name, img, pngMode)
default:
err = writeImage(name, img, format, quality)
err = writeImage(name, img, format, quality, icc)
}
if err != nil {
return err
Expand Down Expand Up @@ -276,8 +294,9 @@ func writePNG(name string, img image.Image, mode string) error {

// writeImage writes img to the file name in one of the other formats
// calamus encodes: a JPEG at quality, a TIFF with LZW, a WebP without
// loss. A TIFF is one page; several pages are several files.
func writeImage(name string, img image.Image, format string, quality int) error {
// loss. A TIFF is one page; several pages are several files. A TIFF
// carries icc, the profile of its samples, unless that is nil.
func writeImage(name string, img image.Image, format string, quality int, icc []byte) error {
f, err := os.Create(name)
if err != nil {
return err
Expand All @@ -286,7 +305,7 @@ func writeImage(name string, img image.Image, format string, quality int) error
case ".jpg", ".jpeg":
err = (&jpeg.Encoder{Quality: quality}).Encode(f, img)
case ".tif", ".tiff":
err = tiff.Encode(f, img, &tiff.Options{Compression: tiff.LZW, Predictor: true})
err = tiff.Encode(f, img, &tiff.Options{Compression: tiff.LZW, Predictor: true, ICCProfile: icc})
case ".webp":
err = webp.Encode(f, img)
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/cera/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func TestPNGModes(t *testing.T) {
for _, mode := range []string{"stdlib", "encode", "stream"} {
name := fmt.Sprintf("%s, transparent %v, %s", tc.file, tc.transparent, mode)
out := filepath.Join(t.TempDir(), "p-%d.png")
if err := run(in, cera.OpenOptions{}, 150, tc.page, out, tc.transparent, time.Minute, false, 0, cera.AnnotsView, cera.BoxCrop, cera.ImageNearest, nil, mode, 0, false); err != nil {
if err := run(in, cera.OpenOptions{}, 150, tc.page, out, tc.transparent, time.Minute, false, 0, cera.AnnotsView, cera.BoxCrop, cera.ImageNearest, nil, mode, 0, false, false); err != nil {
t.Fatalf("%s: %v", name, err)
}
got := decode(t, fmt.Sprintf(out, tc.page))
Expand Down
14 changes: 14 additions & 0 deletions cmykout.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,20 @@ func (d *Document) rgbInks(r, g, b float64) [4]float64 {
return [4]float64{c - k, m - k, y - k, k}
}

// CMYKProfile returns the ICC profile of the CMYK RenderCMYK draws p in,
// for what is written of it to say so (a TIFF, say): the caller's
// (OpenOptions.CMYKProfile), or that of the output intent of the page or
// of the document. It is nil where that is the bundled SWOP profile, with
// NaiveCMYK, and where cera cannot use the intent's profile. The bytes are
// the caller's or decoded anew; they are not to be changed.
func (p *Page) CMYKProfile() []byte {
cs, _ := p.cmykDoc().deviceCMYK()
if cs == nil || cs.icc == nil {
return nil
}
return cs.icc()
}

// cmykPixels is how many pixels of a page RenderCMYK draws at a time: 4 MB
// of RGBA.
const cmykPixels = 1 << 20
Expand Down
38 changes: 38 additions & 0 deletions cmykout_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cera

import (
"bytes"
"context"
"image"
"os"
Expand Down Expand Up @@ -187,3 +188,40 @@ func TestRenderCMYKRegion(t *testing.T) {
t.Errorf("bands %v of region %v", bands, region)
}
}

// TestPageCMYKProfile returns the profile RenderCMYK draws a page in: the
// caller's, the page's output intent's over the document's, none for the
// bundled one.
func TestPageCMYKProfile(t *testing.T) {
toy, swop := readToyProfile(t), swopProfileFile(t)
bad := bytes.Clone(toy)
copy(bad[132+12+4:], []byte{0xff, 0xff, 0xff, 0xff}) // A2B0 out of range
copy(bad[132+24+4:], []byte{0xff, 0xff, 0xff, 0xff}) // A2B1 too
pages := pageIntentPDF(intent("GTS_PDFX", 101), [3]string{intent("GTS_PDFX", 100), "", intent("GTS_PDFX", 102)}, toy, swop, bad)
for _, tt := range []struct {
name string
data []byte
opt OpenOptions
want [3][]byte
}{
{"intents", pages, OpenOptions{}, [3][]byte{toy, swop, swop}},
{"caller's", pages, OpenOptions{CMYKProfile: toy}, [3][]byte{toy, toy, toy}},
{"naive", pages, OpenOptions{NaiveCMYK: true}, [3][]byte{}},
{"ignored", pages, OpenOptions{IgnoreOutputIntent: true}, [3][]byte{}},
{"bundled", pageIntentPDF("", [3]string{}), OpenOptions{}, [3][]byte{}},
} {
doc, err := OpenWith(tt.data, tt.opt)
if err != nil {
t.Fatal(err)
}
for i, want := range tt.want {
p, err := doc.Page(i)
if err != nil {
t.Fatal(err)
}
if got := p.CMYKProfile(); !bytes.Equal(got, want) || (got == nil) != (want == nil) {
t.Errorf("%s, page %d: %d bytes, want %d", tt.name, i, len(got), len(want))
}
}
}
}
8 changes: 5 additions & 3 deletions color.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@ type colorSpace struct {
// colorants are the names of the colorants of a tint space.
colorants []string
// cmyk is the profile a CMYK space converts through, nil for the naive
// conversion.
// conversion; icc returns its bytes, nil for the bundled one.
cmyk *cmyk.Table
icc func() []byte

once sync.Once
tint *[256][3]uint8 // one-component tint transform, tabulated
Expand Down Expand Up @@ -429,7 +430,7 @@ func (d *Document) profileCMYK(ref pdf.Ref, data func() []byte) (*colorSpace, st
}
e := csEntry{nil, "icc-lut"}
if t, err := cmyk.Load(data()); err == nil {
e = csEntry{&colorSpace{kind: csCMYK, n: 4, cmyk: t}, ""}
e = csEntry{&colorSpace{kind: csCMYK, n: 4, cmyk: t, icc: data}, ""}
}
d.csMu.Lock()
defer d.csMu.Unlock()
Expand Down Expand Up @@ -520,7 +521,8 @@ func (d *Document) intentCMYK(list pdf.Object) (cs *colorSpace, approx string, o
continue
}
ref, _ := prof.Ref()
cs, approx = d.profileCMYK(ref, func() []byte { return data })
// Decoded again when asked for (Page.CMYKProfile), not kept.
cs, approx = d.profileCMYK(ref, func() []byte { return d.r.Decode(s).Data })
return cs, approx, true
}
return nil, "", false
Expand Down
3 changes: 2 additions & 1 deletion docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,8 @@ displayed page:
and spot colours through their alternate, RGB through the profile's
colour-to-ink table (B2A) or as PDF converts DeviceRGB; blend modes on
the inks' complements, black kept or taken in the non-separable ones
(#209)
(#209); `Page.CMYKProfile` is the ICC profile they are for, and
`cera -cmyk` writes them as a CMYK TIFF with it

### Metadata

Expand Down
11 changes: 7 additions & 4 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@ colorants named Cyan, Magenta, Yellow and Black on those inks, and RGB is
converted through the profile's colour-to-ink table where it has one, else
as PDF converts DeviceRGB. What is drawn on nothing is paper, no ink. The
page is drawn twice, about twice the time of `Render`; overprint is not
simulated in it yet (#155). A page in another RGB than sRGB is not there
yet.
simulated in it yet (#155). `page.CMYKProfile()` is the ICC profile the
inks are for, to embed in what is written of them (nil for the bundled
SWOP). A page in another RGB than sRGB is not there yet.

A viewer that turns its view asks for the page turned:
`page.Rotated(90)` is the page as it is shown then — a `Page` with its
Expand Down Expand Up @@ -1037,8 +1038,10 @@ it does not draw with: it writes the PNGs with
[calamus](https://github.com/timzifer/calamus), which encodes them in bands
on all cores (encoding was most of the command's time with `image/png`).
The ending of `-o` says what is written: `.png`, `.jpg` (`-quality`),
`.tif` (LZW) or `.webp` (without loss); `-gray` draws in grey. A TIFF is
one page: several pages are several files.
`.tif` (LZW) or `.webp` (without loss); `-gray` draws in grey, `-cmyk`
in the CMYK of the document into a TIFF of inks that carries the profile
of the output intent or of `-cmyk-profile`. A TIFF is one page: several
pages are several files.

Besides drawing, the command does what the library does, as subcommands
(`cera <command> -h` lists the flags of each):
Expand Down
2 changes: 1 addition & 1 deletion document.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ func open(read func(pdf.Options) (*pdf.Document, error), opt OpenOptions) (doc *
if err != nil {
return nil, fmt.Errorf("cera: CMYKProfile: %w", err)
}
devCMYK = &colorSpace{kind: csCMYK, n: 4, cmyk: t}
devCMYK = &colorSpace{kind: csCMYK, n: 4, cmyk: t, icc: func() []byte { return opt.CMYKProfile }}
}
if opt.NaiveCMYK {
devCMYK = spaceCMYKNaive
Expand Down
Loading