Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 31 additions & 0 deletions markedcontent.go
Original file line number Diff line number Diff line change
@@ -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 <</ActualText ")
pdfTextString(replacement).encodePDF(&b)
b.WriteString(">>")
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")
}
73 changes: 73 additions & 0 deletions markedcontent_test.go
Original file line number Diff line number Diff line change
@@ -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 <</ActualText ()>> BDC\n", "/Span <</ActualText <FEFF00E9>>> BDC\n", "/Span <</ActualText (x)>> 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)
}
}
13 changes: 11 additions & 2 deletions page.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading