Class: GeneratedPdfGenerator

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

Overview

Renders a PDF (via Pdf::Toolkit) and stages it as a GeneratedPdf for review
in the PDF studio — the PDF analogue of +ImageGenerationWorker+'s staging step.

Shared by the synchronous studio create action and the async
PdfGenerationWorker. PDF generation is fast (no external API), so the studio
renders inline; the worker exists for the Sunny hand-off and future async
reconstruction.

The stored +layout+ keeps stable references (an image's library id/URL, a
video URL); GeneratedPdfGenerator.render_bytes resolves those to renderable form at render time —
image references are downloaded to a temp file, and a +video+ block expands
into a QR code ("scan to watch") plus a visible link.

Usage:
result = GeneratedPdfGenerator.from_layout(layout:, title: "Spec", created_by_id: 42)
result.success? # => true
result.generated_pdf # => GeneratedPdf (status pending)

Defined Under Namespace

Classes: Result

Class Method Summary collapse

Class Method Details

.from_layout(layout:, title: nil, instructions: nil, created_by_id: nil, source_publication_id: nil, source_record: nil) ⇒ Result

Render a declarative layout into a staged PDF. The original +layout+ (with
its image/video references intact) is what gets stored for iteration.

Parameters:

Returns:



34
35
36
37
38
39
40
41
42
43
# File 'app/services/generated_pdf_generator.rb', line 34

def from_layout(layout:, title: nil, instructions: nil, created_by_id: nil,
                source_publication_id: nil, source_record: nil)
  rendered = render_bytes(layout)
  stage(bytes: rendered.bytes, layout: layout, kind: 'generated',
        title: title, instructions: instructions, created_by_id: created_by_id,
        source_publication_id: source_publication_id, source_record: source_record,
        page_count: rendered.meta[:pages])
rescue Pdf::Toolkit::Error => e
  Result.new(error: e.message)
end

.layout_from_text(title:, subtitle: nil, content: nil) ⇒ Hash

Build a layout Hash from a title/subtitle + a lightweight markdown-ish body:
"# Heading" → heading
"- item" / "* item" → bullets
"caption" → image (src = library Image id or image URL)
"@video URL | cap" → video (renders a QR + a link)
"text" → link
blank line → spacer
anything else → paragraph

Returns:

  • (Hash)


92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'app/services/generated_pdf_generator.rb', line 92

def layout_from_text(title:, subtitle: nil, content: nil)
  blocks = []
  content.to_s.each_line do |raw|
    line = raw.strip
    if line.empty?
      blocks << { 'type' => 'spacer', 'size' => 6 } unless blocks.last&.dig('type') == 'spacer'
    elsif (m = line.match(/\A!\[(.*?)\]\((.+?)\)\z/))
      blocks << { 'type' => 'image', 'source' => m[2].strip, 'caption' => m[1].strip.presence }.compact
    elsif (m = line.match(/\A@video\s+(\S+)(?:\s*\|\s*(.+))?\z/i))
      blocks << { 'type' => 'video', 'url' => m[1].strip, 'caption' => m[2]&.strip.presence }.compact
    elsif (m = line.match(%r{\A\[(.+?)\]\((https?://[^)]+)\)\z}))
      blocks << { 'type' => 'link', 'text' => m[1].strip, 'url' => m[2].strip }
    elsif line.start_with?('#')
      blocks << { 'type' => 'heading', 'text' => line.sub(/\A\#+\s*/, '') }
    elsif line.start_with?('- ', '* ')
      item = line.sub(/\A[-*]\s+/, '')
      if blocks.last&.dig('type') == 'bullets'
        blocks.last['items'] << item
      else
        blocks << { 'type' => 'bullets', 'items' => [item] }
      end
    else
      blocks << { 'type' => 'paragraph', 'text' => line }
    end
  end
  { 'title' => title, 'subtitle' => subtitle.presence, 'blocks' => blocks }.compact
end

.render_bytes(layout) ⇒ Pdf::Toolkit::Result

Resolve a stored layout's media references and render it to a Toolkit result.
Used by both from_layout and the studio's regenerate action so iteration
re-resolves references. Temp files for downloaded images are cleaned up.

Parameters:

  • layout (Hash)

Returns:



50
51
52
53
54
55
# File 'app/services/generated_pdf_generator.rb', line 50

def render_bytes(layout)
  temp_paths = []
  Pdf::Toolkit.generate(layout: resolve_media(layout, temp_paths))
ensure
  temp_paths&.each { |p| File.delete(p) if p && File.exist?(p) }
end

.stage(bytes:, layout: {}, kind: 'generated', title: nil, instructions: nil, created_by_id: nil, source_publication_id: nil, source_record: nil, page_count: nil, filename: nil) ⇒ Result

Stage already-rendered PDF bytes (e.g. from the Sunny pdf_edit/pdf_generate
tools, or a reconstruction loop) with the layout that produced them.

Returns:



60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
# File 'app/services/generated_pdf_generator.rb', line 60

def stage(bytes:, layout: {}, kind: 'generated', title: nil, instructions: nil,
          created_by_id: nil, source_publication_id: nil, source_record: nil,
          page_count: nil, filename: nil)
  gen = GeneratedPdf.new(
    kind:                  kind,
    layout:                layout || {},
    title:                 title,
    suggested_title:       title,
    instructions:          instructions,
    created_by_id:         created_by_id,
    source_publication_id: source_publication_id,
    source_record:         source_record,
    page_count:            page_count
  )
  gen.assign_pdf(bytes, filename: filename || default_filename(title))
  gen.page_count ||= count_pages(bytes)
  gen.save!
  Result.new(generated_pdf: gen)
rescue StandardError => e
  Rails.logger.error "[GeneratedPdfGenerator] #{e.class}: #{e.message}"
  Result.new(error: e.message)
end

.transcript_seed(video_ref) ⇒ String?

Pull a video's transcript text to seed a "summary sheet" generation.

Parameters:

  • video_ref (Video, Integer, String)

    a Video or its id

Returns:

  • (String, nil)


123
124
125
126
127
128
# File 'app/services/generated_pdf_generator.rb', line 123

def transcript_seed(video_ref)
  video = video_ref.is_a?(Video) ? video_ref : Video.find_by(id: video_ref)
  return nil unless video.respond_to?(:transcript)

  video.transcript.presence
end