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
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,52 @@ has two "Markup" layers, and switching one of them at random is a change
nobody asked for and nobody can see, so an ambiguous name is refused with the
count.

`textcolour` paints the text one colour and leaves the rest of the page alone.

```sh
pdfops textcolour -to '#0000ff' report.pdf blue-text.pdf
pdfops textcolour -to black -pages 1-4 scan.pdf fixed.pdf
```

⛔ **Setting a colour before each text object is not enough**, and that is what
the first draft did. A text object that sets its *own* colour overrides it, so
a page whose paragraphs say `1 0 0 rg` came out unchanged while the tool
reported success. Every colour operator **inside** a text object is blanked —
`g`, `rg`, `k`, `cs`, `sc`, `scn` and their stroking capitals — so the colour
asked for is the one that paints.

⛔ **The colour goes outside the text object, wrapped in `q`/`Q`.** A text
object does not save the graphics state, so a colour set inside one leaks into
whatever is drawn after it — which is how a recolouring turns a company's logo
the same shade as its paragraphs. A test asserts the rectangle beside the text
keeps its own red.

Both the filling **and** the stroking colour are set, because text has render
modes: 0 fills, 1 strokes, 2 does both.

⛔ **It splices, and does not re-serialise.** Writing the operations back out
would mean formatting every operand again, including an inline image's
still-encoded bytes, where a formatter that is merely lossless-for-objects can
change what the image *means*. So the rewriting keeps every byte it does not
deliberately change — which is why
[`reader.Operation`](https://github.com/go-pdfkit/reader) carries its byte
range, added for this. A stream with no text comes back **byte for byte**
unchanged, and the colour operators are **blanked with spaces** rather than
deleted so that every offset after one is still the offset the scan reported.

⛔ **What it cannot reach, it counts.** Text drawn through a Form XObject is
not recoloured, and the reason is not difficulty: an XObject is **shared**, so
recolouring one would change pages the caller did not name. A document whose
text is all inside XObjects would otherwise come back looking converted and be
unchanged — the one failure a caller cannot see by opening the result and
glancing at it. So the count is said out loud, and `Doc.UnreachedText` is the
same answer in Go.

And `#f00` is **full** red: each digit of a short hex colour is doubled, not
shifted, so the component is `0xf × 17`. A shift gives 240/255 for what every
browser calls full red, and a logo recoloured to 94 % red is a defect nobody
can see but a printer.

`attachments` lists the files a document carries, and writes them out with
`-to`. A name comes out of the document rather than from whoever runs the
command, so it is stripped to its last element: `../escaped.txt` is written
Expand Down
157 changes: 157 additions & 0 deletions cmd/pdfops/expose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1201,3 +1201,160 @@ func TestLayersRefusesALockedOne(t *testing.T) {
t.Error("a locked layer was switched")
}
}

// textFixture writes a one-page document whose text sets its own colour, with
// a rectangle beside it that must keep its own.
func textFixture(t *testing.T) string {
t.Helper()
w := reader.NewWriter("1.7")
pagesRef := w.Reserve()
body := "1 0 0 rg 5 5 10 10 re f\nBT /F1 12 Tf 1 0 0 rg (words) Tj ET\n"
kids := reader.Array{w.Add(reader.Dict{
"Type": reader.Name("Page"), "Parent": pagesRef,
"Contents": w.Add(&reader.Stream{Dict: reader.Dict{}, Raw: []byte(body)}),
})}
w.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"), "Kids": kids,
"Count": reader.Integer(1),
"MediaBox": reader.Array{reader.Integer(0), reader.Integer(0),
reader.Integer(100), reader.Integer(200)}})
out, err := w.Finish(reader.Dict{"Root": w.Add(reader.Dict{
"Type": reader.Name("Catalog"), "Pages": pagesRef})})
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "text.pdf")
if err := os.WriteFile(path, out, 0o644); err != nil {
t.Fatal(err)
}
return path
}

