fillform 0.1.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 (48) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +6 -0
  3. data/LICENSE +26 -0
  4. data/README.md +126 -0
  5. data/data/afm/Courier-Bold.afm +342 -0
  6. data/data/afm/Courier-BoldOblique.afm +342 -0
  7. data/data/afm/Courier-Oblique.afm +342 -0
  8. data/data/afm/Courier.afm +342 -0
  9. data/data/afm/Helvetica-Bold.afm +2827 -0
  10. data/data/afm/Helvetica-BoldOblique.afm +2827 -0
  11. data/data/afm/Helvetica-Oblique.afm +3051 -0
  12. data/data/afm/Helvetica.afm +3051 -0
  13. data/data/afm/MustRead.html +19 -0
  14. data/data/afm/Symbol.afm +213 -0
  15. data/data/afm/Times-Bold.afm +2588 -0
  16. data/data/afm/Times-BoldItalic.afm +2384 -0
  17. data/data/afm/Times-Italic.afm +2667 -0
  18. data/data/afm/Times-Roman.afm +2419 -0
  19. data/data/afm/ZapfDingbats.afm +225 -0
  20. data/data/agl/LICENSE.md +26 -0
  21. data/data/agl/glyphlist.txt +4325 -0
  22. data/data/agl/zapfdingbats.txt +245 -0
  23. data/lib/fillform/appearance.rb +315 -0
  24. data/lib/fillform/border.rb +135 -0
  25. data/lib/fillform/data_format.rb +45 -0
  26. data/lib/fillform/field.rb +15 -0
  27. data/lib/fillform/filler.rb +82 -0
  28. data/lib/fillform/flattener.rb +114 -0
  29. data/lib/fillform/float32.rb +12 -0
  30. data/lib/fillform/font.rb +165 -0
  31. data/lib/fillform/form.rb +165 -0
  32. data/lib/fillform/glyphs.rb +99 -0
  33. data/lib/fillform/line_breaker.rb +98 -0
  34. data/lib/fillform/merged_widget.rb +20 -0
  35. data/lib/fillform/pdf/document.rb +240 -0
  36. data/lib/fillform/pdf/filters.rb +108 -0
  37. data/lib/fillform/pdf/objects.rb +48 -0
  38. data/lib/fillform/pdf/parser.rb +205 -0
  39. data/lib/fillform/pdf/serializer.rb +47 -0
  40. data/lib/fillform/pdf/text.rb +47 -0
  41. data/lib/fillform/pdf/writer.rb +51 -0
  42. data/lib/fillform/pdf.rb +9 -0
  43. data/lib/fillform/pdf_forms.rb +15 -0
  44. data/lib/fillform/pdftk_wrapper.rb +81 -0
  45. data/lib/fillform/report.rb +180 -0
  46. data/lib/fillform/version.rb +5 -0
  47. data/lib/fillform.rb +65 -0
  48. metadata +92 -0
