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
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,55 @@ document. `onepage` stacks every page onto a single long one. `poster` cuts
each page into tiles so a plan can be printed larger than the paper and
assembled.

`deblank` finds the pages that hold nothing and drops them. It **draws** each
page and counts the pixels that are not paper, because that is the only test
that agrees with a reader: a page whose content stream is full of operators
painting white, or drawing outside the media box, looks blank and is blank, and
a page carrying a picture of nothing is blank too.

```sh
pdfops deblank -list scan.pdf # say which, write nothing
pdfops deblank scan.pdf tidied.pdf
```

⛔ **The default resolution is 150 DPI, and it was raised from 50 because of a
measurement.** On A4:

| | 50 DPI | 150 DPI |
| --- | --- | --- |
| the page | 414 × 585 | 1240 × 1755 |
| one pixel | 4.1e-06 of it | 4.6e-07 |
| an 8-point full stop | **1 pixel** | 4 pixels |
| an 8-point comma | 2 pixels | 8 pixels |

At 50 DPI a full stop is *exactly the mark a speck of dust makes*, so no
threshold at that resolution can tell punctuation from dirt. At 150 there is a
factor of four to set `-max-ink` from, and `TestMeasureTheInkLadder` prints the
whole ladder so that a renderer change which moved either end fails there
instead of silently reclassifying somebody's pages.

⛔ **The zero value is the strictest setting**, not a middle one: a page with a
single inked pixel is kept. This is the input to deleting pages — a page
wrongly called blank destroys content, while a blank page wrongly kept merely
leaves a blank page. For scans, `-max-ink 1e-6` is about two pixels: it
forgives a speck and still keeps a page holding a full stop. It is not the
default because whether that margin holds depends on the scanner, which this
package cannot see.

Three more things it will not do:

- **it will not call a page of solid colour blank.** The test is distance from
*white*, not uniformity; a measure of variance would delete a deliberate
black separator sheet;
- **it will not empty the document.** Every page blank is an error, because a
file of no pages still opens and still has a size, and a caller handed one
would be told nothing was wrong;
- **it does not count annotations as ink** unless asked: a page whose only mark
is a sticky note is blank paper somebody wrote on.

And `-list` writes nothing and takes no output file, because the first thing
anybody should do with a tool that deletes pages is ask it which ones it means.

⛔ `divide` makes the **same grid** as `poster` and is the opposite operation.
`poster` keeps every piece the size of the page it came from, so it
**enlarges**: the pieces taped together make one page the size of a wall.
Expand Down
188 changes: 188 additions & 0 deletions blank.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
// Copyright (c) 2026, the go-pdfkit/ops authors
// All rights reserved.
//
// SPDX-License-Identifier: BSD-3-Clause

package ops

import (
"fmt"

"github.com/go-gfx/gfx/raster"
"github.com/go-pdfkit/reader"
"github.com/go-pdfkit/render"
)

// BlankOptions say how little a page may hold and still count as blank.
//
// ⛔ The zero value is the STRICTEST setting, not a middle one: a page with a
// single inked pixel is kept. That is deliberate and it is the only safe
// default, because this is the input to deleting pages — a page wrongly called
// blank destroys content, while a blank page wrongly kept merely leaves a
// blank page.
type BlankOptions struct {
// DPI is how finely the page is drawn before its pixels are counted. Zero
// means a hundred and fifty.
//
// ⛔ It is not fifty, which is what this first used and what a thumbnail
// would want. MEASURED on A4: at 50 DPI an eight-point full stop covers
// exactly ONE pixel, which is the same mark a speck of dust makes, so no
// threshold at that resolution can tell punctuation from dirt. At 150 it
// covers four and a comma covers eight. See TestMeasureTheInkLadder.
DPI float64

// Tolerance is how far a channel may be from white and still count as
// paper, out of 255. Zero means twelve.
//
// ⛔ Not zero-means-zero: nothing in a rendered page is exactly white.
// Anti-aliasing, a JPEG background and a scan of real paper all land a few
// levels below it, and a tolerance of zero calls every page inked.
Tolerance uint8

// MaxInk is the fraction of a page's pixels that may be ink and have the
// page still count as blank. The zero value keeps any page with a mark on
// it, which is what the zero value SHOULD mean here.
//
// For scans, where dust and the edge of the platen leave specks, the
// figures to set it from are these, measured on A4 at the default 150 DPI:
//
// one pixel 4.6e-07
// a full stop (8pt) 1.8e-06 (four pixels)
// a comma (8pt) 3.7e-06 (eight pixels)
// one word (12pt) 2.0e-04
//
// So 1e-06 tolerates a speck or two and still keeps a page holding a full
// stop. The margin is a factor of four, and it is not offered as a default
// because whether that margin holds depends on the scanner, which this
// package cannot see.
MaxInk float64

// MaxPixels is passed to the renderer, so a media box a file made up
// cannot be asked to fill memory.
MaxPixels int

// KeepAnnotations counts the notes, highlights and form fields a page
// carries as ink. They are NOT counted by default: a page whose only mark
// is a sticky note is blank paper somebody wrote on, and the note is not
// part of what was printed.
KeepAnnotations bool
}

