hexapdf 1.1.1 → 1.3.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -0
  3. data/README.md +1 -1
  4. data/lib/hexapdf/cli/command.rb +63 -63
  5. data/lib/hexapdf/cli/inspect.rb +14 -5
  6. data/lib/hexapdf/cli/modify.rb +0 -1
  7. data/lib/hexapdf/cli/optimize.rb +5 -5
  8. data/lib/hexapdf/composer.rb +14 -0
  9. data/lib/hexapdf/configuration.rb +26 -0
  10. data/lib/hexapdf/content/graphics_state.rb +1 -1
  11. data/lib/hexapdf/digital_signature/signing/signed_data_creator.rb +1 -1
  12. data/lib/hexapdf/document/annotations.rb +173 -0
  13. data/lib/hexapdf/document/layout.rb +45 -6
  14. data/lib/hexapdf/document.rb +28 -7
  15. data/lib/hexapdf/error.rb +11 -3
  16. data/lib/hexapdf/font/true_type/subsetter.rb +15 -2
  17. data/lib/hexapdf/font/true_type_wrapper.rb +1 -0
  18. data/lib/hexapdf/font/type1_wrapper.rb +1 -0
  19. data/lib/hexapdf/layout/style.rb +101 -7
  20. data/lib/hexapdf/object.rb +2 -2
  21. data/lib/hexapdf/pdf_array.rb +25 -3
  22. data/lib/hexapdf/tokenizer.rb +4 -1
  23. data/lib/hexapdf/type/acro_form/appearance_generator.rb +57 -8
  24. data/lib/hexapdf/type/acro_form/field.rb +1 -0
  25. data/lib/hexapdf/type/acro_form/form.rb +7 -6
  26. data/lib/hexapdf/type/acro_form/java_script_actions.rb +9 -2
  27. data/lib/hexapdf/type/acro_form/text_field.rb +9 -2
  28. data/lib/hexapdf/type/annotation.rb +71 -1
  29. data/lib/hexapdf/type/annotations/appearance_generator.rb +348 -0
  30. data/lib/hexapdf/type/annotations/border_effect.rb +99 -0
  31. data/lib/hexapdf/type/annotations/border_styling.rb +160 -0
  32. data/lib/hexapdf/type/annotations/circle.rb +65 -0
  33. data/lib/hexapdf/type/annotations/interior_color.rb +84 -0
  34. data/lib/hexapdf/type/annotations/line.rb +490 -0
  35. data/lib/hexapdf/type/annotations/square.rb +65 -0
  36. data/lib/hexapdf/type/annotations/square_circle.rb +77 -0
  37. data/lib/hexapdf/type/annotations/widget.rb +52 -116
  38. data/lib/hexapdf/type/annotations.rb +8 -0
  39. data/lib/hexapdf/type/form.rb +2 -2
  40. data/lib/hexapdf/version.rb +1 -1
  41. data/lib/hexapdf/writer.rb +0 -1
  42. data/lib/hexapdf/xref_section.rb +7 -4
  43. data/test/hexapdf/content/test_graphics_state.rb +2 -3
  44. data/test/hexapdf/content/test_operator.rb +4 -5
  45. data/test/hexapdf/digital_signature/test_cms_handler.rb +7 -8
  46. data/test/hexapdf/digital_signature/test_handler.rb +2 -3
  47. data/test/hexapdf/digital_signature/test_pkcs1_handler.rb +1 -2
  48. data/test/hexapdf/document/test_annotations.rb +55 -0
  49. data/test/hexapdf/document/test_layout.rb +24 -2
  50. data/test/hexapdf/font/test_true_type_wrapper.rb +7 -0
  51. data/test/hexapdf/font/test_type1_wrapper.rb +7 -0
  52. data/test/hexapdf/font/true_type/test_subsetter.rb +10 -0
  53. data/test/hexapdf/layout/test_style.rb +27 -2
  54. data/test/hexapdf/task/test_optimize.rb +1 -1
  55. data/test/hexapdf/test_composer.rb +7 -0
  56. data/test/hexapdf/test_document.rb +11 -3
  57. data/test/hexapdf/test_object.rb +1 -1
  58. data/test/hexapdf/test_pdf_array.rb +36 -3
  59. data/test/hexapdf/test_stream.rb +1 -2
  60. data/test/hexapdf/test_xref_section.rb +1 -1
  61. data/test/hexapdf/type/acro_form/test_appearance_generator.rb +78 -3
  62. data/test/hexapdf/type/acro_form/test_button_field.rb +7 -6
  63. data/test/hexapdf/type/acro_form/test_field.rb +5 -0
  64. data/test/hexapdf/type/acro_form/test_form.rb +17 -1
  65. data/test/hexapdf/type/acro_form/test_java_script_actions.rb +21 -0
  66. data/test/hexapdf/type/acro_form/test_text_field.rb +7 -1
  67. data/test/hexapdf/type/annotations/test_appearance_generator.rb +482 -0
  68. data/test/hexapdf/type/annotations/test_border_effect.rb +59 -0
  69. data/test/hexapdf/type/annotations/test_border_styling.rb +114 -0
  70. data/test/hexapdf/type/annotations/test_interior_color.rb +37 -0
  71. data/test/hexapdf/type/annotations/test_line.rb +169 -0
  72. data/test/hexapdf/type/annotations/test_widget.rb +35 -81
  73. data/test/hexapdf/type/test_annotation.rb +55 -0
  74. data/test/hexapdf/type/test_form.rb +6 -0
  75. metadata +17 -2
