hexapdf 0.43.0 → 0.45.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/examples/027-composer_optional_content.rb +6 -4
  4. data/examples/030-pdfa.rb +13 -11
  5. data/lib/hexapdf/composer.rb +23 -0
  6. data/lib/hexapdf/content/canvas.rb +3 -3
  7. data/lib/hexapdf/content/canvas_composer.rb +1 -0
  8. data/lib/hexapdf/document/files.rb +7 -2
  9. data/lib/hexapdf/document/layout.rb +15 -3
  10. data/lib/hexapdf/document/metadata.rb +12 -1
  11. data/lib/hexapdf/font/type1/character_metrics.rb +1 -1
  12. data/lib/hexapdf/font/type1/font_metrics.rb +1 -1
  13. data/lib/hexapdf/layout/box.rb +180 -66
  14. data/lib/hexapdf/layout/box_fitter.rb +1 -0
  15. data/lib/hexapdf/layout/column_box.rb +18 -28
  16. data/lib/hexapdf/layout/container_box.rb +6 -6
  17. data/lib/hexapdf/layout/frame.rb +13 -94
  18. data/lib/hexapdf/layout/image_box.rb +4 -4
  19. data/lib/hexapdf/layout/list_box.rb +13 -31
  20. data/lib/hexapdf/layout/style.rb +8 -4
  21. data/lib/hexapdf/layout/table_box.rb +55 -58
  22. data/lib/hexapdf/layout/text_box.rb +84 -71
  23. data/lib/hexapdf/layout/text_fragment.rb +1 -1
  24. data/lib/hexapdf/layout/text_layouter.rb +7 -8
  25. data/lib/hexapdf/parser.rb +5 -2
  26. data/lib/hexapdf/rectangle.rb +4 -4
  27. data/lib/hexapdf/type/file_specification.rb +9 -5
  28. data/lib/hexapdf/type/form.rb +2 -2
  29. data/lib/hexapdf/type/graphics_state_parameter.rb +1 -1
  30. data/lib/hexapdf/version.rb +1 -1
  31. data/test/hexapdf/content/test_canvas_composer.rb +13 -8
  32. data/test/hexapdf/document/test_files.rb +5 -0
  33. data/test/hexapdf/document/test_layout.rb +16 -0
  34. data/test/hexapdf/document/test_metadata.rb +21 -0
  35. data/test/hexapdf/layout/test_box.rb +93 -37
  36. data/test/hexapdf/layout/test_box_fitter.rb +7 -0
  37. data/test/hexapdf/layout/test_column_box.rb +7 -13
  38. data/test/hexapdf/layout/test_container_box.rb +1 -1
  39. data/test/hexapdf/layout/test_frame.rb +7 -46
  40. data/test/hexapdf/layout/test_image_box.rb +14 -6
  41. data/test/hexapdf/layout/test_list_box.rb +26 -27
  42. data/test/hexapdf/layout/test_table_box.rb +47 -54
  43. data/test/hexapdf/layout/test_text_box.rb +83 -83
  44. data/test/hexapdf/test_composer.rb +20 -5
  45. data/test/hexapdf/test_parser.rb +8 -0
  46. data/test/hexapdf/test_serializer.rb +1 -0
  47. data/test/hexapdf/type/test_file_specification.rb +2 -1
  48. metadata +2 -2
@@ -42,7 +42,46 @@ module HexaPDF
42
42
  # A TextBox is used for drawing text, either inside a rectangular box or by flowing it around
43
43
  # objects of a Frame.
44
44
  #
45
+ # The standard usage is through the helper methods Document::Layout#text and
46
+ # Document::Layout#formatted_text.
47
+ #
45
48
  # This class uses TextLayouter behind the scenes to do the hard work.
49
+ #
50
+ # == Used Box Properties
51
+ #
52
+ # The spacing after the last line can be controlled via the style property +last_line_gap+. Also
53
+ # see TextLayouter#style for other style properties taken into account.
54
+ #
55
+ # == Limitations
56
+ #
57
+ # When setting the style property 'position' to +:flow+, padding and border to the left and
58
+ # right as well as a predefined fixed width are not respected and the result will look wrong.
59
+ #
60
+ # == Examples
61
+ #
62
+ # Showing some text:
63
+ #
64
+ # #>pdf-composer
65
+ # composer.box(:text, items: layout.text_fragments("This is some text."))
66
+ # # Or easier with the provided convenience method
67
+ # composer.text("This is also some text")
68
+ #
69
+ # It is possible to flow the text around other objects by using the style property
70
+ # 'position' with the value +:flow+:
71
+ #
72
+ # #>pdf-composer
73
+ # composer.box(:base, width: 30, height: 30,
74
+ # style: {margin: 5, position: :float, background_color: "hp-blue-light"})
75
+ # composer.text("This is some text. " * 20, position: :flow)
76
+ #
77
+ # While top and bottom padding and border can be used with flow positioning, left and right
78
+ # padding and border are not supported and the result will look wrong:
79
+ #
80
+ # #>pdf-composer
81
+ # composer.box(:base, width: 30, height: 30,
82
+ # style: {margin: 5, position: :float, background_color: "hp-blue-light"})
83
+ # composer.text("This is some text. " * 20, padding: 10, position: :flow,
84
+ # text_align: :justify)
46
85
  class TextBox < Box
47
86
 
48
87
  # Creates a new TextBox object with the given inline items (e.g. TextFragment and InlineBox
@@ -67,94 +106,68 @@ module HexaPDF
67
106
  true
68
107
  end
69
108
 
109
+ # :nodoc:
110
+ def draw(canvas, x, y)
111
+ super(canvas, x + @x_offset, y)
112
+ end
113
+
114
+ # :nodoc:
115
+ def empty?
116
+ super && (!@result || @result.lines.empty?)
117
+ end
118
+
119
+ private
120
+
70
121
  # Fits the text box into the Frame.
71
122
  #
72
123
  # Depending on the 'position' style property, the text is either fit into the current region
73
124
  # of the frame using +available_width+ and +available_height+, or fit to the shape of the
74
125
  # frame starting from the top (when 'position' is set to :flow).
75
- #
76
- # The spacing after the last line can be controlled via the style property +last_line_gap+.
77
- #
78
- # Also see TextLayouter#style for other style properties taken into account.
79
- def fit(available_width, available_height, frame)
80
- return false if (@initial_width > 0 && @initial_width > available_width) ||
81
- (@initial_height > 0 && @initial_height > available_height)
82
-
126
+ def fit_content(_available_width, _available_height, frame)
83
127
  frame = frame.child_frame(box: self)
84
- @width = @x_offset = @height = 0
85
- @result = if style.position == :flow
86
- @tl.fit(@items, frame.width_specification, frame.shape.bbox.height,
128
+ @x_offset = 0
129
+
130
+ if style.position == :flow
131
+ height = (@initial_height > 0 ? @initial_height : frame.shape.bbox.height) - reserved_height
132
+ @result = @tl.fit(@items, frame.width_specification(reserved_height_top), height,
87
133
  apply_first_text_indent: !split_box?, frame: frame)
88
- else
89
- @width = reserved_width
90
- @height = reserved_height
91
- width = (@initial_width > 0 ? @initial_width : available_width) - @width
92
- height = (@initial_height > 0 ? @initial_height : available_height) - @height
93
- @tl.fit(@items, width, height, apply_first_text_indent: !split_box?, frame: frame)
94
- end
95
- @width += if @initial_width > 0 || style.text_align == :center || style.text_align == :right
96
- width
97
- elsif style.position == :flow
98
- min_x = +Float::INFINITY
99
- max_x = -Float::INFINITY
100
- @result.lines.each do |line|
101
- min_x = [min_x, line.x_offset].min
102
- max_x = [max_x, line.x_offset + line.width].max
103
- end
104
- min_x.finite? ? (@x_offset = min_x; max_x - min_x) : 0
105
- else
106
- @result.lines.max_by(&:width)&.width || 0
107
- end
108
- @height += if @initial_height > 0 || style.text_valign == :center || style.text_valign == :bottom
109
- height
110
- else
111
- @result.height
112
- end
113
- if style.last_line_gap && @result.lines.last
114
- @height += style.line_spacing.gap(@result.lines.last, @result.lines.last)
134
+ min_x = +Float::INFINITY
135
+ max_x = -Float::INFINITY
136
+ @result.lines.each do |line|
137
+ min_x = [min_x, line.x_offset].min
138
+ max_x = [max_x, line.x_offset + line.width].max
139
+ end
140
+ @width = (min_x.finite? ? (@x_offset = min_x; max_x - min_x) : 0) + reserved_width
141
+ @height = @initial_height > 0 ? @initial_height : @result.height + reserved_height
142
+ else
143
+ @result = @tl.fit(@items, @width - reserved_width, @height - reserved_height,
144
+ apply_first_text_indent: !split_box?, frame: frame)
145
+ if style.text_align == :left && @initial_width == 0
146
+ @width = (@result.lines.max_by(&:width)&.width || 0) + reserved_width
147
+ end
148
+ if style.text_valign == :top && @initial_height == 0
149
+ @height = @result.height + reserved_height
150
+ end
115
151
  end
116
152
 
117
- @result.status == :success ||
118
- (@result.status == :height && @initial_height > 0 && style.overflow == :truncate)
119
- end
120
-
121
- # Splits the text box into two boxes if necessary and possible.
122
- def split(available_width, available_height, frame)
123
- fit(available_width, available_height, frame) unless @result
124
-
125
- if style.position != :flow && (float_compare(@width, available_width) > 0 ||
126
- float_compare(@height, available_height) > 0)
127
- [nil, self]
128
- elsif @result.remaining_items.empty?
129
- [self]
130
- elsif @result.lines.empty?
131
- [nil, self]
132
- else
133
- [self, create_box_for_remaining_items]
153
+ if style.last_line_gap && @result.lines.last && @initial_height == 0
154
+ @height += style.line_spacing.gap(@result.lines.last, @result.lines.last)
134
155
  end
135
- end
136
156
 
137
- # :nodoc:
138
- def draw(canvas, x, y)
139
- super(canvas, x + @x_offset, y)
157
+ if @result.status == :success
158
+ fit_result.success!
159
+ elsif @result.status == :height && !@result.lines.empty?
160
+ fit_result.overflow!
161
+ end
140
162
  end
141
163
 
142
- # :nodoc:
143
- def empty?
144
- super && (!@result || @result.lines.empty?)
164
+ # Splits the text box into two.
165
+ def split_content
166
+ [self, create_box_for_remaining_items]
145
167
  end
146
168
 
147
- private
148
-
149
169
  # Draws the text into the box.
150
170
  def draw_content(canvas, x, y)
151
- return unless @result
152
-
153
- if @result.status == :height && @initial_height > 0 && style.overflow == :error
154
- raise HexaPDF::Error, "Text doesn't fit into box with limited height and " \
155
- "style property overflow is set to :error"
156
- end
157
-
158
171
  return if @result.lines.empty?
159
172
  @result.draw(canvas, x - @x_offset, y + content_height)
160
173
  end
@@ -52,7 +52,7 @@ module HexaPDF
52
52
  # The items of a text fragment may be frozen to indicate that the fragment is potentially used
53
53
  # multiple times.
54
54
  #
55
- # The rectangle with the bottom left corner (#x_min, #y_min) and the top right corner (#x_max,
55
+ # The rectangle with the bottom-left corner (#x_min, #y_min) and the top-right corner (#x_max,
56
56
  # #y_max) describes the minimum bounding box of the whole text fragment and is usually *not*
57
57
  # equal to the box (0, 0)-(#width, #height).
58
58
  class TextFragment
@@ -218,7 +218,10 @@ module HexaPDF
218
218
 
219
219
  # Breaks are detected at: space, tab, zero-width-space, non-breaking space, hyphen,
220
220
  # soft-hypen and any valid Unicode newline separator
221
- BREAK_RE = /[ \u{A}-\u{D}\u{85}\u{2028}\u{2029}\t\u{200B}\u{00AD}\u{00A0}-]/
221
+ BREAK_CHARS = {}
222
+ " \u{A}\u{B}\u{C}\u{D}\u{85}\u{2028}\u{2029}\t\u{200B}\u{00AD}\u{00A0}-".each_char do |c|
223
+ BREAK_CHARS[c] = true
224
+ end
222
225
 
223
226
  # Breaks the items (an array of InlineBox and TextFragment objects) into atomic pieces
224
227
  # wrapped by Box, Glue or Penalty items, and returns those as an array.
@@ -235,7 +238,7 @@ module HexaPDF
235
238
  # Collect characters and kerning values until break character is encountered
236
239
  box_items = []
237
240
  while (glyph = item.items[i]) &&
238
- (glyph.kind_of?(Numeric) || !BREAK_RE.match?(glyph.str))
241
+ (glyph.kind_of?(Numeric) || !BREAK_CHARS.key?(glyph.str))
239
242
  box_items << glyph
240
243
  i += 1
241
244
  end
@@ -428,9 +431,7 @@ module HexaPDF
428
431
  end
429
432
 
430
433
  line = create_unjustified_line
431
- last_line_used = true
432
- last_line_used = yield(line, nil) if item.nil? && !line.items.empty?
433
-
434
+ last_line_used = (item.nil? && !line.items.empty? ? yield(line, nil) : true)
434
435
  item.nil? && last_line_used ? [] : @items[@beginning_of_line_index..-1]
435
436
  end
436
437
 
@@ -500,9 +501,7 @@ module HexaPDF
500
501
  end
501
502
 
502
503
  line = create_unjustified_line
503
- last_line_used = true
504
- last_line_used = yield(line, nil) if item.nil? && !line.items.empty?
505
-
504
+ last_line_used = (item.nil? && !line.items.empty? ? yield(line, nil) : true)
506
505
  item.nil? && last_line_used ? [] : @items[@beginning_of_line_index..-1]
507
506
  end
508
507
 
@@ -365,11 +365,14 @@ module HexaPDF
365
365
  # Need to iterate through the whole lines array in case there are multiple %%EOF to try
366
366
  eof_index = 0
367
367
  while (eof_index = lines[0..(eof_index - 1)].rindex {|l| l.strip == '%%EOF' })
368
- if lines[eof_index - 1].strip =~ /\Astartxref\s(\d+)\z/
368
+ if eof_index > 0 && lines[eof_index - 1].strip =~ /\Astartxref\s(\d+)\z/
369
369
  startxref_offset = $1.to_i
370
370
  startxref_mangled = true
371
371
  break # we found it even if it the syntax is not entirely correct
372
- elsif eof_index < 2 || lines[eof_index - 2].strip != "startxref"
372
+ elsif eof_index < 2
373
+ startxref_missing = true
374
+ break
375
+ elsif lines[eof_index - 2].strip != "startxref"
373
376
  startxref_missing = true
374
377
  else
375
378
  startxref_offset = lines[eof_index - 1].to_i
@@ -48,8 +48,8 @@ module HexaPDF
48
48
  #
49
49
  # [left, bottom, right, top]
50
50
  #
51
- # where +left+ is the bottom left x-coordinate, +bottom+ is the bottom left y-coordinate, +right+
52
- # is the top right x-coordinate and +top+ is the top right y-coordinate.
51
+ # where +left+ is the bottom-left x-coordinate, +bottom+ is the bottom-left y-coordinate, +right+
52
+ # is the top-right x-coordinate and +top+ is the top-right y-coordinate.
53
53
  #
54
54
  # See: PDF2.0 s7.9.5
55
55
  class Rectangle < HexaPDF::PDFArray
@@ -119,8 +119,8 @@ module HexaPDF
119
119
  #:nodoc:
120
120
  RECTANGLE_ERROR_MSG = "A PDF rectangle structure must contain an array of four numbers"
121
121
 
122
- # Ensures that the value is an array containing four numbers that specify the bottom left and
123
- # top right corner.
122
+ # Ensures that the value is an array containing four numbers that specify the bottom-left and
123
+ # top-right corners.
124
124
  def after_data_change
125
125
  super
126
126
  unless value.size == 4 && all? {|v| v.kind_of?(Numeric) }
@@ -158,11 +158,11 @@ module HexaPDF
158
158
  end
159
159
 
160
160
  # :call-seq:
161
- # file_spec.embed(filename, name: File.basename(filename), register: true) -> ef_stream
162
- # file_spec.embed(io, name:, register: true) -> ef_stream
161
+ # file_spec.embed(filename, name: File.basename(filename), mime_type: nil, register: true) -> ef_stream
162
+ # file_spec.embed(io, name:, mime_type: nil, register: true) -> ef_stream
163
163
  #
164
- # Embeds the given file or IO stream into the PDF file, sets the path accordingly and returns
165
- # the created stream object.
164
+ # Embeds the given file or IO stream into the PDF file, sets the path and MIME type
165
+ # accordingly and returns the created stream object.
166
166
  #
167
167
  # If a file is given, the +name+ option defaults to the basename of the file. However, if an
168
168
  # IO object is given, the +name+ argument is mandatory.
@@ -177,13 +177,16 @@ module HexaPDF
177
177
  # name::
178
178
  # The name that should be used as path value and when registering.
179
179
  #
180
+ # mime_type::
181
+ # Optionally specifies the MIME type of the file.
182
+ #
180
183
  # register::
181
184
  # Specifies whether the embedded file will be added to the EmbeddedFiles name tree under
182
185
  # the +name+. If the name is already taken, it's value is overwritten.
183
186
  #
184
187
  # The file has to be available until the PDF document gets written because reading and
185
188
  # writing is done lazily.
186
- def embed(file_or_io, name: nil, register: true)
189
+ def embed(file_or_io, name: nil, mime_type: nil, register: true)
187
190
  name ||= File.basename(file_or_io) if file_or_io.kind_of?(String)
188
191
  if name.nil?
189
192
  raise ArgumentError, "The name argument is mandatory when given an IO object"
@@ -194,6 +197,7 @@ module HexaPDF
194
197
 
195
198
  self[:EF] ||= {}
196
199
  ef_stream = self[:EF][:UF] = self[:EF][:F] = document.add({Type: :EmbeddedFile})
200
+ ef_stream[:Subtype] = mime_type.to_sym if mime_type
197
201
  stat = if file_or_io.kind_of?(String)
198
202
  File.stat(file_or_io)
199
203
  elsif file_or_io.respond_to?(:stat)
@@ -159,8 +159,8 @@ module HexaPDF
159
159
  # retained without the need for parsing its contents.
160
160
  #
161
161
  # If the bounding box of the form XObject doesn't have its origin at (0, 0), the canvas origin
162
- # is translated into the bottom left corner so that this detail doesn't matter when using the
163
- # canvas. This means that the canvas' origin is always at the bottom left corner of the
162
+ # is translated into the bottom-left corner so that this detail doesn't matter when using the
163
+ # canvas. This means that the canvas' origin is always at the bottom-left corner of the
164
164
  # bounding box.
165
165
  #
166
166
  # *Note* that a canvas can only be retrieved for initially empty form XObjects!
@@ -51,7 +51,7 @@ module HexaPDF
51
51
 
52
52
  define_type :ExtGState
53
53
 
54
- define_field :Type, type: Symbol, required: true, default: type
54
+ define_field :Type, type: Symbol, default: type
55
55
  define_field :LW, type: Numeric, version: "1.3"
56
56
  define_field :LC, type: Integer, version: "1.3"
57
57
  define_field :LJ, type: Integer, version: "1.3"
@@ -37,6 +37,6 @@
37
37
  module HexaPDF
38
38
 
39
39
  # The version of HexaPDF.
40
- VERSION = '0.43.0'
40
+ VERSION = '0.45.0'
41
41
 
42
42
  end
@@ -47,21 +47,20 @@ describe HexaPDF::Content::CanvasComposer do
47
47
  end
48
48
 
49
49
  it "splits the box if possible" do
50
- @composer.draw_box(create_box(width: 400, style: {position: :float}))
51
- box = create_box(width: 400, height: 100)
52
- box.define_singleton_method(:split) do |*|
53
- [box, HexaPDF::Layout::Box.new(height: 100) {}]
54
- end
50
+ @composer.draw_box(create_box(width: 300, height: 300, style: {position: :float}))
51
+ box = create_box(style: {mask_mode: :box})
52
+ box.define_singleton_method(:fit_content) {|*| fit_result.overflow! }
53
+ box.define_singleton_method(:split_content) { [box, HexaPDF::Layout::Box.new(height: 100) {}] }
55
54
  @composer.draw_box(box)
56
55
  assert_operators(@composer.canvas.contents,
57
56
  [[:save_graphics_state],
58
- [:concatenate_matrix, [1, 0, 0, 1, 0, 0]],
57
+ [:concatenate_matrix, [1, 0, 0, 1, 0, 541.889764]],
59
58
  [:restore_graphics_state],
60
59
  [:save_graphics_state],
61
- [:concatenate_matrix, [1, 0, 0, 1, 400, 741.889764]],
60
+ [:concatenate_matrix, [1, 0, 0, 1, 300, 0]],
62
61
  [:restore_graphics_state],
63
62
  [:save_graphics_state],
64
- [:concatenate_matrix, [1, 0, 0, 1, 400, 641.889764]],
63
+ [:concatenate_matrix, [1, 0, 0, 1, 0, 441.889764]],
65
64
  [:restore_graphics_state]])
66
65
  end
67
66
 
@@ -77,6 +76,12 @@ describe HexaPDF::Content::CanvasComposer do
77
76
  [:restore_graphics_state]])
