hexapdf 0.13.0 → 0.14.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -0
  3. data/examples/019-acro_form.rb +41 -4
  4. data/lib/hexapdf/cli/split.rb +74 -14
  5. data/lib/hexapdf/document.rb +10 -4
  6. data/lib/hexapdf/layout/text_layouter.rb +2 -2
  7. data/lib/hexapdf/object.rb +22 -0
  8. data/lib/hexapdf/parser.rb +23 -1
  9. data/lib/hexapdf/pdf_array.rb +2 -2
  10. data/lib/hexapdf/type/acro_form/appearance_generator.rb +127 -27
  11. data/lib/hexapdf/type/acro_form/button_field.rb +5 -2
  12. data/lib/hexapdf/type/acro_form/choice_field.rb +64 -10
  13. data/lib/hexapdf/type/acro_form/form.rb +133 -10
  14. data/lib/hexapdf/type/acro_form/text_field.rb +68 -3
  15. data/lib/hexapdf/type/cid_font.rb +1 -1
  16. data/lib/hexapdf/type/font.rb +1 -1
  17. data/lib/hexapdf/type/font_simple.rb +1 -1
  18. data/lib/hexapdf/type/font_type0.rb +3 -3
  19. data/lib/hexapdf/type/form.rb +4 -1
  20. data/lib/hexapdf/type/page.rb +5 -5
  21. data/lib/hexapdf/utils/object_hash.rb +0 -1
  22. data/lib/hexapdf/version.rb +1 -1
  23. data/test/hexapdf/layout/test_text_layouter.rb +9 -1
  24. data/test/hexapdf/test_document.rb +14 -6
  25. data/test/hexapdf/test_object.rb +27 -0
  26. data/test/hexapdf/test_parser.rb +46 -0
  27. data/test/hexapdf/test_pdf_array.rb +1 -1
  28. data/test/hexapdf/test_writer.rb +2 -2
  29. data/test/hexapdf/type/acro_form/test_appearance_generator.rb +286 -34
  30. data/test/hexapdf/type/acro_form/test_button_field.rb +15 -0
  31. data/test/hexapdf/type/acro_form/test_choice_field.rb +92 -9
  32. data/test/hexapdf/type/acro_form/test_form.rb +83 -11
  33. data/test/hexapdf/type/acro_form/test_text_field.rb +75 -1
  34. data/test/hexapdf/type/test_form.rb +7 -0
  35. data/test/hexapdf/utils/test_object_hash.rb +5 -0
  36. data/test/test_helper.rb +2 -0
  37. metadata +4 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 77d9895ece62ee8b8df5afb5a44035868c6b33eb7b43a7cb5b85bd730bee56bc
4
- data.tar.gz: 16b61502ef5c35df588c6a2fd53e1099b80f584276c07ec7a3c23343705ccb42
3
+ metadata.gz: 0cea16b918ff9aa6e7b32759295ef4ab38c899bcbd227d76ad42e0c971360239
4
+ data.tar.gz: 932c5edf01114a59d0a64776f304e29f3c8865a2c2c52c340064180464aabad7
5
5
  SHA512:
