From 5d7531565bec751a820fd88bb54315e8670ff3b6 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 11 Oct 2026 19:14:48 +0200 Subject: [PATCH] textcolour: paint the text one colour, and leave the page alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doc.RecolourText and Doc.UnreachedText, with `pdfops textcolour` over them. This closes BentoPDF's text-color. ⛔ 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 now — g, rg, k, cs, sc, scn and their stroking capitals. ⛔ 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. ⛔ IT SPLICES RATHER THAN RE-SERIALISING, which is why go-pdfkit/reader v0.8.0 was needed first: an Operation now says which bytes of the stream it is. 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. A stream with no text comes back byte for byte unchanged, and the colour operators are blanked with SPACES rather than deleted so every offset after one is still the offset the scan reported — deleting would be correct only if the edits were applied back to front, and a rule correct in one order only is a rule somebody will reorder. ⛔ WHAT IT CANNOT REACH, IT COUNTS. Text inside a Form XObject is not recoloured, and not because it would be hard: 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. ⛔ AND A COMPOSITION DEFECT CAUGHT BEFORE IT SHIPPED: `decorate` rebuilt a page's Contents from the SOURCE's parts, so a page both recoloured and watermarked came back in its original colours with nothing failing. It uses the rewritten stream when there is one. The mutation restoring the old behaviour is caught. #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. The test asserts the short and long forms agree. A page this package MADE rather than borrowed is skipped: its content is the tiles and marks this document composed, and a stamp's colour is the stamp's own business. One guard was removed as dead code a coverage gap found: a check for overlapping edits, which cannot happen because reader asserts its ranges are ordered and disjoint. The invariant is named where it is established. 100% statement coverage with zero uncovered blocks, go vet and gofmt clean. Seven mutations caught. Co-Authored-By: Claude Opus 5 --- README.md | 46 ++++ cmd/pdfops/expose_test.go | 157 +++++++++++++ cmd/pdfops/run.go | 89 ++++++++ doc.go | 10 + go.mod | 2 +- go.sum | 2 + recolour.go | 169 ++++++++++++++ recolour_test.go | 457 ++++++++++++++++++++++++++++++++++++++ write.go | 25 ++- 9 files changed, 955 insertions(+), 2 deletions(-) create mode 100644 recolour.go create mode 100644 recolour_test.go diff --git a/README.md b/README.md index 1f82650..ba585f2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/pdfops/expose_test.go b/cmd/pdfops/expose_test.go index 75a0c7f..e4cc948 100644 --- a/cmd/pdfops/expose_test.go +++ b/cmd/pdfops/expose_test.go @@ -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:]) + } + } +} diff --git a/cmd/pdfops/run.go b/cmd/pdfops/run.go index e5996bc..0c4d030 100644 --- a/cmd/pdfops/run.go +++ b/cmd/pdfops/run.go @@ -64,6 +64,7 @@ var commands = []command{ {"repair", "[-check] [out.pdf]", "rebuild a file whose structure is damaged, and say whether it was", runRepair}, {"layers", "(-list | -show | -hide | -add | -drop ) [out.pdf]", "the optional content a viewer calls layers", runLayers}, {"labels", "(-list | -clear | -rule …) [out.pdf]", "set what a viewer shows in its toolbar: i, ii, iii then 1, 2, 3", runLabels}, + {"textcolour", "-to <#rrggbb> [-pages ] ", "paint the text one colour, leaving the rest of the page alone", runTextColour}, {"strip", "[-annotations] [-bookmarks] ", "write the file without its metadata", runStrip}, {"sanitize", " ", "remove what runs rather than shows: scripts, launching, embedded files", runSanitize}, {"outline", "(-drop | -from ) ", "replace or remove the bookmarks", runOutline}, @@ -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, " "); 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) +} diff --git a/doc.go b/doc.go index 12567c5..314249c 100644 --- a/doc.go +++ b/doc.go @@ -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 @@ -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. diff --git a/go.mod b/go.mod index a1d9497..cd864f5 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index b8a5dd3..c54faa9 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/recolour.go b/recolour.go new file mode 100644 index 0000000..35bfa8b --- /dev/null +++ b/recolour.go @@ -0,0 +1,169 @@ +// Copyright (c) 2026, the go-pdfkit/ops authors +// All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package ops + +import ( + "fmt" + "strconv" + "strings" + + "github.com/go-pdfkit/reader" +) + +// RecolourText paints the text of the pages a range names in one colour, +// leaving everything else on the page as it was. +// +// The colour is red, green and blue, each from zero to one. +// +// ⛔ It reaches the text in each PAGE'S OWN content stream. Text drawn through +// a Form XObject is NOT recoloured, and the reason is not that it would be +// hard: an XObject is SHARED, so recolouring one would change pages the caller +// did not name. [Doc.RecolourText] therefore counts what it could not reach +// and [Doc.UnreachedText] reports it — because a tool that silently did +// nothing to a document whose text is all in XObjects would be worse than one +// that refused. +func (d *Doc) RecolourText(spec string, r, g, b float64) error { + for _, v := range []float64{r, g, b} { + if v < 0 || v > 1 { + return fmt.Errorf("ops: a colour component is from 0 to 1, not %v", v) + } + } + nums, err := ParseRange(spec, len(d.pages)) + if err != nil { + return err + } + if len(nums) == 0 { + return fmt.Errorf("ops: %q names no pages to recolour", spec) + } + colour := [3]float64{r, g, b} + for _, n := range nums { + p := &d.pages[n-1] + // ⛔ A page this package MADE rather than borrowed has no text of its + // own to recolour: its content is the tiles and marks this document + // composed, and a stamp's colour is the stamp's own business. Saying + // so is better than appearing to work. + if p.blank || p.tiles != nil || p.picture != nil { + continue + } + p.recolour = &colour + } + return nil +} + +// UnreachedText reports how many text-showing operations the last write left +// in a colour of their own because they are inside a Form XObject, which is +// shared and so cannot be recoloured for one page without changing others. +// +// It is zero until the document has been written, because until then there is +// nothing to have reached. +func (d *Doc) UnreachedText() int { return d.unreached } + +// colourOps are the operators that set a colour. Every one of them is BLANKED +// inside a text object, because a colour set there would override the one +// being asked for — the first draft only set a colour before each BT, and any +// page whose text set its own colour came out unchanged. +var colourOps = map[string]bool{ + // Non-stroking, then stroking. + "g": true, "rg": true, "k": true, "cs": true, "sc": true, "scn": true, + "G": true, "RG": true, "K": true, "CS": true, "SC": true, "SCN": true, +} + +// recolourContent rewrites a content stream so that every text object paints +// in one colour, and says how many text-showing operations it could not reach. +// +// ⛔ It SPLICES rather than re-serialises. 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. Splicing keeps every byte it does not +// deliberately change, which is why [reader.Operation] carries its range. +// +// The edits are collected and applied in one pass, so that an insertion does +// not move the offsets of the ones after it. +func recolourContent(data []byte, colour [3]float64) (out []byte, unreached int) { + type edit struct { + at, end int + with string + } + var edits []edit + set := colourText(colour) + + depth := 0 + scan := reader.NewContentScanner(data) + for { + op, ok := scan.Next() + if !ok { + break + } + switch { + case op.Operator == "BT": + depth++ + if depth == 1 { + // q and the colour go BEFORE the BT; the Q after the ET. A + // text object does not save the graphics state itself, so a + // colour set inside one would leak into whatever is drawn + // after it — which is how a recolouring turns a company's + // logo the same shade as its paragraphs. + edits = append(edits, edit{at: op.Start, end: op.Start, with: "q " + set + " "}) + } + case op.Operator == "ET": + if depth == 1 { + edits = append(edits, edit{at: op.End, end: op.End, with: " Q"}) + } + if depth > 0 { + depth-- + } + case depth > 0 && colourOps[op.Operator]: + // ⛔ BLANKED, not removed: the replacement is spaces of the same + // length, so every offset after it is still the offset the scan + // reported. Deleting the bytes instead would be correct only if + // the edits were applied back to front, and a rule that is + // correct only in one order is a rule somebody will reorder. + edits = append(edits, edit{at: op.Start, end: op.End, + with: strings.Repeat(" ", op.End-op.Start)}) + case depth > 0 && op.Operator == "Do": + // A Form XObject drawn inside a text object, or an image. Either + // way its own content is not this stream's to change. + unreached++ + case depth == 0 && op.Operator == "Do": + // ⛔ Counted too, and this is the honest half of the limitation: a + // page whose text is ALL inside an XObject would otherwise come + // back unchanged with nothing said. Not every XObject holds text, + // so this counts what MIGHT not have been reached rather than what + // certainly was not — and over-reporting a limit is the safe + // direction for a caller deciding whether to trust the result. + unreached++ + } + } + if len(edits) == 0 { + return data, unreached + } + var sb strings.Builder + sb.Grow(len(data) + len(edits)*len(set) + 16) + // ⛔ No guard against overlapping edits, and that is a removal rather than + // an omission. The edits come from a scan whose ranges go/reader asserts + // are ORDERED AND DISJOINT, so e.at is never behind the last end — which + // made the guard dead code a coverage gap found. The invariant is named + // where it is established rather than re-checked where it is relied on. + at := 0 + for _, e := range edits { + sb.Write(data[at:e.at]) + sb.WriteString(e.with) + at = e.end + } + sb.Write(data[at:]) + return []byte(sb.String()), unreached +} + +// colourText is the operators that set both the filling and the stroking +// colour, so that text comes out in the colour asked for whatever its render +// mode is: mode 0 fills, 1 strokes, 2 does both. +func colourText(c [3]float64) string { + n := func(v float64) string { + return strconv.FormatFloat(v, 'g', -1, 64) + } + rgb := n(c[0]) + " " + n(c[1]) + " " + n(c[2]) + return rgb + " rg " + rgb + " RG" +} diff --git a/recolour_test.go b/recolour_test.go new file mode 100644 index 0000000..a20bbde --- /dev/null +++ b/recolour_test.go @@ -0,0 +1,457 @@ +// Copyright (c) 2026, the go-pdfkit/ops authors +// All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package ops + +import ( + "fmt" + "strings" + "testing" + + "github.com/go-pdfkit/reader" +) + +var blue = [3]float64{0, 0, 1} + +// ⛔ THE CASE THAT CHANGED THE DESIGN. Setting a colour before each BT is not +// enough: a text object that sets its OWN colour overrides it, and a page +// whose paragraphs say "1 0 0 rg" would come out unchanged while the tool +// reported success. The colour operators inside a text object are blanked. +func TestAColourSetInsideTheTextIsNotLeftToWin(t *testing.T) { + const in = "BT /F1 12 Tf 1 0 0 rg (red please) Tj ET\n" + out, _ := recolourContent([]byte(in), blue) + got := string(out) + if !strings.Contains(got, "0 0 1 rg") { + t.Errorf("the asked-for colour is not in %q", got) + } + // ⛔ The original "1 0 0 rg" must no longer be an operation. It is blanked + // rather than deleted, so the bytes are spaces — asserting on the SCAN + // rather than on the text, because "1 0 0 rg" also appears nowhere once + // blanked and a string search would pass on a stream that lost the text + // too. + for _, op := range mustOps(t, out) { + if op.Operator == "rg" && len(op.Operands) == 3 { + if f, _ := reader.ToFloat(op.Operands[0]); f == 1 { + t.Errorf("the original red is still an operation in %q", got) + } + } + } + // And the text itself survives. + if !strings.Contains(got, "(red please) Tj") { + t.Errorf("the text was lost: %q", got) + } +} + +// ⛔ 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 logo the +// same shade as the paragraphs. +func TestTheColourDoesNotLeakPastTheText(t *testing.T) { + const in = "BT (words) Tj ET\n1 0 0 rg 10 10 20 20 re f\n" + out, _ := recolourContent([]byte(in), blue) + got := string(out) + // The q must come before the BT and the Q after the ET, so the rectangle + // after it keeps its own red. + q := strings.Index(got, "q ") + bt := strings.Index(got, "BT") + et := strings.Index(got, "ET") + qq := strings.Index(got, " Q") + rect := strings.Index(got, "re f") + if !(q >= 0 && q < bt && bt < et && et < qq && qq < rect) { + t.Errorf("the wrapping is in the wrong places (%d %d %d %d %d):\n%s", + q, bt, et, qq, rect, got) + } + // ⛔ And the rectangle's own colour is untouched: recolouring the TEXT + // must not recolour the drawing. + var reds int + for _, op := range mustOps(t, out) { + if op.Operator == "rg" && len(op.Operands) == 3 { + if f, _ := reader.ToFloat(op.Operands[0]); f == 1 { + reds++ + } + } + } + if reds != 1 { + t.Errorf("%d red fills survive, want the rectangle's one:\n%s", reds, got) + } +} + +// Both the filling and the stroking colour are set, because text has render +// modes: 0 fills, 1 strokes, 2 does both. A tool that set only the fill would +// leave outlined text in its old colour. +func TestBothTheFillAndTheStrokeAreSet(t *testing.T) { + out, _ := recolourContent([]byte("BT 1 Tr (outlined) Tj ET"), blue) + got := string(out) + if !strings.Contains(got, "0 0 1 rg") { + t.Errorf("no filling colour in %q", got) + } + if !strings.Contains(got, "0 0 1 RG") { + t.Errorf("no stroking colour in %q", got) + } +} + +// Every colour operator a text object can use is blanked, not just rg: a +// document in CMYK or in a named colour space would otherwise keep its own. +func TestEveryColourOperatorInsideTheTextIsBlanked(t *testing.T) { + for _, op := range []string{ + "0 g", "1 0 0 rg", "0 0 0 1 k", "/CS0 cs", "0.5 sc", "0.1 0.2 0.3 scn", + "0 G", "1 0 0 RG", "0 0 0 1 K", "/CS0 CS", "0.5 SC", "0.1 0.2 0.3 SCN", + } { + in := "BT " + op + " (x) Tj ET" + out, _ := recolourContent([]byte(in), blue) + // The operator must no longer be an operation of the stream. + name := op[strings.LastIndex(op, " ")+1:] + for _, got := range mustOps(t, out) { + if got.Operator == name && !isAskedFor(got, blue) { + t.Errorf("%q survived inside the text: %q", op, out) + } + } + if !strings.Contains(string(out), "(x) Tj") { + t.Errorf("%q: the text was lost: %q", op, out) + } + } +} + +// ⛔ A page with no text is returned UNCHANGED, byte for byte. A recolouring +// that rewrote a stream it had nothing to do to would churn every file it +// touched and lose the one guarantee worth having: what is not recoloured is +// not altered. +func TestAStreamWithNoTextIsUntouched(t *testing.T) { + const in = "q 1 0 0 rg 10 10 20 20 re f Q\n" + out, unreached := recolourContent([]byte(in), blue) + if string(out) != in { + t.Errorf("a stream with no text was rewritten:\n%q\n%q", in, out) + } + if unreached != 0 { + t.Errorf("unreached = %d, want 0", unreached) + } +} + +// ⛔ Text inside a Form XObject is COUNTED rather than silently left. A page +// whose text is all in XObjects would otherwise come back unchanged with +// nothing said, which is worse than a refusal. +func TestWhatCannotBeReachedIsCounted(t *testing.T) { + const in = "q /Fm0 Do Q\nBT (on the page) Tj ET\n" + out, unreached := recolourContent([]byte(in), blue) + if unreached != 1 { + t.Errorf("unreached = %d, want 1 for the one Do", unreached) + } + // And the text that IS on the page is still recoloured: a limitation is + // not an excuse to do nothing. + if !strings.Contains(string(out), "0 0 1 rg") { + t.Errorf("the page's own text was not recoloured: %q", out) + } +} + +// Several text objects each get their own wrapping, and nested BT — which the +// format does not allow but a file can hold — does not produce a stray Q. +func TestSeveralTextObjectsAndANestedOne(t *testing.T) { + const in = "BT (one) Tj ET BT (two) Tj ET" + out, _ := recolourContent([]byte(in), blue) + got := string(out) + if n := strings.Count(got, "0 0 1 rg"); n != 2 { + t.Errorf("%d colours set for two text objects:\n%s", n, got) + } + if strings.Count(got, "q ") != 2 || strings.Count(got, " Q") != 2 { + t.Errorf("the wrapping is not balanced:\n%s", got) + } + // ⛔ A nested BT is illegal and a file can still hold one. The wrapping + // must stay balanced: one q and one Q for the outer object, and none for + // the inner, or the graphics state is left unbalanced and everything + // after the page is drawn wrong. + nested, _ := recolourContent([]byte("BT (a) Tj BT (b) Tj ET ET"), blue) + ng := string(nested) + if strings.Count(ng, "q ") != strings.Count(ng, " Q") { + t.Errorf("a nested text object left the state unbalanced:\n%s", ng) + } + if strings.Count(ng, "q ") != 1 { + t.Errorf("%d wrappings for a nested pair, want 1:\n%s", strings.Count(ng, "q "), ng) + } +} + +// ⛔ An ET with no BT before it, which a damaged file can hold, must not +// produce a Q that unbalances the state. +func TestAStrayEndOfTextDoesNotUnbalanceTheState(t *testing.T) { + out, _ := recolourContent([]byte("ET 10 10 20 20 re f"), blue) + got := string(out) + if strings.Contains(got, "Q") { + t.Errorf("a stray ET produced a Q:\n%s", got) + } +} + +// ⛔ An inline image inside a text object keeps its bytes. This is why the +// rewriting splices rather than re-serialises: an image's data is still +// encoded, and formatting it again can change what it means. +func TestAnInlineImageKeepsItsBytes(t *testing.T) { + const in = "BT (x) Tj BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 00> EI ET" + out, _ := recolourContent([]byte(in), blue) + got := string(out) + if !strings.Contains(got, "ID 00> EI") { + t.Errorf("the image's data changed:\n%s", got) + } +} + +func TestTheColourIsFormattedShortly(t *testing.T) { + if got := colourText([3]float64{0, 0, 1}); got != "0 0 1 rg 0 0 1 RG" { + t.Errorf("colourText = %q", got) + } + if got := colourText([3]float64{0.5, 0.25, 0}); got != "0.5 0.25 0 rg 0.5 0.25 0 RG" { + t.Errorf("colourText = %q", got) + } +} + +// mustOps scans a stream and fails if it will not scan. +func mustOps(t *testing.T, data []byte) []reader.Operation { + t.Helper() + ops, err := reader.Operations(data) + if err != nil { + t.Fatalf("the rewritten stream does not scan: %v\n%s", err, data) + } + return ops +} + +// isAskedFor says whether an operation is the colour this test asked for, +// rather than one the original stream had. +func isAskedFor(op reader.Operation, c [3]float64) bool { + if len(op.Operands) != 3 { + return false + } + for i, want := range c { + got, ok := reader.ToFloat(op.Operands[i]) + if !ok || got != want { + return false + } + } + return true +} + +// textPDF builds a document whose pages each hold a text object that sets its +// own colour, which is the case a recolouring has to win against. +func textPDF(t *testing.T, pages int) []byte { + t.Helper() + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + kids := make(reader.Array, 0, pages) + for i := 1; i <= pages; i++ { + body := fmt.Sprintf("1 0 0 rg 5 5 10 10 re f\nBT /F1 12 Tf 1 0 0 rg (page %d) Tj ET\n", i) + kids = append(kids, 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(pages), + "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) + } + return out +} + +// pageText reads back one page's content from a written file. +func pageContentOf(t *testing.T, b []byte, page int) string { + t.Helper() + d, err := reader.Open(b) + if err != nil { + t.Fatal(err) + } + content, err := d.PageContent(page) + if err != nil { + t.Fatal(err) + } + return string(content) +} + +// End to end: the text comes out in the colour asked for, and the rectangle +// beside it keeps its own. +func TestRecolourTextEndToEnd(t *testing.T) { + d, err := Open(textPDF(t, 2)) + if err != nil { + t.Fatal(err) + } + if err := d.RecolourText("all", 0, 0, 1); err != nil { + t.Fatal(err) + } + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + for page := 1; page <= 2; page++ { + got := pageContentOf(t, out, page) + if !strings.Contains(got, "0 0 1 rg") { + t.Errorf("page %d has no blue:\n%s", page, got) + } + if !strings.Contains(got, "re f") { + t.Errorf("page %d lost its rectangle:\n%s", page, got) + } + // ⛔ The rectangle's own red survives: exactly ONE red fill, the one + // outside the text object. + if n := strings.Count(got, "1 0 0 rg"); n != 1 { + t.Errorf("page %d has %d red fills, want the rectangle's one:\n%s", page, n, got) + } + } +} + +// A page range recolours those pages and no others. +func TestRecolourARange(t *testing.T) { + d, err := Open(textPDF(t, 3)) + if err != nil { + t.Fatal(err) + } + if err := d.RecolourText("2", 0, 0, 1); err != nil { + t.Fatal(err) + } + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + if got := pageContentOf(t, out, 2); !strings.Contains(got, "0 0 1 rg") { + t.Errorf("page 2 was not recoloured:\n%s", got) + } + for _, page := range []int{1, 3} { + if got := pageContentOf(t, out, page); strings.Contains(got, "0 0 1 rg") { + t.Errorf("page %d was recoloured and should not have been:\n%s", page, got) + } + } +} + +// ⛔⛔ THE COMPOSITION. A page both recoloured and watermarked must keep both. +// The first draft lost the recolouring: `decorate` rebuilt the page's Contents +// from the SOURCE's parts, throwing away the rewritten stream, and nothing +// failed — the file was valid and the text was its old colour. +func TestRecolourAndWatermarkTogether(t *testing.T) { + d, err := Open(textPDF(t, 1)) + if err != nil { + t.Fatal(err) + } + if err := d.RecolourText("all", 0, 0, 1); err != nil { + t.Fatal(err) + } + if err := d.Watermark("all", "DRAFT"); err != nil { + t.Fatal(err) + } + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + got := pageContentOf(t, out, 1) + if !strings.Contains(got, "0 0 1 rg") { + t.Errorf("the recolouring was lost to the watermark:\n%s", got) + } + if !strings.Contains(got, "DRAFT") { + t.Errorf("the watermark is not there:\n%s", got) + } + // ⛔ And the watermark keeps ITS own colour: a stamp's colour is the + // stamp's business, which is why the recolouring happens before the + // decoration and not over it. + if !strings.Contains(got, "(page 1) Tj") { + t.Errorf("the page's text was lost:\n%s", got) + } +} + +// A page this package MADE has no text of its own to recolour, and saying so +// by doing nothing is better than appearing to work. +func TestRecolouringAMadePageDoesNothing(t *testing.T) { + d := New() + d.Blank(100, 200) + if err := d.Stamp("all", Stamp{Text: "made", Font: Helvetica, Size: 12}); err != nil { + t.Fatal(err) + } + if err := d.RecolourText("all", 0, 0, 1); err != nil { + t.Fatal(err) + } + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + if got := pageContentOf(t, out, 1); !strings.Contains(got, "made") { + t.Errorf("the made page lost its stamp:\n%s", got) + } +} + +func TestRecolourRefusals(t *testing.T) { + d, err := Open(textPDF(t, 2)) + if err != nil { + t.Fatal(err) + } + for _, c := range [][3]float64{{-1, 0, 0}, {0, 2, 0}, {0, 0, -0.1}} { + if err := d.RecolourText("all", c[0], c[1], c[2]); err == nil { + t.Errorf("a colour of %v was accepted", c) + } + } + if err := d.RecolourText("not a range", 0, 0, 1); err == nil { + t.Error("a range that is not one was accepted") + } + if err := New().RecolourText("all", 0, 0, 1); err == nil { + t.Error("an empty document was recoloured") + } +} + +// What could not be reached is reported after the write, because until then +// there is nothing to have reached. +func TestUnreachedTextIsReportedAfterTheWrite(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 a form) 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\nBT (on the page) Tj ET\n")}), + })} + 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) + } + d, err := Open(src) + if err != nil { + t.Fatal(err) + } + if got := d.UnreachedText(); got != 0 { + t.Errorf("UnreachedText before the write = %d, want 0", got) + } + if err := d.RecolourText("all", 0, 0, 1); err != nil { + t.Fatal(err) + } + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + if got := d.UnreachedText(); got != 1 { + t.Errorf("UnreachedText = %d, want 1 for the form", got) + } + // The page's own text IS recoloured: a limitation is not an excuse to do + // nothing. + if got := pageContentOf(t, out, 1); !strings.Contains(got, "0 0 1 rg") { + t.Errorf("the page's own text was not recoloured:\n%s", got) + } +} + +// ⛔ A Do inside a text object is counted too. Drawing an XObject there is not +// something the format allows and files do it anyway, and the count must not +// depend on where the Do sits — a page whose only unreachable text is inside +// one would otherwise be reported as fully recoloured. +func TestADoInsideATextObjectIsCounted(t *testing.T) { + out, unreached := recolourContent([]byte("BT (a) Tj /Fm0 Do ET"), blue) + if unreached != 1 { + t.Errorf("unreached = %d, want 1", unreached) + } + if !strings.Contains(string(out), "0 0 1 rg") { + t.Errorf("the text beside it was not recoloured: %q", out) + } +} diff --git a/write.go b/write.go index dbc98c3..9f251d6 100644 --- a/write.go +++ b/write.go @@ -194,6 +194,17 @@ func (d *Doc) buildBorrowedPage(w *reader.Writer, p Page, parent reader.Ref, whe } ensureGeometry(copied) + // ⛔ The recolouring happens BEFORE the decoration, and the order matters: + // a stamp's own colour is the stamp's business, so recolouring the page's + // text must not reach the marks this document put on top of it. + if p.recolour != nil { + if content, err := p.src.PageContent(p.number); err == nil { + out, unreached := recolourContent(content, *p.recolour) + d.unreached += unreached + copied["Contents"] = w.Add(contentStream(out, nil)) + } + } + extra, resources := d.borrowedExtras(w, p, src) if len(extra) > 0 { d.decorate(w, p, src, copied, extra, resources) @@ -228,7 +239,19 @@ func (d *Doc) borrowedExtras(w *reader.Writer, p Page, src reader.Dict) ([]byte, func (d *Doc) decorate(w *reader.Writer, p Page, src, copied reader.Dict, extra []byte, resources reader.Dict) { before := w.Add(contentStream([]byte("q\n"), nil)) after := w.Add(contentStream(append([]byte("Q\n"), extra...), nil)) - list := append(reader.Array{before}, contentParts(w, p.src, src)...) + // ⛔ The page's content may ALREADY have been rewritten — by a + // recolouring, which replaced Contents with one stream of its own. Taking + // contentParts unconditionally, which is what the first draft did, threw + // that away: a page both recoloured and watermarked came back in its + // original colours, and nothing failed. What is here is used when it is + // a rewritten stream and the source's parts otherwise. + var parts reader.Array + if rewritten, ok := copied["Contents"].(reader.Ref); ok && p.recolour != nil { + parts = reader.Array{rewritten} + } else { + parts = contentParts(w, p.src, src) + } + list := append(reader.Array{before}, parts...) copied["Contents"] = append(list, after) // The resources are rebuilt as a dictionary of this file's own, because