78
77
  end
79
78
 
79
+ it "handles truncated boxes correctly" do
80
+ box = create_box(height: 400, style: {overflow: :truncate})
81
+ box.define_singleton_method(:fit_content) {|*| fit_result.overflow! }
82
+ assert_same(box, @composer.draw_box(box))
83
+ end
84
+
80
85
  it "returns the last drawn box" do
81
86
  box = create_box(height: 400)
82
87
  assert_same(box, @composer.draw_box(box))
@@ -43,6 +43,11 @@ describe HexaPDF::Document::Files do
43
43
  assert_equal('Some file', spec[:Desc])
44
44
  end
45
45
 
46
+ it "optionally sets the MIME type of an embedded file" do
47
+ spec = @doc.files.add(@file.path, mime_type: 'application/pdf')
48
+ assert_equal(:'application/pdf', spec.embedded_file_stream[:Subtype])
49
+ end
50
+
46
51
  it "requires the name argument when given an IO object" do
47
52
  assert_raises(ArgumentError) { @doc.files.add(StringIO.new) }
48
53
  end
@@ -141,6 +141,22 @@ describe HexaPDF::Document::Layout do
141
141
  end
142
142
  end
143
143
 
144
+ describe "styles" do
145
+ it "returns the existing styles" do
146
+ @layout.style(:test, font_size: 20)
147
+ assert_equal([:base, :test], @layout.styles.keys)
148
+ end
149
+
150
+ it "sets multiple styles at once" do
151
+ styles = @layout.styles(
152
+ test: {font_size: 20},
153
+ test2: {font_size: 30},
154
+ )
155
+ assert_same(styles, @layout.styles)
156
+ assert_equal([:base, :test, :test2], @layout.styles.keys)
157
+ end
158
+ end
159
+
144
160
  describe "inline_box" do
