hexapdf 1.8.0 → 1.9.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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/README.md +3 -0
  4. data/Rakefile +1 -1
  5. data/data/hexapdf/fonts/Inter-Bold.ttf +0 -0
  6. data/data/hexapdf/fonts/Inter-BoldItalic.ttf +0 -0
  7. data/data/hexapdf/fonts/Inter-Italic.ttf +0 -0
  8. data/data/hexapdf/fonts/Inter-Regular.ttf +0 -0
  9. data/data/hexapdf/fonts/OFL.txt +92 -0
  10. data/examples/019-acro_form.rb +3 -1
  11. data/examples/030-pdfa.rb +9 -16
  12. data/examples/034-text_shaping.rb +37 -0
  13. data/lib/hexapdf/configuration.rb +13 -1
  14. data/lib/hexapdf/document/annotations.rb +25 -0
  15. data/lib/hexapdf/font/cmap/writer.rb +15 -9
  16. data/lib/hexapdf/font/true_type_wrapper.rb +6 -3
  17. data/lib/hexapdf/font_loader.rb +47 -0
  18. data/lib/hexapdf/layout/container_box.rb +2 -4
  19. data/lib/hexapdf/layout/style.rb +66 -4
  20. data/lib/hexapdf/layout/table_box.rb +96 -13
  21. data/lib/hexapdf/layout/text_fragment.rb +13 -7
  22. data/lib/hexapdf/layout/text_shaper.rb +162 -10
  23. data/lib/hexapdf/type/annotations/appearance_generator.rb +42 -0
  24. data/lib/hexapdf/type/annotations/ink.rb +107 -0
  25. data/lib/hexapdf/type/annotations.rb +1 -0
  26. data/lib/hexapdf/version.rb +1 -1
  27. data/test/hexapdf/digital_signature/common.rb +5 -5
  28. data/test/hexapdf/digital_signature/test_cms_handler.rb +1 -1
  29. data/test/hexapdf/document/test_annotations.rb +10 -0
  30. data/test/hexapdf/document/test_layout.rb +6 -3
  31. data/test/hexapdf/font/cmap/test_writer.rb +8 -6
  32. data/test/hexapdf/font/test_true_type_wrapper.rb +4 -0
  33. data/test/hexapdf/layout/test_container_box.rb +3 -1
  34. data/test/hexapdf/layout/test_style.rb +4 -0
  35. data/test/hexapdf/layout/test_table_box.rb +117 -1
  36. data/test/hexapdf/layout/test_text_fragment.rb +18 -8
  37. data/test/hexapdf/layout/test_text_shaper.rb +55 -5
  38. data/test/hexapdf/type/annotations/test_appearance_generator.rb +63 -0
  39. data/test/hexapdf/type/annotations/test_ink.rb +31 -0
  40. metadata +25 -3
@@ -108,6 +108,17 @@ module HexaPDF
108
108
  # [layout.text('E'), layout.text('F')]]
109
109
  # composer.column(height: 50) {|col| col.table(cells) }
110
110
  #
111
+ # If a cell doesn't completely fit, it is split. Non-split cells in the same continuation row
112
+ # retain their style but are empty:
113
+ #
114
+ # #>pdf-composer
115
+ # cells = [[layout.text('A'), layout.text('B')],
116
+ # [layout.text('C'), layout.text("D1\nD2")],
117
+ # [layout.text('E'), layout.text('F')]]
118
+ # composer.column(height: 50) {|col| col.table(cells) }
119
+ #
120
+ # Note that the above is only true for cells in rows that are not part of a row-span.
121
+ #
111
122
  # It is also possible to use row and column spans:
112
123
  #
113
124
  # #>pdf-composer
@@ -237,6 +248,7 @@ module HexaPDF
237
248
 
238
249
  # Fits the children of the table cell into the given rectangular area.
239
250
  def fit_content(available_width, available_height, frame)
251
+ @remaining_boxes = nil
240
252
  width = available_width - reserved_width
241
253
  height = @used_height = available_height - reserved_height
242
254
  return if width <= 0 || height <= 0
@@ -245,21 +257,27 @@ module HexaPDF
245
257
  case children
246
258
  when Box
247
259
  child_result = frame.fit(children)
248
- if child_result.success?
260
+ box, @remaining_boxes = children.split if child_result.overflow?
261
+ if child_result.success? || box
249
262
  @preferred_width = child_result.x + child_result.box.width + reserved_width
