convert

package module
v0.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 10, 2026 License: BSD-3-Clause Imports: 24 Imported by: 0

README

convert

Pictures into a PDF, a PDF back into pictures, a PDF redrawn, and comic archives both ways. Pure Go, CGO-free, including GOOS=js.

pdfconv to-pdf       -dpi 300 -o scan.pdf page1.jpg page2.jpg logo.svg
pdfconv from-pdf     -dpi 150 -format png -pages 1,4-6 -o ./pages scan.pdf
pdfconv redraw       -greyscale -invert out.pdf grey.pdf
pdfconv redraw       -scanner -skew 0.6 clean.pdf looks-scanned.pdf
pdfconv from-archive book.cbz book.pdf
pdfconv to-archive   -title "Volume 3" book.pdf book.cbz
pdfconv bundle       send-these.zip a.pdf b.pdf c.pdf
pdfconv formats
pdf, err := convert.ToPDF(images, convert.Options{DPI: 300})
err = convert.WriteTo(f, images, convert.Options{DPI: 300, Page: pdfkit.A4})
pages, err := convert.FromPDF(pdf, convert.RasterOptions{DPI: 150, Format: "png"})
out, err := convert.Redraw(pdf, convert.RedrawOptions{Greyscale: true})
pdf, err = convert.ArchiveToPDF(cbz, convert.Options{})
err = convert.ToArchive(w, pdf, convert.RasterOptions{DPI: 150})
pdf, err = convert.SVGToPDF(drawing, convert.Options{DPI: 72})

⛔ page10 comes after page2

A comic archive names its pages and nothing else records the order. Sorted as bytes, page10.png comes before page2.png, so a hundred-page book reads 1, 10, 11, … 2, 20, … — and nothing says so: every page is present, the count is right, and the file opens.

The ordering is natural: a run of digits compares as a number, without being parsed, so a page numbered past what an int holds still sorts. It is case-insensitive, because an archive mixes Page01 and page02 and a case-sensitive sort reads the book in two halves.

Going the other way, page names are zero-padded to the width of the last page number, not to a fixed four: padded too narrowly, a thousand-page document reads 1, 10, 100, 1000, 101 in every reader that sorts by name — which is every reader of this format.

What an archiver added — __MACOSX/, .DS_Store, ComicInfo.xml, a thumbnail — is skipped rather than refused. Real archives all carry some; refusing them would refuse most real files, and counting them puts rubbish in the middle of the book.

Going out, a ComicInfo.xml goes in first. It is what Komga, Kavita and Calibre read to know what a book is called and how many pages it has; without it a reader shows an untitled pile and has to open every entry to count it. The title is escaped — it comes from a caller by way of a filename, and an ampersand in it would otherwise produce metadata no reader can parse, which is worse than none because it looks like metadata.

What is not here

⛔ CBR is not read. A .cbr is a RAR archive, and this package reads ZIP only. go-filesystems/rar is the fleet's pure-Go RAR reader if that is wanted; it is left out here because it pulls a decoder and two modules in for one format variant.

bundle is the other half of what a reader might expect from "PDF to ZIP": it packs whole files, unchanged. Several PDFs that have to travel together are a packaging problem, not a conversion one. It is written to a temporary beside the target and renamed, so a failure leaves nothing under the final name — a half-written ZIP has its directory missing from the end, so it does not even open, but it is there and dated and somebody will try to send it.

SVG is DRAWN, not translated

An SVG handed to to-pdf is recognised by its content and rasterised at the chosen DPI. ⛔ Both are vector languages, so a reader could reasonably expect the paths to survive — and they do not. Turning one into the other means reimplementing a renderer's worth of semantics (gradients in two unit systems, clip paths, stroke joins, transforms, text layout), and a half-done translation produces a file that opens and is quietly wrong. Drawing it is a complete answer that is honest about its own resolution.

The pixel ceiling is passed through to go-gfx/gfx/svg: an SVG is the kind of thing people accept from strangers, and its own width and height decide an allocation — a hundred bytes declaring width="40000" cost 6.1 GiB before that was bounded.

Why it is small

Both ends were already here. go-pdfkit/pdfkit writes a PDF and can place an image on a page; go-pdfkit/render draws a page into a raster; go-images/images already had Invert, Grayscale, AdjustBrightness, AdjustContrast and Rotate. What was missing was the join — so this is wiring and codecs, not a new engine.

redraw RASTERISES, and says so everywhere

