Skip to content

textcolour: paint the text one colour, and leave the page alone - #47

Merged
tannevaled merged 1 commit into
mainfrom
the-colour-the-text-is-painted-in
Oct 11, 2026
Merged

tannevaled merged 1 commit into
mainfrom
the-colour-the-text-is-painted-in

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

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

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. A tool that set only the fill would
leave outlined text in its old colour.

⛔ 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 carries its byte
range, added for this. A stream with no text comes back byte for byte
unchanged.

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 that is
correct in one order only is a rule somebody will reorder.

⛔ 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
reported:

3 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

Doc.UnreachedText is the same answer in Go, and it is zero until the document
has been written because until then there is nothing to have reached.

#f00 is full red

Each digit of a short hex colour is doubled, not shifted: #f00 is
ff0000, so the component is 0xf × 17. A shift gives 240/255 for what every
browser and design tool 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, and says the wrong number out loud so nobody has to work out which is
which.

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 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 6df80eb into main Oct 11, 2026
1 check passed
@tannevaled
tannevaled deleted the the-colour-the-text-is-painted-in branch October 11, 2026 17:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant