jot_pdf 0.1.0

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3d54ecbe709e2588c085509efb20725711f4d5a3480d1767c5f5131c63c28437
4
+ data.tar.gz: 3189d100f3c69f510edcc26104052280b52462880e01e0d16133895ddb63ba51
5
+ SHA512:
6
+ metadata.gz: fce06e57874121739df0f30b446e19693b121259b2898fa302e08a79d875cc1bd440824e05eb7614715e9ad3f4c207bd80d2d53cadfa0f49f6d24438f15deebe
7
+ data.tar.gz: 46372e13fb42152db255094a8822fd7431dac84b59ba5fc9ee3e51ffc27f35f45ae0c0d0ecb2d5aff1cf6f99315b2e76cc231a6d157e49e25e060cba5fa44195
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,63 @@
1
+ AllCops:
2
+ NewCops: enable
3
+ TargetRubyVersion: 3.1
4
+
5
+ Style/StringLiterals:
6
+ Enabled: true
7
+ EnforcedStyle: double_quotes
8
+
9
+ Style/StringLiteralsInInterpolation:
10
+ Enabled: true
11
+ EnforcedStyle: double_quotes
12
+
13
+ Style/TrailingCommaInArguments:
14
+ EnforcedStyleForMultiline: consistent_comma
15
+
16
+ Style/TrailingCommaInArrayLiteral:
17
+ EnforcedStyleForMultiline: consistent_comma
18
+
19
+ Style/TrailingCommaInHashLiteral:
20
+ EnforcedStyleForMultiline: consistent_comma
21
+
22
+ Metrics/AbcSize:
23
+ Enabled: false
24
+
25
+ Metrics/CyclomaticComplexity:
26
+ Enabled: false
27
+
28
+ Metrics/MethodLength:
29
+ Enabled: false
30
+
31
+ Metrics/BlockLength:
32
+ Enabled: false
33
+
34
+ Metrics/ModuleLength:
35
+ Enabled: false
36
+
37
+ Metrics/PerceivedComplexity:
38
+ Enabled: false
39
+
40
+ Metrics/ParameterLists:
41
+ Enabled: false
42
+
43
+ Naming/BlockForwarding:
44
+ EnforcedStyle: explicit
45
+
46
+ Style/ArgumentsForwarding:
47
+ UseAnonymousForwarding: false
48
+
49
+ Style/Semicolon:
50
+ AllowAsExpressionSeparator: true
51
+
52
+ Naming/MethodParameterName:
53
+ Enabled: false
54
+
55
+ Lint/AmbiguousOperatorPrecedence:
56
+ Enabled: false
57
+
58
+ Lint/EmptyBlock:
59
+ Enabled: false
60
+
61
+ # TODO: enable this
62
+ Style/Documentation:
63
+ Enabled: false
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2024-12-29
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 coord_e
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,121 @@
1
+ # JotPDF
2
+
3
+ Streaming PDF writer DSL for Ruby. Check out a live-editing demo with ruby.wasm: https://jotpdf.coord-e.dev/
4
+
5
+ ## Status
6
+
7
+ JotPDF is in its early stages, and the API is fairly unstable.
8
+
9
+ ## Usage
10
+
11
+ ```ruby
12
+ # Gemfile
13
+ gem 'jot_pdf'
14
+ ```
15
+
16
+ JotPDF offers two types of DSLs. One is `JotPDF::Core`, a low-level API that directly exposes the structure of PDFs. The other is `JotPDF::Document`, a relatively high-level API built on top of `JotPDF::Core`, designed to be useful for actual document generation.
17
+
18
+ ### JotPDF::Core
19
+
20
+ A PDF consists of a header, object definitions, `xref` (cross-reference table), and a trailer (including `trailer`, `startxref`, and an EOF marker).
21
+
22
+ ```ruby
23
+ require "jot_pdf"
24
+
25
+ JotPDF::Core.write($stdout) do
26
+ header
27
+
28
+ # Emit your objects here
29
+
30
+ xref
31
+ trailer do
32
+ end
33
+ end
34
+ ```
35
+
36
+ Use `obj` to define an object; it returns a reference to the object.
37
+
38
+ ```ruby
39
+ require "jot_pdf"
40
+
41
+ JotPDF::Core.write($stdout) do
42
+ header
43
+
44
+ obj.of_dict do
45
+ entry("Type").of_name "Pages"
46
+ entry("Kids").of_array {}
47
+ entry("Count").of_int 0
48
+ end => pages_obj
49
+
50
+ obj.of_dict do
51
+ entry("Type").of_name "Catalog"
52
+ entry("Pages").of_ref pages_obj
53
+ end => catalog_obj
54
+
55
+ xref
56
+ trailer do
57
+ # JotPDF automatically inserts Size entry in the trailer dictionary
58
+ entry("Root").of_ref catalog_obj
59
+ end
60
+ end
61
+ ```
62
+
63
+ Use `alloc_obj` to declare an object and emit its contents later.
64
+
65
+ ```ruby
66
+ require "jot_pdf"
67
+
68
+ JotPDF::Core.write($stdout) do
69
+ header
70
+
71
+ alloc_obj => pages_obj
72
+
73
+ obj.of_dict do
74
+ entry("Type").of_name "Catalog"
75
+ entry("Pages").of_ref pages_obj
76
+ end => catalog_obj
77
+
78
+ obj(pages_obj).of_dict do
79
+ entry("Type").of_name "Pages"
80
+ entry("Kids").of_array {}
81
+ entry("Count").of_int 0
82
+ end
83
+
84
+ xref
85
+ trailer do
86
+ entry("Root").of_ref catalog_obj
87
+ end
88
+ end
89
+ ```
90
+
91
+ ### JotPDF::Document
92
+
93
+ Use `page` to emit a page.
94
+
95
+ ```ruby
96
+ require "jot_pdf"
97
+
98
+ JotPDF::Document.write($stdout) do
99
+ page width: 210, height: 297 do
100
+ text "Hello, World!", x: 10, y: 200
101
+ end
102
+ end
103
+ ```
104
+
105
+ ## Development
106
+
107
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
108
+
109
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
110
+
111
+ ## Contributing
112
+
113
+ Bug reports and pull requests are welcome on GitHub at https://github.com/coord-e/jot_pdf. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/coord-e/jot_pdf/blob/master/CODE_OF_CONDUCT.md).
114
+
115
+ ## License
116
+
117
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
118
+
119
+ ## Code of Conduct
120
+
121
+ Everyone interacting in the JotPDF project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/coord-e/jot_pdf/blob/master/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ require "steep/rake_task"
13
+
14
+ Steep::RakeTask.new do |t|
15
+ t.check.severity_level = :error
16
+ t.watch.verbose
17
+ end
18
+
19
+ task default: %i[steep spec rubocop]
data/Steepfile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ D = Steep::Diagnostic
4
+
5
+ target :lib do
6
+ signature "sig"
7
+ check "lib"
8
+ configure_code_diagnostics(D::Ruby.default)
9
+ # TODO: How to type instance variables inside the DSL?
10
+ # configure_code_diagnostics(D::Ruby.strict)
11
+ end
@@ -0,0 +1,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "docile"
4
+
5
+ module JotPDF
6
+ module Core
7
+ class CrossReferenceTableEntry < Data.define(:offset, :generation, :usage)
8
+ end
9
+
10
+ # separating definition for steep
11
+ class CrossReferenceTableEntry
12
+ def self.default
13
+ CrossReferenceTableEntry.new(
14
+ offset: 0,
15
+ generation: 65_535,
16
+ usage: :free,
17
+ )
18
+ end
19
+ end
20
+
21
+ class ObjectRef < Data.define(:number, :generation)
22
+ end
23
+
24
+ class Writer
25
+ attr_reader :objects, :io, :offset
26
+
27
+ def initialize(io)
28
+ @objects = [
29
+ CrossReferenceTableEntry.default,
30
+ ]
31
+ @section = [0]
32
+ @io = io
33
+ @offset = 0
34
+ end
35
+
36
+ def new_object
37
+ number = @objects.size
38
+ @objects << CrossReferenceTableEntry.default
39
+ ObjectRef.new(number:, generation: 0)
40
+ end
41
+
42
+ def update_object_entry(object_ref)
43
+ @section << object_ref.number
44
+ @objects[object_ref.number] = CrossReferenceTableEntry.new(
45
+ generation: object_ref.generation,
46
+ offset: @offset,
47
+ usage: :in_use,
48
+ )
49
+ end
50
+
51
+ def finish_section
52
+ result = @section.dup
53
+ @section.clear
54
+ result
55
+ end
56
+
57
+ def <<(data)
58
+ s = data.to_s
59
+ @io << s
60
+ @offset += s.bytesize
61
+ self
62
+ end
63
+ end
64
+
65
+ class WriteContext
66
+ def initialize(writer)
67
+ @writer = writer
68
+ end
69
+
70
+ def objects
71
+ @writer.objects
72
+ end
73
+
74
+ def dsl(&block)
75
+ Docile.dsl_eval(self, &block)
76
+ end
77
+ end
78
+
79
+ class DictionaryWriteContext < WriteContext
80
+ def entry(name, &block)
81
+ @writer << "/#{name}"
82
+ if block
83
+ ObjectWriteContext.new(@writer).dsl(&block)
84
+ @writer << "\n"
85
+ else
86
+ # TODO: Why does this need annotation?
87
+ # @type var finalizer: ^() -> ObjectRef
88
+ finalizer = proc { @writer << "\n" }
89
+ ObjectInterm.new(writer: @writer, finalizer:)
90
+ end
91
+ end
92
+ end
93
+
94
+ class ContentStreamWriteContext < WriteContext
95
+ def op(operator, &block)
96
+ ObjectWriteContext.new(@writer).dsl(&block) if block
97
+ @writer << " " << operator << "\n"
98
+ end
99
+ end
100
+
101
+ class ObjectWriteContext < WriteContext
102
+ def null
103
+ @writer << " null"
104
+ end
105
+
106
+ def bool(value)
107
+ @writer << " #{value}"
108
+ end
109
+
110
+ def name(name)
111
+ @writer << " /#{name}"
112
+ end
113
+
114
+ def num(value)
115
+ @writer << " " << value.to_s
116
+ end
117
+
118
+ def str(value)
119
+ @writer << " (" << value.to_s << ")"
120
+ end
121
+
122
+ def hexstr(value)
123
+ @writer << " <" << value.to_s << ">"
124
+ end
125
+
126
+ def ref(object_ref)
127
+ @writer << " #{object_ref.number} #{object_ref.generation} R"
128
+ end
129
+
130
+ def array(&block)
131
+ @writer << " ["
132
+ ObjectWriteContext.new(@writer).dsl(&block)
133
+ @writer << "]"
134
+ end
135
+
136
+ def dict(&block)
137
+ @writer << " <<\n"
138
+ DictionaryWriteContext.new(@writer).dsl(&block)
139
+ @writer << ">>"
140
+ end
141
+
142
+ def stream
143
+ @writer << "\nstream\n"
144
+ stream_start = @writer.offset
145
+ yield @writer
146
+ stream_size = @writer.offset - stream_start
147
+ @writer << "endstream"
148
+ stream_size
149
+ end
150
+
151
+ def content_stream(&block)
152
+ stream do |w|
153
+ ContentStreamWriteContext.new(w).dsl(&block)
154
+ end
155
+ end
156
+ end
157
+
158
+ class ObjectInterm
159
+ def initialize(writer:, finalizer:)
160
+ @writer = writer
161
+ @finalizer = finalizer
162
+ end
163
+
164
+ def of_null
165
+ @writer << " null"
166
+ @finalizer.call
167
+ end
168
+
169
+ def of_bool(value)
170
+ @writer << " #{value}"
171
+ @finalizer.call
172
+ end
173
+
174
+ def of_name(name)
175
+ @writer << " /#{name}"
176
+ @finalizer.call
177
+ end
178
+
179
+ def of_num(i)
180
+ @writer << " #{i}"
181
+ @finalizer.call
182
+ end
183
+
184
+ def of_str(value)
185
+ @writer << " (" << value.to_s << ")"
186
+ @finalizer.call
187
+ end
188
+
189
+ def of_hexstr(value)
190
+ @writer << " <" << value.to_s << ">"
191
+ @finalizer.call
192
+ end
193
+
194
+ def of_ref(object_ref)
195
+ @writer << " #{object_ref.number} #{object_ref.generation} R"
196
+ @finalizer.call
197
+ end
198
+
199
+ def of_dict(&block)
200
+ @writer << " <<\n"
201
+ DictionaryWriteContext.new(@writer).dsl(&block)
202
+ @writer << ">>"
203
+ @finalizer.call
204
+ end
205
+
206
+ def of_array(&block)
207
+ @writer << " ["
208
+ ObjectWriteContext.new(@writer).dsl(&block)
209
+ @writer << " ]"
210
+ @finalizer.call
211
+ end
212
+ end
213
+
214
+ class DocumentContext < WriteContext
215
+ def header(version = "1.4")
216
+ @writer << "%PDF-#{version}\n"
217
+ @writer << "%\xff\xff\xff\xff\n"
218
+ end
219
+
220
+ def alloc_obj
221
+ @writer.new_object
222
+ end
223
+
224
+ def obj(object_ref = nil, &block)
225
+ object_ref ||= @writer.new_object
226
+ @writer.update_object_entry(object_ref)
227
+ @writer << "#{object_ref.number} #{object_ref.generation} obj"
228
+ if block
229
+ ObjectWriteContext.new(@writer).dsl(&block)
230
+ @writer << "\nendobj\n"
231
+ object_ref
232
+ else
233
+ # TODO: Why does this need annotation?
234
+ # @type var finalizer: ^() -> ObjectRef
235
+ finalizer = proc { @writer << "\nendobj\n"; object_ref }
236
+ ObjectInterm.new(writer: @writer, finalizer:)
237
+ end
238
+ end
239
+
240
+ def xref
241
+ @prev_xref_offset = @xref_offset
242
+ @xref_offset = @writer.offset
243
+ @writer << "xref\n"
244
+ section_objs = @writer.finish_section
245
+ section_objs.sort.slice_when { |prev, curr| curr != prev.next }.each do |subsection|
246
+ @writer << "#{subsection.first} #{subsection.size}\n"
247
+ subsection.each do |n|
248
+ object = objects[n]
249
+ u = object.usage == :in_use ? "n" : "f"
250
+ # each entry ends with SP LF
251
+ @writer << "#{object.offset.to_s.rjust(10, "0")} #{object.generation.to_s.rjust(5, "0")} #{u} \n"
252
+ end
253
+ end
254
+ end
255
+
256
+ def trailer(&block)
257
+ @writer << "trailer\n<<\n"
258
+ DictionaryWriteContext.new(@writer).dsl do
259
+ entry("Size").of_num objects.size
260
+ entry("Prev").of_num @prev_xref_offset if @prev_xref_offset
261
+ dsl(&block)
262
+ end
263
+ @writer << ">>\n"
264
+
265
+ @writer << "startxref\n"
266
+ @writer << @xref_offset.to_s
267
+ @writer << "\n"
268
+
269
+ @writer << "%%EOF\n"
270
+ end
271
+
272
+ def dsl(&block)
273
+ Docile.dsl_eval(self, &block)
274
+ end
275
+ end
276
+
277
+ def self.write(io, &block)
278
+ Docile.dsl_eval(DocumentContext.new(Writer.new(io)), &block)
279
+ end
280
+ end
281
+ end
@@ -0,0 +1,508 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ttfunk"
4
+ require "ttfunk/subset"
5
+
6
+ module JotPDF
7
+ module Document
8
+ class StandardFont
9
+ attr_reader :name
10
+
11
+ def initialize(name)
12
+ @name = name
13
+ end
14
+
15
+ def unicode_to_code(codepoint)
16
+ raise if codepoint > 255
17
+
18
+ codepoint
19
+ end
20
+
21
+ def use(text)
22
+ text.each_codepoint.map { |c| format("%02x", unicode_to_code(c)) }.join
23
+ end
24
+ end
25
+
26
+ class NonstandardFont
27
+ attr_reader :subset
28
+
29
+ def initialize(font)
30
+ @subset = TTFunk::Subset.for(font, :unicode_8bit)
31
+ end
32
+
33
+ def name
34
+ @subset.original.name.postscript_name
35
+ end
36
+
37
+ def unicode_to_code(codepoint)
38
+ @subset.from_unicode(codepoint)
39
+ end
40
+
41
+ def use(text)
42
+ text.each_codepoint.map { |c| @subset.use(c); format("%02x", unicode_to_code(c)) }.join
43
+ end
44
+
45
+ def encode_subset
46
+ @subset.encode
47
+ end
48
+ end
49
+
50
+ class FontManager
51
+ attr_writer :default_font
52
+
53
+ def initialize(default_font:)
54
+ @fonts = {}
55
+ @default_font = default_font
56
+ end
57
+
58
+ def load_font(path)
59
+ font = TTFunk::File.open(path)
60
+ name = font.name.postscript_name
61
+ @fonts[name] = NonstandardFont.new(font)
62
+ end
63
+
64
+ def require(spec = nil)
65
+ spec ||= @default_font
66
+ if spec.is_a? Symbol
67
+ @fonts[spec] ||= StandardFont.new(spec)
68
+ else
69
+ @fonts.fetch(spec)
70
+ end
71
+ end
72
+
73
+ def loaded_fonts
74
+ @fonts
75
+ end
76
+ end
77
+
78
+ class PageContext
79
+ def initialize(ctx, font_manager:)
80
+ @ctx = ctx
81
+ @font_manager = font_manager
82
+ end
83
+
84
+ def color(color = nil, r: nil, g: nil, b: nil)
85
+ # rubocop:disable Style/RedundantCondition
86
+ r, g, b =
87
+ if color
88
+ colorspec(color)
89
+ else
90
+ colorspec(r:, g:, b:)
91
+ end
92
+ # rubocop:enable Style/RedundantCondition
93
+ @ctx.dsl do
94
+ op("rg") { num r; num g; num b }
95
+ end
96
+ end
97
+
98
+ def stroke_color(color = nil, r: nil, g: nil, b: nil)
99
+ # rubocop:disable Style/RedundantCondition
100
+ r, g, b =
101
+ if color
102
+ colorspec(color)
103
+ else
104
+ colorspec(r:, g:, b:)
105
+ end
106
+ # rubocop:enable Style/RedundantCondition
107
+ @ctx.dsl do
108
+ op("RG") { num r; num g; num b }
109
+ end
110
+ end
111
+
112
+ def stroke_width(width)
113
+ @ctx.dsl do
114
+ op("w") { num width }
115
+ end
116
+ end
117
+
118
+ def rect(x:, y:, width:, height:)
119
+ @ctx.dsl do
120
+ op("re") { num x; num y; num width; num height }
121
+ end
122
+ end
123
+
124
+ def path(*args)
125
+ @ctx.dsl do
126
+ x0, y0 = args.shift
127
+ op("m") { num x0; num y0 }
128
+ args.each do |x, y|
129
+ op("l") { num x; num y }
130
+ end
131
+ op("h")
132
+ end
133
+ end
134
+
135
+ def stroke
136
+ @ctx.dsl do
137
+ op("s")
138
+ end
139
+ end
140
+
141
+ def fill
142
+ @ctx.dsl do
143
+ op("f")
144
+ end
145
+ end
146
+
147
+ def text(text = nil, **kwargs, &block)
148
+ @ctx.dsl do
149
+ op("BT")
150
+ tc = TextContext.new(@ctx, font_manager: @font_manager, **kwargs)
151
+ tc.show text if text
152
+ tc.dsl(&block) if block
153
+ op("ET")
154
+ end
155
+ end
156
+
157
+ def image(n, x:, y:, width:, height:)
158
+ @ctx.dsl do
159
+ op("cm") { num width; num 0; num 0; num height; num x; num y }
160
+ op("Do") { name n }
161
+ end
162
+ end
163
+
164
+ def dsl(&block)
165
+ Docile.dsl_eval(self, &block)
166
+ end
167
+
168
+ private
169
+
170
+ def colorspec(color = nil, r: nil, g: nil, b: nil)
171
+ r ||= 0.0
172
+ g ||= 0.0
173
+ b ||= 0.0
174
+
175
+ if color
176
+ b = (color & 0xff).to_f
177
+ g = ((color >> 8) & 0xff).to_f
178
+ r = ((color >> 16) & 0xff).to_f
179
+ end
180
+
181
+ r /= 256.0 if r > 1
182
+ g /= 256.0 if g > 1
183
+ b /= 256.0 if b > 1
184
+
185
+ [r, g, b]
186
+ end
187
+ end
188
+
189
+ class TextContext < PageContext
190
+ undef_method :text
191
+
192
+ attr_reader :base_x, :base_y
193
+
194
+ def initialize(ctx, font_manager:, x: 0.0, y: 0.0, font: nil, size: nil, line_height: nil)
195
+ super(ctx, font_manager:)
196
+ @base_x = 0.0
197
+ @base_y = 0.0
198
+ move(x:, y:)
199
+ font(font, **{ size: }.compact)
200
+ @line_height = line_height
201
+ end
202
+
203
+ def font(spec, size: nil)
204
+ @font = @font_manager.require(spec)
205
+ size ||= @size
206
+ @size ||= size
207
+ @ctx.dsl do
208
+ op("Tf") { name @font.name; num(size || 15) }
209
+ end
210
+ end
211
+
212
+ def move(x:, y:)
213
+ @base_x += x
214
+ @base_y += y
215
+ @ctx.dsl do
216
+ op("Td") { num x; num y }
217
+ end
218
+ end
219
+
220
+ def linebreak(factor: 1.0)
221
+ # @type var factor: ::Numeric
222
+ move x: 0.0, y: -line_height * factor
223
+ end
224
+
225
+ def show(text)
226
+ @ctx.dsl do
227
+ text.each_line(chomp: true).with_index do |line, idx|
228
+ # @type self: JotPDF::Core::ContentStreamWriteContext & TextContext
229
+ move x: 0.0, y: -line_height unless idx.zero?
230
+ op("Tj") { hexstr @font.use(line) }
231
+ end
232
+ end
233
+ end
234
+
235
+ private
236
+
237
+ def line_height
238
+ @line_height || @size
239
+ end
240
+ end
241
+
242
+ class ImageContext
243
+ attr_reader :image_obj
244
+
245
+ def initialize(core, width:, height:)
246
+ @core = core
247
+ @width = width
248
+ @height = height
249
+ @mask_obj = core.alloc_obj
250
+ @image_obj = core.alloc_obj
251
+ end
252
+
253
+ def alpha(&block)
254
+ @core.dsl do
255
+ alloc_obj => length_obj
256
+ stream_size = nil
257
+ obj(@mask_obj) do
258
+ dict do
259
+ entry("Type").of_name "XObject"
260
+ entry("Subtype").of_name "Image"
261
+ entry("Width").of_num @width
262
+ entry("Height").of_num @height
263
+ entry("ColorSpace").of_name "DeviceGray"
264
+ entry("Decode").of_array { num 0; num 1 }
265
+ entry("BitsPerComponent").of_num 8
266
+ entry("Length").of_ref length_obj
267
+ end
268
+ stream(&block) => stream_size
269
+ end
270
+ obj(length_obj) { num stream_size }
271
+ end
272
+ end
273
+
274
+ def rgb(&block)
275
+ @core.dsl do
276
+ alloc_obj => length_obj
277
+ stream_size = nil
278
+ obj(@image_obj) do
279
+ dict do
280
+ entry("Type").of_name "XObject"
281
+ entry("Subtype").of_name "Image"
282
+ entry("Width").of_num @width
283
+ entry("Height").of_num @height
284
+ entry("ColorSpace").of_name "DeviceRGB"
285
+ entry("BitsPerComponent").of_num 8
286
+ entry("SMask").of_ref @mask_obj
287
+ entry("Length").of_ref length_obj
288
+ end
289
+ stream(&block) => stream_size
290
+ end
291
+ obj(length_obj) { num stream_size }
292
+ end
293
+ end
294
+
295
+ def dsl(&block)
296
+ Docile.dsl_eval(self, &block)
297
+ end
298
+ end
299
+
300
+ class DocumentWriter
301
+ attr_reader :pages, :images, :font_manager
302
+
303
+ def initialize(core, resources_obj:, pages_obj:)
304
+ @core = core
305
+ @pages = []
306
+ @pages_obj = pages_obj
307
+ @resources_obj = resources_obj
308
+ @images = {}
309
+ @font_manager = FontManager.new(default_font: :Helvetica)
310
+ end
311
+
312
+ def default_font(spec)
313
+ @font_manager.default_font = spec
314
+ end
315
+
316
+ def load_font(path)
317
+ @font_manager.load_font(path)
318
+ end
319
+
320
+ def image(n, width:, height:, &block)
321
+ ic = ImageContext.new(@core, width:, height:)
322
+ ic.dsl(&block)
323
+ @images[n] = ic.image_obj
324
+ end
325
+
326
+ def page(width:, height:, &block)
327
+ @core.dsl do
328
+ alloc_obj => length_obj
329
+
330
+ stream_size = nil
331
+ obj do
332
+ dict { entry("Length") { ref length_obj } }
333
+ content_stream do
334
+ PageContext.new(self, font_manager: @font_manager).dsl(&block)
335
+ end => stream_size
336
+ end => contents_obj
337
+
338
+ obj(length_obj) { num stream_size }
339
+
340
+ obj.of_dict do
341
+ entry("Type") { name "Page" }
342
+ entry("Parent") { ref @pages_obj }
343
+ entry("MediaBox").of_array { num 0; num 0; num width; num height }
344
+ entry("Resources") { ref @resources_obj }
345
+ entry("Contents") { ref contents_obj }
346
+ end => page_obj
347
+ @pages << page_obj
348
+ end
349
+ end
350
+
351
+ def dsl(&block)
352
+ Docile.dsl_eval(self, &block)
353
+ end
354
+ end
355
+
356
+ def self.generate_unicode_cmap(mapping)
357
+ <<~CMAP
358
+ /CIDInit /ProcSet findresource begin
359
+ 12 dict begin
360
+ begincmap
361
+ /CIDSystemInfo 3 dict dup begin
362
+ /Registry (Adobe) def
363
+ /Ordering (UCS) def
364
+ /Supplement 0 def
365
+ end def
366
+ /CMapName /Adobe-Identity-UCS def
367
+ /CMapType 2 def
368
+
369
+ 1 begincodespacerange
370
+ <00> <FF>
371
+ endcodespacerange
372
+
373
+ #{mapping.length} beginbfchar
374
+ #{mapping.map do |code, codepoint|
375
+ format("<%<code>02X><%<codepoint>s>", code:, codepoint: codepoint.chr(::Encoding::UTF_16BE).unpack1("H*"))
376
+ end.join("\n")}
377
+ endbfchar
378
+
379
+ endcmap
380
+ CMapName currentdict /CMap defineresource pop
381
+ end
382
+ end
383
+ CMAP
384
+ end
385
+
386
+ def self.write(io, &block)
387
+ Core.write(io) do
388
+ header
389
+
390
+ alloc_obj => resources_obj
391
+ alloc_obj => pages_obj
392
+ writer = Document::DocumentWriter.new(self, resources_obj:, pages_obj:)
393
+ writer.dsl(&block)
394
+
395
+ # @type var font_file_objs: Hash[::String | Symbol, ObjectRef]
396
+ # @type var widths_objs: Hash[::String | Symbol, ObjectRef]
397
+ # @type var tounicode_objs: Hash[::String | Symbol, ObjectRef]
398
+ font_file_objs = {}
399
+ widths_objs = {}
400
+ tounicode_objs = {}
401
+ writer.font_manager.loaded_fonts.each do |n, f|
402
+ # TODO: How can Steep use is_a? on _Font?
403
+ # rubocop:disable Style/CaseEquality
404
+ next unless NonstandardFont === f
405
+ # rubocop:enable Style/CaseEquality
406
+
407
+ subset_data = f.encode_subset
408
+ obj do
409
+ dict do
410
+ entry("Length").of_num subset_data.bytesize
411
+ entry("Length1").of_num subset_data.bytesize # always required for TrueType
412
+ end
413
+ stream do |stream|
414
+ stream << subset_data
415
+ end
416
+ end => font_file_obj
417
+ font_file_objs[n] = font_file_obj
418
+
419
+ subset = TTFunk::File.new(subset_data)
420
+ obj.of_array do
421
+ (subset.os2.first_char_index..subset.os2.last_char_index).each do |code|
422
+ gid = subset.cmap.tables.first[code]
423
+ width_in_units = subset.horizontal_metrics.for(gid).advance_width
424
+ num (Float(width_in_units) * 1000 / subset.header.units_per_em).to_i
425
+ end
426
+ end => widths_obj
427
+ widths_objs[n] = widths_obj
428
+
429
+ alloc_obj => length_obj
430
+ stream_size = nil
431
+ obj do
432
+ dict { entry("Length").of_ref length_obj }
433
+ stream do |w|
434
+ w << generate_unicode_cmap(f.subset.to_unicode_map)
435
+ end => stream_size
436
+ end => tounicode_obj
437
+ obj(length_obj).of_num stream_size
438
+ tounicode_objs[n] = tounicode_obj
439
+ end
440
+
441
+ obj(resources_obj).of_dict do
442
+ entry("XObject").of_dict do
443
+ writer.images.each do |n, r|
444
+ entry(n).of_ref r
445
+ end
446
+ end
447
+ entry("ProcSet").of_array { name "PDF"; name "Text"; name "ImageB"; name "ImageC"; name "ImageI" }
448
+ entry("Font").of_dict do
449
+ writer.font_manager.loaded_fonts.each do |n, f|
450
+ entry(n.to_s).of_dict do
451
+ entry("Type") { name "Font" }
452
+ case f
453
+ when StandardFont
454
+ entry("Subtype") { name "Type1" }
455
+ entry("BaseFont") { name n.to_s }
456
+ when NonstandardFont
457
+ subset = TTFunk::File.new(f.encode_subset)
458
+ # https://github.com/prawnpdf/prawn/blob/aaea7f6beda092ba48001414125a576dcf891362/lib/prawn/fonts/ttf.rb#L446-L447
459
+ base_name = subset.name.postscript_name[0, 33].delete("\0")
460
+ entry("Subtype").of_name "TrueType"
461
+ entry("FirstChar").of_num subset.os2.first_char_index
462
+ entry("LastChar").of_num subset.os2.last_char_index
463
+ entry("ToUnicode").of_ref tounicode_objs[n]
464
+ entry("BaseFont").of_name base_name
465
+ entry("Widths").of_ref widths_objs[n]
466
+ entry("FontDescriptor").of_dict do
467
+ entry("Ascent").of_num subset.ascent
468
+ entry("Descent").of_num subset.descent
469
+ entry("CapHeight").of_num subset.os2.cap_height
470
+ entry("StemV").of_num 0
471
+ entry("ItalicAngle").of_num 0
472
+ entry("Flags").of_num 0b100
473
+ entry("FontBBox").of_array do
474
+ subset.bbox.each do |i|
475
+ num i
476
+ end
477
+ end
478
+ entry("FontName").of_name base_name
479
+ entry("XHeight").of_num subset.os2.x_height
480
+ entry("FontFile2").of_ref font_file_objs[n]
481
+ end
482
+ end
483
+ end
484
+ end
485
+ end
486
+ end => resources_obj
487
+
488
+ obj(pages_obj).of_dict do
489
+ entry("Type") { name "Pages" }
490
+ entry("Kids").of_array do
491
+ writer.pages.each do |page|
492
+ ref page
493
+ end
494
+ end
495
+ entry("Count") { num writer.pages.size }
496
+ end
497
+ obj.of_dict do
498
+ entry("Type") { name "Catalog" }
499
+ entry("Pages") { ref pages_obj }
500
+ end => root_obj
501
+ xref
502
+ trailer do
503
+ entry("Root") { ref root_obj }
504
+ end
505
+ end
506
+ end
507
+ end
508
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JotPDF
4
+ VERSION = "0.1.0"
5
+ end
data/lib/jot_pdf.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "jot_pdf/version"
4
+ require_relative "jot_pdf/core"
5
+ require_relative "jot_pdf/document"
6
+
7
+ module JotPDF
8
+ end
@@ -0,0 +1,96 @@
1
+ module JotPDF
2
+ module Core
3
+ class CrossReferenceTableEntry
4
+ attr_reader offset: Integer
5
+ attr_reader generation: Integer
6
+ attr_reader usage: Symbol
7
+
8
+ def initialize: (offset: Integer, generation: Integer, usage: Symbol) -> void
9
+ def self.default: () -> CrossReferenceTableEntry
10
+ end
11
+
12
+ class ObjectRef
13
+ attr_reader number: Integer
14
+ attr_reader generation: Integer
15
+
16
+ def initialize: (number: Integer, generation: Integer) -> void
17
+ end
18
+
19
+ class Writer
20
+ @objects: Array[CrossReferenceTableEntry]
21
+ @io: IO
22
+ @offset: Integer
23
+ @section: Array[Integer]
24
+
25
+ attr_reader objects: Array[CrossReferenceTableEntry]
26
+ attr_reader io: IO
27
+ attr_reader offset: Integer
28
+
29
+ def initialize: (IO) -> void
30
+ def new_object: () -> ObjectRef
31
+ def update_object_entry: (ObjectRef) -> void
32
+ def finish_section: () -> Array[Integer]
33
+ def <<: (_ToS) -> self
34
+ end
35
+
36
+ class WriteContext
37
+ @writer: Writer
38
+
39
+ def initialize: (Writer) -> void
40
+ def objects: () -> Array[CrossReferenceTableEntry]
41
+ def dsl: [T] () { () [self: self] -> T } -> T
42
+ end
43
+
44
+ class DictionaryWriteContext < WriteContext
45
+ def entry: (_ToS) { () [self: ObjectWriteContext] -> untyped } -> void | (_ToS) -> ObjectInterm[void]
46
+ end
47
+
48
+ class ContentStreamWriteContext < WriteContext
49
+ def op: (_ToS) ?{ () [self: ObjectWriteContext] -> untyped } -> void
50
+ end
51
+
52
+ class ObjectWriteContext < WriteContext
53
+ def null: () -> void
54
+ def bool: (_ToS) -> void
55
+ def name: (_ToS) -> void
56
+ def num: (_ToS) -> void
57
+ def str: (_ToS) -> void
58
+ def hexstr: (_ToS) -> void
59
+ def ref: (ObjectRef) -> void
60
+ def array: () { () [self: ObjectWriteContext] -> untyped } -> void
61
+ def dict: () { () [self: DictionaryWriteContext] -> untyped } -> void
62
+ def stream: () { (Writer) -> untyped } -> Integer
63
+ def content_stream: () { () [self: ContentStreamWriteContext] -> untyped } -> Integer
64
+ end
65
+
66
+ class ObjectInterm[T]
67
+ @writer: Writer
68
+ @finalizer: ^() -> T
69
+
70
+ def initialize: (writer: Writer, finalizer: ^() -> T) -> void
71
+ def of_null: () -> T
72
+ def of_bool: (_ToS) -> T
73
+ def of_name: (_ToS) -> T
74
+ def of_num: (_ToS) -> T
75
+ def of_str: (_ToS) -> T
76
+ def of_hexstr: (_ToS) -> T
77
+ def of_ref: (ObjectRef) -> T
78
+ def of_dict: () { () [self: DictionaryWriteContext] -> untyped } -> T
79
+ def of_array: () { () [self: ObjectWriteContext] -> untyped } -> T
80
+ end
81
+
82
+ class DocumentContext < WriteContext
83
+ @prev_xref_offset: Integer?
84
+ @xref_offset: Integer?
85
+
86
+ def header: (?::String version) -> void
87
+ def alloc_obj: () -> ObjectRef
88
+ def obj: (?ObjectRef object_ref) { () [self: ObjectWriteContext] -> untyped } -> ObjectRef | (?ObjectRef object_ref) -> ObjectInterm[ObjectRef]
89
+ def xref: () -> void
90
+ def trailer: () { () [self: DictionaryWriteContext] -> untyped } -> void
91
+ def dsl: [T] () { () [self: self] -> T } -> T
92
+ end
93
+
94
+ def self.write: (IO) { () [self: DocumentContext] -> void } -> void
95
+ end
96
+ end
@@ -0,0 +1,129 @@
1
+ module JotPDF
2
+ module Document
3
+ interface _Font
4
+ def name: () -> (::String | ::Symbol)
5
+ def unicode_to_code: (::Integer) -> ::Integer
6
+ def use: (::String) -> ::String
7
+ end
8
+
9
+ class StandardFont
10
+ @name: ::Symbol
11
+ attr_reader name: ::Symbol
12
+
13
+ def initialize: (::Symbol) -> void
14
+
15
+ def unicode_to_code: (::Integer) -> ::Integer
16
+ def use: (::String) -> ::String
17
+ end
18
+
19
+ class NonstandardFont
20
+ @subset: TTFunk::Subset
21
+ attr_reader subset: TTFunk::Subset
22
+
23
+ def initialize: (TTFunk::File) -> void
24
+
25
+ def name: () -> ::String
26
+ def unicode_to_code: (::Integer) -> ::Integer
27
+ def use: (::String) -> ::String
28
+
29
+ def encode_subset: () -> ::String
30
+ end
31
+
32
+ class FontManager
33
+ @fonts: Hash[::String | ::Symbol, _Font]
34
+ @default_font: ::String | ::Symbol
35
+ attr_writer default_font: ::String | ::Symbol
36
+
37
+ def initialize: (default_font: ::String | ::Symbol) -> void
38
+ def load_font: (::String) -> _Font
39
+ def require: (?(::String | ::Symbol | nil)) -> _Font
40
+ def loaded_fonts: () -> Hash[::String | ::Symbol, _Font]
41
+ end
42
+
43
+ class PageContext
44
+ @ctx: JotPDF::Core::ContentStreamWriteContext
45
+ @font_manager: FontManager
46
+
47
+ def initialize: (JotPDF::Core::ContentStreamWriteContext, font_manager: FontManager) -> void
48
+ def color: (::Integer) -> void | (r: ::Float, g: ::Float, b: ::Float) -> void
49
+ def stroke_color: (::Integer) -> void | (r: ::Float, g: ::Float, b: ::Float) -> void
50
+ def stroke_width: (::Float) -> void
51
+ def rect: (x: ::Float, y: ::Float, width: ::Float, height: ::Float) -> void
52
+ def path: (*[::Float, ::Float]) -> void
53
+ def stroke: () -> void
54
+ def fill: () -> void
55
+ def text: (?::String?, ?x: ::Float, ?y: ::Float, ?font: ::String | ::Symbol, ?size: ::Float, ?line_height: ::Float) ?{ () [self: TextContext] -> void } -> void
56
+ def image: (::String, x: ::Float, y: ::Float, width: ::Float, height: ::Float) -> void
57
+
58
+ def dsl: [T] () { () [self: self] -> T } -> T
59
+
60
+ private
61
+
62
+ def colorspec: (::Integer) -> [::Float, ::Float, ::Float] | (r: ::Float?, g: ::Float?, b: ::Float?) -> [::Float, ::Float, ::Float]
63
+ end
64
+
65
+ class TextContext < PageContext
66
+ @base_x: ::Float
67
+ @base_y: ::Float
68
+ @line_height: ::Float?
69
+ @font: _Font
70
+ @size: ::Float
71
+ attr_reader base_x: ::Float
72
+ attr_reader base_y: ::Float
73
+
74
+ def initialize: (JotPDF::Core::ContentStreamWriteContext, font_manager: FontManager, ?x: ::Float, ?y: ::Float, ?font: ::String?, ?size: ::Float?, ?line_height: ::Float?) -> void
75
+
76
+ def font: (::String | ::Symbol | nil, ?size: ::Float | nil) -> void
77
+ def move: (x: ::Float, y: ::Float) -> void
78
+ def linebreak: (?factor: ::Float) -> void
79
+ def show: (::String) -> void
80
+
81
+ private
82
+
83
+ def line_height: () -> (::Float)
84
+ end
85
+
86
+ class ImageContext
87
+ @core: JotPDF::Core::DocumentContext
88
+ @width: ::Float
89
+ @height: ::Float
90
+ @mask_obj: JotPDF::Core::ObjectRef
91
+ @image_obj: JotPDF::Core::ObjectRef
92
+
93
+ attr_reader image_obj: JotPDF::Core::ObjectRef
94
+
95
+ def initialize: (JotPDF::Core::DocumentContext, width: ::Float, height: ::Float) -> void
96
+
97
+ def alpha: () { (JotPDF::Core::Writer) -> void } -> void
98
+ def rgb: () { (JotPDF::Core::Writer) -> void } -> void
99
+
100
+ def dsl: [T] () { () [self: self] -> T } -> T
101
+ end
102
+
103
+ class DocumentWriter
104
+ @core: JotPDF::Core::DocumentContext
105
+ @pages: Array[JotPDF::Core::ObjectRef]
106
+ @pages_obj: JotPDF::Core::ObjectRef
107
+ @resources_obj: JotPDF::Core::ObjectRef
108
+ @images: Hash[::String, JotPDF::Core::ObjectRef]
109
+ @font_manager: FontManager
110
+
111
+ attr_reader pages: Array[JotPDF::Core::ObjectRef]
112
+ attr_reader images: Hash[::String, JotPDF::Core::ObjectRef]
113
+ attr_reader font_manager: FontManager
114
+
115
+ def initialize: (JotPDF::Core::DocumentContext, resources_obj: JotPDF::Core::ObjectRef, pages_obj: JotPDF::Core::ObjectRef ) -> void
116
+
117
+ def default_font: (::String | ::Symbol) -> void
118
+ def load_font: (::String) -> void
119
+ def image: (::String, width: ::Float, height: ::Float) { () [self: ImageContext] -> void } -> void
120
+ def page: (width: ::Float, height: ::Float) { () [self: PageContext] -> void } -> void
121
+
122
+ def dsl: [T] () { () [self: DocumentWriter] -> T } -> T
123
+ end
124
+
125
+ def self.generate_unicode_cmap: (Hash[::Integer, ::Integer]) -> ::String
126
+
127
+ def self.write: (IO) { () [self: DocumentWriter] -> void } -> void
128
+ end
129
+ end
data/sig/jot_pdf.rbs ADDED
@@ -0,0 +1,3 @@
1
+ module JotPDF
2
+ VERSION: String
3
+ end
metadata ADDED
@@ -0,0 +1,89 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jot_pdf
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - coord_e
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-01-16 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: docile
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: ttfunk
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.0'
41
+ description:
42
+ email:
43
+ - me@coord-e.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - ".rspec"
49
+ - ".rubocop.yml"
50
+ - CHANGELOG.md
51
+ - LICENSE.txt
52
+ - README.md
53
+ - Rakefile
54
+ - Steepfile
55
+ - lib/jot_pdf.rb
56
+ - lib/jot_pdf/core.rb
57
+ - lib/jot_pdf/document.rb
58
+ - lib/jot_pdf/version.rb
59
+ - sig/jot_pdf.rbs
60
+ - sig/jot_pdf/core.rbs
61
+ - sig/jot_pdf/document.rbs
62
+ homepage: https://github.com/coord-e/jot_pdf
63
+ licenses:
64
+ - MIT
65
+ metadata:
66
+ homepage_uri: https://github.com/coord-e/jot_pdf
67
+ source_code_uri: https://github.com/coord-e/jot_pdf
68
+ changelog_uri: https://github.com/coord-e/jot_pdf/blob/main/CHANGELOG.md
69
+ rubygems_mfa_required: 'true'
70
+ post_install_message:
71
+ rdoc_options: []
72
+ require_paths:
73
+ - lib
74
+ required_ruby_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: 3.1.0
79
+ required_rubygems_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ requirements: []
85
+ rubygems_version: 3.5.11
86
+ signing_key:
87
+ specification_version: 4
88
+ summary: Streaming PDF writer DSL
89
+ test_files: []