@@ -174,11 +174,59 @@ module HexaPDF
174
174
 
175
175
  alias create_radio_button_appearances create_check_box_appearances
176
176
 
177
- # Creates the appropriate appearances for push buttons.
177
+ # Creates the appropriate appearances for push button fields
178
178
  #
179
- # This is currently a dummy implementation raising an error.
179
+ # The following describes how the appearance is built:
180
+ #
181
+ # * The widget's rectangle /Rect must be defined.
182
+ #
183
+ # * If the font size (used for the caption) is zero, a font size of
184
+ # +acro_form.default_font_size+ is used.
185
+ #
186
+ # * The line width, style and color of the rectangle are taken from the widget's border
187
+ # style. See HexaPDF::Type::Annotations::Widget#border_style.
188
+ #
189
+ # * The background color is determined by the widget's background color. See
190
+ # HexaPDF::Type::Annotations::Widget#background_color.
180
191
  def create_push_button_appearances
181
- raise HexaPDF::Error, "Push button appearance generation not yet supported"
192
+ default_resources = @document.acro_form(create: true).default_resources
193
+ border_style = @widget.border_style
194
+ padding = border_style.width
195
+ marker_style = @widget.marker_style
196
+ font = retrieve_font_information(marker_style.font_name, default_resources)
197
+
198
+ @widget[:AS] = :N
199
+ @widget.flag(:print)
200
+ @widget.unflag(:hidden)
201
+ rect = @widget[:Rect]
202
+
203
+ width, height, matrix = perform_rotation(rect.width, rect.height)
204
+
205
+ form = (@widget[:AP] ||= {})[:N] ||= @document.add({Type: :XObject, Subtype: :Form})
206
+ # Wrap existing object in Form class in case the PDF writer didn't include the /Subtype
207
+ # key or the type of the object is wrong; we can do this since we know this has to be a
208
+ # Form object
209
+ unless form.type == :XObject && form[:Subtype] == :Form
210
+ form = @document.wrap(form, type: :XObject, subtype: :Form)
211
+ end
212
+ form.value.replace({Type: :XObject, Subtype: :Form, BBox: [0, 0, width, height],
213
+ Matrix: matrix, Resources: HexaPDF::Object.deep_copy(default_resources)})
214
+ form.contents = ''
215
+
216
+ canvas = form.canvas
217
+ apply_background_and_border(border_style, canvas)
218
+
219
+ style = HexaPDF::Layout::Style.new(font: font, font_size: marker_style.size,
220
+ fill_color: marker_style.color)
221
+ if (text = marker_style.style) && text.kind_of?(String)
222
+ items = @document.layout.text_fragments(marker_style.style, style: style)
223
+ layouter = Layout::TextLayouter.new(style)
224
+ layouter.style.text_align(:center).text_valign(:center).line_spacing(:proportional, 1.25)
225
+ result = layouter.fit(items, width - 2 * padding, height - 2 * padding)
226
+ unless result.lines.empty?
227
+ result.draw(canvas, padding, height - padding)
228
+ end
229
+ end
182
230
  end