6
- metadata.gz: 490fca7cfa535ebfab2af613dacf3ff9e9a6b0b2b76c865ceeb98a45d1cc7b668772dbd3a46ea00a5b82eb1374f36cb42b604754bbb1353e103cde726bc7e886
7
- data.tar.gz: 1f85edaa9b2214218bb586d8c5409c9f741893fa0effdb60c64d294be231893ed96f1efa9db286d9a98f094caeb3fb272d318782dc7977adb8b252425d72cfb4
6
+ metadata.gz: 5883c5788487830b0403459b38b4ed1761c1015688977e9823f3c572f1ad645b06eb0578b185ce26f7f02c560050dd8ec7c09e8524b59cd35df4fd6abd1fb4aa
7
+ data.tar.gz: cdda51a089c86f27319fe424c9a74dc599ed60860338ef49958cd6a820141fa87a0624f2c657565e3f1b4a2392300807b89886178da6af62d22fa03fb543e372
@@ -1,3 +1,54 @@
1
+ ## 0.14.0 - 2020-12-30
2
+
3
+ ### Added
4
+
5
+ * Support for creating AcroForm multiline text fields and their appearances
6
+ * Support for creating AcroForm comb text fields and their appearances
7
+ * Support for creating AcroForm password fields and their appearances
8
+ * Support for creating AcroForm file select fields and their appearances
9
+ * Support for creating AcroForm list box appearances
10
+ * [HexaPDF::Type::AcroForm::ChoiceField#list_box_top_index] and its setter
11
+ method
12
+ * [HexaPDF::Type::AcroForm::ChoiceField#update_widgets] to create appearances if
13
+ they don't exist
14
+ * Methods for caching data to [HexaPDF::Object]
15
+ * Support for splitting by page size to CLI command `hexapdf split`
16
+
17
+ ### Changed
18
+
19
+ * [HexaPDF::Utils::ObjectHash#oids] to be public instead of private
20
+ * Cross-reference table parsing to handle invalidly numbered main sections
21
+ * [HexaPDF::Document#cache] and [HexaPDF::Object#cache] to allow updating
22
+ values for existing keys
23
+ * Appearance creation methods of AcroForm objects to allow forcing the creation
24
+ of new appearances
25
+ * [HexaPDF::Type::AcroForm::AppearanceGenerator#create_text_appearances] to
26
+ re-use existing form objects
27
+ * AcroForm field creation methods to allow specifying often used field
28
+ properties
29
+
30
+ ### Fixed
31
+
32
+ * Missing usage of `:sort` flag for AcroForm choice fields
33
+ * Setting the `/I` field for AcroForm list boxes with multiple selection
34
+ * [HexaPDF::Layout::TextLayouter::SimpleLineWrapping] to remove glue items
35
+ (whitespace) before a hard line break
36
+ * Infinite loop when reconstructing the cross-reference table
37
+ * [HexaPDF::Type::AcroForm::ChoiceField] to support export values for option
38
+ items
39
+ * AcroForm text field appearance creation to only create a new appearance if the
40
+ field's value has changed
41
+ * AcroForm choice field appearance creation to only create a new appearance if
42
+ the involved dictionary fields' values have changed
43
+ * [HexaPDF::Type::AcroForm::ChoiceField#list_box_top_index=] to raise an error
44
+ if no option items are set
45
+ * [HexaPDF::PDFArray#to_ary] to return an array with preprocessed values
46
+ * [HexaPDF::Type::Form#contents=] to clear cached values to avoid returning e.g.
47
+ an invalid canvas object later
48
+ * [HexaPDF::Type::AcroForm::ButtonField#update_widgets] to create appearances if
49
+ they don't exist
50
+
51
+
1
52
  ## 0.13.0 - 2020-11-15
2
53
 
3
54
  ### Added
@@ -42,10 +42,47 @@ rb = form.create_radio_button("Radio")
42
42
  end
43
43
  rb.field_value = :button0
44
44
 
45
- canvas.text("Text field", at: [50, 450])
46
- tx = form.create_text_field("Single Line")
47
- widget = tx.create_widget(page, Rect: [200, 445, 500, 465])
48
- tx.set_default_appearance_string(font_size: 16)
45
+ canvas.text("Text fields", at: [50, 450])
46
+
47
+ canvas.text("Single line", at: [70, 420])
48
+ tx = form.create_text_field("Single Line", font_size: 16)
49
+ widget = tx.create_widget(page, Rect: [200, 415, 500, 435])
49
50
  tx.field_value = "A sample test string!"
50
51
 
52
+ canvas.text("Multiline", at: [70, 390])
53
+ tx = form.create_multiline_text_field("Multiline", font_size: 0, align: :right)
54
+ widget = tx.create_widget(page, Rect: [200, 325, 500, 405])
55
+ widget.border_style(color: 0, width: 1)
56
+ tx.field_value = "A sample test string! " * 30 + "\nNew line\n\nAnother line"
57
+
58
+ canvas.text("Password", at: [70, 300])
59
+ tx = form.create_password_field("Password", font_size: 16)
60
+ widget = tx.create_widget(page, Rect: [200, 295, 500, 315])
61
+
62
+ canvas.text("File select", at: [70, 270])
63
+ tx = form.create_file_select_field("File Select", font_size: 16)
64
+ widget = tx.create_widget(page, Rect: [200, 265, 500, 285])
65
+ tx.field_value = "path/to/file.pdf"
66
+
67
+ canvas.text("Comb", at: [70, 240])
68
+ tx = form.create_comb_text_field("Comb field", max_chars: 10, font_size: 16, align: :center)
69
+ widget = tx.create_widget(page, Rect: [200, 220, 500, 255])
70
+ widget.border_style(color: [30, 128, 0], width: 1)
71
+ tx.field_value = 'Hello'
72
+
73
+ canvas.text("Combo Box", at: [50, 170])
74
+ cb = form.create_combo_box("Combo Box", font_size: 12, editable: true,
75
+ option_items: ['Value 1', 'Another value', 'Choose me!'])
76
+ widget = cb.create_widget(page, Rect: [200, 150, 500, 185])
77
+ widget.border_style(width: 1)
78
+ cb.field_value = 'Another value'
79
+
80
+ canvas.text("List Box", at: [50, 120])
81
+ lb = form.create_list_box("List Box", font_size: 15, align: :center, multi_select: true,
82
+ option_items: 1.upto(7).map {|i| "Value #{i}" })
83
+ widget = lb.create_widget(page, Rect: [200, 50, 500, 135])
84
+ widget.border_style(width: 1)
85
+ lb.list_box_top_index = 1
86
+ lb.field_value = ['Value 6', 'Value 2']
87
+
51
88
  doc.write('acro_form.pdf', optimize: true)
@@ -44,16 +44,28 @@ module HexaPDF
44
44
 
45
45
  def initialize #:nodoc:
46
46
  super('split', takes_commands: false)
47
- short_desc("Split a PDF file into individual pages")
47
+ short_desc("Split a PDF file")
48
48
  long_desc(<<~EOF)
49
- If no OUTPUT_SPEC is specified, the pages are named <PDF>_0001.pdf, <PDF>_0002.pdf, ...
50
- and so on. To specify a custom name, provide the OUTPUT_SPEC argument. It can contain a
51
- printf-style format definition like '%04d' to specify the place where the page number
52
- should be inserted.
49
+ The default strategy is to split a PDF into individual pages, i.e. splitting is done by
50
+ page number. It is also possible to split by page size where pages with the same page size
51
+ get put into the same output PDF.
52
+
53
+ If no OUTPUT_SPEC is specified, the resulting PDF files are named <PDF>_0001.pdf,
54
+ <PDF>_0002.pdf, ... when splitting by page number and <PDF>_A4.pdf, <PDF>_Letter.pdf, ...
55
+ when splitting by page size.
56
+
57
+ To specify a custom name, provide the OUTPUT_SPEC argument. It can contain a printf-style
58
+ format definition like '%04d' to specify the place where the page number should be
59
+ inserted. In case of splitting by page size, the place of the format defintion is replaced
60
+ with the name of the page size, e.g. A4 or Letter.
53
61
 
54
62
  The optimization and encryption options are applied to each created output file.
55
63
  EOF
56
64
 
65
+ options.on("--strategy STRATEGY", "-s", [:page_number, :page_size], "Defines how the PDF " \
66
+ "file should be split: page_number or page_size (default: page_number)") do |s|
67
+ @strategy = s
68
+ end
57
69
  options.on("--password PASSWORD", "-p", String,
58
70
  "The password for decryption. Use - for reading from standard input.") do |pwd|
59
71
  @password = (pwd == '-' ? read_password : pwd)
@@ -62,23 +74,71 @@ module HexaPDF
62
74
  define_encryption_options
63
75
 
64
76
  @password = nil
77
+ @strategy = :page_number
65
78
  end
66
79
 
67
80
  def execute(pdf, output_spec = pdf.sub(/\.pdf$/i, '_%04d.pdf')) #:nodoc:
68
- output_spec = output_spec.sub('%', '%<page>')
69
81
  with_document(pdf, password: @password) do |doc|
70
- doc.pages.each_with_index do |page, index|
71
- output_file = sprintf(output_spec, page: index + 1)
72
- maybe_raise_on_existing_file(output_file)
73
- out = HexaPDF::Document.new
74
- out.pages.add(out.import(page))
75
- apply_encryption_options(out)
76
- apply_optimization_options(out)
77
- write_document(out, output_file)
82
+ if @strategy == :page_number
83
+ split_by_page_number(doc, output_spec)
84
+ else
85
+ split_by_page_size(doc, output_spec)
78
86
  end
79
87
  end
80
88
  end
81
89
 
90
+ private
91
+
92
+ # Splits the document into individual pages.
93
+ def split_by_page_number(doc, output_spec)
94
+ doc.pages.each_with_index do |page, index|
95
+ output_file = sprintf(output_spec, index + 1)
96
+ maybe_raise_on_existing_file(output_file)
97
+ out = HexaPDF::Document.new
98
+ out.pages.add(out.import(page))
99
+ apply_encryption_options(out)
100
+ apply_optimization_options(out)
101
+ write_document(out, output_file)
102
+ end
103
+ end
104
+
105
+ # Splits the document into files based on the page sizes.
106
+ def split_by_page_size(doc, output_spec)
107
+ output_spec = output_spec.sub(/%.*?[a-zA-Z]/, '%s')
108
+ out_files = Hash.new do |hash, key|
109
+ output_file = sprintf(output_spec, key)
110
+ maybe_raise_on_existing_file(output_file)
111
+ out = HexaPDF::Document.new
112
+ out.config['output_file'] = output_file
113
+ hash[key] = out
114
+ end
115
+
116
+ doc.pages.each do |page|
117
+ out = out_files[page_size_name(page.box(:media).value)]
118
+ out.pages.add(out.import(page))
119
+ end
120
+
121
+ out_files.each_value do |out|
122
+ apply_encryption_options(out)
123
+ apply_optimization_options(out)
124
+ write_document(out, out.config['output_file'])
125
+ end
126
+ end
127
+
128
+ # Tries to retrieve a page size name based on the media box. If this is not possible, the
129
+ # returned page size name consists of width x height.
130
+ def page_size_name(media_box)
131
+ @page_name_cache ||= {}
132
+ return @page_name_cache[media_box] if @page_name_cache.key?(media_box)
133
+
134
+ paper_size = HexaPDF::Type::Page::PAPER_SIZE.find do |_name, box|
135
+ box.each_with_index.all? {|entry, index| (entry - media_box[index]).abs < 5 }
136
+ end
137
+
138
+ @page_name_cache[media_box] =
139
+ paper_size ? paper_size[0] : "%.0fx%.0f" % media_box.values_at(2, 3)
140
+ end
141
+
82
142
  end
83
143
 
84
144
  end
@@ -469,15 +469,21 @@ module HexaPDF
469
469
  @listeners[name]&.each {|obj| obj.call(*args) }
470
470
  end
471
471
 
472
- # Caches the value or the return value of the given block using the given Object::PDFData and
473
- # key arguments as composite hash key. If a cached value already exists, it is just returned.
472
+ UNSET = ::Object.new # :nordoc:
473
+
474
+ # Caches and returns the given +value+ or the value of the given block using the given
475
+ # +pdf_data+ and +key+ arguments as composite cache key. If a cached value already exists and
476
+ # +update+ is +false+, the cached value is just returned.
477
+ #
478
+ # Set +update+ to +true+ to force an update of the cached value.
474
479
  #
475
480
  # This facility can be used to cache expensive operations in PDF objects that are easy to
476
481
  # compute again.
477
482
  #
478
483
  # Use #clear_cache to clear the cache if necessary.
479
- def cache(pdf_data, key, value = nil)
480
- @cache[pdf_data][key] ||= value || yield
484
+ def cache(pdf_data, key, value = UNSET, update: false)
485
+ return @cache[pdf_data][key] if cached?(pdf_data, key) && !update
486
+ @cache[pdf_data][key] = (value == UNSET ? yield : value)
481
487
  end
482
488
 
483
489
  # Returns +true+ if there is a value cached for the composite key consisting of the given
@@ -388,7 +388,7 @@ module HexaPDF
388
388
  end
389
389
  when :penalty
390
390
  if item.penalty <= -Penalty::INFINITY
391
- add_box_item(item.item) if item.item
391
+ add_box_item(item.item) if item.width > 0
392
392
  break unless yield(create_unjustified_line, item)
393
393
  reset_after_line_break(index + 1)
394
394
  elsif item.penalty >= Penalty::INFINITY
@@ -458,7 +458,7 @@ module HexaPDF
458
458
  end
459
459
  when :penalty
460
460
  if item.penalty <= -Penalty::INFINITY
461
- add_box_item(item.item) if item.item
461
+ add_box_item(item.item) if item.width > 0
462
462
  break unless (action = yield(create_unjustified_line, item))
463
463
  reset_after_line_break_variable_width(index + 1, true, action)
464
464
  elsif item.penalty >= Penalty::INFINITY
@@ -284,6 +284,28 @@ module HexaPDF
284
284
  obj
285
285
  end
286
286
 
287
+ # Caches and returns the given +value+ or the value of the block under the given cache key. If
288
+ # there is already a cached value for the key and +update+ is +false+, it is just returned.
289
+ #
290
+ # Set +update+ to +true+ to force an update of the cached value.
291
+ #
292
+ # This uses Document#cache internally.
293
+ def cache(key, value = Document::UNSET, update: false, &block)
294
+ document.cache(@data, key, value, update: update, &block)
295
+ end
296
+
297
+ # Returns +true+ if there is a cached value for the given key.
298
+ #
299
+ # This uses Document#cached? internally.
300
+ def cached?(key)
301
+ document.cached?(@data, key)
302
+ end
303
+
304
+ # Clears the cache for this object.
305
+ def clear_cache
306
+ document.clear_cache(@data)
307
+ end
308
+
287
309
  # Compares this object to another object.
288
310
  #
289
311
  # If the other object does not respond to +oid+ or +gen+, +nil+ is returned. Otherwise objects
@@ -267,6 +267,27 @@ module HexaPDF
267
267
  raise_malformed("Trailer is #{trailer.class} instead of dictionary ", pos: @tokenizer.pos)
268
268
  end
269
269
 
270
+ unless trailer[:Prev] || xref.max_oid == 0 || xref.entry?(0)
271
+ first_entry = xref[xref.oids[0]]
272
+ test_entry = xref[xref.oids[-1]]
273
+ @tokenizer.pos = test_entry.pos + @header_offset
274
+ test_oid = @tokenizer.next_token
275
+ first_oid = first_entry.oid
276
+
277
+ force_failure = !first_entry.free? || first_entry.gen != 65535 ||
278
+ !test_oid.kind_of?(Integer) || xref.oids[-1] - test_oid != first_oid
279
+ maybe_raise("Main cross-reference section has invalid numbering",
280
+ pos: offset + @header_offset, force: force_failure)
281
+
282
+ new_xref = XRefSection.new
283
+ xref.oids.each do |oid|
284
+ entry = xref[oid]
285
+ entry.oid -= first_oid
286
+ new_xref.send(:[]=, entry.oid, entry.gen, entry)
287
+ end
288
+ xref = new_xref
289
+ end
290
+
270
291
  [xref, trailer]
271
292
  end
272
293
 
@@ -359,8 +380,9 @@ module HexaPDF
359
380
  xref = XRefSection.new
360
381
  @tokenizer.pos = 0
361
382
  while true
383
+ @tokenizer.skip_whitespace
362
384
  pos = @tokenizer.pos
363
- @tokenizer.scan_until(/(\n|\r\n?)+|\z/)
385
+ @tokenizer.scan_until(/(\n|\r\n?)+/)
364
386
  next_new_line_pos = @tokenizer.pos
365
387
  @tokenizer.pos = pos
366
388
 
@@ -181,9 +181,9 @@ module HexaPDF
181
181
  self
182
182
  end
183
183
 
184
- # Returns a duplicate of the underlying array.
184
+ # Returns an array containing the preprocessed values (like in #[]).
185
185
  def to_ary
186
- value.dup
186
+ each.to_a
187
187
  end
188
188
 
189
189
  private
@@ -37,6 +37,7 @@
37
37
  require 'hexapdf/error'
38
38
  require 'hexapdf/layout/style'
39
39
  require 'hexapdf/layout/text_fragment'
40
+ require 'hexapdf/layout/text_layouter'
40
41
 
41
42
  module HexaPDF
42
43
  module Type
@@ -80,14 +81,8 @@ module HexaPDF
80
81
  else
81
82
  raise HexaPDF::Error, "Unsupported button field type"
82
83
  end
83
- when :Tx
84
+ when :Tx, :Ch
84
85
  create_text_appearances
85
- when :Ch
86
- if @field.combo_box?
87
- create_text_appearances
88
- else
89
- raise HexaPDF::Error, "List box not supported yet"
90
- end
91
86
  else
92
87
  raise HexaPDF::Error, "Unsupported field type #{@field.field_type}"
93
88
  end
@@ -249,38 +244,35 @@ module HexaPDF
249
244
  rect.height = style.scaled_y_max - style.scaled_y_min + 2 * padding
250
245
  end
251
246
 
252
- form = (@widget[:AP] ||= {})[:N] = @document.add({Type: :XObject, Subtype: :Form,
253
- BBox: [0, 0, rect.width, rect.height]})
247
+ form = (@widget[:AP] ||= {})[:N] ||= @document.add({Type: :XObject, Subtype: :Form})
248
+ form.value.replace({Type: :XObject, Subtype: :Form, BBox: [0, 0, rect.width, rect.height]})
249
+ form.contents = ''
254
250
  form[:Resources] = HexaPDF::Object.deep_copy(default_resources)
255
251
 
256
252
  canvas = form.canvas
257
253
  apply_background_and_border(border_style, canvas)
258
254
  style.font_size = calculate_font_size(font, font_size, rect, border_style)
255
+ style.clear_cache
259
256
 
260
257
  canvas.marked_content_sequence(:Tx) do
261
- if (value = @field.field_value)
258
+ if @field.field_value || @field.concrete_field_type == :list_box
262
259
  canvas.save_graphics_state do
263
260
  canvas.rectangle(padding, padding, rect.width - 2 * padding,
264
261
  rect.height - 2 * padding).clip_path.end_path
265
- fragment = HexaPDF::Layout::TextFragment.create(value, style)
266
- # Adobe seems to be left/right-aligning based on twice the border width and
267
- # vertically centering based on the cap height, if enough space is available
268
- x = case @field.text_alignment
269
- when :left then 2 * padding
270
- when :right then [rect.width - 2 * padding - fragment.width, 2 * padding].max
271
- when :center then [(rect.width - fragment.width) / 2.0, 2 * padding].max
272
- end
273
- cap_height = font.wrapped_font.cap_height * font.scaling_factor / 1000.0 *
274
- style.font_size
275
- y = padding + (rect.height - 2 * padding - cap_height) / 2.0
276
- y = padding - style.scaled_font_descender if y < 0
277
- fragment.draw(canvas, x, y)
262
+ if @field.concrete_field_type == :multiline_text_field
263
+ draw_multiline_text(canvas, rect, style, padding)
264
+ elsif @field.concrete_field_type == :list_box
265
+ draw_list_box(canvas, rect, style, padding)
266
+ else
267
+ draw_single_line_text(canvas, rect, style, padding)
268
+ end
278
269
  end
279
270
  end
280
271
  end
281
272
  end
282
273
 
283
274
  alias create_combo_box_appearances create_text_appearances
275
+ alias create_list_box_appearances create_text_appearances
284
276
 
285
277
  private
286
278
 
@@ -341,6 +333,13 @@ module HexaPDF
341
333
  canvas.circle(rect.width / 2.0, rect.height / 2.0, [width / 2.0, height / 2.0].min)
342
334
  else
343
335
  canvas.rectangle(offset, offset, width, height)
336
+ if @field.concrete_field_type == :comb_text_field
337
+ cell_width = rect.width.to_f / @field[:MaxLen]
338
+ 1.upto(@field[:MaxLen] - 1) do |i|
339
+ canvas.line(i * cell_width, border_style.width,
340
+ i * cell_width, border_style.width + height)
341
+ end
342
+ end
344
343
  end
345
344
  end
346
345
  canvas.stroke
@@ -385,14 +384,115 @@ module HexaPDF
385
384
  end
386
385
  end
387
386
 
387
+ # Draws a single line of text inside the widget's rectangle.
388
+ def draw_single_line_text(canvas, rect, style, padding)
389
+ value = @field.field_value
390
+ fragment = HexaPDF::Layout::TextFragment.create(value, style)
391
+
392
+ if @field.concrete_field_type == :comb_text_field
393
+ unless @field.key?(:MaxLen)
394
+ raise HexaPDF::Error, "Missing or invalid dictionary field /MaxLen for comb text field"
395
+ end
396
+ new_items = []
397
+ cell_width = rect.width.to_f / @field[:MaxLen]
398
+ scaled_cell_width = cell_width / style.scaled_font_size.to_f
399
+ fragment.items.each_cons(2) do |a, b|
400
+ new_items << a << -(scaled_cell_width - a.width / 2.0 - b.width / 2.0)
401
+ end
402
+ new_items << fragment.items.last
403
+ fragment.items.replace(new_items)
404
+ fragment.clear_cache
405
+ # Adobe always seems to add 1 to the first offset...
406
+ x_offset = 1 + (cell_width - style.scaled_item_width(fragment.items[0])) / 2.0
407
+ x = case @field.text_alignment
408
+ when :left then x_offset
409
+ when :right then x_offset + cell_width * (@field[:MaxLen] - value.length)
410
+ when :center then x_offset + cell_width * ((@field[:MaxLen] - value.length) / 2)
411
+ end
412
+ else
413
+ # Adobe seems to be left/right-aligning based on twice the border width
414
+ x = case @field.text_alignment
415
+ when :left then 2 * padding
416
+ when :right then [rect.width - 2 * padding - fragment.width, 2 * padding].max
417
+ when :center then [(rect.width - fragment.width) / 2.0, 2 * padding].max
418
+ end
419
+ end
420
+
421
+ # Adobe seems to be vertically centering based on the cap height, if enough space is
422
+ # available
423
+ cap_height = style.font.wrapped_font.cap_height * style.font.scaling_factor / 1000.0 *
424
+ style.font_size
425
+ y = padding + (rect.height - 2 * padding - cap_height) / 2.0
426
+ y = padding - style.scaled_font_descender if y < 0
427
+ fragment.draw(canvas, x, y)
428
+ end
429
+
430
+ # Draws multiple lines of text inside the widget's rectangle.
431
+ def draw_multiline_text(canvas, rect, style, padding)
432
+ items = [Layout::TextFragment.create(@field.field_value, style)]
433
+ layouter = Layout::TextLayouter.new(style)
434
+ layouter.style.align(@field.text_alignment).line_spacing(:proportional, 1.25)
435
+
436
+ result = nil
437
+ if style.font_size == 0 # need to auto-size text
438
+ style.font_size = 12 # Adobe seems to use this as starting point
439
+ style.clear_cache
440
+ loop do
441
+ result = layouter.fit(items, rect.width - 4 * padding, rect.height - 4 * padding)
442
+ break if result.status == :success || style.font_size <= 4 # don't make text too small
443
+ style.font_size -= 1
444
+ style.clear_cache
445
+ end
446
+ else
447
+ result = layouter.fit(items, rect.width - 4 * padding, 2**20)
448
+ end
449
+
450
+ unless result.lines.empty?
451
+ result.draw(canvas, 2 * padding, rect.height - 2 * padding - result.lines[0].height / 2.0)
452
+ end
453
+ end
454
+
455
+ # Draws the visible option items of the list box in the widget's rectangle.
456
+ def draw_list_box(canvas, rect, style, padding)
457
+ option_items = @field.option_items
458
+ top_index = @field.list_box_top_index
459
+ items = [Layout::TextFragment.create(option_items[top_index..-1].join("\n"), style)]
460
+
461
+ indices = @field[:I] || []
462
+ value_indices = [@field.field_value].flatten.compact.map {|val| option_items.index(val) }
463
+ indices = value_indices if indices != value_indices
464
+
465
+ layouter = Layout::TextLayouter.new(style)
466
+ layouter.style.align(@field.text_alignment).line_spacing(:proportional, 1.25)
467
+ result = layouter.fit(items, rect.width - 4 * padding, rect.height)
468
+
469
+ unless result.lines.empty?
470
+ top_gap = style.line_spacing.gap(result.lines[0], result.lines[0])
471
+ line_height = style.line_spacing.baseline_distance(result.lines[0], result.lines[0])
472
+ canvas.fill_color(153, 193, 218) # Adobe's color for selection highlighting
473
+ indices.map! {|i| rect.height - padding - (i - top_index + 1) * line_height }.each do |y|
474
+ next if y + line_height > rect.height || y + line_height < padding
475
+ canvas.rectangle(padding, y, rect.width - 2 * padding, line_height)
476
+ end
477
+ canvas.fill if canvas.graphics_object == :path
478
+ result.draw(canvas, 2 * padding, rect.height - padding - top_gap)
479
+ end
480
+ end
481
+
388
482
  # Calculates the font size for text fields based on the font and font size of the default
389
483
  # appearance string, the annotation rectangle and the border style.
390
484
  def calculate_font_size(font, font_size, rect, border_style)
391
485
  if font_size == 0
392
- unit_font_size = (font.wrapped_font.bounding_box[3] - font.wrapped_font.bounding_box[1]) *
393
- font.scaling_factor / 1000.0
394
- # The constant factor was found empirically by checking what Adobe Reader etc. do
395
- (rect.height - 2 * border_style.width) / unit_font_size * 0.83
486
+ if @field.concrete_field_type == :multiline_text_field
487
+ 0 # Handled by multiline drawing code
488
+ elsif @field.concrete_field_type == :list_box
489
+ 12 # Seems to be Adobe's default
490
+ else
491
+ unit_font_size = (font.wrapped_font.bounding_box[3] - font.wrapped_font.bounding_box[1]) *
492
+ font.scaling_factor / 1000.0
493
+ # The constant factor was found empirically by checking what Adobe Reader etc. do
494
+ (rect.height - 2 * border_style.width) / unit_font_size * 0.83
495
+ end
396
496
  else
397
497
  font_size
398
498
  end