// The measured defaults. See TestMeasureTheInkLadder for where they come from.
const (
defaultBlankDPI = 150.0
defaultTolerance = 12
)

func (o BlankOptions) dpi() float64 {
if o.DPI > 0 {
return o.DPI
}
return defaultBlankDPI
}

func (o BlankOptions) tolerance() uint8 {
if o.Tolerance > 0 {
return o.Tolerance
}
return defaultTolerance
}

// inkFraction is the share of an image's pixels that are not paper.
//
// A pixel is paper when every one of its four channels is within tol of 255.
// ⛔ The ALPHA channel is included on purpose: a fully transparent pixel is
// black-with-nothing in this representation, so a renderer asked for a
// transparent background would otherwise report blank paper as solid ink.
func inkFraction(img *raster.Image, tol uint8) float64 {
if img == nil || img.W <= 0 || img.H <= 0 {
return 0
}
floor := 255 - tol
ink := 0
for i := 0; i+3 < len(img.Pix); i += 4 {
if img.Pix[i] < floor || img.Pix[i+1] < floor ||
img.Pix[i+2] < floor || img.Pix[i+3] < floor {
ink++
}
}
return float64(ink) / float64(img.W*img.H)
}

// BlankPages reports which pages of a file hold almost nothing, counting from
// one and in order.
//
// Each page is DRAWN and its pixels counted, because that is the only test
// that agrees with a reader. A page whose content stream is full of operators
// painting white, or drawing outside the media box, looks blank and is blank;
// a page carrying an image of nothing is blank too, and no amount of reading
// the content stream says so.
//
// ⛔ What it will not do is call a page of solid colour blank. The test is
// distance from WHITE, not uniformity: a deliberately black separator sheet
// carries information, and a measure of variance would delete it.
func BlankPages(pdf []byte, opt BlankOptions) ([]int, error) {
d, err := reader.Open(pdf)
if err != nil {
return nil, err
}
ropt := render.Options{
DPI: opt.dpi(),
MaxPixels: opt.MaxPixels,
NoAnnotations: !opt.KeepAnnotations,
}
tol := opt.tolerance()
var blank []int
for i := 1; i <= d.PageCount(); i++ {
img, err := render.Page(d, i, ropt)
if err != nil {
return nil, fmt.Errorf("page %d: %w", i, err)
}
if inkFraction(img, tol) <= opt.MaxInk {
blank = append(blank, i)
}
}
return blank, nil
}