250
263
  @height = @preferred_height = child_result.box.height + reserved_height
251
264
  @fit_results = [child_result]
252
- fit_result.success!
265
+ box ? fit_result.overflow! : fit_result.success!
253
266
  end
254
267
  when Array
255
268
  box_fitter = BoxFitter.new([frame])
256
269
  children.each {|box| box_fitter.fit(box) }
257
- if box_fitter.success?
270
+ if box_fitter.success? || !box_fitter.fit_results.empty?
258
271
  max_x_result = box_fitter.fit_results.max_by {|result| result.x + result.box.width }
259
272
  @preferred_width = max_x_result.x + max_x_result.box.width + reserved_width
260
273
  @height = @preferred_height = box_fitter.content_heights[0] + reserved_height
261
274
  @fit_results = box_fitter.fit_results
262
- fit_result.success!
275
+ if box_fitter.success?
276
+ fit_result.success!
277
+ else
278
+ @remaining_boxes = box_fitter.remaining_boxes
279
+ fit_result.overflow!
280
+ end
263
281
  end
264
282
  else
265
283
  @preferred_width = reserved_width
@@ -274,6 +292,14 @@ module HexaPDF
274
292
  end
275
293
  end
276
294
 
295
+ # Splits the content of the cell.
296
+ def split_content
297
+ box = create_split_box
298
+ box.instance_variable_set(:@children, @remaining_boxes)
299
+ box.instance_variable_set(:@remaining_boxes, nil)
300
+ [self, box]
301
+ end
302
+
277
303
  # Draws the content of the cell.
278
304
  def draw_content(canvas, x, y)
279
305
  return if @fit_results.empty?
@@ -363,7 +389,7 @@ module HexaPDF
363
389
  # Note that the same cell instance may be returned for different (row, column) arguments if
364
390
  # the cell spans more than one row and/or column.
365
391
  def [](row, column)
366
- @cells[row]&.[](column)
392
+ row == @overridden_row_index ? @overridden_row_cells[column] : @cells[row]&.[](column)
367
393
  end
368
394
 
369
395
  # Returns the number of rows.
@@ -378,7 +404,15 @@ module HexaPDF
378
404
 
379
405
  # Iterates over each row.
380
406
  def each_row(&block)
381
- @cells.each(&block)
407
+ return to_enum(__method__) unless block_given?
408
+
409
+ if @overridden_row_index
410
+ @cells[0...@overridden_row_index].each(&block)
411
+ block&.call(@overridden_row_cells)
412
+ @cells[(@overridden_row_index + 1)..-1].each(&block)
413
+ else
414
+ @cells.each(&block)
415
+ end
382
416
  end
383
417
 
384
418
  # Applies the given style properties to all cells and optionally yields all cells for more
@@ -409,12 +443,19 @@ module HexaPDF
409
443
  row_heights = {}
410
444
  zero_height_rows = {}
411
445
  row_spans = []
446
+ split_row_cells = nil
412
447
 
413
448
  @cells[start_row..-1].each.with_index(start_row) do |columns, row_index|
449
+ columns = @overridden_row_cells if row_index == @overridden_row_index
450
+
451
+ # Rows containing row-spanning cells aren't supported for cell-splitting
452
+ row_has_row_spans = columns.any? {|cell| cell.row != row_index || cell.row_span > 1 }
453
+
414
454
  # 1. Fit all columns of the row and record the max height of all non-row-span cells. If
415
455
  # a row has zero height (usually because it only has row-span cells), record that
416
456
  # information. Additionally store all cells with row-spans.
417
457
  row_fit = true
458
+ row_has_split_cells = false
418
459
  row_height = 0
419
460
  columns.each_with_index do |cell, col_index|
420
461
  next if cell.row != row_index || cell.column != col_index
@@ -423,9 +464,14 @@ module HexaPDF
423
464
  else
424
465
  column_info[cell.column].last
425
466
  end
426
- unless cell.fit(available_cell_width, available_height, frame).success?
427
- row_fit = false
428
- break
467
+ cell_fit_result = cell.fit(available_cell_width, available_height, frame)
468
+ unless cell_fit_result.success?
469
+ if !row_has_row_spans && cell_fit_result.overflow?
470
+ row_has_split_cells = true
471
+ else
472
+ row_fit = false
473
+ break
474
+ end
429
475
  end
430
476
  if row_height < cell.preferred_height && cell.row_span == 1
431
477
  row_height = cell.preferred_height
@@ -462,6 +508,11 @@ module HexaPDF
462
508
  available_height -= cell.preferred_height - row_span_height
463
509
  end
464
510
  end
511
+
512
+ if row_has_split_cells
513
+ split_row_cells = create_split_row_cells(columns)
514
+ break
515
+ end
465
516
  else
466
517
  last_fitted_row_index = columns.min_by(&:row).row - 1 if height != available_height
467
518
  break
@@ -473,6 +524,7 @@ module HexaPDF
473
524
  # final height and top-left corner of each cell needs to be set.
474
525
  running_height = 0
475
526
  @cells[start_row..last_fitted_row_index].each.with_index(start_row) do |columns, row_index|
527
+ columns = @overridden_row_cells if row_index == @overridden_row_index
476
528
  columns.each_with_index do |cell, col_index|
477
529
  next if cell.row != row_index || cell.column != col_index
478
530
  cell.left = column_info[cell.column].first
@@ -488,13 +540,15 @@ module HexaPDF
488
540
  end
489
541
  end
490
542
 
491
- [height - available_height, last_fitted_row_index < start_row ? -1 : last_fitted_row_index]
543
+ [height - available_height, last_fitted_row_index < start_row ? -1 : last_fitted_row_index,
544
+ split_row_cells]
492
545
  end
493
546
 
494
547
  # Draws the rows from +start_row+ to +end_row+ on the given +canvas+, with the top-left
495
548
  # corner of the resulting table being at (+x+, +y+).
496
549
  def draw_rows(start_row, end_row, canvas, x, y)
497
550
  @cells[start_row..end_row].each.with_index(start_row) do |columns, row_index|
551
+ columns = @overridden_row_cells if row_index == @overridden_row_index
498
552
  columns.each_with_index do |cell, col_index|
499
553
  next if cell.row != row_index || cell.column != col_index
500
554
  cell.draw(canvas, x + cell.left, y - cell.top - cell.height)
@@ -557,6 +611,22 @@ module HexaPDF
557
611
  end
558
612
  end
559
613
 
614
+ # Splits all cells in +columns+ and returns the new array.
615
+ def create_split_row_cells(columns)
616
+ columns.map.with_index do |cell, col_index|
617
+ next cell unless cell.column == col_index
618
+ _, split_box = cell.split
619
+ if split_box
620
+ split_box
621
+ else
622
+ # Create an empty clone of the fully fit cell
623
+ empty_cell = cell.send(:create_split_box)
624
+ empty_cell.instance_variable_set(:@children, nil)
625
+ empty_cell
626
+ end
627
+ end
628
+ end
629
+
560
630
  end
561
631
 
562
632
  # The Cells instance containing the data of the table.
@@ -682,6 +752,7 @@ module HexaPDF
682
752
  @special_cells_fit_not_successful = false
683
753
  [@header_cells, @footer_cells].each do |special_cells|
684
754
  next unless special_cells
755
+ # Ignore split-cells (3rd result element) as not fully-fit headers/footers lead to failure
685
756
  special_used_height, last_fitted_row_index = special_cells.fit_rows(0, height, columns, frame)
686
757
  height -= special_used_height
687
758
  used_height += special_used_height
@@ -689,13 +760,14 @@ module HexaPDF
689
760
  return nil if @special_cells_fit_not_successful
690
761
  end
691
762
 
692
- main_used_height, @last_fitted_row_index = @cells.fit_rows(@start_row_index, height, columns, frame)
763
+ main_used_height, @last_fitted_row_index, @split_row_cells =
764
+ @cells.fit_rows(@start_row_index, height, columns, frame)
693
765
  used_height += main_used_height
694
766
 
695
767
  update_content_width { columns[-1].sum + rw }
696
768
  update_content_height { used_height + rh }
697
769
 
698
- if @last_fitted_row_index == @cells.number_of_rows - 1
770
+ if @last_fitted_row_index == @cells.number_of_rows - 1 && !@split_row_cells
699
771
  fit_result.success!
700
772
  elsif @last_fitted_row_index >= 0
701
773
  fit_result.overflow!
@@ -724,8 +796,19 @@ module HexaPDF
724
796
  # Splits the content of the table box. This method is called from Box#split.
