pdf 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/execution/00-overview.md +121 -0
  3. data/.claude/execution/01-core.md +324 -0
  4. data/.claude/execution/02-renderer.md +237 -0
  5. data/.claude/execution/03-components.md +551 -0
  6. data/.claude/execution/04-builders.md +322 -0
  7. data/.claude/execution/05-view-layout.md +362 -0
  8. data/.claude/execution/06-evaluators.md +494 -0
  9. data/.claude/execution/07-entry-extensibility.md +435 -0
  10. data/.claude/execution/08-integration-tests.md +978 -0
  11. data/Rakefile +7 -3
  12. data/examples/01_basic_invoice.rb +139 -0
  13. data/examples/02_report_with_layout.rb +266 -0
  14. data/examples/03_inherited_views.rb +318 -0
  15. data/examples/04_conditional_content.rb +421 -0
  16. data/examples/05_custom_components.rb +442 -0
  17. data/examples/README.md +123 -0
  18. data/lib/pdf/blueprint.rb +50 -0
  19. data/lib/pdf/builders/content_builder.rb +96 -0
  20. data/lib/pdf/builders/footer_builder.rb +24 -0
  21. data/lib/pdf/builders/header_builder.rb +31 -0
  22. data/lib/pdf/component.rb +43 -0
  23. data/lib/pdf/components/alert.rb +42 -0
  24. data/lib/pdf/components/context.rb +16 -0
  25. data/lib/pdf/components/date.rb +28 -0
  26. data/lib/pdf/components/heading.rb +12 -0
  27. data/lib/pdf/components/hr.rb +12 -0
  28. data/lib/pdf/components/logo.rb +15 -0
  29. data/lib/pdf/components/paragraph.rb +12 -0
  30. data/lib/pdf/components/qr_code.rb +38 -0
  31. data/lib/pdf/components/spacer.rb +11 -0
  32. data/lib/pdf/components/span.rb +12 -0
  33. data/lib/pdf/components/subtitle.rb +12 -0
  34. data/lib/pdf/components/table.rb +48 -0
  35. data/lib/pdf/components/title.rb +12 -0
  36. data/lib/pdf/content_evaluator.rb +218 -0
  37. data/lib/pdf/dynamic_components.rb +17 -0
  38. data/lib/pdf/footer_evaluator.rb +66 -0
  39. data/lib/pdf/header_evaluator.rb +56 -0
  40. data/lib/pdf/layout.rb +61 -0
  41. data/lib/pdf/renderer.rb +153 -0
  42. data/lib/pdf/resolver.rb +36 -0
  43. data/lib/pdf/version.rb +1 -1
  44. data/lib/pdf/view.rb +113 -0
  45. data/lib/pdf.rb +74 -1
  46. metadata +127 -2
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdf
4
+ class HeaderEvaluator
5
+ def initialize(context, renderer)
6
+ @context = context
7
+ @renderer = renderer
8
+ @resolver = Resolver.new(context)
9
+ end
10
+
11
+ def evaluate(blueprint)
12
+ blueprint.elements.each { |el| evaluate_element(el) }
13
+ end
14
+
15
+ private
16
+
17
+ def evaluate_element(element)
18
+ type = element[:type]
19
+ args = element[:args]
20
+ options = element[:options]
21
+
22
+ case type
23
+ when :logo
24
+ component(:logo).render(args.first, **options)
25
+
26
+ when :qr_code
27
+ # QR code generates from data, not file path
28
+ data = @resolver.resolve(options[:data])
29
+ component(:qr_code).render(data: data, **options.except(:data))
30
+
31
+ when :context
32
+ lines = @resolver.resolve(args.first)
33
+ component(:context).render(lines, **options)
34
+
35
+ when :header_text
36
+ content = @resolver.resolve(args.first)
37
+ component(:paragraph).render(content, **options)
38
+
39
+ else
40
+ render_custom_component(type, args, options)
41
+ end
42
+ end
43
+
44
+ def render_custom_component(type, args, options)
45
+ return unless Pdf.component_registered?(type)
46
+
47
+ comp = component(type)
48
+ resolved_args = args.map { |a| @resolver.resolve(a) }
49
+ comp.render(*resolved_args, **options)
50
+ end
51
+
52
+ def component(type)
53
+ Pdf.component(type).new(@renderer.pdf)
54
+ end
55
+ end
56
+ end
data/lib/pdf/layout.rb ADDED
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdf
4
+ class Layout
5
+ class << self
6
+ def inherited(subclass)
7
+ subclass.instance_variable_set(:@header_blueprint, nil)
8
+ subclass.instance_variable_set(:@footer_blueprint, nil)
9
+ subclass.instance_variable_set(:@margins_config, Pdf::DEFAULT_MARGINS.dup)
10
+ end
11
+
12
+ def header(&block)
13
+ builder = Builders::HeaderBuilder.new
14
+ builder.instance_eval(&block)
15
+ @header_blueprint = builder.blueprint
16
+ end
17
+
18
+ def footer(&block)
19
+ builder = Builders::FooterBuilder.new
20
+ builder.instance_eval(&block)
21
+ @footer_blueprint = builder.blueprint
22
+ end
23
+
24
+ def margins(**options)
25
+ @margins_config = Pdf::DEFAULT_MARGINS.merge(options)
26
+ end
27
+
28
+ attr_reader :header_blueprint, :footer_blueprint
29
+
30
+ def margins_config
31
+ @margins_config || Pdf::DEFAULT_MARGINS
32
+ end
33
+ end
34
+
35
+ attr_reader :view
36
+
37
+ def initialize(view)
38
+ @view = view
39
+ end
40
+
41
+ def render(renderer)
42
+ renderer.setup(margins: self.class.margins_config)
43
+
44
+ render_header(renderer) if self.class.header_blueprint
45
+ view.render_content(renderer)
46
+ setup_footer(renderer) if self.class.footer_blueprint
47
+ end
48
+
49
+ private
50
+
51
+ def render_header(renderer)
52
+ evaluator = HeaderEvaluator.new(view, renderer)
53
+ evaluator.evaluate(self.class.header_blueprint)
54
+ end
55
+
56
+ def setup_footer(renderer)
57
+ evaluator = FooterEvaluator.new(view, renderer)
58
+ evaluator.setup(self.class.footer_blueprint)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prawn"
4
+ require "prawn/table"
5
+
6
+ module Pdf
7
+ # Default margins used by both Renderer and Layout
8
+ DEFAULT_MARGINS = { top: 72, right: 36, bottom: 72, left: 36 }.freeze
9
+
10
+ class Renderer
11
+ attr_reader :pdf
12
+
13
+ def initialize
14
+ @pdf = nil
15
+ @footer_callback = nil
16
+ end
17
+
18
+ def setup(margins: {})
19
+ m = Pdf::DEFAULT_MARGINS.merge(margins)
20
+ @pdf = Prawn::Document.new(
21
+ margin: [m[:top], m[:right], m[:bottom], m[:left]]
22
+ )
23
+ setup_fonts
24
+ self
25
+ end
26
+
27
+ def finalize
28
+ apply_footer if @footer_callback
29
+ @pdf.render
30
+ end
31
+
32
+ # --- Primitives ---
33
+
34
+ def text(content, **options)
35
+ @pdf.text content.to_s, **options
36
+ end
37
+
38
+ def text_box(content, at:, **options)
39
+ @pdf.text_box content.to_s, at: at, **options
40
+ end
41
+
42
+ def move_down(amount)
43
+ @pdf.move_down amount
44
+ end
45
+
46
+ def stroke_horizontal_rule
47
+ @pdf.stroke_horizontal_rule
48
+ end
49
+
50
+ def start_new_page
51
+ @pdf.start_new_page
52
+ end
53
+
54
+ def cursor
55
+ @pdf.cursor
56
+ end
57
+
58
+ def bounds
59
+ @pdf.bounds
60
+ end
61
+
62
+ def bounding_box(point, **options, &block)
63
+ @pdf.bounding_box(point, **options, &block)
64
+ end
65
+
66
+ def canvas(&block)
67
+ @pdf.canvas(&block)
68
+ end
69
+
70
+ def float(&block)
71
+ @pdf.float(&block)
72
+ end
73
+
74
+ # Image embedding - raises if file not found (no silent failures)
75
+ def image(path, **options)
76
+ raise Pdf::FileNotFoundError, "Image not found: #{path}" unless File.exist?(path)
77
+
78
+ @pdf.image path, **options
79
+ end
80
+
81
+ # Image from string data (for generated images like QR codes)
82
+ def image_data(data, **options)
83
+ @pdf.image StringIO.new(data), **options
84
+ end
85
+
86
+ def fill_color(color)
87
+ @pdf.fill_color color
88
+ end
89
+
90
+ def stroke_color(color)
91
+ @pdf.stroke_color color
92
+ end
93
+
94
+ def line_width(width)
95
+ @pdf.line_width width
96
+ end
97
+
98
+ def fill_and_stroke_rounded_rectangle(point, width, height, radius)
99
+ @pdf.fill_and_stroke_rounded_rectangle point, width, height, radius
100
+ end
101
+
102
+ def draw_text(content, at:, **options)
103
+ @pdf.draw_text content.to_s, at: at, **options
104
+ end
105
+
106
+ def width_of(content, **options)
107
+ @pdf.width_of content.to_s, **options
108
+ end
109
+
110
+ def height_of(content, **options)
111
+ @pdf.height_of content.to_s, **options
112
+ end
113
+
114
+ def table(data, **options, &block)
115
+ @pdf.table(data, **options, &block)
116
+ end
117
+
118
+ def page_number
119
+ @pdf.page_number
120
+ end
121
+
122
+ def page_count
123
+ @pdf.page_count
124
+ end
125
+
126
+ def repeat(page_filter, options = {}, &block)
127
+ @pdf.repeat(page_filter, options, &block)
128
+ end
129
+
130
+ # --- Escape hatch for direct Prawn access ---
131
+
132
+ def raw(&block)
133
+ yield @pdf
134
+ end
135
+
136
+ # --- Footer setup (deferred) ---
137
+
138
+ def setup_footer(&block)
139
+ @footer_callback = block
140
+ end
141
+
142
+ private
143
+
144
+ def setup_fonts
145
+ # Use Helvetica by default (built into Prawn)
146
+ # Users can override in their app
147
+ end
148
+
149
+ def apply_footer
150
+ @footer_callback&.call(@pdf)
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdf
4
+ class ResolutionError < StandardError; end
5
+
6
+ class Resolver
7
+ def initialize(context)
8
+ @context = context
9
+ end
10
+
11
+ def resolve(value)
12
+ case value
13
+ when Symbol then resolve_symbol(value)
14
+ when Proc then @context.instance_exec(&value)
15
+ when Array then value.map { |v| resolve(v) }
16
+ when Hash then value.transform_values { |v| resolve(v) }
17
+ else value
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def resolve_symbol(sym)
24
+ if @context.respond_to?(sym, true)
25
+ return @context.send(sym)
26
+ end
27
+
28
+ if @context.respond_to?(:data) && @context.data.is_a?(Hash)
29
+ return @context.data[sym] if @context.data.key?(sym)
30
+ return @context.data[sym.to_s] if @context.data.key?(sym.to_s)
31
+ end
32
+
33
+ raise ResolutionError, "Cannot resolve :#{sym}"
34
+ end
35
+ end
36
+ end
data/lib/pdf/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pdf
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
data/lib/pdf/view.rb ADDED
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdf
4
+ class View
5
+ class << self
6
+ def inherited(subclass)
7
+ # CRITICAL: Inherit parent's blueprint via dup, not empty blueprint
8
+ # This allows child views to extend parent views
9
+ parent_blueprint = @blueprint || Blueprint.new
10
+ subclass.instance_variable_set(:@blueprint, parent_blueprint.dup)
11
+ subclass.instance_variable_set(:@content_builder, nil)
12
+ end
13
+
14
+ def blueprint
15
+ @blueprint ||= Blueprint.new
16
+ end
17
+
18
+ def layout(klass)
19
+ blueprint.set_layout(klass)
20
+ end
21
+
22
+ # DSL methods - delegate to ContentBuilder
23
+ %i[title subtitle date heading paragraph span hr spacer alert table page_break].each do |method|
24
+ define_method(method) do |*args, **options, &block|
25
+ content_builder.send(method, *args, **options, &block)
26
+ end
27
+ end
28
+
29
+ def section(title_text, **options, &block)
30
+ content_builder.section(title_text, **options, &block)
31
+ end
32
+
33
+ def each(source, **options, &block)
34
+ content_builder.each(source, **options, &block)
35
+ end
36
+
37
+ def partial(method_name)
38
+ content_builder.partial(method_name)
39
+ end
40
+
41
+ def page_break_if(threshold:)
42
+ content_builder.page_break_if(threshold: threshold)
43
+ end
44
+
45
+ def render_if(condition, &block)
46
+ content_builder.render_if(condition, &block)
47
+ end
48
+
49
+ def render_unless(condition, &block)
50
+ content_builder.render_unless(condition, &block)
51
+ end
52
+
53
+ def raw(&block)
54
+ content_builder.raw(&block)
55
+ end
56
+
57
+ # Dynamic component support via DynamicComponents pattern
58
+ def method_missing(name, *args, **options, &block)
59
+ if Pdf.component_registered?(name)
60
+ content_builder.send(name, *args, **options, &block)
61
+ else
62
+ super
63
+ end
64
+ end
65
+
66
+ def respond_to_missing?(name, include_private = false)
67
+ Pdf.component_registered?(name) || super
68
+ end
69
+
70
+ private
71
+
72
+ def content_builder
73
+ @content_builder ||= Builders::ContentBuilder.new(blueprint)
74
+ end
75
+ end
76
+
77
+ attr_reader :data
78
+
79
+ def initialize(data = {})
80
+ @data = data.is_a?(Hash) ? symbolize_keys(data) : data
81
+ end
82
+
83
+ def to_pdf
84
+ renderer = Renderer.new
85
+ layout_class = self.class.blueprint.layout_class
86
+
87
+ if layout_class
88
+ layout = layout_class.new(self)
89
+ layout.render(renderer)
90
+ else
91
+ renderer.setup
92
+ render_content(renderer)
93
+ end
94
+
95
+ renderer.finalize
96
+ end
97
+
98
+ def to_file(path)
99
+ File.binwrite(path, to_pdf)
100
+ end
101
+
102
+ def render_content(renderer)
103
+ evaluator = ContentEvaluator.new(self, renderer)
104
+ evaluator.evaluate(self.class.blueprint)
105
+ end
106
+
107
+ private
108
+
109
+ def symbolize_keys(hash)
110
+ hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
111
+ end
112
+ end
113
+ end
data/lib/pdf.rb CHANGED
@@ -2,7 +2,80 @@
2
2
 