func TestTextColour(t *testing.T) {
in := textFixture(t)
out := filepath.Join(t.TempDir(), "out.pdf")
if code, _, msg := exec("textcolour", "-to", "#0000ff", in, out); code != 0 {
t.Fatalf("code %d: %s", code, msg)
}
got := contentsOf(t, out)[0]
if !strings.Contains(got, "0 0 1 rg") {
t.Errorf("the text is not blue:\n%s", got)
}
// ⛔ The rectangle keeps its own red: exactly one red fill survives.
if n := strings.Count(got, "1 0 0 rg"); n != 1 {
t.Errorf("%d red fills, want the rectangle's one:\n%s", n, got)
}
}

// ⛔ #f00 is FULL red, 255 and not 240. Each digit is doubled, not shifted —
// a shift gives 240/255 for what every browser and design tool calls #f00,
// and a logo recoloured to 94% red is a defect nobody can see but a printer.
func TestAShortHexIsDoubledAndNotShifted(t *testing.T) {
r, g, b, err := parseColour("#f00")
if err != nil {
t.Fatal(err)
}
if r != 1 || g != 0 || b != 0 {
t.Errorf("#f00 = %v %v %v, want 1 0 0", r, g, b)
}
// And the long form agrees with the short one, which is the property that
// makes the doubling right rather than merely different.
lr, lg, lb, err := parseColour("#ff0000")
if err != nil {
t.Fatal(err)
}
if r != lr || g != lg || b != lb {
t.Errorf("#f00 = %v %v %v but #ff0000 = %v %v %v", r, g, b, lr, lg, lb)
}
// A shift would give this instead, and the test says the number out loud
// so that nobody has to work out which is which.
if r == 240.0/255 {
t.Error("#f00 came out as 240/255, which is the shift and not the doubling")
}
}

func TestParseColour(t *testing.T) {
for s, want := range map[string][3]float64{
"black": {0, 0, 0}, "white": {1, 1, 1}, "RED": {1, 0, 0},
"green": {0, 1, 0}, "blue": {0, 0, 1},
"#000": {0, 0, 0}, "#fff": {1, 1, 1},
"#808080": {128.0 / 255, 128.0 / 255, 128.0 / 255},
"0000ff": {0, 0, 1},
} {
r, g, b, err := parseColour(s)
if err != nil {
t.Errorf("%q: %v", s, err)
continue
}
if [3]float64{r, g, b} != want {
t.Errorf("parseColour(%q) = %v %v %v, want %v", s, r, g, b, want)
}
}
for _, s := range []string{"", "#", "#ff", "#ggg", "#gggggg", "#1234567", "puce"} {
if _, _, _, err := parseColour(s); err == nil {
t.Errorf("parseColour(%q) was accepted", s)
}
}
}

// ⛔ What could not be reached is SAID. A document whose text is all inside a
// Form XObject would otherwise come back looking converted and be unchanged —
// the one failure a caller cannot see by opening the result and glancing at it.
func TestTextColourSaysWhatItCouldNotReach(t *testing.T) {
w := reader.NewWriter("1.7")
pagesRef := w.Reserve()
form := w.Add(&reader.Stream{
Dict: reader.Dict{"Type": reader.Name("XObject"), "Subtype": reader.Name("Form"),
"BBox": reader.Array{reader.Integer(0), reader.Integer(0),
reader.Integer(10), reader.Integer(10)}},
Raw: []byte("BT (inside) Tj ET")})
kids := reader.Array{w.Add(reader.Dict{
"Type": reader.Name("Page"), "Parent": pagesRef,
"Resources": reader.Dict{"XObject": reader.Dict{"Fm0": form}},
"Contents": w.Add(&reader.Stream{Dict: reader.Dict{},
Raw: []byte("q /Fm0 Do Q BT (outside) Tj ET")})})}
w.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"), "Kids": kids,
"Count": reader.Integer(1),
"MediaBox": reader.Array{reader.Integer(0), reader.Integer(0),
reader.Integer(100), reader.Integer(100)}})
src, err := w.Finish(reader.Dict{"Root": w.Add(reader.Dict{
"Type": reader.Name("Catalog"), "Pages": pagesRef})})
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "form.pdf")
if err := os.WriteFile(path, src, 0o644); err != nil {
t.Fatal(err)
}
out := filepath.Join(t.TempDir(), "out.pdf")
code, said, msg := exec("textcolour", "-to", "blue", path, out)
if code != 0 {
t.Fatalf("code %d: %s", code, msg)
}
if !strings.Contains(said, "not recoloured") {
t.Errorf("it did not say what it could not reach: %q", said)
}
// And a document with nothing unreachable says nothing.
plain := filepath.Join(t.TempDir(), "plain.pdf")
if code, said, _ := exec("textcolour", "-to", "blue", textFixture(t), plain); code != 0 ||
strings.Contains(said, "not recoloured") {
t.Errorf("a plain document said %q", said)
}
}

func TestTextColourRefusals(t *testing.T) {
in := textFixture(t)
out := filepath.Join(t.TempDir(), "out.pdf")
for _, args := range [][]string{
{"textcolour", in, out},
{"textcolour", "-to", "blue", in},
{"textcolour", "-to", "puce", in, out},
{"textcolour", "-to", "blue", "-pages", "nonsense", in, out},
{"textcolour", "-to", "blue", "/no/such/file.pdf", out},
{"textcolour", "-to", "blue", in, "/no/such/dir/out.pdf"},
{"textcolour", "-nonsense", in, out},
} {
if code, _, _ := exec(args...); code != 1 {
t.Errorf("%v was accepted", args[1:])
}
}
}
89 changes: 89 additions & 0 deletions cmd/pdfops/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ var commands = []command{
{"repair", "[-check] <in.pdf> [out.pdf]", "rebuild a file whose structure is damaged, and say whether it was", runRepair},
{"layers", "(-list | -show <name> | -hide <name> | -add <name> | -drop <name>) <in.pdf> [out.pdf]", "the optional content a viewer calls layers", runLayers},
{"labels", "(-list | -clear | -rule <spec> …) <in.pdf> [out.pdf]", "set what a viewer shows in its toolbar: i, ii, iii then 1, 2, 3", runLabels},
{"textcolour", "-to <#rrggbb> [-pages <range>] <in.pdf> <out.pdf>", "paint the text one colour, leaving the rest of the page alone", runTextColour},
{"strip", "[-annotations] [-bookmarks] <in.pdf> <out.pdf>", "write the file without its metadata", runStrip},
{"sanitize", "<in.pdf> <out.pdf>", "remove what runs rather than shows: scripts, launching, embedded files", runSanitize},
{"outline", "(-drop | -from <toc.txt>) <in.pdf> <out.pdf>", "replace or remove the bookmarks", runOutline},
Expand Down Expand Up @@ -1937,3 +1938,91 @@ func runLayers(c *context, args []string) error {
}
return save(d, fs.Arg(1))
}

// runTextColour paints the text one colour, leaving the rest of the page as
// it was.
func runTextColour(c *context, args []string) error {
fs := flags("textcolour")
to := fs.String("to", "", "the colour, as #rgb, #rrggbb, or a name: black, white, red, green, blue")
pages := fs.String("pages", "all", "which pages")
if err := fs.Parse(args); err != nil {
return err
}
if err := wantArgs(fs, 2, "<in.pdf> <out.pdf>"); err != nil {
return err
}
if *to == "" {
return fmt.Errorf("-to is required: what colour should the text be?")
}
r, g, b, err := parseColour(*to)
if err != nil {
return err
}
d, err := c.open(fs.Arg(0))
if err != nil {
return err
}
if err := d.RecolourText(*pages, r, g, b); err != nil {
return err
}
if err := save(d, fs.Arg(1)); err != nil {
return err
}
// ⛔ Said out loud, and AFTER the write because that is when it is known.
// A document whose text is all inside Form XObjects would otherwise come
// back looking converted and be unchanged — the one failure a caller
// cannot see by opening the result and glancing at it.
if n := d.UnreachedText(); n > 0 {
fmt.Fprintf(c.out, "%d drawing(s) were not recoloured: text inside a "+
"form or an image is shared between pages, so recolouring it for "+
"one page would change others\n", n)
}
return nil
}