// DropBlank removes the pages that hold almost nothing and reports which went,
// numbered as they were BEFORE the removal — because a caller who wants to say
// what happened needs the numbers the reader saw.
//
// ⛔ It refuses to empty the document. A file of no pages is still a file: it
// opens, it has a size, and a caller who handed over a stack of scans that
// turned out to be blank would be told nothing was wrong while being given
// something no reader can show. Every page blank is a result worth an error.
func (d *Doc) DropBlank(opt BlankOptions) ([]int, error) {
// ⛔ No separate check for an empty document: Bytes refuses one, and that
// is the one place that should know a document with no pages cannot be
// written. A second copy of the rule here was a branch no test could
// reach, and a rule stated twice is a rule that will disagree with itself.
pdf, err := d.Bytes()
if err != nil {
return nil, err
}
blank, err := BlankPages(pdf, opt)
if err != nil {
return nil, err
}
if len(blank) == 0 {
return nil, nil
}
if len(blank) == len(d.pages) {
return nil, fmt.Errorf("ops: every one of the %d pages looks blank: "+
"removing them all would leave a file no reader can show", len(d.pages))
}
drop := make(map[int]bool, len(blank))
for _, n := range blank {
drop[n] = true
}
keep := make([]Page, 0, len(d.pages)-len(blank))
for i, p := range d.pages {
if !drop[i+1] {
keep = append(keep, p)
}
}
d.pages = keep
return blank, nil
}
89 changes: 89 additions & 0 deletions blank_measure_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package ops

import (
"testing"

"github.com/go-pdfkit/reader"
"github.com/go-pdfkit/render"
)

func renderOne(t *testing.T, text string, size, dpi float64) (int, int, float64) {
t.Helper()
d := New()
d.Blank(595, 842) // A4, so the figures are the ones a real page gives
if text != "" {
if err := d.Stamp("all", Stamp{Text: text, Font: Helvetica, Size: size}); err != nil {
t.Fatal(err)
}
}
pdf, err := d.Bytes()
if err != nil {
t.Fatal(err)
}
src, err := reader.Open(pdf)
if err != nil {
t.Fatal(err)
}
img, err := render.Page(src, 1, render.Options{DPI: dpi})
if err != nil {
t.Fatal(err)
}
return img.W, img.H, inkFraction(img, defaultTolerance)
}

// TestMeasureTheInkLadder prints what a page of each kind actually measures,
// at each resolution worth considering. It is how the defaults were chosen.
func TestMeasureTheInkLadder(t *testing.T) {
for _, dpi := range []float64{50, 100, 150, 300} {
w, h, _ := renderOne(t, "", 0, dpi)
onePixel := 1 / float64(w*h)
t.Logf("--- %g DPI: %d x %d, one pixel = %.3e of the page", dpi, w, h, onePixel)
for _, r := range []struct {
name string
text string
size float64
}{
{"nothing", "", 0},
{"a full stop, 6pt", ".", 6},
{"a full stop, 8pt", ".", 8},
{"a comma, 8pt", ",", 8},
{"one word, 12pt", "word", 12},
} {
_, _, ink := renderOne(t, r.text, r.size, dpi)
px := ink / onePixel
t.Logf(" %-18s ink=%.3e (%.0f pixels)", r.name, ink, px)
}
}
}

// TestMeasureAFaintMark says what a nearly-white mark actually measures, which
// is what the Tolerance default is for. A mutation that set the default
// tolerance to zero SURVIVED the first version of this suite: every fixture
// rendered to exactly white, so the field was inert as far as any test could
// see, and the doc comment's claim was unproven.
func TestMeasureAFaintMark(t *testing.T) {
for _, grey := range []float64{1.0, 0.99, 0.97, 0.95, 0.9} {
d := New()
d.Blank(595, 842)
if err := d.Stamp("all", Stamp{
Text: "faint", Font: Helvetica, Size: 48,
Colour: [3]float64{grey, grey, grey},
}); err != nil {
t.Fatal(err)
}
pdf, err := d.Bytes()
if err != nil {
t.Fatal(err)
}
src, err := reader.Open(pdf)
if err != nil {
t.Fatal(err)
}
img, err := render.Page(src, 1, render.Options{DPI: defaultBlankDPI})
if err != nil {
t.Fatal(err)
}
t.Logf("grey %.2f (%3.0f/255): ink at tol 0 = %.3e, at tol 12 = %.3e",
grey, grey*255, inkFraction(img, 0), inkFraction(img, defaultTolerance))
}
}
Loading
Loading