3
3
  require_relative "pdf/version"
4
4
 
5
+ # Core
6
+ require_relative "pdf/blueprint"
7
+ require_relative "pdf/resolver"
8
+ require_relative "pdf/component"
9
+ require_relative "pdf/dynamic_components"
10
+ require_relative "pdf/renderer"
11
+
12
+ # Components
13
+ require_relative "pdf/components/title"
14
+ require_relative "pdf/components/subtitle"
15
+ require_relative "pdf/components/heading"
16
+ require_relative "pdf/components/paragraph"
17
+ require_relative "pdf/components/span"
18
+ require_relative "pdf/components/date"
19
+ require_relative "pdf/components/hr"
20
+ require_relative "pdf/components/spacer"
21
+ require_relative "pdf/components/alert"
22
+ require_relative "pdf/components/table"
23
+ require_relative "pdf/components/logo"
24
+ require_relative "pdf/components/qr_code"
25
+ require_relative "pdf/components/context"
26
+
27
+ # Builders
28
+ require_relative "pdf/builders/content_builder"
29
+ require_relative "pdf/builders/header_builder"
30
+ require_relative "pdf/builders/footer_builder"
31
+
32
+ # Evaluators
33
+ require_relative "pdf/content_evaluator"
34
+ require_relative "pdf/header_evaluator"
35
+ require_relative "pdf/footer_evaluator"
36
+
37
+ # View & Layout
38
+ require_relative "pdf/view"
39
+ require_relative "pdf/layout"
40
+
5
41
  module Pdf