⛔ What comes out has no text in it: no selection, no search, no copy, no screen reader, and a much larger file. That is not a shortcoming — changing the colours a page is painted in means painting it — but it is a thing a caller has to have decided on purpose, which is why the verb is named for what it does rather than for what it is for.

Rotating, cropping, stamping and reordering keep the text. Those live in go-pdfkit/ops, not here, and redraw with no change asked for says so on stderr before it does anything.

-greyscale drop the colour
-invert turn light into dark
-brightness −1 (black) to 1 (white)
-contrast 1 leaves it, 2 doubles it, 0.5 halves it
-background #rgb, #rrggbb or #rrggbbaa, painted by the renderer
-scanner skew, grain and a worn lamp

The order of the transforms is written down rather than left to chance: colour, then tone, then the scanner's damage last. Greyscale after a contrast change is not the same picture as contrast after greyscale, and a caller who sets both would otherwise be guessing.

The scanner's grain is reproducible: a zero Seed is a fixed seed, not a random one, because two runs over the same file must produce the same bytes or nothing downstream can be compared, cached or checksummed. ⛔ The page index is mixed into it, or every page of a document gets identical grain — the one thing a scanner never does.

Two defects this found, both invisible to a byte count
-background painted nothing it was composited under the finished raster, and the renderer had already filled the page with opaque white. The option was documented, shipped and inert.
-brightness changed nothing images.AdjustBrightness adds its delta in channel units, 0–255, not in the −1..1 this option is documented in. A brightness of 0.4 added 0.4 of a level out of 255.

Both were found by tests that read pixels. The page count, the byte count and the exit status were right throughout.

Formats

read PNG, JPEG, GIF (standard library); BMP, TIFF, WebP (golang.org/x/image)
write PNG, JPEG, GIF, BMP, TIFF

⛔ WebP is refused by name, not as an unknown format. There is no WebP encoder in pure Go. "I do not know that format" would be a lie — it reads here perfectly well — and writing a PNG under a .webp name would be worse than either. The two refusals are different sentences and a test asserts that neither reads like the other.

⛔ The content decides the format, never the name. A scanner writing JPEG into page.png is ordinary; a converter that believed the extension would hand the decoder the wrong reader, or refuse a file it can read.

What decides the page

A picture has pixels, a page has points, and only the DPI says which page a picture is:

pdfconv to-pdf -dpi 300 scan.jpg   # 2480x3508 comes out A4
pdfconv to-pdf -dpi 72  scan.jpg   # the same file comes out a metre tall

With no -page, each page is sized to its own picture — which is what a set of scans of different sizes needs, and why an empty page size is an instruction rather than a missing value. With -page a4 the picture is fitted inside the paper:

  • ⛔ one scale for both axes. Scaling them separately fills the page and stretches the picture, and a stretched scan is a defect nobody reports because the page looks full;
  • ⛔ only ever shrink. "Fit on this paper" is not "make it as large as this paper": a 32-pixel logo blown up to A4 is a poster nobody asked for;
  • centred, and a margin wider than the paper gives way rather than erasing the picture — the picture is the content, the margin is a preference.

What it refuses

no pictures an empty PDF is a file, and a caller who passed an empty slice sees one and believes it worked
a picture past MaxPixels the size is decided by the input; the default ceiling is a hundred megapixels
-margin with no -page a page sized to its picture has no room to leave, and ignoring it lets somebody believe their pages have one
a page number past the end and it says how many there are
a PDF that opens and holds no pages said out loud, because an empty result otherwise reads like a selection that matched nothing

A page that cannot be written stops the run: a directory that looks like a complete conversion and is one page short is not something anybody checks. For the same reason the output directory is created after the conversion succeeds, so a failure leaves nothing behind that could be mistaken for "no pages".

Checks

100 % statement coverage on both packages, go vet and gofmt clean, and builds for nine targets including js/wasm, linux/loong64 and linux/s390x.

The end-to-end witness is a picture, not a number: a four-quadrant swatch goes in, comes back through a real rasteriser, and each quadrant is sampled in its own corner — ⛔ a flat fill survives being drawn upside down, so the test has to be able to see an orientation it does not expect.

Licence

BSD-3-Clause.

Documentation

Overview

Package convert turns pictures into a PDF and a PDF back into pictures.

Everything either side of the PDF was already here: github.com/go-pdfkit/pdfkit writes one and can place an image on a page, and github.com/go-pdfkit/render draws a page into a raster. What was missing was the join — which is why this package is wiring and codecs rather than a new engine.