725
797
  def split_content
726
798
  box = create_split_box
727
- box.instance_variable_set(:@start_row_index, @last_fitted_row_index + 1)
799
+
800
+ if @split_row_cells
801
+ cells = @cells.clone
802
+ cells.instance_variable_set(:@overridden_row_cells, @split_row_cells)
803
+ cells.instance_variable_set(:@overridden_row_index, @last_fitted_row_index)
804
+ box.instance_variable_set(:@cells, cells)
805
+ box.instance_variable_set(:@start_row_index, @last_fitted_row_index)
806
+ else
807
+ box.instance_variable_set(:@start_row_index, @last_fitted_row_index + 1)
808
+ end
809
+
728
810
  box.instance_variable_set(:@last_fitted_row_index, -1)
811
+ box.instance_variable_set(:@split_row_cells, nil)
729
812
  box.instance_variable_set(:@special_cells_fit_not_successful, nil)
730
813
  header_cells = @header ? Cells.new(@header.call(self), cell_style: @cell_style) : nil
731
814
  box.instance_variable_set(:@header_cells, header_cells)
@@ -111,20 +111,24 @@ module HexaPDF
111
111
  font = style.font
112
112
  text.each_codepoint do |codepoint|
113
113
  glyph = font.decode_codepoint(codepoint)
114
- if glyph.valid? || glyph.control_char?
114
+ if glyph.valid?
115
115
  items << glyph
116
116
  else
117
117
  unless items.empty?
118
- result << shaper.shape_text(new(items, style))
118
+ result.append(*shaper.shape_text(new(items, style)))
119
119
  items = []
120
120
  end
121
- fallback = yield(codepoint, glyph)
122
- unless fallback.empty?
123
- result << shaper.shape_text(new(fallback, styles[fallback.first.font_wrapper]))
121
+ if glyph.control_char?
122
+ result.append(new([glyph], style))
123
+ else
124
+ fallback = yield(codepoint, glyph)
125
+ unless fallback.empty?
126
+ result.append(*shaper.shape_text(new(fallback, styles[fallback.first.font_wrapper])))
127
+ end
124
128
  end
125
129
  end
126
130
  end
127
- result << shaper.shape_text(new(items, style)) unless items.empty?
131
+ result.append(*shaper.shape_text(new(items, style))) unless items.empty?
128
132
  result
129
133
  end
130
134
 
@@ -251,7 +255,9 @@ module HexaPDF
251
255
  tx = x - tlm.e
252
256
  ty = y - tlm.f
253
257
  if tx.abs < PRECISION
254
- if (ty + canvas.graphics_state.leading).abs < PRECISION
258
+ if ty.abs < PRECISION
259
+ # do nothing
260
+ elsif (ty + canvas.graphics_state.leading).abs < PRECISION
255
261
  canvas.move_text_cursor
256
262
  else
257
263
  canvas.move_text_cursor(offset: [0, ty], absolute: false)
@@ -34,8 +34,71 @@
34
34
  # commercial licenses are available at <https://gettalong.at/hexapdf/>.
35
35
  #++
36
36
 
37
+ require 'hexapdf/error'
37
38
  require 'hexapdf/layout/numeric_refinements'
38
39
 
40
+ HARFBUZZ_AVAILABLE = begin
41
+ require 'harfbuzz'
42
+ true
43
+ rescue LoadError
44
+ end
45
+
46
+ if HARFBUZZ_AVAILABLE
47
+ class HarfBuzz::Buffer #:nodoc:
48
+
49
+ GLYPH_INFO_SIZE = HarfBuzz::C::HbGlyphInfoT.size
50
+ GLYPH_INFO_CODEPOINT_OFFSET = HarfBuzz::C::HbGlyphInfoT.offset_of(:codepoint)
51
+ GLYPH_INFO_CLUSTER_OFFSET = HarfBuzz::C::HbGlyphInfoT.offset_of(:cluster)
52
+ GLYPH_POS_SIZE = HarfBuzz::C::HbGlyphPositionT.size
53
+ GLYPH_POS_XADVANCE_OFFSET = HarfBuzz::C::HbGlyphPositionT.offset_of(:x_advance)
54
+ GLYPH_POS_YADVANCE_OFFSET = HarfBuzz::C::HbGlyphPositionT.offset_of(:y_advance)
55
+ GLYPH_POS_XOFFSET_OFFSET = HarfBuzz::C::HbGlyphPositionT.offset_of(:x_offset)
56
+ GLYPH_POS_YOFFSET_OFFSET = HarfBuzz::C::HbGlyphPositionT.offset_of(:y_offset)
57
+
58
+ # Iterates efficiently over the shaping result without creating intermediary objects.
59
+ def each_result
60
+ return enum_for(__method__) unless block_given?
61
+
62
+ length_ptr = FFI::MemoryPointer.new(:uint)
63
+ infos_ptr = HarfBuzz::C.hb_buffer_get_glyph_infos(@ptr, length_ptr)
64
+ length_ptr = FFI::MemoryPointer.new(:uint)
65
+ positions_ptr = HarfBuzz::C.hb_buffer_get_glyph_positions(@ptr, length_ptr)
66
+ length = length_ptr.read_uint
67
+
68
+ return if infos_ptr.null? || positions_ptr.null? || length.zero?
69
+
70
+ last_info_cluster_offset = (length - 1) * GLYPH_INFO_SIZE + GLYPH_INFO_CLUSTER_OFFSET
71
+ i = 0
72
+ while i < length
73
+ info_offset = i * GLYPH_INFO_SIZE
74
+ pos_offset = i * GLYPH_POS_SIZE
75
+
76
+ glyph_id = infos_ptr.get_uint32(info_offset + GLYPH_INFO_CODEPOINT_OFFSET)
77
+ cluster = infos_ptr.get_uint32(info_offset + GLYPH_INFO_CLUSTER_OFFSET)
78
+
79
+ next_cluster = nil
80
+ tmp_offset = info_offset + GLYPH_INFO_CLUSTER_OFFSET + GLYPH_INFO_SIZE
81
+ while tmp_offset <= last_info_cluster_offset &&
82
+ (next_cluster = infos_ptr.get_uint32(tmp_offset)) == cluster
83
+ tmp_offset += GLYPH_INFO_SIZE
84
+ next_cluster = nil
85
+ end
86
+
87
+ x_advance = positions_ptr.get_int32(pos_offset + GLYPH_POS_XADVANCE_OFFSET)
88
+ y_advance = positions_ptr.get_int32(pos_offset + GLYPH_POS_YADVANCE_OFFSET)
89
+ x_offset = positions_ptr.get_int32(pos_offset + GLYPH_POS_XOFFSET_OFFSET)
90
+ y_offset = positions_ptr.get_int32(pos_offset + GLYPH_POS_YOFFSET_OFFSET)
91
+
92
+ yield(glyph_id, cluster, next_cluster, x_advance, y_advance, x_offset, y_offset)
93
+
94
+ i += 1
95
+ end
96
+
97
+ self
98
+ end
99
+ end
100
+ end
101
+
39
102
  module HexaPDF
40
103
  module Layout
41
104
 
@@ -44,23 +107,33 @@ module HexaPDF
44
107
  # This class is used to perform text shaping, i.e. changing the position of glyphs (e.g. for
45
108
  # kerning) or substituting one or more glyphs for other glyphs (e.g. for ligatures).
46
109
  #
47
- # Status of the implementation:
110
+ # The class contains two shaping engines: A very limited custom one and one based on
111
+ # HarfBuzz. Which one is used for shaping can be selected via the Style#shaping_engine property.
48
112
  #
49
- # * All text shaping functionality possible for Type1 fonts is implemented, i.e. kerning and
50
- # ligature substitution.
113
+ # The custom implementation is always used for Type1 fonts and supports kerning and ligature
114
+ # substitution. It also supports the 'kern' table for TrueType fonts if HarfBuzz is not used.
51
115
  #
52
- # * For TrueType fonts only kerning via the 'kern' table is implemented.
116
+ # For complex scripts or the need of special font features it is recommended to use the shaping
117
+ # engine based on HarfBuzz, even though it is slightly slower. For it to work the
118
+ # +harfbuzz-ruby+ gem needs to be installed. OpenType features can be activated and deactivated
119
+ # using Style#font_features.
53
120
  class TextShaper
54
121
 
55
- # Shapes the given text fragment in-place.
122
+ # Shapes the given text fragment. Returns either the in-place modified fragment or, for
123
+ # complex shaping, an array of fragments.
56
124
  #
57
- # The following shaping options, retrieved from the text fragment's Style#font_features, are
58
- # supported:
59
- #
60
- # :kern:: Pair-wise kerning.
61
- # :liga:: Ligature substitution.
125
+ # The style properties Style#shaping_engine, Style#font_features, Style#font_script,
126
+ # Style#language and Style#direction are used for shaping.
62
127
  def shape_text(text_fragment)
63
128
  font = text_fragment.style.font
129
+ if text_fragment.style.shaping_engine == :harfbuzz && font.font_type == :TrueType
130
+ unless HARFBUZZ_AVAILABLE
131
+ raise HexaPDF::Error, "Shaping engine harfbuzz required but the needed Rubygem " \
132
+ "harfbuzz-ruby is not available"
133
+ end
134
+ return harfbuzz_shape_text(text_fragment)
135
+ end
136
+
64
137
  if text_fragment.style.font_features[:liga] && font.wrapped_font.features.include?(:liga)
65
138
  if font.font_type == :Type1
66
139
  process_type1_ligatures(text_fragment)
@@ -76,11 +149,90 @@ module HexaPDF
76
149
  end
77
150
  text_fragment.clear_cache
78
151
  end
152
+
79
153
  text_fragment
80
154
  end
81
155
 
82
156
  private
83
157
 
158
+ # Shapes the text fragment with HarfBuzz.
159
+ def harfbuzz_shape_text(text_fragment, text = nil)
160
+ text ||= text_fragment.items.map(&:str).join
161
+ style = text_fragment.style
162
+
163
+ # Cache the used main Harfbuzz font objects
164
+ hb_font = style.font.pdf_object.document.cache('harfbuzz', style.font.filename) do
165
+ blob = HarfBuzz::Blob.from_file!(style.font.filename)
166
+ face = HarfBuzz::Face.new(blob, 0)
167
+ HarfBuzz::Font.new(face)
168
+ end
169
+
170
+ # Prepare the buffer and then shape the text. We are using cluster level 1 as this is the
171
+ # recommended level.
172
+ buffer = HarfBuzz::Buffer.new
173
+ buffer.add_utf8(text)
174
+ buffer.cluster_level = 1
175
+ buffer.direction = style.direction
176
+ buffer.script = style.font_script if style.font_script?
177
+ buffer.language = style.language if style.language?
178
+ buffer.guess_segment_properties
179
+ HarfBuzz.shape(hb_font, buffer, HarfBuzz::Feature.from_hash(style.font_features))
180
+
181
+ # Prepare the iteration over the shaping result. The final output will either be
182
+ # +text_fragment+ (no non-zero y_offsets) or +result+ containing at least two TextFragment
183
+ # instances.
184
+ result = nil
185
+ font = style.font
186
+ fragment = text_fragment
187
+ fragment.clear_cache
188
+ items = text_fragment.items.clear
189
+ last_cluster = nil
190
+ last_y_offset = 0
191
+ buffer.each_result do |glyph_id, cluster, next_cluster, x_advance, y_advance, x_offset, y_offset|
192
+ advance = (x_advance - x_offset) * font.scaling_factor
193
+
194
+ # 1. Determine the source characters for each glyph via their cluster numbers. If two or
195
+ # more glyphs have the same cluster number, the first gets the resulting string while the
196
+ # rest map to an empty string. Otherwise copying from the PDF would result in multiple
197
+ # copies of the resulting string.
198
+ str = (cluster == last_cluster ? '' : text.byteslice(cluster...(next_cluster || text.bytesize)))
199
+
200
+ # 2. Handle invalid glyphs with id=0 by mapping them to an InvalidGlyph instance
201
+ if glyph_id.zero?
202
+ glyph = font.decode_codepoint(str.ord)
203
+ advance = glyph.width
204
+ else
205
+ glyph = font.glyph(glyph_id, str)
206
+ end
207
+
208
+ # 3. Handle differing y_offsets by creating TextFragment instances with appropriate text
209
+ # rise properties.
210
+ if y_offset != last_y_offset
211
+ (result ||= []) << fragment
212
+ items = []
213
+ if y_offset.zero?
214
+ fragment = text_fragment.dup_attributes(items)
215
+ else
216
+ fragment = TextFragment.new(items, style.dup, properties: text_fragment.properties)
217
+ fragment.style.text_rise += y_offset * font.scaling_factor * fragment.style.font_size *
218
+ fragment.style.font.pdf_object.glyph_scaling_factor
219
+ end
220
+ end
221
+
222
+ # 4. Handle the correct x-positioning using x_offset. Also addjust the horizontal advance
223
+ # based on the glyph's fixed advance width as well as x_advance and x_offset (via
224
+ # +advance+).
225
+ items << -x_offset * font.scaling_factor unless x_offset.zero?
226
+ items << glyph
227
+ items << glyph.width - advance if glyph.width - advance != 0
228
+
229
+ last_cluster = cluster
230
+ last_y_offset = y_offset
231
+ end
232
+
233
+ result ? result.append(fragment) : text_fragment
234
+ end
235
+
84
236
  # Processes the text fragment and substitutes ligatures.
