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