Formats

Reading: PNG, JPEG and GIF from the standard library; BMP, TIFF and WebP from golang.org/x/image. Writing: PNG, JPEG, GIF, BMP and TIFF.

⛔ There is no WebP ENCODER in pure Go, so FromPDF refuses "webp" by name instead of quietly writing something else. A converter that answers a request it did not carry out is worse than one that says no.

What decides the page

A picture has pixels and a DPI; a PDF page has points. ToPDF sizes each page to its picture at the DPI given, so a 300-dpi scan comes back the size of the paper it came off. Asking for a fixed page size instead fits the picture inside it, centred, never cropping and never stretching: see Options.Page.

Pure Go

CGO-free, including GOOS=js. No C anywhere in the chain.

Index

Constants

View Source
const DefaultMaxPixels = 100_000_000

DefaultMaxPixels is how large a picture Decode will read when no ceiling is given. A hundred megapixels is A4 at a thousand dots to the inch.

⛔ Said in bytes, because "a hundred megapixels" does not tell anybody what it permits: a decoded picture is four bytes a pixel, so this default lets a file of a few dozen bytes cost FOUR HUNDRED MEGABYTES. That is bounded, which is the property that matters and the one this package did not have — but a service taking pictures from strangers should set Options.MaxPixels to what its own pages actually need rather than inherit this.

Variables

View Source
var (
	// MaxArchiveEntries is how many files an archive may hold.
	//
	// It bounds an allocation made before anything is read, the same way
	// go-odf/odf's does: a ZIP's central directory costs about forty-six bytes
	// an entry, so a small file can declare a great many.
	MaxArchiveEntries = 4096

	// MaxArchiveEntryBytes is how large any one entry may be once opened, and
	// MaxArchiveBytes how much the whole archive may come to. One entry under
	// the ceiling says nothing about a thousand of them.
	MaxArchiveEntryBytes uint64 = 128 << 20
	MaxArchiveBytes      uint64 = 512 << 20
)

An archive of pictures is what a comic book is: a ZIP whose entries are the pages, in the order their NAMES put them. CBZ, CBR's zipped cousin, and "here are the scans in a zip" are all the same thing. ⛔ Variables rather than constants, and that is not a style choice: a ceiling of half a gigabyte cannot be reached by a test that has to run in a second, so a constant here would be a guard nobody could exercise — and this package has already shipped two of those. A test lowers them; nothing else should.

Functions

func ArchiveToPDF added in v0.4.0

func ArchiveToPDF(src []byte, opt Options) ([]byte, error)

ArchiveToPDF lays the pictures in a ZIP onto pages, in the order their names put them.

⛔ The ORDER is the whole job. A comic archive names its pages and nothing else records the sequence, so "page10.jpg" has to follow "page2.jpg" — which a byte-wise sort does not do, and which is the single defect every naive reader of this format has. See naturalLess.

Entries that are not pictures are skipped rather than refused: real archives carry a ComicInfo.xml, a thumbnail, a __MACOSX folder, a readme.

func Bundle added in v0.4.0

func Bundle(w io.Writer, files map[string][]byte) error

Bundle packs whole files into a ZIP, unchanged.

⛔ It converts NOTHING, and that is the point: it is the one tool here whose job is to leave its input alone. Several PDFs that have to travel together — a submission, a set of invoices, a chapter each — are a packaging problem, not a conversion one, and running them through a renderer to put them in a zip would lose every byte of what made them worth sending.

The names are the ones given, with their directories stripped: an archive whose entries are absolute paths from somebody's disk tells a stranger where the files lived.

func Decode

func Decode(r io.Reader) (image.Image, string, error)

Decode reads a picture and says what format it turned out to be.

⛔ It reads the CONTENT. A file's name is a claim its bytes do not have to honour: a scanner that writes JPEG into "page.png" is ordinary, and a converter that believed the extension would hand the encoder the wrong picture or refuse a file it can read perfectly well. It refuses a picture of more than DefaultMaxPixels pixels, and it refuses it by its HEADER — see decodeWithin for why that distinction is the guard.

func DecodeBytes

func DecodeBytes(b []byte) (image.Image, string, error)

DecodeBytes is Decode over a slice.

func Encoders

func Encoders() []string

Encoders names every format FromPDF can write, in a stable order.

func LooksLikeSVG added in v0.4.0

