Module: Yaml::Converter::Renderer::PdfHexapdf

Defined in:
lib/yaml/converter/renderer/pdf_hexapdf.rb

Overview

Native PDF rendering using HexaPDF. Basic layout: title lines, YAML block in monospace, notes as italic paragraphs.

For PDF rendering via pandoc, see PandocShell.

Class Method Summary collapse

Class Method Details

.average_char_width(size, font) ⇒ Object



139
140
141
# File 'lib/yaml/converter/renderer/pdf_hexapdf.rb', line 139

def average_char_width(size, font)
  (font == "Courier") ? size * 0.6 : size * 0.5
end

.draw_lines(canvas, lines, content:, left: content.left, cursor: content.cursor, width: content.width, size: 11, font: "Helvetica", style: nil) ⇒ Object



112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/yaml/converter/renderer/pdf_hexapdf.rb', line 112

def draw_lines(canvas, lines, content:, left: content.left, cursor: content.cursor, width: content.width, size: 11, font: "Helvetica", style: nil)
  variant = font_variant(style)
  if variant
    canvas.font(font, variant: variant, size: size)
  else
    canvas.font(font, size: size)
  end
  y = cursor
  Array(lines).each do |line|
    wrapped_lines(line.to_s, width: width, size: size, font: font).each do |wrapped|
      break if y <= content.bottom

      canvas.text(wrapped, at: [left, y])
      y -= line_height(size)
    end
  end
  content.cursor = y if left == content.left && width == content.width
  y
end

.draw_page_number(canvas, page:) ⇒ Object



154
155
156
157
# File 'lib/yaml/converter/renderer/pdf_hexapdf.rb', line 154

def draw_page_number(canvas, page:)
  canvas.font("Helvetica", size: 9)
  canvas.text("Page 1 of 1", at: [page.box.width - 136, 36])
end

.draw_two_column_layout(canvas, yaml_section:, notes:, content:, options: {}) ⇒ Object



100
101
102
103
104
105
106
107
108
109
110
# File 'lib/yaml/converter/renderer/pdf_hexapdf.rb', line 100

def draw_two_column_layout(canvas, yaml_section:, notes:, content:, options: {})
  gap = 16
  column_width = (content.width - gap) / 2.0
  left = content.left
  right = content.left + column_width + gap
  cursor = content.cursor

  yaml_bottom = draw_lines(canvas, yaml_section, content: content, left: left, cursor: cursor, width: column_width, size: options[:pdf_yaml_font_size] || 9, font: "Courier")
  notes_bottom = draw_lines(canvas, notes.map { |note| "NOTE: #{note}" }, content: content, left: right, cursor: cursor, width: column_width, size: options[:pdf_body_font_size] || 11, font: "Helvetica", style: :italic)
  content.cursor = [yaml_bottom, notes_bottom].min
end

.extract_notes(markdown) ⇒ Array<String>

Extract note strings from Markdown blockquote lines.

Parameters:

  • markdown (String)

Returns:

  • (Array<String>)


189
190
191
# File 'lib/yaml/converter/renderer/pdf_hexapdf.rb', line 189

def extract_notes(markdown)
  markdown.lines.grep(/^> NOTE:/).map { |l| l.sub(/^> NOTE:\s*/, "").strip }
end

.fenced_yaml(markdown) ⇒ Array<String>

Return the lines inside the first fenced “‘yaml block.

Parameters:

  • markdown (String)

Returns:

  • (Array<String>)


169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
# File 'lib/yaml/converter/renderer/pdf_hexapdf.rb', line 169

def fenced_yaml(markdown)
  inside = false
  lines = []
  markdown.each_line do |l|
    if l.start_with?("```yaml")
      inside = true
      next
    elsif inside && l.strip == "```"
      inside = false
      break
    elsif inside
      lines << l.rstrip
    end
  end
  lines
end

.font_variant(style) ⇒ Object



143
144
145
146
147
148
# File 'lib/yaml/converter/renderer/pdf_hexapdf.rb', line 143

def font_variant(style)
  return :bold if style == :bold
  return :italic if style == :italic

  nil