85
237
  def process_type1_ligatures(text_fragment)
86
238
  items = text_fragment.items
@@ -77,6 +77,7 @@ module HexaPDF
77
77
  when :Circle then create_square_circle_appearance(:circle)
78
78
  when :Polygon then create_polygon_polyline_appearance(:polygon)
79
79
  when :PolyLine then create_polygon_polyline_appearance(:polyline)
80
+ when :Ink then create_ink_appearance
80
81
  else
81
82
  raise HexaPDF::Error, "Appearance regeneration for #{@annot[:Subtype]} not yet supported"
82
83
  end
@@ -362,6 +363,47 @@ module HexaPDF
362
363
 
363
364
  end
364
365
 
366
+ # Creates the appropriate appearance for an ink annotation.
367
+ #
368
+ # See: HexaPDF::Type::Annotations::Ink
369
+ def create_ink_appearance
370
+ # Prepare the annotation
371
+ form = (@annot[:AP] ||= {})[:N] ||=
372
+ @document.add({Type: :XObject, Subtype: :Form, BBox: [0, 0, 0, 0]})
373
+ form.contents = ""
374
+ @annot.flag(:print)
375
+ @annot.unflag(:hidden)
376
+
377
+ # Get all needed values from the annotation
378
+ paths = @annot.paths
379
+ border_style = @annot.border_style
380
+ opacity = @annot.opacity
381
+
382
+ # Calculate the annotation's rectangle as well as the form bounding box
383
+ x_coords = []
384
+ y_coords = []
385
+ paths.each do |path|
386
+ path.each_with_index {|coord, index| (index.even? ? x_coords : y_coords) << coord }
387
+ end
388
+ min_x, max_x = x_coords.minmax
389
+ min_y, max_y = y_coords.minmax
390
+ padding = 4 * border_style.width
391
+ rect = [min_x - padding, min_y - padding, max_x + padding, max_y + padding]
392
+ @annot[:Rect] = rect
393
+ form[:BBox] = rect.dup
394
+
395
+ # Set the appropriate graphics state
396
+ canvas = form.canvas(translate: false)
397
+ canvas.opacity(**opacity.to_h)
398
+ canvas.stroke_color(border_style.color) if border_style.color
399
+ canvas.line_width(border_style.width)
400
+ canvas.line_dash_pattern(border_style.style) if border_style.style.kind_of?(Array)
401
+
402
+ # Draw the polylines
403
+ paths.each {|path| canvas.polyline(*path) }
404
+ border_style.color ? canvas.stroke : canvas.end_path
405
+ end
406
+
365
407
  # Calculates the padding needed around the line endings based on the line ending +style+ and
366
408
  # the +border_width+.
367
409
  def calculate_line_ending_padding(style, border_width)
