Skip to content

feat(run): say which bytes of the stream drew a run - #31

Merged
tannevaled merged 1 commit into
mainfrom
feat/run-operator-spans
Nov 7, 2026
Merged

tannevaled merged 1 commit into
mainfrom
feat/run-operator-spans

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Closes #30.

What

Run.Op is the byte range of the operation that drew the run, and Run.InForm
says whether that range is in the page's own content stream or a Form
XObject's.

runs, _ := extract.Runs(doc, 1)
cut := content[:runs[1].Op.From] + content[runs[1].Op.To:]
// the page still draws runs[0], in the same place, and no longer draws runs[1]

Why

To change a page rather than only read it. A redaction has to cut the glyphs
inside a region out of the content stream — ISO 32000-1 §12.5.6.23 requires a
conforming reader to "remove all traces of the specified content", not to draw
a box over it — and deciding which glyphs are inside a region is precisely
what this package already does: the text matrix, Td/TD/Tm/T*,
Tc/Tw/Tz, the font metrics. The only thing missing was where to cut.

reader.Operation already carries Start and End, and says why:

⛔ They are here so that a content stream can be edited IN PLACE.

So this is a pass-through, not a new mechanism: the walk records the span and
the depth of each operation it dispatches, and show puts them on the run.

The alternative, and why it is worse

A caller could pair runs with operators by counting them — the nth run from
the nth text-showing operator. That does not survive a Form XObject,
invisible text, a run split across operators, or an operator that draws
nothing. Worse, it fails in the way no test catches: both sides of the
comparison move together, so an off-by-one puts the cut in the wrong place and
everything still passes. The thing that computes the placement is the only
thing that knows both halves.

The other alternative — a second text state machine in the caller — is a second
implementation of Runs, and the first time the two disagreed the document
would be cut somewhere other than where the caller was told the text was.

Two things the API says out loud

  • ⛔ Several runs can share one span. A TJ array shows several strings
    with a single operator, so being rid of one piece means rewriting the
    span, not deleting it — deleting it would take the other pieces away too.
    TestTheRunsOfOneTJShareItsSpan pins this, and checks the two pieces really
    are drawn apart so the fixture is not vacuous.
  • ⛔ InForm means the span is not the page's. Its offsets are in the
    form's stream; cutting the page there would cut somewhere else entirely, and
    an XObject is shared, so editing it to change one page changes every page that
    draws it. A caller editing a page must refuse such a run — the shape
    ops.Doc.RecolourText already has, where UnreachedText counts what it could
    not reach. The test reads the form's span back out of the form's own stream
    to show which stream it indexes.

The test that matters

TestASpanIsEnoughToRemoveARun cuts a span out of a real stream, re-extracts,
and checks that

  • the removed string is gone from the stream and from the runs;
  • the surviving run says the same thing at the same place and the same
    width
    — removing one operator must not shift the text that stays, which is
    the difference between a redaction and a re-typesetting, and the reason the
    span covers the operands too.

A span nobody has cut with is a number, not a feature.

The control

With the walk not recording (e.op, e.depth = Span{}, 0) all four tests fail,
each saying what went wrong:

run 0 ("one") carries no span
the span reads "", want "[(one) -500 (two)] TJ"
text drawn by a form does not say so, so a caller would cut the page at the form's offsets
the cut stream still holds the string: "BT /F1 10 Tf 20 100 Td (keep me) Tj 0 -20 Td (remove me) Tj ET"

Checks

gofmt, go vet and the full suite clean; 63 functions at 100.0% by
go tool cover -func, which is the instrument the gate reads.

Next in the chain: ops#67
(redaction that removes rather than covers).

🤖 Generated with Claude Code

Run.Op is the byte range of the operation that drew the run, and
Run.InForm says whether that range is the page's own content or a Form
XObject's. Closes #30.

It is what a caller needs to CHANGE a page rather than only read it: a
redaction has to cut the glyphs inside a region out of the content
stream, and which glyphs are inside is what this package already works
out — the text matrix, Td/TD/Tm/T*, Tc/Tw/Tz, the font metrics. The only
thing missing was where to cut.

reader.Operation already carries Start and End for exactly this reason
("They are here so that a content stream can be edited IN PLACE"), so
this is a pass-through: the walk records the span and the depth of every
operation it dispatches, and show() puts them on the run.

The alternative was for each caller to pair runs with operators by
counting them. That does not survive a form, invisible text, a run split
across operators, or an operator that draws nothing — and it fails
where no test notices, because both sides of the comparison move
together.

Two things the API has to say out loud, and does:

  - several runs can share one span. A TJ array shows several strings
    with one operator, so being rid of one piece means REWRITING the
    span, not deleting it.
  - InForm means the span indexes the form's stream. Cutting the page
    there would cut somewhere else, and an XObject is shared, so editing
    it to change one page changes every page that draws it.

The last test is the one that matters: it cuts a span out of a real
stream, re-extracts, and checks that the remaining run says the same
thing AT THE SAME PLACE and the removed one is gone — a span nobody has
cut with is a number, not a feature. All four fail with the walk not
recording (empty spans, and a form's text claiming to be the page's).

63 functions at 100.0%, vet clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 7e3c483 into main Nov 7, 2026
1 check passed
@tannevaled
tannevaled deleted the feat/run-operator-spans branch November 7, 2026 21:44
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.

Say which bytes of the content stream a run came from, so a page can be edited and not only read

1 participant