145
161
  it "takes a box as argument" do
146
162
  box = HexaPDF::Layout::Box.create(width: 10, height: 10)
@@ -187,6 +187,27 @@ describe HexaPDF::Document::Metadata do
187
187
  assert_equal(metadata, @doc.catalog[:Metadata].stream.sub(/(?<=id=")\w+/, ''))
188
188
  end
189
189
 
190
+ it "writes the custom metadata" do
191
+ @metadata.delete
192
+ @metadata.custom_metadata("<rdf:Description>Test</rdf:Description>")
193
+ @metadata.custom_metadata("<rdf:Description>Test2</rdf:Description>")
194
+ @doc.write(StringIO.new, update_fields: false)
195
+ metadata = <<~XMP
196
+ <?xpacket begin="" id=""?>
197
+ <x:xmpmeta xmlns:x="adobe:ns:meta/">
198
+ <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
199
+ <rdf:Description rdf:about="" xmlns:pdf="http://ns.adobe.com/pdf/1.3/">
200
+ <pdf:Producer>HexaPDF version #{HexaPDF::VERSION}</pdf:Producer>
201
+ </rdf:Description>
202
+ <rdf:Description>Test</rdf:Description>
203
+ <rdf:Description>Test2</rdf:Description>
204
+ </rdf:RDF>
205
+ </x:xmpmeta>
206
+ <?xpacket end="r"?>
207
+ XMP
208
+ assert_equal(metadata, @doc.catalog[:Metadata].stream.sub(/(?<=id=")\w+/, ''))
209
+ end
210
+
190
211
  it "writes the XMP metadata" do
191
212
  title = HexaPDF::Document::Metadata::LocalizedString.new('Der Titel')
192
213
  title.language = 'de'