@@ -0,0 +1,107 @@
1
+ # -*- encoding: utf-8; frozen_string_literal: true -*-
2
+ #
3
+ #--
4
+ # This file is part of HexaPDF.
5
+ #
6
+ # HexaPDF - A Versatile PDF Creation and Manipulation Library For Ruby
7
+ # Copyright (C) 2014-2026 Thomas Leitner
8
+ #
9
+ # HexaPDF is free software: you can redistribute it and/or modify it
10
+ # under the terms of the GNU Affero General Public License version 3 as
11
+ # published by the Free Software Foundation with the addition of the
12
+ # following permission added to Section 15 as permitted in Section 7(a):
13
+ # FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY
14
+ # THOMAS LEITNER, THOMAS LEITNER DISCLAIMS THE WARRANTY OF NON
15
+ # INFRINGEMENT OF THIRD PARTY RIGHTS.
16
+ #
17
+ # HexaPDF is distributed in the hope that it will be useful, but WITHOUT
18
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
19
+ # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
20
+ # License for more details.
21
+ #
22
+ # You should have received a copy of the GNU Affero General Public License
23
+ # along with HexaPDF. If not, see <http://www.gnu.org/licenses/>.
24
+ #
25
+ # The interactive user interfaces in modified source and object code
26
+ # versions of HexaPDF must display Appropriate Legal Notices, as required
27
+ # under Section 5 of the GNU Affero General Public License version 3.
28
+ #
29
+ # In accordance with Section 7(b) of the GNU Affero General Public
30
+ # License, a covered work must retain the producer line in every PDF that
31
+ # is created or manipulated using HexaPDF.
32
+ #
33
+ # If the GNU Affero General Public License doesn't fit your need,
34
+ # commercial licenses are available at <https://gettalong.at/hexapdf/>.
35
+ #++
36
+
37
+ require 'hexapdf/type/annotation'
38
+
39
+ module HexaPDF
40
+ module Type
41
+ module Annotations
42
+
43
+ # An ink annotation is a markup annotation that displays a freehand "scribble" composed of one
44
+ # or more disjoint paths.
45
+ #
46
+ # The convenience method #add_path can be used to add paths to the annotation.
47
+ #
48
+ # The style of the scribble can be customized using the convenience methods Annotation#opacity
49
+ # and BorderStyling#border_style (note that only a simple line dash pattern is supported).
50
+ #
51
+ # Example:
52
+ #
53
+ # #>pdf-small
54
+ # doc.annotations.create_scribble(doc.pages[0]).
55
+ # add_path(10, 10, 50, 40, 40, 80, 80, 60).
56
+ # add_path(5, 90, 20, 90, 15, 50, 80, 5).
57
+ # border_style(color: "hp-blue", width: 2, style: [3, 1]).
58
+ # regenerate_appearance
59
+ #
60
+ # See: PDF2.0 s12.5.6.13, HexaPDF::Type::MarkupAnnotation
61
+ class Ink < MarkupAnnotation
62
+
63
+ include BorderStyling
64
+
65
+ define_field :Subtype, type: Symbol, required: true, default: :Ink
66
+ define_field :InkList, type: PDFArray, required: true
67
+ define_field :BS, type: :Border
68
+ define_field :Path, type: PDFArray, version: '2.0'
69
+
70
+ # :call-seq:
71
+ # annot.paths => [[x00, y00, x01, y01, ...], [x10, y10, x11, y11, ...], ...]
72
+ #
73
+ # Returns an array with all the paths of this annotation.
74
+ def paths
75
+ self[:InkList].to_a
76
+ end
77
+
78
+ # :call-seq:
79
+ # annot.add_path => [x0, y0, x1, y1, ...]
80
+ # annot.add_path(*points) => annot
81
+ #
82
+ # Adds the path consisting of the given +points+ to the list of paths and returns self.
83
+ #
84
+ # Adding at least one path is required. Note, however, that without setting the appearance
85
+ # style through convenience methods like #border_style nothing will be shown.
86
+ #
87
+ # Example:
88
+ #
89
+ # #>pdf-small
90
+ # doc.annotations.create_scribble(doc.pages[0]).
91
+ # add_path(10, 10, 40, 60, 90, 90).
92
+ # regenerate_appearance
93
+ def add_path(*points)
94
+ if points.length % 2 != 0
95
+ raise ArgumentError, "An even number of arguments must be provided"
96
+ else
97
+ self[:InkList] ||= []
98
+ self[:InkList] << points
99
+ self
100
+ end
101
+ end
102
+
103
+ end
104
+
105
+ end
106
+ end
107
+ end
@@ -60,6 +60,7 @@ module HexaPDF
60
60
  autoload(:PolygonPolyline, 'hexapdf/type/annotations/polygon_polyline')
61
61
  autoload(:Polygon, 'hexapdf/type/annotations/polygon')
62
62
  autoload(:Polyline, 'hexapdf/type/annotations/polyline')
63
+ autoload(:Ink, 'hexapdf/type/annotations/ink')
63
64
 
64
65
  end
65
66
 
@@ -37,6 +37,6 @@
37
37
  module HexaPDF
38
38
 
39
39
  # The version of HexaPDF.
40
- VERSION = '1.8.0'
40
+ VERSION = '1.9.0'
41
41
 
42
42
  end