end

.header_lines(markdown) ⇒ Array<String>

Extract leading ‘# Title lines`.

Parameters:

  • markdown (String)

Returns:

  • (Array<String>)


162
163
164
# File 'lib/yaml/converter/renderer/pdf_hexapdf.rb', line 162

def header_lines(markdown)
  markdown.lines.take_while { |l| l.start_with?("# ") }.map { |l| l.sub(/^# /, "").strip }
end

.line_height(size) ⇒ Object



150
151
152
# File 'lib/yaml/converter/renderer/pdf_hexapdf.rb', line 150

def line_height(size)
  size * 1.25
end

.normalize_margin(margin) ⇒ Object



93
94
95
96
97
98
# File 'lib/yaml/converter/renderer/pdf_hexapdf.rb', line 93

def normalize_margin(margin)
  values = Array(margin).map(&:to_i)
  return [36, 36, 36, 36] unless values.size == 4

  values
end

.page_size(name) ⇒ Object



82
83
84
85
86
87
88
89
90
91
# File 'lib/yaml/converter/renderer/pdf_hexapdf.rb', line 82

def page_size(name)
  case name.to_s.upcase
  when "A4"
    :A4
  when "LEGAL"
    [0, 0, 612, 1008]
  else
    :Letter
  end
end

.render(markdown:, out_path:, options: {}) ⇒ Boolean

Render a PDF document from the given markdown string.

Parameters:

  • markdown (String)

    Markdown that includes fenced YAML and blockquote notes

  • out_path (String)

    Destination PDF path

  • options (Hash) (defaults to: {})

    PDF options (see Config defaults: page size, margins, font sizes, two-column notes)

Options Hash (options:):

  • :pdf_page_size (String) — default: "LETTER"
  • :pdf_margin (Array<Integer>) — default: [36, 36, 36, 36]
  • :pdf_title_font_size (Integer) — default: 14
  • :pdf_body_font_size (Integer) — default: 11
  • :pdf_yaml_font_size (Integer) — default: 9
  • :pdf_two_column_notes (Boolean) — default: false

Returns:

  • (Boolean)

    true if rendering succeeded



25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# File 'lib/yaml/converter/renderer/pdf_hexapdf.rb', line 25

def render(markdown:, out_path:, options: {})
  require "hexapdf"

  notes = extract_notes(markdown)
  yaml_section = fenced_yaml(markdown)
  title_lines = header_lines(markdown)

  document = HexaPDF::Document.new
  page = document.pages.add(page_size(options[:pdf_page_size] || "LETTER"))
  margin = normalize_margin(options[:pdf_margin] || [36, 36, 36, 36])
  content = ContentBox.new(page: page, margin: margin)
  canvas = page.canvas

  draw_lines(canvas, title_lines, content: content, size: options[:pdf_title_font_size] || 14, font: "Helvetica", style: :bold)
  content.move_down(10) if title_lines.any?

  if options[:pdf_two_column_notes] && notes.any?
    draw_two_column_layout(canvas, yaml_section: yaml_section, notes: notes, content: content, options: options)
  else
    draw_lines(canvas, yaml_section, content: content, size: options[:pdf_yaml_font_size] || 9, font: "Courier")
    content.move_down(10)
    draw_lines(canvas, notes.map { |note| "NOTE: #{note}" }, content: content, size: options[:pdf_body_font_size] || 11, font: "Helvetica", style: :italic)
  end

  draw_page_number(canvas, page: page)
  document.write(out_path, optimize: true)
  true
rescue => e
  warn("hexapdf pdf failed: #{e.class}: #{e.message}")
  false
end

.wrapped_lines(line, width:, size:, font:) ⇒ Object



132
133
134
135
136
137
# File 'lib/yaml/converter/renderer/pdf_hexapdf.rb', line 132

def wrapped_lines(line, width:, size:, font:)
  max_chars = [(width / average_char_width(size, font)).floor, 1].max
  return [line] if line.length <= max_chars

  line.scan(/.{1,#{max_chars}}/)
end