6
42
  class Error < StandardError; end
7
- # Your code goes here...
43
+ class FileNotFoundError < Error; end
44
+
45
+ class << self
46
+ def components
47
+ @components ||= {}
48
+ end
49
+
50
+ def register_component(name, klass)
51
+ unless klass < Component
52
+ raise ArgumentError, "#{klass} must inherit from Pdf::Component"
53
+ end
54
+
55
+ components[name.to_sym] = klass
56
+ end
57
+
58
+ def component(name)
59
+ components[name.to_sym] || raise(Error, "Unknown component: #{name}")
60
+ end
61
+
62
+ def component_registered?(name)
63
+ components.key?(name.to_sym)
64
+ end
65
+ end
66
+
67
+ # Register built-in components
68
+ register_component :title, Components::Title
69
+ register_component :subtitle, Components::Subtitle
70
+ register_component :heading, Components::Heading
71
+ register_component :paragraph, Components::Paragraph
72
+ register_component :span, Components::Span
73
+ register_component :date, Components::Date
74
+ register_component :hr, Components::Hr
75
+ register_component :spacer, Components::Spacer
76
+ register_component :alert, Components::Alert
77
+ register_component :table, Components::Table
78
+ register_component :logo, Components::Logo
79
+ register_component :qr_code, Components::QrCode
80
+ register_component :context, Components::Context
8
81
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pdf
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben
@@ -9,7 +9,91 @@ autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
11
  date: 2025-12-15 00:00:00.000000000 Z