@@ -0,0 +1,245 @@
1
+ # -----------------------------------------------------------
2
+ # Copyright 2002-2019 Adobe (http://www.adobe.com/).
3
+ #
4
+ # Redistribution and use in source and binary forms, with or
5
+ # without modification, are permitted provided that the
6
+ # following conditions are met:
7
+ #
8
+ # Redistributions of source code must retain the above
9
+ # copyright notice, this list of conditions and the following
10
+ # disclaimer.
11
+ #
12
+ # Redistributions in binary form must reproduce the above
13
+ # copyright notice, this list of conditions and the following
14
+ # disclaimer in the documentation and/or other materials
15
+ # provided with the distribution.
16
+ #
17
+ # Neither the name of Adobe nor the names of its contributors
18
+ # may be used to endorse or promote products derived from this
19
+ # software without specific prior written permission.
20
+ #
21
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
22
+ # CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
23
+ # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
24
+ # MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
25
+ # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
26
+ # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
27
+ # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
28
+ # NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
29
+ # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
30
+ # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
31
+ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
32
+ # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
33
+ # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
34
+ # -----------------------------------------------------------
35
+ # Name: ITC Zapf Dingbats Glyph List
36
+ # Table version: 2.0
37
+ # Date: September 20, 2002
38
+ # URL: https://github.com/adobe-type-tools/agl-aglfn
39
+ #
40
+ # Format: two semicolon-delimited fields:
41
+ # (1) glyph name--upper/lowercase letters and digits
42
+ # (2) Unicode scalar value--four uppercase hexadecimal digits
43
+ #
44
+ a100;275E
45
+ a101;2761
46
+ a102;2762
47
+ a103;2763
48
+ a104;2764
49
+ a105;2710
50
+ a106;2765
51
+ a107;2766
52
+ a108;2767
53
+ a109;2660
54
+ a10;2721
55
+ a110;2665
56
+ a111;2666
57
+ a112;2663
58
+ a117;2709
59
+ a118;2708
60
+ a119;2707
61
+ a11;261B
62
+ a120;2460
63
+ a121;2461
64
+ a122;2462
65
+ a123;2463
66
+ a124;2464
67
+ a125;2465
68
+ a126;2466
69
+ a127;2467
70
+ a128;2468
71
+ a129;2469
72
+ a12;261E
73
+ a130;2776
74
+ a131;2777
75
+ a132;2778
76
+ a133;2779
77
+ a134;277A
78
+ a135;277B
79
+ a136;277C
80
+ a137;277D
81
+ a138;277E
82
+ a139;277F
83
+ a13;270C
84
+ a140;2780
85
+ a141;2781
86
+ a142;2782
87
+ a143;2783
88
+ a144;2784
89
+ a145;2785
90
+ a146;2786
91
+ a147;2787
92
+ a148;2788
93
+ a149;2789
94
+ a14;270D
95
+ a150;278A
96
+ a151;278B
97
+ a152;278C
98
+ a153;278D
99
+ a154;278E
100
+ a155;278F
101
+ a156;2790
102
+ a157;2791
103
+ a158;2792
104
+ a159;2793
105
+ a15;270E
106
+ a160;2794
107
+ a161;2192
108
+ a162;27A3
109
+ a163;2194
110
+ a164;2195
111
+ a165;2799
112
+ a166;279B
113
+ a167;279C
114
+ a168;279D
115
+ a169;279E
116
+ a16;270F
117
+ a170;279F
118
+ a171;27A0
119
+ a172;27A1
120
+ a173;27A2
121
+ a174;27A4
122
+ a175;27A5
123
+ a176;27A6
124
+ a177;27A7
125
+ a178;27A8
126
+ a179;27A9
127
+ a17;2711
128
+ a180;27AB
129
+ a181;27AD
130
+ a182;27AF
131
+ a183;27B2
132
+ a184;27B3
133
+ a185;27B5
134
+ a186;27B8
135
+ a187;27BA
136
+ a188;27BB
137
+ a189;27BC
138
+ a18;2712
139
+ a190;27BD
140
+ a191;27BE
141
+ a192;279A
142
+ a193;27AA
143
+ a194;27B6
144
+ a195;27B9
145
+ a196;2798
146
+ a197;27B4
147
+ a198;27B7
148
+ a199;27AC
149
+ a19;2713
150
+ a1;2701
151
+ a200;27AE
152
+ a201;27B1
153
+ a202;2703
154
+ a203;2750
155
+ a204;2752
156
+ a205;276E
157
+ a206;2770
158
+ a20;2714
159
+ a21;2715
160
+ a22;2716
161
+ a23;2717
162
+ a24;2718
163
+ a25;2719
164
+ a26;271A
165
+ a27;271B
166
+ a28;271C
167
+ a29;2722
168
+ a2;2702
169
+ a30;2723
170
+ a31;2724
171
+ a32;2725
172
+ a33;2726
173
+ a34;2727
174
+ a35;2605
175
+ a36;2729
176
+ a37;272A
177
+ a38;272B
178
+ a39;272C
179
+ a3;2704
180
+ a40;272D
181
+ a41;272E
182
+ a42;272F
183
+ a43;2730
184
+ a44;2731
185
+ a45;2732
186
+ a46;2733
187
+ a47;2734
188
+ a48;2735
189
+ a49;2736
190
+ a4;260E
191
+ a50;2737
192
+ a51;2738
193
+ a52;2739
194
+ a53;273A
195
+ a54;273B
196
+ a55;273C
197
+ a56;273D
198
+ a57;273E
199
+ a58;273F
200
+ a59;2740
201
+ a5;2706
202
+ a60;2741
203
+ a61;2742
204
+ a62;2743
205
+ a63;2744
206
+ a64;2745
207
+ a65;2746
208
+ a66;2747
209
+ a67;2748
210
+ a68;2749
211
+ a69;274A
212
+ a6;271D
213
+ a70;274B
214
+ a71;25CF
215
+ a72;274D
216
+ a73;25A0
217
+ a74;274F
218
+ a75;2751
219
+ a76;25B2
220
+ a77;25BC
221
+ a78;25C6
222
+ a79;2756
223
+ a7;271E
224
+ a81;25D7
225
+ a82;2758
226
+ a83;2759
227
+ a84;275A
228
+ a85;276F
229
+ a86;2771
230
+ a87;2772
231
+ a88;2773
232
+ a89;2768
233
+ a8;271F
234
+ a90;2769
235
+ a91;276C
236
+ a92;276D
237
+ a93;276A
238
+ a94;276B
239
+ a95;2774
240
+ a96;2775
241
+ a97;275B
242
+ a98;275C
243
+ a99;275D
244
+ a9;2720
245
+ # END
@@ -0,0 +1,315 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fillform
4
+ # The appearance stream iText's TextField#getAppearance builds: background and border of the widget,
5
+ # then the text with iText's auto font size, baseline, comb cells, alignment and line spacing.
6
+ class Appearance
7
+ COMB = 1 << 24
8
+ MULTILINE = 1 << 12
9
+ MULTILINE_MAX_AUTO_SIZE = 12
10
+ MIN_AUTO_SIZE = 4
11
+
12
+ DEFAULT_APPEARANCE = /(\S*)\s+([\d.]+)\s+Tf/
13
+ ROTATIONS = { 90 => [0, 1, -1, 0, :height, 0], 180 => [-1, 0, 0, -1, :width, :height],
14
+ 270 => [0, -1, 1, 0, 0, :width] }.freeze
15
+
16
+ # The fonts are looked up with a block: font(resources, name).
17
+ def initialize(doc, field, form:, &fonts)
18
+ @doc = doc
19
+ @field = field
20
+ @form = form
21
+ @fonts = fonts
22
+ end
23
+
24
+ def fill(value, shown = value)
25
+ @field[:V] = PDF::Text.encode(value)
26
+ @field.widgets.each { |widget| widget[:AP] = { N: stream_for(widget, shown) } }
27
+ end
28
+
29
+ # A combo box shows the text of the option whose export value is the value.
30
+ def fill_choice(value)
31
+ option = Array(@doc.deref(@field[:Opt])).map { |item| @doc.deref(item) }.find do |item|
32
+ item.is_a?(Array) && PDF::Text.decode(@doc.deref(item.first).to_s) == value
33
+ end
34
+ fill(value, option ? PDF::Text.decode(@doc.deref(option.last).to_s) : value)
35
+ end
36
+
37
+ # A list box shows the options from the first selected one on, the selected ones highlighted.
38
+ def fill_list(values)
39
+ options = choice_options
40
+ selections = values.filter_map { |value| options.index { |export, _display| export == value } }
41
+ @field[:V] = values.map { |value| PDF::Text.encode(value) }
42
+ @field[:I] = selections
43
+ @field.widgets.each do |widget|
44
+ widget[:AP] = { N: list_stream_for(widget, options.map(&:last), selections) }
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ LIST_HIGHLIGHT = Color.new([10, 36, 106].map { |component| component / 255.0 })
51
+
52
+ # [export, display] of each option.
53
+ def choice_options
54
+ Array(@doc.deref(@field[:Opt])).map do |option|
55
+ option = @doc.deref(option)
56
+ texts = Array(option).map { |text| PDF::Text.decode(@doc.deref(text).to_s) }
57
+ [texts.first, texts.last]
58
+ end
59
+ end
60
+
61
+ def list_stream_for(widget, choices, selections)
62
+ configure(widget)
63
+ @border = Border.new(@widget, @width, @height)
64
+ content = @border.appearance.b
65
+ content << "/Tx BMC ".b << list_text(choices, selections) << "EMC ".b unless choices.empty?
66
+ form_xobject(content)
67
+ end
68
+
69
+ def merged(key) = @widget[key]
70
+
71
+ # iText reads the attributes of each widget merged with the ones of its field and the form.
72
+ def configure(widget)
73
+ @widget = MergedWidget.new(widget, @field)
74
+ default_appearance = (merged(:DA) || @form[:DA]).to_s.b
75
+ name, size = default_appearance.match(DEFAULT_APPEARANCE)&.captures
76
+ resources = merged(:DR) || @form[:DR]
77
+ @font = @fonts.call(resources, name.to_s.split("/").last)
78
+ @font_size = size.to_f
79
+ @color = default_appearance.sub(DEFAULT_APPEARANCE, "").strip
80
+ @width, @height = box(widget.rect)
81
+ @width, @height = @height, @width if [90, 270].include?(rotation)
82
+ end
83
+
84
+ # The rotation of the widget's appearance characteristics, in multiples of 90 degrees.
85
+ def rotation
86
+ characteristics = @widget[:MK]
87
+ degrees = characteristics.is_a?(Hash) ? @doc.deref(characteristics[:R]).to_i : 0
88
+ (degrees % 90).zero? ? degrees % 360 : 0
89
+ end
90
+
91
+ def form_xobject(content)
92
+ matrix = ROTATIONS.fetch(rotation, [1, 0, 0, 1, 0, 0]).map do |value|
93
+ { width: @width, height: @height }.fetch(value, value)
94
+ end
95
+ @doc.add(PDF::Stream.compressed({ Type: :XObject, Subtype: :Form,
96
+ BBox: [0, 0, @width, @height].map { |number| n(number).to_f },
97
+ Matrix: matrix.map { |number| number.is_a?(Float) ? n(number).to_f : number },
98
+ Resources: { Font: { @font.key => @font.resource } } }, content))
99
+ end
100
+
101
+ # iText's TextField#getListAppearance
102
+ def list_text(choices, selections)
103
+ inset = @border.inner_width
104
+ size = @font_size.zero? ? MULTILINE_MAX_AUTO_SIZE : @font_size
105
+ leading = f(@font.top(size) - @font.bottom(size))
106
+ rows = visible_choices(choices.size, selections, f(inner_height / leading).to_i + 1)
107
+ clip = [inset, inset, f(@width - f(2 * inset)), f(@height - f(2 * inset))].map { |number| n(number) }.join(" ")
108
+ top = f(inset + inner_height)
109
+ "q #{clip} re W n #{LIST_HIGHLIGHT.fill} #{highlights(selections, rows, top, leading)}".b <<
110
+ list_rows(choices, selections, rows, size, top) << "Q ".b
111
+ end
112
+
113
+ def visible_choices(count, selections, fitting)
114
+ first = selections.first ? selections.first.clamp(0, count) : 0
115
+ return (first...(first + fitting)) if first + fitting <= count
116
+
117
+ ([0, count - fitting].max...count)
118
+ end
119
+
120
+ # iText also highlights a selection just below the last row it shows.
121
+ def highlights(selections, rows, top, leading)
122
+ inset = @border.inner_width
123
+ selections.grep(rows.begin..rows.end).map do |index|
124
+ "#{n(inset)} #{n(f(top - f(f(index - rows.begin + 1) * leading)))} " \
125
+ "#{n(f(@width - f(2 * inset)))} #{n(leading)} re f "
126
+ end.join
127
+ end
128
+
129
+ def list_rows(choices, selections, rows, size, top)
130
+ x = f(@border.inner_width * 2)
131
+ leading = f(@font.top(size) - @font.bottom(size))
132
+ baseline = f(top - @font.top(size))
133
+ rows.map do |index|
134
+ color = selections.include?(index) ? "1 g" : @color
135
+ chars = LineBreaker.rstrip(choices[index].gsub(/\r\n?|\n/, " ").chars)
136
+ y = shown_y(baseline)
137
+ baseline = f(baseline - leading)
138
+ "#{color} ".b << text_object(size, [[x, y, chars]])
139
+ end.join.b
140
+ end
141
+
142
+ def stream_for(widget, value)
143
+ configure(widget)
144
+ @border = Border.new(@widget, @width, @height, comb_cells: comb? ? max_length : 0)
145
+ form_xobject("#{@border.appearance}/Tx BMC ".b << variable_text(value) << "EMC ".b)
146
+ end
147
+
148
+ # iText computes the box in single precision.
149
+ def box(rect)
150
+ llx, lly, urx, ury = rect.pack("e4").unpack("e4")
151
+ [urx - llx, ury - lly].pack("e2").unpack("e2")
152
+ end
153
+
154
+ def variable_text(value)
155
+ return "".b if value.empty?
156
+
157
+ clip = [offset, offset, f(@width - f(2 * offset)), f(@height - f(2 * offset))].map { |number| n(number) }
158
+ "q #{clip.join(" ")} re W n #{@color} ".b << text(value) << "Q ".b
159
+ end
160
+
161
+ # iText's offX: the clipping inset
162
+ def offset = [@border.inner_width, padding].min
163
+
164
+ # iText's offsetX: the text inset
165
+ def padding = [@border.inner_width, 1].max
166
+
167
+ # iText's h: the height inside the border
168
+ def inner_height
169
+ height = f(@height - f(@border.width * 2))
170
+ @border.inner_width == @border.width ? height : f(height - f(@border.width * 2))
171
+ end
172
+
173
+ def text(value)
174
+ return multiline_text(value) if multiline?
175
+
176
+ chars = value.gsub(/\r\n?|\n/, " ").chars
177
+ size = @font_size.zero? ? auto_size(chars) : f(@font_size)
178
+ baseline = single_line_baseline(size)
179
+ comb? ? comb_text(chars, size, baseline) : single_line_text(chars, size, baseline)
180
+ end
181
+
182
+ # iText's ColumnText#showTextAligned: a column of 20000 points from the anchor of the alignment.
183
+ def single_line_text(chars, size, baseline)
184
+ anchor = case alignment
185
+ when :center then f(@width / 2)
186
+ when :right then f(@width - f(2 * padding))
187
+ else f(2 * padding)
188
+ end
189
+ left, right = case alignment
190
+ when :center then [f(anchor - 20_000), f(anchor + 20_000)]
191
+ when :right then [f(anchor - 20_000), anchor]
192
+ else [anchor, f(anchor + 20_000)]
193
+ end
194
+ line = LineBreaker.new(chars, f(right - left)) { |char| @font.char_width(char, size) }.lines.first
195
+ text_object(size, [[aligned_x(left, line.width_left), shown_y(baseline), line.chars]])
196
+ end
197
+
198
+ # PdfLine#indentLeft: the width left on the line moves it right, or half of it to the center.
199
+ def aligned_x(left, width_left)
200
+ case alignment
201
+ when :center then f(left + f(width_left / 2))
202
+ when :right then f(left + width_left)
203
+ else left
204
+ end
205
+ end
206
+
207
+ # ColumnText places the first line one leading (2 points) below the top of its column.
208
+ def shown_y(baseline) = f(f(baseline + 2) - 2)
209
+
210
+ def comb_text(chars, size, baseline)
211
+ step = f(@width / max_length)
212
+ start = f(f(step / 2) + f(comb_position(chars.size) * step))
213
+ glyphs = chars.map do |char|
214
+ x = f(start - f(@font.char_width(char, size) / 2))
215
+ start = f(start + step)
216
+ [x, baseline, [char]]
217
+ end
218
+ text_object(size, glyphs)
219
+ end
220
+
221
+ def comb_position(shown)
222
+ free = max_length - [max_length, shown].min
223
+ case alignment
224
+ when :center then free / 2
225
+ when :right then free
226
+ else 0
227
+ end
228
+ end
229
+
230
+ # iText's ColumnText in a column from 2 * offsetX to the width less 2 * offsetX, whose top is one
231
+ # leading above the first baseline.
232
+ def multiline_text(value)
233
+ paragraphs = value.split(/\r\n|\r|\n/, -1).map(&:chars)
234
+ size = @font_size.zero? ? multiline_auto_size(paragraphs) : f(@font_size)
235
+ leading = f(size * @font.line_factor)
236
+ left = f(2 * padding)
237
+ width = f(f(@width - f(2 * padding)) - left)
238
+ y = f(f(f(padding + inner_height) - @font.top(size)) + leading)
239
+ glyphs = lines(paragraphs, size, width).map do |line|
240
+ y = f(y - leading)
241
+ [aligned_x(left, line.width_left), y, line.chars]
242
+ end
243
+ text_object(size, glyphs)
244
+ end
245
+
246
+ def lines(paragraphs, size, width)
247
+ paragraphs.flat_map do |chars|
248
+ LineBreaker.new(chars, width) { |char| @font.char_width(char, size) }.lines
249
+ end
250
+ end
251
+
252
+ def text_object(size, glyphs)
253
+ shown = glyphs.map { |x, y, chars| "1 0 0 1 #{n(x)} #{n(y)} Tm ".b << literal(chars) << " Tj".b }
254
+ "BT #{PDF::Serializer.name(@font.key)} #{n(size)} Tf ".b << shown.join(" ").b << " ET ".b
255
+ end
256
+
257
+ # ColumnText#getWidth measures the text at size 1 in a 20000 point column: a text of whitespace
258
+ # only is an empty line, measures 20000 and gets the smallest size.
259
+ def auto_size(chars)
260
+ max_size = f(inner_height / @font.auto_size_factor)
261
+ line = LineBreaker.new(chars, 20_000) { |char| @font.char_width(char, 1) }.lines.first
262
+ text_width = f(20_000 - line.width_left)
263
+ return [max_size, MIN_AUTO_SIZE].max if text_width.zero?
264
+
265
+ [max_size, f(f(@width - f(4 * padding)) / text_width)].min.clamp(MIN_AUTO_SIZE..)
266
+ end
267
+
268
+ # iText shrinks the text from at most 12 points in tenths of the range, until ColumnText fits it in
269
+ # a column of the field's height.
270
+ def multiline_auto_size(paragraphs)
271
+ size = f(inner_height / @font.line_factor)
272
+ return MIN_AUTO_SIZE if size <= MIN_AUTO_SIZE
273
+
274
+ size = MULTILINE_MAX_AUTO_SIZE if size > MULTILINE_MAX_AUTO_SIZE
275
+ step = [f(f(size - MIN_AUTO_SIZE) / 10), f(0.2)].max
276
+ width = f(@width - f(4 * padding))
277
+ size = f(size - step) while size > MIN_AUTO_SIZE && !fits?(paragraphs, size, width)
278
+ [size, MIN_AUTO_SIZE].max
279
+ end
280
+
281
+ def fits?(paragraphs, size, width)
282
+ leading = f(@font.line_factor * size)
283
+ y = 0.0
284
+ lines(paragraphs, size, width).all? do
285
+ next false if f(y - leading) < -inner_height
286
+
287
+ y = f(y - leading)
288
+ end
289
+ end
290
+
291
+ def single_line_baseline(size)
292
+ ascent = @font.ascent(size)
293
+ descent = @font.descent(size)
294
+ baseline = f(offset + f(f(f(@height - f(2 * offset)) - ascent) / 2))
295
+ baseline = offset if baseline < offset
296
+ return baseline unless f(baseline - offset) < -descent
297
+
298
+ [f(-descent + offset), [baseline, f(f(@height - offset) - ascent)].max].min # rubocop:disable Style/ComparableClamp
299
+ end
300
+
301
+ def alignment = { 1 => :center, 2 => :right }.fetch(merged(:Q).to_i, :left)
302
+
303
+ def max_length = merged(:MaxLen).to_i
304
+
305
+ def comb? = merged(:Ff).to_i.anybits?(COMB) && max_length.positive?
306
+
307
+ def multiline? = merged(:Ff).to_i.anybits?(MULTILINE)
308
+
309
+ def literal(chars) = "(#{@font.encode_all(chars).pack("C*").gsub(/[()\\]/n) { |c| "\\#{c}" }})".b
310
+
311
+ def n(number) = Fillform.number(number)
312
+
313
+ def f(value) = Float32.f(value)
314
+ end
315
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fillform
4
+ # Background and border of a widget, read and drawn like iText's BaseField#getBorderAppearance.
5
+ class Border
6
+ STYLES = { D: :dashed, B: :beveled, I: :inset, U: :underline }.freeze
7
+
8
+ attr_reader :width, :style
9
+
10
+ def initialize(widget, box_width, box_height, comb_cells: 0)
11
+ @box_width = box_width
12
+ @box_height = box_height
13
+ @comb_cells = comb_cells
14
+ characteristics = widget.resolve(widget[:MK])
15
+ characteristics = {} unless characteristics.is_a?(Hash)
16
+ @color = Color.from(characteristics[:BC])
17
+ @background = Color.from(characteristics[:BG])
18
+ @width = @color ? 1 : 0
19
+ @style = :solid
20
+ read_style(widget)
21
+ end
22
+
23
+ # The width the text keeps from the box: beveled and inset borders draw a second frame.
24
+ def inner_width = %i[beveled inset].include?(style) ? width * 2 : width
25
+
26
+ def appearance
27
+ operations = []
28
+ operations << "#{@background.fill} 0 0 #{n(@box_width)} #{n(@box_height)} re f" if @background
29
+ operations.concat(border)
30
+ operations.empty? ? "" : "q #{operations.join(" ")} Q "
31
+ end
32
+
33
+ private
34
+
35
+ # iText reads the legacy /Border array only when there is no border style dictionary.
36
+ def read_style(widget)
37
+ if (style = widget.resolve(widget[:BS])).is_a?(Hash)
38
+ @width = style[:W] if style.key?(:W)
39
+ @style = STYLES.fetch(style[:S], :solid)
40
+ elsif widget.key?(:Border) && (border = widget.resolve(widget[:Border])).is_a?(Array) && border.length >= 3
41
+ @width = border[2]
42
+ @style = :dashed if border.length >= 4
43
+ end
44
+ end
45
+
46
+ def border
47
+ case style
48
+ when :underline then stroked? ? [stroke_setup, underline] : []
49
+ when :beveled then frame + ["1 g", top_frame, (@background || Color.white).darker.fill, bottom_frame]
50
+ when :inset then frame + ["0.5 g", top_frame, "0.75 g", bottom_frame]
51
+ else frame(dashed: style == :dashed) + comb_dividers
52
+ end
53
+ end
54
+
55
+ def comb_dividers
56
+ return [] unless stroked? && @comb_cells > 1
57
+
58
+ step = @box_width / @comb_cells
59
+ bottom = n(width / 2.0)
60
+ top = n(@box_height - (width / 2.0))
61
+ lines = (1...@comb_cells).map { |cell| "#{n(step * cell)} #{bottom} m #{n(step * cell)} #{top} l" }
62
+ ["#{lines.join(" ")} S"]
63
+ end
64
+
65
+ def stroked? = width.positive? && @color
66
+
67
+ def underline = "0 #{n(width / 2.0)} m #{n(@box_width)} #{n(width / 2.0)} l S"
68
+
69
+ def stroke_setup = "#{@color.stroke} #{n(width)} w"
70
+
71
+ def frame(dashed: false)
72
+ return [] unless stroked?
73
+
74
+ inset = width / 2.0
75
+ ["#{"[3] 0 d " if dashed}#{stroke_setup}",
76
+ "#{n(inset)} #{n(inset)} #{n(@box_width - width)} #{n(@box_height - width)} re S"]
77
+ end
78
+
79
+ def top_frame
80
+ path([[width, width], [width, @box_height - width], [@box_width - width, @box_height - width],
81
+ [@box_width - (2 * width), @box_height - (2 * width)], [2 * width, @box_height - (2 * width)],
82
+ [2 * width, 2 * width], [width, width]])
83
+ end
84
+
85
+ def bottom_frame
86
+ path([[width, width], [@box_width - width, width], [@box_width - width, @box_height - width],
87
+ [@box_width - (2 * width), @box_height - (2 * width)], [@box_width - (2 * width), 2 * width],
88
+ [2 * width, 2 * width], [width, width]])
89
+ end
90
+
91
+ def path(points)
92
+ (x, y), *rest = points
93
+ "#{n(x)} #{n(y)} m #{rest.map { |px, py| "#{n(px)} #{n(py)} l" }.join(" ")} f"
94
+ end
95
+
96
+ def n(number) = Fillform.number(number)
97
+ end
98
+
99
+ # A color of the widget's appearance characteristics, rounded like a java.awt.Color.
100
+ class Color
101
+ def self.from(components)
102
+ components = Array(components).map(&:to_f)
103
+ new(components) unless components.empty?
104
+ end
105
+
106
+ def self.white = new([1, 1, 1])
107
+
108
+ def initialize(components)
109
+ @components = components.size == 3 ? components.map { |c| (c * 255).round / 255.0 } : components
110
+ end
111
+
112
+ def fill = "#{values} #{operator}"
113
+
114
+ def stroke = "#{values} #{operator.upcase}"
115
+
116
+ # java.awt.Color#darker, always in RGB
117
+ def darker = Color.new(rgb.map { |c| ((c * 255).round * 0.7).floor / 255.0 })
118
+
119
+ private
120
+
121
+ def rgb
122
+ case @components.size
123
+ when 1 then @components * 3
124
+ when 4
125
+ cyan, magenta, yellow, black = @components
126
+ [cyan, magenta, yellow].map { |c| (1 - c - black).clamp(0.0, 1.0) }
127
+ else @components
128
+ end
129
+ end
130
+
131
+ def values = @components.map { |c| Fillform.number(c) }.join(" ")
132
+
133
+ def operator = { 1 => "g", 3 => "rg", 4 => "k" }.fetch(@components.size)
134
+ end
135
+ end