func LooksLikeSVG(b []byte) bool

LooksLikeSVG says whether these bytes are an SVG document.

⛔ By their CONTENT, like every other format this package reads. It is more than a prefix test because an SVG legitimately begins with an XML declaration, a doctype, a comment or a byte-order mark, and a file that opens with any of those is still an SVG — refusing it for want of a "<svg" in the first four bytes would refuse most of what Inkscape writes.

func PageSizes

func PageSizes() []string

PageSizes names every paper size this package knows, in a stable order.

func ParsePageSize

func ParsePageSize(s string) (pdfkit.PageSize, error)

ParsePageSize reads a paper size: a name such as "a4", optionally with "-landscape", or "WIDTHxHEIGHT" in points.

⛔ "" is NOT an error and NOT a default size: it means "size each page to its own picture", which is a different instruction from "use A4". A set of scans of different sizes must not all be forced onto one paper because the caller said nothing.

func RasterizeSVG added in v0.4.0

func RasterizeSVG(doc []byte, opt Options) (image.Image, error)

RasterizeSVG draws an SVG and hands back the picture.

func ReadersToPDF

func ReadersToPDF(rs []io.Reader, opt Options) ([]byte, error)

ReadersToPDF decodes each reader and lays the pictures out as ToPDF does. The format of each is decided by its CONTENT, never by a name.

func Redraw added in v0.3.0

func Redraw(pdf []byte, opt RedrawOptions) ([]byte, error)

Redraw draws every page, changes the pixels, and lays the results back into a new PDF.

⛔ IT RASTERISES. What comes out has no text in it: no selection, no search, no copy, no screen reader, and a file many times larger. That is not a shortcoming of this implementation — changing the colours a page is PAINTED in means painting it — but it is a thing a caller has to have decided on purpose, which is why the function is named for what it does rather than for what it is for.

Where the operation can be done without redrawing, it belongs somewhere else: rotating, cropping, stamping and reordering are github.com/go-pdfkit/ops, and they keep the text.

func SVGToPDF added in v0.4.0

func SVGToPDF(doc []byte, opt Options) ([]byte, error)

SVGToPDF draws an SVG document and lays it on a page.

⛔ IT RASTERISES. An SVG is vector and so is a PDF, so a reader could reasonably expect the paths to survive — and they do not. Turning one vector language into another means reimplementing a renderer's worth of semantics (gradients in two unit systems, clip paths, stroke joins, transforms, text layout), and a half-done translation produces a file that opens and is quietly wrong. Drawing it is a complete answer that is honest about its own resolution.

Options.DPI is what that resolution is: at 72 one SVG user unit is one point, and the drawing comes out the size the document says.

func ToArchive added in v0.4.0

func ToArchive(w io.Writer, pdf []byte, opt RasterOptions, title string) error

ToArchive draws the pages and writes them into a ZIP: one picture per page, named so that they come back in order, with a ComicInfo.xml beside them.

⛔ The ComicInfo.xml is not decoration. It is what Komga, Kavita and Calibre read to know how many pages a book has and what it is called; without it a reader shows the archive as an untitled pile and has to open every entry to count it. A CBZ with no metadata is a CBZ a library will not shelve. The title, when given, goes into that metadata; it is the book's name, not a filename.

func ToPDF

func ToPDF(images []image.Image, opt Options) ([]byte, error)

ToPDF lays each picture on a page of its own, in the order given.

func WriteTo

func WriteTo(w io.Writer, images []image.Image, opt Options) error

WriteTo is ToPDF straight to a writer, so a conversion of a hundred scans does not hold the finished PDF in memory as well as the pictures.

Types

type Encoder

type Encoder func(w io.Writer, m image.Image, quality int) error

Encoder is a picture format this package can WRITE. Reading is wider than writing — WebP decodes and does not encode — so the two are separate tables rather than one list with exceptions in it.

type Options