183
231
 
184
232
  # Creates the appropriate appearances for text fields, combo box fields and list box fields.
@@ -207,7 +255,8 @@ module HexaPDF
207
255
  # Note: Rich text fields are currently not supported!
208
256
  def create_text_appearances
209
257
  default_resources = @document.acro_form.default_resources
210
- font, font_size, font_color = retrieve_font_information(default_resources)
258
+ font_name, font_size, font_color = @field.parse_default_appearance_string(@widget)
259
+ font = retrieve_font_information(font_name, default_resources)
211
260
  style = HexaPDF::Layout::Style.new(font: font, font_size: font_size, fill_color: font_color)
212
261
  border_style = @widget.border_style
213
262
  padding = [1, border_style.width].max
@@ -475,9 +524,9 @@ module HexaPDF
475
524
  end
476
525
  end
477
526
 
478
- # Returns the font wrapper and font size to be used for a variable text field.
479
- def retrieve_font_information(resources)
480
- font_name, font_size, font_color = @field.parse_default_appearance_string(@widget)
527
+ # Returns the font wrapper, font size and font color to be used for variable text fields and
528
+ # push button captions.
529
+ def retrieve_font_information(font_name, resources)
481
530
  font_object = resources.font(font_name) rescue nil
482
531
  font = font_object&.font_wrapper
483
532
  unless font
@@ -493,7 +542,7 @@ module HexaPDF
493
542
  raise(HexaPDF::Error, "Font #{font_name} of the AcroForm's default resources not usable")
494
543
  end
495
544
  end
496
- [font, font_size, font_color]
545
+ font
497
546
  end
498
547
 
499
548
  # Calculates the font size for single line text fields using auto-sizing, based on the font
@@ -392,6 +392,7 @@ module HexaPDF
392
392
  break # Each annotation dictionary may only appear on one page, see PDF2.0 12.5.2
393
393
  end
394
394
  end
395
+ document.revisions.current.update(self)
395
396
  widget
396
397
  end
397
398
 
@@ -568,12 +568,12 @@ module HexaPDF
568
568
  seen = {} # used for combining field
569
569
 
570
570
  validate_array = lambda do |parent, container|
571
- container.reject! do |field|
571
+ container.map! do |field|
572
572
  if !field.kind_of?(HexaPDF::Object) || !field.kind_of?(HexaPDF::Dictionary) || field.null?
573
573
  yield("Invalid object in AcroForm field hierarchy", true)
574
- next true
574
+ next nil
575
575
  end
576
- next false unless field.key?(:T) # Skip widgets
576
+ next field unless field.key?(:T) # Skip widgets
577
577
 
578
578
  field = Field.wrap(document, field)
579
579
  reject = false
@@ -597,14 +597,15 @@ module HexaPDF
597
597
  widget[:Parent] = other_field
598
598
  kids << widget
599
599
  end
600
+ document.delete(field)
600
601
  reject = true
601
602
  elsif !reject
602
603
  seen[name] = field
603
604
  end
604
605
 
605
- validate_array.call(field, field[:Kids]) if field.key?(:Kids)
606
- reject
607
- end
606
+ validate_array.call(field, field[:Kids]) if !field.null? && field.key?(:Kids)
607
+ reject ? nil : field
608
+ end.compact!
608
609
  end
609
610
  validate_array.call(nil, root_fields)
610
611
 
@@ -496,7 +496,7 @@ module HexaPDF
496
496
  else
497
497
  nil
498
498
  end
499
- result && (result == result.truncate ? result.to_i.to_s : result.to_s)
499
+ result && (result.finite? && result == result.truncate ? result.to_i.to_s : result.to_s)
500
500
  end
501
501
 
502
502
  AF_SIMPLE_CALCULATE_MAPPING = { #:nodoc:
@@ -613,7 +613,14 @@ module HexaPDF
613
613
 
614
614
  # Returns the numeric value of the string, interpreting comma as point.
615
615
  def af_make_number(value)
616
- value.to_s.tr(',', '.').to_f
616
+ value = value.to_s
617
+ if value.match?(/(?:[+-])?Inf(?:inity)?/i)
618
+ value.start_with?('-') ? -Float::INFINITY : Float::INFINITY
619
+ elsif value.match?(/NaN/i)
620
+ Float::NAN
621
+ else
622
+ value.tr(',', '.').to_f
623
+ end
617
624
  end
618
625
 
619
626
  # Formats the numeric value according to the format string and separator style.
@@ -176,7 +176,7 @@ module HexaPDF
176
176
  end
177
177
  str = str.gsub(/[[:space:]]/, ' ') if str && concrete_field_type == :single_line_text_field
178
178
  if key?(:MaxLen) && str && str.length > self[:MaxLen]
179
- raise HexaPDF::Error, "Value exceeds maximum allowed length of #{self[:MaxLen]}"
179
+ str = @document.config['acro_form.text_field.on_max_len_exceeded'].call(self, str)
180
180
  end
181
181
  self[:V] = str
182
182
  update_widgets
@@ -348,7 +348,14 @@ module HexaPDF
348
348
  return
349
349
  end
350
350
  if (max_len = self[:MaxLen]) && field_value && field_value.length > max_len
351
- yield("Text contents of field '#{full_field_name}' is too long")
351
+ correctable = true
352
+ begin
353
+ str = @document.config['acro_form.text_field.on_max_len_exceeded'].call(self, field_value)
354
+ rescue HexaPDF::Error
355
+ correctable = false
356
+ end
357
+ yield("Text contents of field '#{full_field_name}' is too long", correctable)
358
+ self.field_value = str if correctable
352
359
  end
353
360
  if comb_text_field? && !max_len
354
361
  yield("Comb text field needs a value for /MaxLen")
@@ -113,6 +113,18 @@ module HexaPDF
113
113
 
114
114
  end
115
115
 
116
+ # Border effect dictionary used by square, circle and polygon annotation types.
117
+ #
118
+ # See: PDF2.0 s12.5.4
119
+ class BorderEffect < Dictionary
120
+
121
+ define_type :XXBorderEffect
122
+
123
+ define_field :S, type: Symbol, default: :S, allowed_values: [:C, :S]
124
+ define_field :I, type: Numeric, default: 0, allowed_values: [0, 1, 2]
125
+
126
+ end
127
+
116
128
  extend Utils::BitField
117
129
 
118
130
  define_type :Annot
@@ -133,7 +145,7 @@ module HexaPDF
133
145
  define_field :OC, type: Dictionary, version: '1.5'
134
146
  define_field :AF, type: PDFArray, version: '2.0'
135
147
  define_field :ca, type: Numeric, default: 1.0, version: '2.0'
136
- define_field :CA, type: Numeric, default: 1.0, version: '2.0'
148
+ define_field :CA, type: Numeric, default: 1.0, version: '1.4'
137
149
  define_field :BM, type: Symbol, version: '2.0'
138
150
  define_field :Lang, type: String, version: '2.0'
139
151
 
@@ -259,6 +271,64 @@ module HexaPDF
259
271
  xobject
260
272
  end
261
273
 
274
+ # Regenerates the appearance stream of the annotation.
275
+ #
276
+ # This uses the information stored in the annotation to regenerate the appearance.
277
+ #
278
+ # See: Annotations::AppearanceGenerator
279
+ def regenerate_appearance
280
+ appearance_generator_class = document.config.constantize('annotation.appearance_generator')
281
+ appearance_generator_class.new(self).create_appearance
282
+ end
283
+
284
+ # :call-seq:
285
+ # annot.contents => contents or +nil+
286
+ # annot.contents(text) => annot
287
+ #
288
+ # Returns the text of the annotation when no argument is given. Otherwise sets the text and
289
+ # returns self.
290
+ #
291
+ # The contents is used differently depending on the annotation type. It is either the text
292
+ # that should be displayed for the annotation or an alternate description of the annotation's
293
+ # contents.
294
+ #
295
+ # A value of +nil+ means deleting the existing contents entry.
296
+ def contents(text = :UNSET)
297
+ if text == :UNSET
298
+ self[:Contents]
299
+ else
300
+ self[:Contents] = text
301
+ self
302
+ end
303
+ end
304
+
305
+ # Describes the opacity values +fill_alpha+ and +stroke_alpha+ of an annotation.
306
+ #
307
+ # See Annotation#opacity
308
+ Opacity = Struct.new(:fill_alpha, :stroke_alpha)
309
+
310
+ # :call-seq:
311
+ # annotation.opacity => current_values
312
+ # annotation.opacity(fill_alpha:) => annotation
313
+ # annotation.opacity(stroke_alpha:) => annotation
314
+ # annotation.opacity(fill_alpha:, stroke_alpha:) => annotation
315
+ #
316
+ # Returns an Opacity instance representing the fill and stroke alpha values when no arguments
317
+ # are given. Otherwise sets the provided alpha values and returns self.
318
+ #
319
+ # The fill and stroke alpha values are used when regenerating the annotation's appearance
320
+ # stream and determine how opaque drawn elements will be. Note that the fill alpha value
321
+ # applies not just to fill values but to all non-stroking operations (e.g. images, ...).
322
+ def opacity(fill_alpha: nil, stroke_alpha: nil)
323
+ if !fill_alpha.nil? || !stroke_alpha.nil?
324
+ self[:CA] = stroke_alpha unless stroke_alpha.nil?
325
+ self[:ca] = fill_alpha unless fill_alpha.nil?
326
+ self
327
+ else
328
+ Opacity.new(key?(:ca) ? self[:ca] : self[:CA], self[:CA])
329
+ end
330
+ end
331
+
262
332
  private
263
333
 
264
334
  def perform_validation(&block) #:nodoc:
@@ -0,0 +1,348 @@
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-2025 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/error'
38
+
39
+ module HexaPDF
40
+ module Type
41
+ module Annotations
42
+
43
+ # The AppearanceGenerator class provides methods for generating the appearance streams of
44
+ # annotations except those for widgets (see HexaPDF::Type::AcroForm::AppearanceGenerator for
45
+ # those).
46
+ #
47
+ # There is one private create_TYPE_appearance method for each annotation type. This allows
48
+ # subclassing the appearance generator and adjusting the appearances to one's needs.
49
+ #
50
+ # By default, an existing appearance is overwritten and the +:print+ flag is set as well as
51
+ # the +:hidden+ flag unset on the annotation so that the appearance will appear on print-outs.
52
+ #
53
+ # Also note that the annotation's /Rect entry is modified so that it contains the whole
54
+ # generated appearance.
55
+ #
56
+ # The visual appearances are chosen to be similar to those used by Adobe Acrobat and others.
57
+ # By subclassing and overriding the necessary methods it is possible to define custom
58
+ # appearances.
59
+ #
60
+ # The default annotation appearance generator for a document can be changed using the
61
+ # 'annotation.appearance_generator' configuration option.
62
+ #
63
+ # See: PDF2.0 s12.5
64
+ class AppearanceGenerator
65
+
66
+ # Creates a new instance for the given +annotation+.
67
+ def initialize(annotation)
68
+ @annot = annotation
69
+ @document = annotation.document
70
+ end
71
+
72
+ # Creates the appropriate appearance for the annotation provided on initialization.
73
+ def create_appearance
74
+ case @annot[:Subtype]
75
+ when :Line then create_line_appearance
76
+ when :Square then create_square_circle_appearance(:square)
77
+ when :Circle then create_square_circle_appearance(:circle)
78
+ else
79
+ raise HexaPDF::Error, "Appearance regeneration for #{@annot[:Subtype]} not yet supported"
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ # Creates the appropriate appearance for a line annotation.
86
+ #
87
+ # Nearly all the needed information can be taken from the annotation object itself. However,
88
+ # the PDF specification doesn't specify where to take the font related information (font,
89
+ # size, alignment...) from. Therefore this is currently hard-coded as left-aligned Helvetica
90
+ # in size 9.
91
+ #
92
+ # There are also some other decisions that are left to the implementation, like padding
93
+ # around the annotation or the size of the line ending shapes. Those are implemented to be
94
+ # similar to how viewers create the appearance.
95
+ #
96
+ # See: HexaPDF::Type::Annotations::Line
97
+ def create_line_appearance
98
+ # Prepare the annotation
99
+ form = (@annot[:AP] ||= {})[:N] ||=
100
+ @document.add({Type: :XObject, Subtype: :Form, BBox: [0, 0, 0, 0]})
101
+ form.contents = ""
102
+ @annot.flag(:print)
103
+ @annot.unflag(:hidden)
104
+
105
+ # Get or calculate all needed values from the annotation
106
+ x0, y0, x1, y1 = @annot.line
107
+ style = @annot.border_style
108
+ line_ending_style = @annot.line_ending_style
109
+ opacity = @annot.opacity
110
+ ll = @annot.leader_line_length
111
+ lle = @annot.leader_line_extension_length
112
+ llo = @annot.leader_line_offset
113
+
114
+ angle = Math.atan2(y1 - y0, x1 - x0)
115
+ cos_angle = Math.cos(angle)
116
+ sin_angle = Math.sin(angle)
117
+ line_length = Math.sqrt((y1 - y0) ** 2 + (x1 - x0) ** 2)
118
+ ll_sign = (ll > 0 ? 1 : -1)
119
+ ll_y = ll_sign * (ll.abs + lle + llo)
120
+ line_y = (ll != 0 ? ll_sign * (llo + ll.abs) : 0)
121
+
122
+ captioned = @annot.captioned
123
+ contents = @annot.contents.to_s
124
+ if captioned && !contents.empty?
125
+ cap_position = @annot.caption_position
126
+ cap_style = HexaPDF::Layout::Style.new(font: 'Helvetica', font_size: 9,
127
+ fill_color: style.color || 'black',
128
+ line_spacing: 1.25)
129
+ cap_items = @document.layout.text_fragments(contents, style: cap_style)
130
+ layouter = Layout::TextLayouter.new(cap_style)
131
+ cap_result = layouter.fit(cap_items, 2**20, 2**20)
132
+ cap_width = cap_result.lines.max_by(&:width).width + 2 # for padding left/right
133
+ cap_offset = @annot.caption_offset
134
+
135
+ cap_x = (line_length - cap_width) / 2.0 + cap_offset[0]
136
+ # Note that the '+ 2' is just so that there is a small gap to the line
137
+ cap_y = line_y + cap_offset[1] +
138
+ (cap_position == :inline ? cap_result.height / 2.0 : cap_result.height + 2)
139
+ end
140
+
141
+ # Calculate annotation rectangle and form bounding box. This considers the line's start
142
+ # and end points as well as the end points of the leader lines, the line ending style and
143
+ # the caption when calculating the bounding box.
144
+ #
145
+ # The result could still be improved by tailoring to the specific line ending style.
146
+ calculate_le_padding = lambda do |le_style|
147
+ case le_style
148
+ when :square, :circle, :diamond, :slash, :open_arrow, :closed_arrow
149
+ 3 * style.width
150
+ when :ropen_arrow, :rclosed_arrow
151
+ 10 * style.width
152
+ else
153
+ 0
154
+ end
155
+ end
156
+ dstart = calculate_le_padding.call(line_ending_style.start_style)
157
+ dend = calculate_le_padding.call(line_ending_style.end_style)
158
+ if captioned
159
+ cap_ulx = x0 + cos_angle * cap_x - sin_angle * cap_y
160
+ cap_uly = y0 + sin_angle * cap_x + cos_angle * cap_y
161
+ end
162
+ min_x, max_x = [x0, x0 - sin_angle * ll_y, x0 - sin_angle * line_y - cos_angle * dstart,
163
+ x1, x1 - sin_angle * ll_y, x1 - sin_angle * line_y + cos_angle * dend,
164
+ *([cap_ulx,
165
+ cap_ulx + cos_angle * cap_width,
166
+ cap_ulx - sin_angle * cap_result.height,
167
+ cap_ulx + cos_angle * cap_width - sin_angle * cap_result.height
168
+ ] if captioned)
169
+ ].minmax
170
+ min_y, max_y = [y0, y0 + cos_angle * ll_y,
171
+ y0 + cos_angle * line_y - ([cos_angle, sin_angle].max) * dstart,
172
+ y1, y1 + cos_angle * ll_y,
173
+ y1 + cos_angle * line_y + ([cos_angle, sin_angle].max) * dend,
174
+ *([cap_uly,
175
+ cap_uly + sin_angle * cap_width,
176
+ cap_uly - cos_angle * cap_result.height,
177
+ cap_uly + sin_angle * cap_width - cos_angle * cap_result.height
178
+ ] if captioned)
179
+ ].minmax
180
+
181
+ padding = 4 * style.width
182
+ rect = [min_x - padding, min_y - padding, max_x + padding, max_y + padding]
183
+ @annot[:Rect] = rect
184
+ form[:BBox] = rect.dup
185
+
186
+ # Set the appropriate graphics state and transform the canvas so that the line is
187
+ # unrotated and its start point at the origin.
188
+ canvas = form.canvas(translate: false)
189
+ canvas.opacity(**opacity.to_h)
190
+ canvas.stroke_color(style.color) if style.color
191
+ canvas.fill_color(@annot.interior_color) if @annot.interior_color
192
+ canvas.line_width(style.width)
193
+ canvas.line_dash_pattern(style.style) if style.style.kind_of?(Array)
194
+ canvas.transform(cos_angle, sin_angle, -sin_angle, cos_angle, x0, y0)
195
+
196
+ stroke_op = (style.color ? :stroke : :end_path)
197
+ fill_op = (style.color && @annot.interior_color ? :fill_stroke :
198
+ (style.color ? :stroke : :fill))
199
+
200
+ # Draw leader lines and line
201
+ if ll != 0
202
+ canvas.line(0, ll_sign * llo, 0, ll_y)
203
+ canvas.line(line_length, ll_sign * llo, line_length, ll_y)
204
+ end
205
+ if captioned && cap_position == :inline
206
+ canvas.line(0, line_y, [[0, cap_x].max, line_length].min, line_y)
207
+ canvas.line([[cap_x + cap_width, 0].max, line_length].min, line_y, line_length, line_y)
208
+ else
209
+ canvas.line(0, line_y, line_length, line_y)
210
+ end
211
+ canvas.send(stroke_op)
212
+
213
+ # Draw line endings
214
+ if line_ending_style.start_style != :none
215
+ do_fill = draw_line_ending(canvas, line_ending_style.start_style, 0, line_y,
216
+ style.width, 0)
217
+ canvas.send(do_fill ? fill_op : stroke_op)
218
+ end
219
+ if line_ending_style.end_style != :none
220
+ do_fill = draw_line_ending(canvas, line_ending_style.end_style, line_length, line_y,
221
+ style.width, Math::PI)
222
+ canvas.send(do_fill ? fill_op : stroke_op)
223
+ end
224
+
225
+ # Draw caption, adding half of the padding added to cap_width
226
+ cap_result.draw(canvas, cap_x + 1, cap_y) if captioned
227
+ end
228
+
229
+ # Creates the appropriate appearance for a square or circle annotation depending on the
230
+ # given +type+ (which can either be +:square+ or +:circle+).
231
+ #
232
+ # The cloudy border effect is not supported.
233
+ #
234
+ # See: HexaPDF::Type::Annotations::Square, HexaPDF::Type::Annotations::Circle
235
+ def create_square_circle_appearance(type)
236
+ # Prepare the annotation
237
+ form = (@annot[:AP] ||= {})[:N] ||=
238
+ @document.add({Type: :XObject, Subtype: :Form, BBox: [0, 0, 0, 0]})
239
+ form.contents = ""
240
+ @annot.flag(:print)
241
+ @annot.unflag(:hidden)
242
+
243
+ rect = @annot[:Rect]
244
+ x, y, w, h = rect.left, rect.bottom, rect.width, rect.height
245
+ border_style = @annot.border_style
246
+ interior_color = @annot.interior_color
247
+ opacity = @annot.opacity
248
+
249
+ # Take the differences array into account. If it exists, the boundary of the actual
250
+ # rectangle is the one with the differences applied to /Rect.
251
+ #
252
+ # If the differences array doesn't exist, we assume that the /Rect is the rectangle we
253
+ # want to draw, with the line width split on both side (like with Canvas#rectangle). In
254
+ # this case we need to update /Rect accordingly so that the line width on the outside is
255
+ # correctly shown.
256
+ line_width_adjustment = border_style.width / 2.0
257
+ if (rd = @annot[:RD])
258
+ x += rd[0]
259
+ y += rd[3]
260
+ w -= rd[0] + rd[2]
261
+ h -= rd[1] + rd[3]
262
+ else
263
+ @annot[:RD] = [0, 0, 0, 0]
264
+ x = rect.left -= line_width_adjustment
265
+ y = rect.bottom -= line_width_adjustment
266
+ w = rect.width += line_width_adjustment
267
+ h = rect.height += line_width_adjustment
268
+ end
269
+ x += line_width_adjustment
270
+ y += line_width_adjustment
271
+ w -= 2 * line_width_adjustment
272
+ h -= 2 * line_width_adjustment
273
+
274
+ x -= rect.left
275
+ y -= rect.bottom
276
+ form[:BBox] = [0, 0, rect.width, rect.height]
277
+
278
+ if border_style.color || interior_color
279
+ canvas = form.canvas
280
+ canvas.opacity(**opacity.to_h)
281
+ canvas.stroke_color(border_style.color) if border_style.color
282
+ canvas.fill_color(interior_color) if interior_color
283
+ canvas.line_width(border_style.width)
284
+ canvas.line_dash_pattern(border_style.style) if border_style.style.kind_of?(Array)
285
+
286
+ if type == :square
287
+ canvas.rectangle(x, y, w, h)
288
+ else
289
+ canvas.ellipse(x + w / 2.0, y + h / 2.0, a: w / 2.0, b: h / 2.0)
290
+ end
291
+
292
+ if border_style.color && interior_color
293
+ canvas.fill_stroke
294
+ elsif border_style.color
295
+ canvas.stroke
296
+ else
297
+ canvas.fill
298
+ end
299
+ end
300
+ end
301
+
302
+ # Draws the line ending style +type+ at the position (+x+, +y+) and returns +true+ if the
303
+ # shape needs to be filled.
304
+ #
305
+ # The argument +angle+ specifies the angle at which the line ending style should be drawn.
306
+ #
307
+ # The +line_width+ is needed because the size of the line ending depends on it.
308
+ def draw_line_ending(canvas, type, x, y, line_width, angle)
309
+ lw3 = 3 * line_width
310
+
311
+ case type
312
+ when :square
313
+ canvas.rectangle(x - lw3, y - lw3, 2 * lw3, 2 * lw3)
314
+ true
315
+ when :circle
316
+ canvas.circle(x, y, lw3)
317
+ true
318
+ when :diamond
319
+ canvas.polygon(x + lw3, y, x, y + lw3, x - lw3, y, x, y - lw3)
320
+ true
321
+ when :open_arrow, :closed_arrow, :ropen_arrow, :rclosed_arrow
322
+ arrow_cos = Math.cos(Math::PI / 6 + angle)
323
+ arrow_sin = Math.sin(Math::PI / 6 + angle)
324
+ dir = (type == :ropen_arrow || type == :rclosed_arrow ? -1 : 1)
325
+ canvas.polyline(x + dir * arrow_cos * 3 * lw3, y + arrow_sin * 3 * lw3, x, y,
326
+ x + dir * arrow_cos * 3 * lw3, y - arrow_sin * 3 * lw3)
327
+ if type == :closed_arrow || type == :rclosed_arrow
328
+ canvas.close_subpath
329
+ true
330
+ else
331
+ false
332
+ end
333
+ when :butt
334
+ canvas.line(x, y + lw3, x, y - lw3)
335
+ false
336
+ when :slash
337
+ sin_60 = Math.sin(Math::PI / 3)
338
+ cos_60 = Math.cos(Math::PI / 3)
339
+ canvas.line(x + cos_60 * lw3, y + sin_60 * lw3, x - cos_60 * lw3, y - sin_60 * lw3)
340
+ false
341
+ end
342
+ end
343
+
344
+ end
345
+
346
+ end
347
+ end
348
+ end