From bd3568cee5bcb5f180959c83d86cc5d9416d4f33 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 11 Oct 2026 19:02:20 +0200 Subject: [PATCH] An Operation says which bytes of the stream it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operation gains Start and End: the byte range of the operation in the stream it was scanned from, Start at its first operand (or at the operator when it has none) and End just past the operator. ⛔ THEY ARE HERE SO THAT A CONTENT STREAM CAN BE EDITED IN PLACE. A tool that wants to put something before every BT has two honest ways to find one: re-serialise the whole stream, which risks changing what an inline image's bytes mean, or know where the operator was — and this scanner is the only thing that does know. "BT" appears inside strings, inside names and inside an inline image's data, and a byte search cannot tell those from an operator. The test pins the discriminator: in `(BT in a string) Tj BT /F1 12 Tf ET` a byte search finds index 1, inside somebody's text, and the scan reports index 20. End is the LEXER's position rather than the operator's start plus its length, because an inline image's operator token is "BI" and its operation runs to the EI three keywords later — measuring from the operator would put the end in the middle of the image's data, where a splice corrupts the image instead of the stream around it. Start is the first OPERAND's position, so that splicing before an operation does not land between its operands and its operator. The ranges of a stream are asserted ordered and disjoint, which is what lets a caller splice at several of them in one pass, and a stream with rubbish in it keeps honest ranges around what the scan stepped over. 100% statement coverage, go vet and gofmt clean. Two mutations caught: the range reduced to the operator alone, and the first-operand mark overwritten by each later operand. Co-Authored-By: Claude Opus 5 --- content.go | 58 ++++++++++-- contentrange_test.go | 207 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 257 insertions(+), 8 deletions(-) create mode 100644 contentrange_test.go diff --git a/content.go b/content.go index 5b8b5e4..156a76f 100644 --- a/content.go +++ b/content.go @@ -13,6 +13,19 @@ type Operation struct { Operator string Operands []Object Image *InlineImage + + // Start and End are the operation's byte range in the stream it was + // scanned from: Start is the first byte of its first operand, or of the + // operator itself when it has none, and End is just past the operator. + // + // ⛔ They are here so that a content stream can be edited IN PLACE. A tool + // that wants to put something before every BT has only two honest ways to + // find one: re-serialise the whole stream, which risks changing what an + // inline image's bytes mean, or know where the operator was — and this + // scanner is the only thing that does know, because a "BT" inside a + // string or inside an inline image's data is not an operator and a byte + // search cannot tell. + Start, End int } // An InlineImage is the dictionary and the still-encoded bytes of a BI … ID … @@ -108,12 +121,16 @@ func expandColourSpace(k Name, v Object) Object { type ContentScanner struct { lex lexer pending []Object - err error + // at is where the operands waiting for an operator began, so that an + // operation can say which bytes of the stream it is. -1 means none are + // waiting and the operator's own position is the start. + at int + err error } // NewContentScanner reads operations from a decoded content stream. func NewContentScanner(data []byte) *ContentScanner { - return &ContentScanner{lex: lexer{buf: data}} + return &ContentScanner{lex: lexer{buf: data}, at: -1} } // Err reports the first malformed token the scan stepped over, if any. @@ -141,12 +158,15 @@ func (s *ContentScanner) Next() (Operation, bool) { } switch string(t.text) { case "true": + s.mark(t.pos) s.pending = append(s.pending, Bool(true)) continue case "false": + s.mark(t.pos) s.pending = append(s.pending, Bool(false)) continue case "null": + s.mark(t.pos) s.pending = append(s.pending, Null{}) continue case "BI": @@ -155,14 +175,23 @@ func (s *ContentScanner) Next() (Operation, bool) { s.note(err) return Operation{}, false } - return s.emit("BI", img), true + return s.emit("BI", img, t.pos), true } - return s.emit(string(t.text), nil), true + return s.emit(string(t.text), nil, t.pos), true + } +} + +// mark remembers where the operands waiting for an operator began. Only the +// FIRST matters: an operation's start is the start of its first operand. +func (s *ContentScanner) mark(pos int) { + if s.at < 0 { + s.at = pos } } // operand parses one operand, an already-read token starting it. func (s *ContentScanner) operand(t token) { + s.mark(t.pos) p := &parser{lex: s.lex} o, err := p.parseFrom(t) s.lex = p.lex @@ -176,10 +205,23 @@ func (s *ContentScanner) operand(t token) { s.pending = append(s.pending, o) } -// emit finishes an operation and clears the operands waiting for it. -func (s *ContentScanner) emit(op string, img *InlineImage) Operation { - out := Operation{Operator: op, Operands: s.pending, Image: img} - s.pending = nil +// emit finishes an operation and clears the operands waiting for it. opPos is +// where the operator's own token began, which is the operation's start when no +// operands preceded it. +func (s *ContentScanner) emit(op string, img *InlineImage, opPos int) Operation { + start := s.at + if start < 0 { + start = opPos + } + out := Operation{ + Operator: op, Operands: s.pending, Image: img, + // ⛔ End is the lexer's position and not opPos plus the operator's + // length: an inline image's operator token is "BI" and its operation + // runs to the EI three keywords later, so measuring from the operator + // would put the end in the middle of the image's data. + Start: start, End: s.lex.pos, + } + s.pending, s.at = nil, -1 return out } diff --git a/contentrange_test.go b/contentrange_test.go new file mode 100644 index 0000000..21b6f18 --- /dev/null +++ b/contentrange_test.go @@ -0,0 +1,207 @@ +// Copyright (c) 2026, the go-pdfkit/reader authors +// All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package reader + +import ( + "strings" + "testing" +) + +// ⛔ The reason Operation carries a byte range: a tool that wants to put +// something before every BT cannot find one by searching for the bytes. "BT" +// appears inside strings, inside names, and inside an inline image's data, +// and none of those is an operator. Only the scan knows which is which. +func TestTheRangeIsOfTheOperationAndNotOfTheBytes(t *testing.T) { + // A "BT" inside a string, and then a real one. + const stream = "(BT in a string) Tj\nBT /F1 12 Tf ET\n" + var starts []int + var ops []string + s := NewContentScanner([]byte(stream)) + for { + op, ok := s.Next() + if !ok { + break + } + ops = append(ops, op.Operator) + starts = append(starts, op.Start) + // Every range must be inside the stream and non-empty. + if op.Start < 0 || op.End > len(stream) || op.End <= op.Start { + t.Errorf("%s: range [%d,%d) of a %d-byte stream", + op.Operator, op.Start, op.End, len(stream)) + } + // ⛔ And the operator's own text must be the END of its range, which + // is what makes splicing possible: insert at Start to go before the + // operation, at End to go after it. + if op.Operator != "BI" { + got := stream[op.End-len(op.Operator) : op.End] + if got != op.Operator { + t.Errorf("%s ends at %d, where the stream says %q", + op.Operator, op.End, got) + } + } + } + if strings.Join(ops, ",") != "Tj,BT,Tf,ET" { + t.Fatalf("operators = %v", ops) + } + // ⛔ THE DISCRIMINATOR. A byte search for "BT" finds index 1 — inside the + // string the Tj shows — while the scan reports the real one at index 20. + // A tool inserting "before the first BT" by searching would splice into + // the middle of somebody's text and corrupt the string. + byteSearch := strings.Index(stream, "BT") + scanned := starts[1] // the BT operation + if byteSearch != 1 { + t.Fatalf("the fixture's first \"BT\" byte is at %d, not inside the "+ + "string, so this proves nothing", byteSearch) + } + if scanned == byteSearch { + t.Error("the scan agrees with the byte search, so the range adds nothing") + } + if got := stream[scanned : scanned+2]; got != "BT" { + t.Errorf("the scanned BT is at %d, where the stream says %q", scanned, got) + } +} + +// An operation with operands starts at the FIRST operand, so that splicing +// before it does not land between the operands and the operator. +func TestAnOperationStartsAtItsFirstOperand(t *testing.T) { + const stream = " 1 0 0 RG 10 20 30 40 re S\n" + s := NewContentScanner([]byte(stream)) + op, ok := s.Next() + if !ok { + t.Fatal("no operation") + } + if op.Operator != "RG" { + t.Fatalf("first operator = %q", op.Operator) + } + // "1" is at index 2, after the two leading spaces. + if op.Start != 2 { + t.Errorf("Start = %d, want 2 (the first operand, not the operator)", op.Start) + } + if got := stream[op.Start:op.End]; got != "1 0 0 RG" { + t.Errorf("the range holds %q", got) + } +} + +// An operator with no operands starts at itself. +func TestAnOperatorWithNoOperandsStartsAtItself(t *testing.T) { + const stream = "q BT ET Q" + s := NewContentScanner([]byte(stream)) + for want := range map[string]bool{"q": true} { + _ = want + } + var got []string + for { + op, ok := s.Next() + if !ok { + break + } + got = append(got, stream[op.Start:op.End]) + } + if strings.Join(got, "|") != "q|BT|ET|Q" { + t.Errorf("the ranges hold %v, want each operator alone", got) + } +} + +// ⛔ An inline image's range runs to its EI, three keywords after the operator +// the scan reports. Measuring the end from the operator's own length would put +// it in the middle of the image's data — and splicing there would corrupt the +// image rather than the stream around it. +func TestAnInlineImageRangeCoversAllThreeKeywords(t *testing.T) { + const stream = "q BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 00> EI Q" + s := NewContentScanner([]byte(stream)) + var image Operation + found := false + for { + op, ok := s.Next() + if !ok { + break + } + if op.Operator == "BI" { + image, found = op, true + } + } + if !found { + t.Fatal("no inline image was scanned") + } + held := stream[image.Start:image.End] + if !strings.HasPrefix(held, "BI") { + t.Errorf("the range begins %q", held[:min(4, len(held))]) + } + if !strings.Contains(held, "ID") || !strings.Contains(held, "EI") { + t.Errorf("the range holds %q, want all three keywords", held) + } + // And the Q after it is outside the range, or the splice would swallow it. + if strings.Contains(held, "Q") { + t.Errorf("the range reaches past the image into %q", held) + } +} + +// The ranges of a whole stream must not overlap and must stay in order, which +// is what lets a caller splice at several of them in one pass. +func TestTheRangesAreOrderedAndDisjoint(t *testing.T) { + const stream = "q 1 0 0 RG BT /F1 12 Tf (hello) Tj ET Q 0 g 1 1 2 2 re f" + s := NewContentScanner([]byte(stream)) + prevEnd := 0 + n := 0 + for { + op, ok := s.Next() + if !ok { + break + } + n++ + if op.Start < prevEnd { + t.Errorf("%s starts at %d, before the previous end %d", + op.Operator, op.Start, prevEnd) + } + prevEnd = op.End + } + if n < 8 { + t.Fatalf("only %d operations, so this proves little", n) + } + if prevEnd > len(stream) { + t.Errorf("the last end is %d past the stream", prevEnd-len(stream)) + } +} + +// Rubbish in the stream must not make a range that points outside it: the scan +// steps over what it cannot read and the operations around it keep honest +// ranges. +func TestRubbishDoesNotBreakTheRanges(t *testing.T) { + const stream = "q ) ) ) 1 0 0 RG Q" + s := NewContentScanner([]byte(stream)) + for { + op, ok := s.Next() + if !ok { + break + } + if op.Start < 0 || op.End > len(stream) || op.End <= op.Start { + t.Errorf("%s: range [%d,%d) of a %d-byte stream", + op.Operator, op.Start, op.End, len(stream)) + } + } + if s.Err() == nil { + t.Error("the scan reported no error over a stream with rubbish in it") + } +} + +// Operations, which is the whole-stream form, carries the ranges too — a +// caller that used it rather than the scanner would otherwise have to rescan. +func TestOperationsCarriesTheRanges(t *testing.T) { + const stream = "1 0 0 RG S" + ops, err := Operations([]byte(stream)) + if err != nil { + t.Fatal(err) + } + if len(ops) != 2 { + t.Fatalf("%d operations", len(ops)) + } + if got := stream[ops[0].Start:ops[0].End]; got != "1 0 0 RG" { + t.Errorf("the first range holds %q", got) + } + if got := stream[ops[1].Start:ops[1].End]; got != "S" { + t.Errorf("the second range holds %q", got) + } +}