type Options struct {
	// DPI is how many pixels of the picture go to an inch of paper. Zero means
	// 72, at which one pixel is one point and a picture comes out at the size
	// a PDF viewer calls 100%.
	//
	// It is what turns pixels into a page: a 2480x3508 scan at 300 is A4, and
	// the same scan at 72 is a page a metre tall. There is no right answer this
	// package could pick for you, only a sane default.
	DPI float64

	// Page, when set, is the page size every picture is placed on, and the
	// picture is FITTED inside it: scaled down to fit if it is too big, centred
	// either way, never cropped and never stretched out of proportion.
	//
	// When it is the zero value each page is sized to its own picture, which is
	// what a set of scans of different sizes needs.
	Page pdfkit.PageSize

	// Margin is how much paper is left around a fitted picture, in points. It
	// is only consulted when Page is set, because a page sized to its picture
	// has no room to leave.
	Margin float64

	// Title goes in the document information dictionary.
	Title string

	// MaxPixels refuses a picture larger than this, so that a file cannot
	// decide how much memory this package uses. Zero means a hundred million,
	// which is A4 at a thousand dots to the inch and well past any scanner.
	MaxPixels int
}

Options says how pictures become pages.

type Page

type Page struct {
	Number int    // one-based, as the file numbers it
	Format string // the format actually written
	Data   []byte
}

Page is one drawn page and the bytes it encoded to.

func FromPDF

func FromPDF(pdf []byte, opt RasterOptions) ([]Page, error)

FromPDF draws the pages of a PDF and encodes each one.

func FromPDFReader

func FromPDFReader(r io.Reader, opt RasterOptions) ([]Page, error)

FromPDFReader is FromPDF over a reader, for a file on disk.

type RasterOptions

type RasterOptions struct {
	// DPI is dots per inch. Zero means 150, which is legible on a screen and
	// not so large that a hundred-page file fills a disk.
	DPI float64

	// Pages selects which pages to draw, one-based, in the order given. Nil
	// means every page in the file's own order.
	Pages []int

	// Format is what to encode as: png, jpeg, gif, bmp or tiff. Empty means
	// png, which is lossless and what a page of text should be.
	Format string

	// Quality is the JPEG quality, 1 to 100. Zero means 85. It is ignored by
	// every other format.
	Quality int

	// Password opens an encrypted file.
	Password string

	// MaxPixels refuses a page that would come out larger than this. Zero
	// leaves it to the renderer, whose own default is a little over A4 at 600
	// dots to the inch.
	MaxPixels int
}

RasterOptions says how pages become pictures.

type RedrawOptions added in v0.3.0

type RedrawOptions struct {
	RasterOptions

	// Title goes in the new document's information dictionary.
	Title string

	// Greyscale drops the colour.
	Greyscale bool

	// Invert turns light into dark. It is what a reader wants at night, and
	// what a plotter wants of a dark-background figure.
	Invert bool

	// Brightness shifts every channel, from -1 (black) to +1 (white). Zero
	// leaves it alone.
	Brightness float64

	// Contrast multiplies the distance from mid-grey: 1 leaves it alone, 2
	// doubles it, 0.5 halves it. Zero means 1 — ⛔ a zero that meant "no
	// contrast at all" would turn every page into a flat grey rectangle for
	// every caller who set only the other fields.
	Contrast float64

	// Background is the colour the page is painted on. nil leaves the
	// renderer's own white.
	//
	// ⛔ It is handed to the RENDERER rather than composited afterwards, and
	// the difference is the whole option. A first version painted the colour
	// under the finished raster — which does nothing at all, because the
	// renderer has already filled the page with opaque white and there is no
	// transparency left to show through. The option was documented, shipped,
	// and had no effect; a screenshot found it, a byte count did not.
	Background *color.RGBA

	// Scanner, when set, makes the page look like it went through one.
	Scanner *ScannerEffect
}

RedrawOptions says what to do to the pixels. Everything is off by default: the zero value redraws the pages unchanged, which is `rasterize-pdf`.

type ScannerEffect added in v0.3.0

type ScannerEffect struct {
	// Skew is how far the sheet sits off square, in degrees. A tenth to a
	// degree is what a careless hand produces.
	Skew float64

	// Noise is how much grain, from 0 to 1.
	Noise float64

	// Fade lightens the page, as a worn lamp does, from 0 to 1.
	Fade float64

	// Seed makes the grain reproducible. ⛔ Zero means a FIXED seed, not a
	// random one: two runs over the same file must produce the same bytes, or
	// nothing downstream can be compared, cached or checksummed — and an
	// effect that is different every time is one nobody can test.
	Seed uint64
}

ScannerEffect is the small damage a flatbed does: the sheet is never quite straight, the lamp is never quite even, and the sensor adds grain.

Directories

Path Synopsis
cmd
pdfconv command
pdfconv turns pictures into a PDF and a PDF back into pictures.
pdfconv turns pictures into a PDF and a PDF back into pictures.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL