Class: Pdf::TextReplacer

Inherits:
Object
  • Object
show all
Defined in:
app/services/pdf/text_replacer.rb

Overview

Find-and-replace in a PDF's TEXT layer ("everywhere the spec says X, make
it Y"). Each occurrence of +search+ is located with PDF::Reader's
positioned text runs, redacted with a cover rectangle, and overlaid with
+replacement+ at the same position and font size via Pdf::Toolkit.stamp.

Honest limits (surfaced in +meta+ / errors, never hidden):

  • Only matches that fit inside a SINGLE text run are replaced. A phrase
    fragmented across runs (kerning splits, mixed styling) is reported in
    the not-found error message — the operator handles those by hand.
  • The overlay uses Helvetica; the original font is NOT matched, and a
    replacement longer than the search text overflows the vacated width.
    Review staged output in the PDF studio before importing.
  • Rasterized text (inside images) is invisible here — that's the
    image-translation pipeline's job, and encoding-corrupted text layers
    (broken ToUnicode CMaps) extract as mojibake and won't match.
  • Redaction is VISUAL (standard overlay redaction): the original text
    stays in the text layer underneath the cover rectangle, so text
    extraction (docling, search indexing) will read BOTH the old and new
    values until a true content-stream redaction exists. The rendered
    document is correct; re-indexes of the revised PDF are not, yet.

Examples:

result = Pdf::TextReplacer.replace('/tmp/spec.pdf', search: '240V', replacement: '208V')
result.meta # => { replacements: 3, pages: [1, 4], skipped_spans: 0 }

Defined Under Namespace

Classes: Result

Constant Summary collapse

COVER_PAD_X =

Cover-rectangle padding (points) so glyph descenders/antialiasing at the
edges don't peek out from under the redaction.

1.0
DESCENT_RATIO =
0.25
LINE_HEIGHT_RATIO =
1.3

Class Method Summary collapse

Class Method Details

.replace(path, search:, replacement:, case_sensitive: false, background: 'FFFFFF') ⇒ Result

Parameters:

  • path (String)

    local PDF file path

  • search (String)

    text to find (literal, not regex)

  • replacement (String)

    text to overlay in its place

  • case_sensitive (Boolean) (defaults to: false)

    default false

  • background (String) (defaults to: 'FFFFFF')

    cover rectangle color hex (default white)

Returns:

Raises:



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# File 'app/services/pdf/text_replacer.rb', line 46

def replace(path, search:, replacement:, case_sensitive: false, background: 'FFFFFF')
  Pdf::Loader.load!
  raise Pdf::Toolkit::Error, 'search must be present' if search.to_s.strip.empty?

  hits, found_in_page_text = collect_matches(path, search.to_s, case_sensitive)
  raise Pdf::Toolkit::Error, not_found_message(search, found_in_page_text) if hits.empty?

  bytes = hits.group_by { |h| h[:page] }.reduce(File.binread(path)) do |data, (page, page_hits)|
    operations = page_hits.flat_map do |hit|
      [cover_op(hit, background), text_op(hit, replacement)]
    end
    with_temp_pdf(data) { |tmp| Pdf::Toolkit.stamp(tmp, operations:, pages: page).bytes }
  end

  Result.new(bytes:, meta: {
    replacements: hits.size,
    pages:        hits.pluck(:page).uniq.sort
  })
end