idml 0.1.0 → 0.2.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.
@@ -1,426 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # DEPRECATED: This module is superseded by Idml::Render::PdfrbWriter.
4
- # The Pipeline now uses pdfrb (Pdfrb::Document) for all PDF assembly.
5
- # This module remains for backward compatibility but is not used by
6
- # any active code path. See TODO.pdf/51-remove-dead-code.md.
7
-
8
- module Idml
9
- module Render
10
- # Minimal PDF file writer. Assembles content-stream strings into
11
- # a valid PDF with page tree, font resources, image XObjects,
12
- # embedded TrueType fonts, cross-reference table, and trailer.
13
- class PdfWriter
14
- def initialize
15
- @pages = []
16
- @images = {}
17
- @embedded_fonts = {}
18
- @info = {}
19
- @next_id = 1
20
- @catalog_id = alloc_id
21
- @pages_id = alloc_id
22
- @info_id = alloc_id
23
- end
24
-
25
- def add_page(width:, height:, content:, fonts: {}, xobjects: [])
26
- font_objs = build_fonts(fonts)
27
- xobject_objs = build_xobjects(xobjects)
28
- content_id = alloc_id
29
- page_id = alloc_id
30
-
31
- @pages << {
32
- id: page_id,
33
- width: width,
34
- height: height,
35
- content_id: content_id,
36
- content: content,
37
- font_objs: font_objs,
38
- xobject_objs: xobject_objs,
39
- }
40
- page_id
41
- end
42
-
43
- # Register a JPEG image as a PDF image XObject (DCTDecode).
44
- # Returns the XObject name to use in content streams (e.g. "Im1").
45
- def add_jpeg_image(data:, width:, height:, colorspace: :DeviceRGB)
46
- add_image_object(data: data, width: width, height: height,
47
- colorspace: colorspace, filter: :DCTDecode)
48
- end
49
-
50
- # Register a PNG image as a PDF image XObject (FlateDecode with
51
- # PNG predictor). The IDAT chunk data is embedded directly; the
52
- # PDF viewer handles decompression and unfiltering.
53
- def add_png_image(data:, width:, height:, colorspace: :DeviceRGB)
54
- idat = Render::Image.png_idat_data(data)
55
- return nil unless idat
56
-
57
- name = add_image_object(data: idat, width: width, height: height,
58
- colorspace: colorspace, filter: :FlateDecode)
59
- @images[name][:decode_parms] = png_decode_parms(width, colorspace)
60
- name
61
- end
62
-
63
- # Auto-detect format and register the image.
64
- def add_image(data:)
65
- format = Render::Image.detect_format(data)
66
- return nil unless format
67
-
68
- dims = image_dimensions(data, format)
69
- return nil unless dims
70
-
71
- cs = image_colorspace(data, format) || :DeviceRGB
72
- register_format_image(data, format, dims, cs)
73
- end
74
-
75
- def image_dimensions(data, format)
76
- if format == :png
77
- Render::Image.png_dimensions(data)
78
- else
79
- Render::Image.jpeg_dimensions(data)
80
- end
81
- end
82
-
83
- def image_colorspace(data, format)
84
- if format == :png
85
- Render::Image.png_colorspace(data)
86
- else
87
- Render::Image.jpeg_colorspace(data)
88
- end
89
- end
90
-
91
- def register_format_image(data, format, dims, cs)
92
- case format
93
- when :jpeg
94
- add_jpeg_image(data: data, width: dims[0], height: dims[1],
95
- colorspace: cs)
96
- when :png
97
- add_png_image(data: data, width: dims[0], height: dims[1],
98
- colorspace: cs)
99
- end
100
- end
101
-
102
- def add_image_object(data:, width:, height:, colorspace:, filter:)
103
- name = "Im#{@images.length + 1}"
104
- id = alloc_id
105
- @images[name] = {
106
- id: id,
107
- data: data.dup.force_encoding("ASCII-8BIT"),
108
- width: width,
109
- height: height,
110
- colorspace: colorspace,
111
- filter: filter,
112
- }
113
- name
114
- end
115
-
116
- def png_decode_parms(width, colorspace)
117
- colors = colorspace == :DeviceGray ? 1 : 3
118
- "<< /Predictor 15 /Columns #{width} /Colors #{colors} /BitsPerComponent 8 >>"
119
- end
120
-
121
- # Register a TrueType font for embedding (FontFile2). Returns the
122
- # PostScript name to use as the value in add_page's fonts hash.
123
- def register_embedded_font(metrics:, data:)
124
- ps_name = metrics.postscript_name
125
- @embedded_fonts[ps_name] = {
126
- metrics: metrics,
127
- data: data.dup.force_encoding("ASCII-8BIT"),
128
- }
129
- ps_name
130
- end
131
-
132
- def write(path)
133
- File.binwrite(path, build_pdf)
134
- end
135
-
136
- # Set PDF metadata (Title, Author, Subject, etc.).
137
- # Values should be strings; keys are PDF Info dictionary keys.
138
- def set_info(hash)
139
- @info.merge!(hash)
140
- end
141
-
142
- # Add a bookmark (outline entry) pointing to a page.
143
- # title: display text; page_index: 0-based page index.
144
- def add_bookmark(title, page_index)
145
- @outlines ||= []
146
- @outlines << { title: title, page_index: page_index }
147
- end
148
-
149
- # Set XMP metadata packet (XML string). Embedded as a stream
150
- # object referenced from the Catalog.
151
- def set_xmp(xml)
152
- @xmp = xml
153
- end
154
-
155
- # Set OutputIntent with an ICC profile. Required for PDF/A.
156
- # profile_data: binary ICC profile data.
157
- def set_output_intent(profile_data)
158
- @icc_profile = profile_data
159
- end
160
-
161
- private
162
-
163
- def alloc_id
164
- id = @next_id
165
- @next_id += 1
166
- id
167
- end
168
-
169
- def build_fonts(fonts)
170
- fonts.map do |name, ps_name|
171
- embedded = @embedded_fonts[ps_name]
172
- entry = {
173
- name: name,
174
- ps_name: ps_name,
175
- id: alloc_id,
176
- embedded: embedded,
177
- }
178
- if embedded
179
- entry[:descriptor_id] = alloc_id
180
- entry[:file_id] = alloc_id
181
- end
182
- entry
183
- end
184
- end
185
-
186
- # xobjects: Array of names referencing registered images.
187
- def build_xobjects(xobject_names)
188
- xobject_names.filter_map do |name|
189
- img = @images[name]
190
- next unless img
191
-
192
- { name: name, id: img[:id] }
193
- end
194
- end
195
-
196
- def build_pdf
197
- objects = {}
198
-
199
- objects[@catalog_id] = build_catalog(objects)
200
-
201
- page_refs = @pages.map { |p| "#{p[:id]} 0 R" }.join(" ")
202
- objects[@pages_id] =
203
- "<< /Type /Pages /Kids [#{page_refs}] /Count #{@pages.length} >>"
204
-
205
- @pages.each { |page| build_page_objects(page, objects) }
206
-
207
- @images.each_value do |img|
208
- objects[img[:id]] = build_image_xobject(img)
209
- end
210
-
211
- objects[@info_id] = build_info_object
212
-
213
- assemble_pdf(objects)
214
- end
215
-
216
- def build_catalog(objects)
217
- dict = "<< /Type /Catalog /Pages #{@pages_id} 0 R"
218
- outlines_ref = build_outlines(objects)
219
- dict << " /Outlines #{outlines_ref}" if outlines_ref
220
- xmp_ref = build_xmp(objects)
221
- dict << " /Metadata #{xmp_ref}" if xmp_ref
222
- output_ref = build_output_intent(objects)
223
- dict << " /OutputIntents [#{output_ref}]" if output_ref
224
- "#{dict} >>"
225
- end
226
-
227
- def build_outlines(objects)
228
- return nil unless @outlines&.any?
229
-
230
- outlines_id = alloc_id
231
- item_ids = @outlines.map { alloc_id }
232
- @outlines.each_with_index do |entry, i|
233
- objects[item_ids[i]] = outline_item(entry, i, item_ids, outlines_id)
234
- end
235
- objects[outlines_id] = outlines_root(outlines_id, item_ids)
236
- "#{outlines_id} 0 R"
237
- end
238
-
239
- def outline_item(entry, index, item_ids, outlines_id)
240
- prev_ref = index.positive? ? " /Prev #{item_ids[index - 1]} 0 R" : ""
241
- next_ref = next_outline_ref(index, item_ids)
242
- page_ref = outline_page_ref(entry[:page_index])
243
- title = escape_pdf_string(entry[:title].to_s)
244
- "<< /Title (#{title}) /Parent #{outlines_id} 0 R" \
245
- "#{prev_ref}#{next_ref} /Dest [#{page_ref} 0 R /Fit] >>"
246
- end
247
-
248
- def next_outline_ref(index, item_ids)
249
- return "" unless index < item_ids.length - 1
250
-
251
- " /Next #{item_ids[index + 1]} 0 R"
252
- end
253
-
254
- def outline_page_ref(page_index)
255
- page = @pages[page_index] || @pages.first
256
- page ? page[:id] : @pages_id
257
- end
258
-
259
- def outlines_root(_outlines_id, item_ids)
260
- "<< /Type /Outlines /First #{item_ids.first} 0 R " \
261
- "/Last #{item_ids.last} 0 R /Count #{item_ids.length} >>"
262
- end
263
-
264
- def build_info_object
265
- entries = @info.map do |key, value|
266
- "/#{key} (#{escape_pdf_string(value.to_s)})"
267
- end.join(" ")
268
- "<< #{entries} >>"
269
- end
270
-
271
- def escape_pdf_string(str)
272
- str.gsub("\\", "\\\\\\\\").gsub("(", "\\(").gsub(")", "\\)")
273
- end
274
-
275
- def build_xmp(objects)
276
- return nil unless @xmp
277
-
278
- xmp_id = alloc_id
279
- data = @xmp.dup.force_encoding("ASCII-8BIT")
280
- objects[xmp_id] = "<< /Type /Metadata /Subtype /XML " \
281
- "/Length #{data.bytesize} >>\nstream\n#{data}\nendstream"
282
- "#{xmp_id} 0 R"
283
- end
284
-
285
- def build_output_intent(objects)
286
- return nil unless @icc_profile
287
-
288
- icc_id = alloc_id
289
- data = @icc_profile.dup.force_encoding("ASCII-8BIT")
290
- objects[icc_id] = "<< /N 3 /Alternate /DeviceRGB " \
291
- "/Length #{data.bytesize} >>\nstream\n#{data}\nendstream"
292
- intent_id = alloc_id
293
- objects[intent_id] = "<< /Type /OutputIntent /S /GTS_PDFA1 " \
294
- "/OutputConditionIdentifier (sRGB) " \
295
- "/Info (sRGB IEC61966-2.1) " \
296
- "/DestOutputProfile #{icc_id} 0 R >>"
297
- "#{intent_id} 0 R"
298
- end
299
-
300
- def build_page_objects(page, objects)
301
- font_dict = page[:font_objs].map do |f|
302
- "/#{f[:name]} #{f[:id]} 0 R"
303
- end.join(" ")
304
- xobject_dict = page[:xobject_objs].map do |x|
305
- "/#{x[:name]} #{x[:id]} 0 R"
306
- end.join(" ")
307
- objects[page[:id]] = build_page_object(page, font_dict, xobject_dict)
308
- objects[page[:content_id]] = build_content_stream(page[:content])
309
- page[:font_objs].each do |f|
310
- if f[:embedded]
311
- build_embedded_font_objects(f, objects)
312
- else
313
- objects[f[:id]] = build_type1_font(f[:ps_name])
314
- end
315
- end
316
- end
317
-
318
- def build_embedded_font_objects(f, objects)
319
- metrics = f[:embedded][:metrics]
320
- data = f[:embedded][:data]
321
- desc = Render::FontEmbedder.descriptor(metrics)
322
- widths = Render::FontEmbedder.widths_array(metrics)
323
-
324
- objects[f[:id]] = build_truetype_font(f, desc, widths)
325
- objects[f[:descriptor_id]] = build_font_descriptor(desc, f[:file_id])
326
- objects[f[:file_id]] = build_fontfile2(data)
327
- end
328
-
329
- def build_page_object(page, font_dict, xobject_dict)
330
- resources = "<< /Font << #{font_dict} >>"
331
- resources << " /XObject << #{xobject_dict} >>" unless xobject_dict.empty?
332
- resources << " >>"
333
- "<< /Type /Page /Parent #{@pages_id} 0 R " \
334
- "/MediaBox [0 0 #{page[:width]} #{page[:height]}] " \
335
- "/Contents #{page[:content_id]} 0 R " \
336
- "/Resources #{resources} >>"
337
- end
338
-
339
- def build_content_stream(content)
340
- "<< /Length #{content.bytesize} >>\nstream\n#{content}\nendstream"
341
- end
342
-
343
- def build_type1_font(ps_name)
344
- "<< /Type /Font /Subtype /Type1 /BaseFont /#{ps_name} >>"
345
- end
346
-
347
- def build_truetype_font(f, desc, widths)
348
- "<< /Type /Font /Subtype /TrueType " \
349
- "/BaseFont /#{desc[:font_name]} " \
350
- "/FirstChar #{Render::FontEmbedder::FIRST_CHAR} " \
351
- "/LastChar #{Render::FontEmbedder::LAST_CHAR} " \
352
- "/Widths [#{widths.join(' ')}] " \
353
- "/FontDescriptor #{f[:descriptor_id]} 0 R " \
354
- "/Encoding /WinAnsiEncoding >>"
355
- end
356
-
357
- def build_font_descriptor(desc, file_id)
358
- bbox = desc[:font_bbox].join(" ")
359
- "<< /Type /FontDescriptor /FontName /#{desc[:font_name]} " \
360
- "/Flags #{desc[:flags]} " \
361
- "/FontBBox [#{bbox}] " \
362
- "/ItalicAngle #{desc[:italic_angle]} " \
363
- "/Ascent #{desc[:ascent]} /Descent #{desc[:descent]} " \
364
- "/CapHeight #{desc[:cap_height]} /StemV #{desc[:stem_v]} " \
365
- "/FontFile2 #{file_id} 0 R >>"
366
- end
367
-
368
- def build_fontfile2(data)
369
- result = String.new(encoding: "ASCII-8BIT")
370
- result << "<< /Length #{data.bytesize} /Length1 #{data.bytesize} >>\n"
371
- result << "stream\n"
372
- result << data
373
- result << "\nendstream"
374
- result
375
- end
376
-
377
- def build_image_xobject(img)
378
- data = img[:data]
379
- filter = img[:filter] || :DCTDecode
380
- result = String.new(encoding: "ASCII-8BIT")
381
- result << "<< /Type /XObject /Subtype /Image /Width #{img[:width]} "
382
- result << "/Height #{img[:height]} /BitsPerComponent 8 "
383
- result << "/ColorSpace /#{img[:colorspace]} /Filter /#{filter} "
384
- if img[:decode_parms]
385
- result << "/DecodeParms #{img[:decode_parms]} "
386
- end
387
- result << "/Length #{data.bytesize} >>\nstream\n"
388
- result << data
389
- result << "\nendstream"
390
- result
391
- end
392
-
393
- def assemble_pdf(objects)
394
- pdf = "%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".b
395
- offsets = {}
396
- (1...@next_id).each do |id|
397
- next unless objects[id]
398
-
399
- offsets[id] = pdf.bytesize
400
- pdf << "#{id} 0 obj\n"
401
- pdf << to_binary(objects[id])
402
- pdf << "\nendobj\n"
403
- end
404
- xref_pos = pdf.bytesize
405
- pdf << "xref\n0 #{@next_id}\n0000000000 65535 f \n"
406
- (1...@next_id).each do |id|
407
- pdf << if offsets[id]
408
- format("%010d 00000 n \n", offsets[id])
409
- else
410
- "0000000000 65535 f \n"
411
- end
412
- end
413
- pdf << "trailer\n<< /Size #{@next_id} /Root #{@catalog_id} 0 R " \
414
- "/Info #{@info_id} 0 R >>\n"
415
- pdf << "startxref\n#{xref_pos}\n%%EOF"
416
- pdf
417
- end
418
-
419
- def to_binary(str)
420
- return str if str.encoding == Encoding::ASCII_8BIT
421
-
422
- str.dup.force_encoding("ASCII-8BIT")
423
- end
424
- end
425
- end
426
- end
@@ -1,31 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Idml
4
- module Render
5
- module PdfrbExt
6
- # PDF `W` operator: intersect current path with clipping path
7
- # (nonzero winding rule). Must be followed by `n` to end the path.
8
- class Clip < Pdfrb::Content::Operator::NoArg
9
- class << self
10
- def name
11
- "W"
12
- end
13
- end
14
-
15
- register
16
- end
17
-
18
- # PDF `n` operator: end path without painting. Commonly used
19
- # after `W` to set a clip without filling or stroking.
20
- class EndPath < Pdfrb::Content::Operator::NoArg
21
- class << self
22
- def name
23
- "n"
24
- end
25
- end
26
-
27
- register
28
- end
29
- end
30
- end
31
- end
@@ -1,24 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Idml
4
- module Render
5
- module PdfrbExt
6
- # PDF `Do` operator: invoke an XObject (image or form) by name.
7
- # pdfrb 0.3.0 doesn't include this operator; we register it here
8
- # so Canvas#emit_op can draw images.
9
- class InvokeXObject < Pdfrb::Content::Operator::Base
10
- class << self
11
- def name
12
- "Do"
13
- end
14
-
15
- def serialize(_serializer, xobject_name)
16
- "/#{xobject_name} Do\n"
17
- end
18
- end
19
-
20
- register
21
- end
22
- end
23
- end
24
- end
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Idml
4
- module Render
5
- # Extensions to the pdfrb gem for operators not yet implemented in
6
- # pdfrb 0.3.0 (e.g., XObject invocation via `Do`).
7
- module PdfrbExt
8
- autoload :InvokeXObject, "#{__dir__}/pdfrb_ext/invoke_xobject"
9
- autoload :Clip, "#{__dir__}/pdfrb_ext/clip"
10
- autoload :EndPath, "#{__dir__}/pdfrb_ext/clip"
11
- end
12
- end
13
- end
@@ -1,91 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # DEPRECATED: This module is superseded by Canvas#text method.
4
- # Text rendering is now handled by pdfrb's Canvas API.
5
-
6
- module Idml
7
- module Render
8
- # Converts positioned glyphs to PDF text-showing operators.
9
- module Text
10
- module_function
11
-
12
- # Begin text block
13
- def begin_text
14
- "BT"
15
- end
16
-
17
- # End text block
18
- def end_text
19
- "ET"
20
- end
21
-
22
- # Set font: `/FontName size Tf`
23
- def set_font(font_name, size)
24
- format("/%<name>s %<size>.1f Tf", name: font_name, size: size)
25
- end
26
-
27
- # Move text position: `x y Td`
28
- def move_to(x, y)
29
- format("%<x>.2f %<y>.2f Td", x: x, y: y)
30
- end
31
-
32
- # Set text matrix: `a b c d e f Tm`
33
- def set_matrix(a:, b:, c:, d:, e:, f:)
34
- format("%<a>.4f %<b>.4f %<c>.4f %<d>.4f %<e>.2f %<f>.2f Tm",
35
- a: a, b: b, c: c, d: d, e: e, f: f)
36
- end
37
-
38
- # Show a text string: `(escaped) Tj`
39
- def show(text_string)
40
- "(#{escape(text_string)}) Tj"
41
- end
42
-
43
- # Build a complete text-showing block for a run of glyphs
44
- # at the same font/size, positioned at (x, y).
45
- def show_run(text_string:, font_name:, size:, x:, y:)
46
- [
47
- begin_text,
48
- set_font(font_name, size),
49
- move_to(x, y),
50
- show(text_string),
51
- end_text,
52
- ].join("\n")
53
- end
54
-
55
- # Emit positioned glyphs. Groups glyphs by y coordinate into
56
- # lines, sets the text matrix per-line for absolute positioning,
57
- # then shows the line text. Multiple lines within one BT/ET block.
58
- def show_positioned(glyphs)
59
- return "" if glyphs.empty?
60
-
61
- lines = group_by_line(glyphs)
62
- lines.map do |line|
63
- [
64
- set_matrix(a: 1, b: 0, c: 0, d: 1,
65
- e: line.first.x, f: line.first.y),
66
- show(line.map { |g| [g.codepoint].pack("U") }.join),
67
- ].join("\n")
68
- end.join("\n")
69
- end
70
-
71
- def group_by_line(glyphs)
72
- result = []
73
- current = nil
74
- glyphs.each do |glyph|
75
- if current.nil? || current.first.y != glyph.y
76
- current = []
77
- result << current
78
- end
79
- current << glyph
80
- end
81
- result
82
- end
83
- private_class_method :group_by_line
84
-
85
- # Escape characters that are special in PDF strings.
86
- def escape(str)
87
- str.to_s.gsub("\\", "\\\\\\\\").gsub("(", "\\(").gsub(")", "\\)")
88
- end
89
- end
90
- end
91
- end