// parseColour reads #rgb, #rrggbb, or one of a few names.
//
// ⛔ The names are the ones a person types when they mean "make it black
// again", and nothing more: a long colour list is a dictionary somebody has
// to maintain, and "#rrggbb" is what a design tool gives you anyway.
func parseColour(s string) (r, g, b float64, err error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "black":
return 0, 0, 0, nil
case "white":
return 1, 1, 1, nil
case "red":
return 1, 0, 0, nil
case "green":
return 0, 1, 0, nil
case "blue":
return 0, 0, 1, nil
}
hex := strings.TrimPrefix(strings.TrimSpace(s), "#")
switch len(hex) {
case 3:
// ⛔ Each digit is DOUBLED, not shifted: #f00 is ff0000, so the
// component is 0xf*17 and not 0xf*16. A shift gives 240/255 for what
// every browser and design tool calls full red.
var v [3]int64
for i := 0; i < 3; i++ {
n, convErr := strconv.ParseInt(hex[i:i+1], 16, 32)
if convErr != nil {
return 0, 0, 0, fmt.Errorf("%q is not a colour", s)
}
v[i] = n * 17
}
return float64(v[0]) / 255, float64(v[1]) / 255, float64(v[2]) / 255, nil
case 6:
var v [3]int64
for i := 0; i < 3; i++ {
n, convErr := strconv.ParseInt(hex[i*2:i*2+2], 16, 32)
if convErr != nil {
return 0, 0, 0, fmt.Errorf("%q is not a colour", s)
}
v[i] = n
}
return float64(v[0]) / 255, float64(v[1]) / 255, float64(v[2]) / 255, nil
}
return 0, 0, 0, fmt.Errorf("%q is not a colour: say #rgb, #rrggbb, or a name", s)
}
10 changes: 10 additions & 0 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ type Doc struct {
// that union does not exist until the pages are known to be final.
layerEdits []layerEdit

// unreached counts the text-showing operations the last write could not
// recolour, because they are inside a Form XObject. See
// [Doc.UnreachedText].
unreached int

// attached are files to put inside the document, and dropped are the names
// of files it came in with that are not to go back out. A file the source
// carried is kept by being copied at write time rather than held here, so
Expand Down Expand Up @@ -89,6 +94,11 @@ type Page struct {

// marks is the text drawn on top of whatever the page already shows.
marks []stampInstance

// recolour, when set, is the colour this page's own text is to be painted
// in. A pointer rather than a value with a flag beside it, because black
// is a colour somebody asks for.
recolour *[3]float64
}

// Open reads a PDF file held in memory, with the empty password.
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ require (
github.com/go-gfx/gfx v0.34.1
github.com/go-pdfkit/extract v0.5.0
github.com/go-pdfkit/forms v0.5.0
github.com/go-pdfkit/reader v0.7.0
github.com/go-pdfkit/reader v0.8.0
github.com/go-pdfkit/xfa v0.22.0
)

Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ github.com/go-pdfkit/pdffont v0.3.1 h1:+K7opHGRERDZ5EMkA12s1GT48t8GpncArhnyzyc5b
github.com/go-pdfkit/pdffont v0.3.1/go.mod h1:eFJ/7t9AvcX76KY5qNJMMKetz8hUUUdVM1qSWLi3S1g=
github.com/go-pdfkit/reader v0.7.0 h1:+Fz5i8Zq6beBpTc3pNTbJ3Qf14aroA5txgeVzVGLTSY=
github.com/go-pdfkit/reader v0.7.0/go.mod h1:EYHHREPKT/orDzlGiFPnPVPPuaRDLiAYezIr0cCFXaA=
github.com/go-pdfkit/reader v0.8.0 h1:vLxnOlGJjFV0un7chvTft559neLl6jmW4xrEXub4Y38=
github.com/go-pdfkit/reader v0.8.0/go.mod h1:EYHHREPKT/orDzlGiFPnPVPPuaRDLiAYezIr0cCFXaA=
github.com/go-pdfkit/render v0.67.2 h1:oBIFoROjorKednM/8Xz9Dhf9VArkFFaL4jZpCEcI72M=
github.com/go-pdfkit/render v0.67.2/go.mod h1:S+p47RMo1vUykzxC+5YMzvQ8hjHBVX6HoHIAyJlzmp4=
github.com/go-pdfkit/xfa v0.22.0 h1:IbdMcNYN40kataz/n+3pkz2METftMh+re/MkcY2eCbw=
Expand Down
Loading
Loading