12
- dependencies: []
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: prawn
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.4'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.4'
27
+ - !ruby/object:Gem::Dependency
28
+ name: prawn-table
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.2'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.2'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rqrcode
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '2.0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '2.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: minitest
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '5.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '5.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rake
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '13.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '13.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rubocop
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.21'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1.21'
13
97
  description: A Ruby library for generating and manipulating PDF documents
14
98
  email:
15
99
  - ben@dee.mx
@@ -17,6 +101,15 @@ executables: []
17
101
  extensions: []
18
102
  extra_rdoc_files: []
19
103
  files:
104
+ - ".claude/execution/00-overview.md"
105
+ - ".claude/execution/01-core.md"
106
+ - ".claude/execution/02-renderer.md"
107
+ - ".claude/execution/03-components.md"
108
+ - ".claude/execution/04-builders.md"
109
+ - ".claude/execution/05-view-layout.md"
110
+ - ".claude/execution/06-evaluators.md"
111
+ - ".claude/execution/07-entry-extensibility.md"
112
+ - ".claude/execution/08-integration-tests.md"
20
113
  - ".rspec"
21
114
  - ".rubocop.yml"
22
115
  - CHANGELOG.md
@@ -24,8 +117,40 @@ files:
24
117
  - LICENSE.txt
25
118
  - README.md
