diff --git a/README.md b/README.md index 1c2c5dd..02b59c4 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,12 @@ TrueType font, a synthesised CFF2 font and a bundled OFL OpenType/CFF font. - **Bookmarks** (`AddOutlineItem`) nest by level; since v0.16.0 `AddOutlineItemWith` styles a title bold or italic and points it at a place on the page (`[page /XYZ x y null]`) instead of fitting the whole page. +- **Replacement text** (after v0.16.0): `Page.BeginActualText(s)` … + `EndMarkedContent()` wrap what is drawn in a `/Span` with `/ActualText`, the + text extraction, copying and search use instead of the glyphs. An empty + string hides decorations from the text (numbers in a margin, a numbered disc + after a line of code): Poppler's `pdftotext` then leaves them out. Spans left + open are closed when the page is written. - **Encryption is not written here.** It is not missing from the organisation: `go-pdfkit/reader` reads RC4-40 through AES-256 and writes AES-128 and AES-256 with permissions and both passwords. diff --git a/markedcontent.go b/markedcontent.go new file mode 100644 index 0000000..0839ce2 --- /dev/null +++ b/markedcontent.go @@ -0,0 +1,31 @@ +package pdfkit + +import "bytes" + +// BeginActualText starts a marked-content span (/Span ... BDC) whose text, +// for extraction, copying and searching, is replacement instead of the +// glyphs drawn inside it. An empty replacement makes what is drawn +// invisible to text extraction while it still shows: numbers in a margin, a +// numbered disc after a line of code. Readers that honour ActualText — +// Poppler's pdftotext does — then leave it out of the text; a reader that +// does not still draws and extracts it as before. End the span with +// EndMarkedContent; spans nest. A span may enclose whole text objects +// (BT ... ET), which is how it is meant to be used. +func (p *Page) BeginActualText(replacement string) { + var b bytes.Buffer + b.WriteString("/Span <>") + p.op(b.String(), "BDC") + p.marked++ +} + +// EndMarkedContent ends the innermost span BeginActualText started. Without +// one open it writes nothing, so a stray call cannot unbalance the stream. +func (p *Page) EndMarkedContent() { + if p.marked == 0 { + return + } + p.marked-- + p.op("", "EMC") +} diff --git a/markedcontent_test.go b/markedcontent_test.go new file mode 100644 index 0000000..674d225 --- /dev/null +++ b/markedcontent_test.go @@ -0,0 +1,73 @@ +// Copyright (c) 2026 the go-pdfkit/pdfkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package pdfkit + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestActualTextSpan(t *testing.T) { + _, p, f := loadSynth(t) + p.SetFont(f, 12) + p.BeginActualText("") + if err := p.Text(10, 10, "Hi"); err != nil { + t.Fatal(err) + } + p.EndMarkedContent() + p.EndMarkedContent() // stray: writes nothing + p.BeginActualText("é") + p.BeginActualText("x") + c := content(p) // closes the two left open + for _, want := range []string{"/Span <> BDC\n", "/Span <>> BDC\n", "/Span <> BDC\n"} { + if !strings.Contains(c, want) { + t.Errorf("content lacks %q\n%s", want, c) + } + } + if b, e := strings.Count(c, "BDC"), strings.Count(c, "EMC"); b != 3 || e != 3 { + t.Errorf("%d BDC, %d EMC: unbalanced\n%s", b, e, c) + } + if i, j := strings.Index(c, "BDC"), strings.Index(c, "BT"); i > j { + t.Error("the span must enclose the text object") + } +} + +// What an empty ActualText encloses is drawn but not extracted: checked +// with Poppler's pdftotext when it is installed. +func TestActualTextHiddenFromExtraction(t *testing.T) { + bin, err := exec.LookPath("pdftotext") + if err != nil { + t.Skip("pdftotext not installed") + } + doc, p, f := loadSynth(t) + p.SetFont(f, 12) + if err := p.Text(10, 700, "Hi"); err != nil { + t.Fatal(err) + } + p.BeginActualText("") + if err := p.Text(10, 600, "HiHi"); err != nil { + t.Fatal(err) + } + p.EndMarkedContent() + var b bytes.Buffer + if err := doc.Write(&b); err != nil { + t.Fatal(err) + } + in := filepath.Join(t.TempDir(), "a.pdf") + if err := os.WriteFile(in, b.Bytes(), 0o644); err != nil { + t.Fatal(err) + } + out, err := exec.Command(bin, in, "-").Output() + if err != nil { + t.Fatal(err) + } + if text := strings.TrimSpace(string(out)); text != "Hi" { + t.Errorf("pdftotext extracted %q, want only the text outside the span", text) + } +} diff --git a/page.go b/page.go index 9760078..0cb992c 100644 --- a/page.go +++ b/page.go @@ -18,6 +18,9 @@ type Page struct { width float64 height float64 buf bytes.Buffer + // marked counts the marked-content spans open (markedcontent.go); the + // page closes them when it is finished. + marked int usedFonts map[*Font]bool usedImages map[*imageXObject]bool @@ -339,8 +342,14 @@ func (p *Page) SetAlpha(fill, stroke float64) { p.op("/GS"+strconv.Itoa(idx), "gs") } -// finishContent returns the accumulated content-stream bytes. -func (p *Page) finishContent() []byte { return p.buf.Bytes() } +// finishContent returns the accumulated content-stream bytes, closing any +// marked-content span left open: a stream with an unbalanced BDC is invalid. +func (p *Page) finishContent() []byte { + for p.marked > 0 { + p.EndMarkedContent() + } + return p.buf.Bytes() +} // resources builds the page's /Resources dictionary from the fonts and images // it used and any transparency states it declared.