26
119
  - Rakefile
120
+ - examples/01_basic_invoice.rb
121
+ - examples/02_report_with_layout.rb
122
+ - examples/03_inherited_views.rb
123
+ - examples/04_conditional_content.rb
124
+ - examples/05_custom_components.rb
125
+ - examples/README.md
27
126
  - lib/pdf.rb
127
+ - lib/pdf/blueprint.rb
128
+ - lib/pdf/builders/content_builder.rb
129
+ - lib/pdf/builders/footer_builder.rb
130
+ - lib/pdf/builders/header_builder.rb
131
+ - lib/pdf/component.rb
132
+ - lib/pdf/components/alert.rb
133
+ - lib/pdf/components/context.rb
134
+ - lib/pdf/components/date.rb
135
+ - lib/pdf/components/heading.rb
136
+ - lib/pdf/components/hr.rb
137
+ - lib/pdf/components/logo.rb
138
+ - lib/pdf/components/paragraph.rb
139
+ - lib/pdf/components/qr_code.rb
140
+ - lib/pdf/components/spacer.rb
141
+ - lib/pdf/components/span.rb
142
+ - lib/pdf/components/subtitle.rb
143
+ - lib/pdf/components/table.rb
144
+ - lib/pdf/components/title.rb
145
+ - lib/pdf/content_evaluator.rb
146
+ - lib/pdf/dynamic_components.rb
147
+ - lib/pdf/footer_evaluator.rb
148
+ - lib/pdf/header_evaluator.rb
149
+ - lib/pdf/layout.rb
150
+ - lib/pdf/renderer.rb
151
+ - lib/pdf/resolver.rb
28
152
  - lib/pdf/version.rb
153
+ - lib/pdf/view.rb
29
154
  - sig/pdf.rbs
30
155
  homepage: https://github.com/getnvoi/pdf
31
156
  licenses: