pdf-wrapper 0.0.7 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,337 @@
1
+ module PDF
2
+ class Wrapper
3
+
4
+ # change the default font size
5
+ def font_size(size)
6
+ @default_font_size = size.to_i unless size.nil?
7
+ end
8
+ alias font_size= font_size
9
+
10
+ # change the default font to write with
11
+ def font(fontname, style = nil, weight = nil)
12
+ @default_font = fontname
13
+ @default_font_style = style unless style.nil?
14
+ @default_font_weight = weight unless weight.nil?
15
+ end
16
+
17
+ # add text to the page, bounded by a box with dimensions HxW, with it's top left corner
18
+ # at x,y. Any text that doesn't fit it the box will be silently dropped.
19
+ #
20
+ # In addition to the standard text style options (see the documentation for text()), cell() supports
21
+ # the following options:
22
+ #
23
+ # <tt>:border</tt>:: Which sides of the cell should have a border? A string with any combination the letters tblr (top, bottom, left, right). Nil for no border, defaults to all sides.
24
+ # <tt>:border_width</tt>:: How wide should the border be?
25
+ # <tt>:border_color</tt>:: What color should the border be?
26
+ # <tt>:fill_color</tt>:: A background color for the cell. Defaults to none.
27
+ # <tt>:radius</tt>:: Give the border around the cell rounded corners. Implies :border => "tblr"
28
+ def cell(str, x, y, w, h, opts={})
29
+ # TODO: add a wrap option so wrapping can be disabled
30
+ # TODO: add an option for vertical alignment
31
+
32
+ options = default_text_options
33
+ options.merge!({:border => "tblr", :border_width => @default_line_width, :border_color => :black, :fill_color => nil, :padding => device_to_user_dist(3,0).first, :radius => nil})
34
+ options.merge!(opts)
35
+ options.assert_valid_keys(default_text_options.keys + [:width, :border, :border_width, :border_color, :fill_color, :padding, :radius])
36
+
37
+ # apply padding
38
+ textw = w - (options[:padding] * 2)
39
+ texth = h - (options[:padding] * 2)
40
+
41
+ # if the user wants a rounded rectangle, we'll draw the border with a rectangle instead
42
+ # of 4 lines
43
+ options[:border] = nil if options[:radius]
44
+
45
+ # normalise the border
46
+ options[:border] = "" unless options[:border]
47
+ options[:border].downcase!
48
+
49
+ save_coords do
50
+ translate(x, y) do
51
+ # draw a border around the cell
52
+ if options[:radius]
53
+ rectangle(0,0,w,h, :radius => options[:radius], :color => options[:border_color], :fill_color => options[:fill_color], :line_width => options[:border_width])
54
+ else
55
+ rectangle(0,0,w,h, :color => options[:fill_color], :fill_color => options[:fill_color]) if options[:fill_color]
56
+ line(0,0,w,0, :color => options[:border_color], :line_width => options[:border_width]) if options[:border].include?("t")
57
+ line(0,h,w,h, :color => options[:border_color], :line_width => options[:border_width]) if options[:border].include?("b")
58
+ line(0,0,0,h, :color => options[:border_color], :line_width => options[:border_width]) if options[:border].include?("l")
59
+ line(w,0,w,h, :color => options[:border_color], :line_width => options[:border_width]) if options[:border].include?("r")
60
+ end
61
+
62
+ layout = build_pango_layout(str.to_s, textw, options)
63
+
64
+ color(options[:color]) if options[:color]
65
+
66
+ # draw the context on our cairo layout
67
+ render_layout(layout, options[:padding], options[:padding], texth, :auto_new_page => false)
68
+ end
69
+
70
+ end
71
+ end
72
+
73
+ # Write text to the page
74
+ #
75
+ # By default the text will be rendered using all the space within the margins and using
76
+ # the default font styling set by default_font(), default_font_size, etc
77
+ #
78
+ # There is no way to place a bottom bound (or height) onto the text. Text will wrap as
79
+ # necessary and take all the room it needs. For finer grained control of text boxes, see the
80
+ # cell method.
81
+ #
82
+ # To override all these defaults, use the options hash
83
+ #
84
+ # Positioning Options:
85
+ #
86
+ # <tt>:left</tt>:: The x co-ordinate of the left-hand side of the text.
87
+ # <tt>:top</tt>:: The y co-ordinate of the top of the text.
88
+ # <tt>:width</tt>:: The width of the text to wrap at
89
+ #
90
+ # Text Style Options:
91
+ #
92
+ # <tt>:font</tt>:: The font family to use as a string
93
+ # <tt>:font_size</tt>:: The size of the font in points
94
+ # <tt>:alignment</tt>:: Align the text along the left, right or centre. Use :left, :right, :center
95
+ # <tt>:wrap</tt>:: The wrapping technique to use if required. Use :word, :char or :wordchar. Default is :wordchar
96
+ # <tt>:justify</tt>:: Justify the text so it exapnds to fill the entire width of each line. Note that this only works in pango >= 1.17
97
+ # <tt>:spacing</tt>:: Space between lines in PDF points
98
+ # <tt>:markup</tt>:: Interpret the text as a markup language. Default is nil (none).
99
+ #
100
+ # = Markup
101
+ #
102
+ # If the markup option is specified, the text can be modified in various ways. At this stage
103
+ # the only markup syntax implemented is :pango.
104
+ #
105
+ # == Pango Markup
106
+ #
107
+ # Full details on the Pango markup language are avaialble at http://ruby-gnome2.sourceforge.jp/hiki.cgi?pango-markup
108
+ #
109
+ # The format is vaguely XML-like.
110
+ #
111
+ # Bold: "Some of this text is <b>bold</b>."
112
+ # Italics: "Some of this text is in <b>italics</b>."
113
+ # Strikethrough: "My name is <s>Bob</s>James."
114
+ # Monospace Font: "Code:\n<tt>puts 1</tt>."
115
+ #
116
+ # For more advanced control, use span tags
117
+ #
118
+ # Big and Bold: Some of this text is <span weight="bold" font_desc="20">bold</span>.
119
+ # Stretched: Some of this text is <span stretch="extraexpanded">funny looking</span>.
120
+ def text(str, opts={})
121
+ # TODO: add converters from various markup languages to pango markup. (markdown, textile, etc)
122
+ # TODO: add a wrap option so wrapping can be disabled
123
+ #
124
+ # the non pango way to add text to the cairo context, not particularly useful for
125
+ # PDF generation as it doesn't support wrapping text or other advanced layout features
126
+ # and I really don't feel like re-implementing all that
127
+ # @context.show_text(str)
128
+
129
+ # the "pango way"
130
+ x, y = current_point
131
+ options = default_text_options.merge!({:left => x, :top => y})
132
+ options.merge!(opts)
133
+ options.assert_valid_keys(default_text_options.keys + default_positioning_options.keys)
134
+
135
+ # if the user hasn't specified a width, make the text wrap on the right margin
136
+ options[:width] = absolute_right_margin - options[:left] if options[:width].nil?
137
+
138
+ layout = build_pango_layout(str.to_s, options[:width], options)
139
+
140
+ color(options[:color]) if options[:color]
141
+
142
+ # draw the context on our cairo layout
143
+ y = render_layout(layout, options[:left], options[:top], points_to_bottom_margin(options[:top]), :auto_new_page => true)
144
+
145
+ move_to(options[:left], y + device_y_to_user_y(5))
146
+ end
147
+
148
+ # Returns the amount of vertical space needed to display the supplied text at the requested width
149
+ # opts is an options hash that specifies various attributes of the text. See the text function for more information.
150
+ def text_height(str, width, opts = {})
151
+ options = default_text_options.merge(opts)
152
+ options.assert_valid_keys(default_text_options.keys)
153
+ options[:width] = width || body_width
154
+
155
+ layout = build_pango_layout(str.to_s, options[:width], options)
156
+ width, height = layout.size
157
+
158
+ return height / Pango::SCALE
159
+ end
160
+
161
+ # Returns the amount of horizontal space needed to display the supplied text with the requested options
162
+ # opts is an options hash that specifies various attributes of the text. See the text function for more information.
163
+ # The text is assumed to not wrap.
164
+ def text_width(str, opts = {})
165
+ options = default_text_options.merge(opts)
166
+ options.assert_valid_keys(default_text_options.keys)
167
+
168
+ layout = build_pango_layout(str.to_s, -1, options)
169
+ width, height = layout.size
170
+
171
+ return width / Pango::SCALE
172
+ end
173
+
174
+ private
175
+
176
+ # takes a string and a range of options and creates a pango layout for us. Pango
177
+ # does all the hard work of calculating text layout, wrapping, fonts, sizes,
178
+ # direction and more. Thank $diety.
179
+ #
180
+ # The string should be encoded using utf-8. If you get unexpected characters in the
181
+ # rendered output, check the string encoding. Under Ruby 1.9 compatible VMs, any
182
+ # non utf-8 strings will be automatically converted if possible.
183
+ #
184
+ # The layout will be constrained to the requested width, but has no maximum height. It
185
+ # is up to some other part of the code to decide how much of the layout should actually
186
+ # be rendered to the document, when page breaks should be inserted, etc. To specify no
187
+ # wrapping, set width to nil. This will result in a single line layout that is as wide
188
+ # as it needs to be to fit the entire string.
189
+ #
190
+ # options:
191
+ # <tt>:markup</tt>:: The markup language of the string. See Wrapper#text for more information
192
+ # <tt>:spacing</tt>:: The spacing between lines. See Wrapper#text for more information
193
+ # <tt>:alignment</tt>:: The alignment of the text. See Wrapper#text for more information
194
+ # <tt>:justify</tt>:: Should spacing between words be tweaked so each edge of the line touches
195
+ # the edge of the layout. See Wrapper#text for more information
196
+ # <tt>:font</tt>:: The font to use. See Wrapper#text for more information
197
+ # <tt>:font_size</tt>:: The font size to use. See Wrapper#text for more information
198
+ # <tt>:wrap</tt>:: The wrap technique to use. See Wrapper#text for more information
199
+ def build_pango_layout(str, w, opts = {})
200
+ options = default_text_options.merge!(opts)
201
+
202
+ # if the user hasn't specified a width, make the layout as wide as the page body
203
+ w = body_width if w.nil?
204
+
205
+ # even though this is a private function, raise this error to force calling functions
206
+ # to decide how they want to handle converting non-strings into strings for rendering
207
+ raise ArgumentError, 'build_pango_layout must be passed a string' unless str.kind_of?(String)
208
+
209
+ # if we're running under a M17n aware VM, ensure the string provided is UTF-8 or can be
210
+ # converted to UTF-8
211
+ if RUBY_VERSION >= "1.9"
212
+ begin
213
+ str = str.encode("UTF-8")
214
+ rescue
215
+ raise ArgumentError, 'Strings must be supplied with a UTF-8 encoding, or an encoding that can be converted to UTF-8'
216
+ end
217
+ end
218
+
219
+ # The pango way:
220
+ load_libpango
221
+
222
+ # create a new Pango layout that our text will be added to
223
+ layout = @context.create_pango_layout
224
+ if options[:markup] == :pango
225
+ layout.markup = str.to_s
226
+ else
227
+ layout.text = str.to_s
228
+ end
229
+ if w.nil? || w < 0
230
+ layout.width = -1
231
+ else
232
+ # width is specified in user points
233
+ layout.width = w * Pango::SCALE
234
+ end
235
+ # spacing is specified in user points
236
+ layout.spacing = device_y_to_user_y(options[:spacing] * Pango::SCALE)
237
+
238
+ # set the alignment of the text in the layout
239
+ if options[:alignment].eql?(:left)
240
+ layout.alignment = Pango::Layout::ALIGN_LEFT
241
+ elsif options[:alignment].eql?(:right)
242
+ layout.alignment = Pango::Layout::ALIGN_RIGHT
243
+ elsif options[:alignment].eql?(:center) || options[:alignment].eql?(:centre)
244
+ layout.alignment = Pango::Layout::ALIGN_CENTER
245
+ else
246
+ raise ArgumentError, "Invalid alignment requested"
247
+ end
248
+
249
+ # set the wrapping technique text of the layout
250
+ if options[:wrap].eql?(:word)
251
+ layout.wrap = Pango::Layout::WRAP_WORD
252
+ elsif options[:wrap].eql?(:char)
253
+ layout.wrap = Pango::Layout::WRAP_CHAR
254
+ elsif options[:wrap].eql?(:wordchar)
255
+ layout.wrap = Pango::Layout::WRAP_WORD_CHAR
256
+ else
257
+ raise ArgumentError, "Invalid wrap technique requested"
258
+ end
259
+
260
+ # justify the text if need be - only works in pango >= 1.17
261
+ layout.justify = true if options[:justify]
262
+
263
+ # setup the font that will be used to render the text
264
+ fdesc = Pango::FontDescription.new(options[:font])
265
+ # font size should be specified in device points for simplicity's sake.
266
+ fdesc.size = device_y_to_user_y(options[:font_size] * Pango::SCALE)
267
+ layout.font_description = fdesc
268
+ @context.update_pango_layout(layout)
269
+
270
+ return layout
271
+ end
272
+
273
+ def default_text_options
274
+ { :font => @default_font,
275
+ :font_size => @default_font_size,
276
+ :alignment => :left,
277
+ :wrap => :wordchar,
278
+ :justify => false,
279
+ :spacing => 0,
280
+ :color => nil,
281
+ :markup => nil
282
+ }
283
+ end
284
+
285
+ # renders a pango layout onto our main context
286
+ # based on a function of the same name found in the text2.rb sample file
287
+ # distributed with rcairo - it's still black magic to me and has a few edge
288
+ # cases where it doesn't work too well. Needs to be improved.
289
+ def render_layout(layout, x, y, h, opts = {})
290
+ # we can't use context.show_pango_layout, as that won't start
291
+ # a new page if the layout hits the bottom margin. Instead,
292
+ # we iterate over each line of text in the layout and add it to
293
+ # the canvas, page breaking as necessary
294
+ options = {:auto_new_page => true }
295
+ options.merge!(opts)
296
+
297
+ offset = 0
298
+ baseline = 0
299
+
300
+ iter = layout.iter
301
+ loop do
302
+ line = iter.line
303
+ ink_rect, logical_rect = iter.line_extents
304
+
305
+ # calculate the relative starting co-ords of this line
306
+ baseline = iter.baseline / Pango::SCALE
307
+ linex = logical_rect.x / Pango::SCALE
308
+
309
+ if baseline - offset >= h
310
+ # our text is using the maximum amount of vertical space we want it to
311
+ if options[:auto_new_page]
312
+ # create a new page and we can continue adding text
313
+ offset = baseline
314
+ start_new_page
315
+ else
316
+ # the user doesn't want us to continue on the next page, so
317
+ # stop adding lines to the canvas
318
+ break
319
+ end
320
+ end
321
+
322
+ # move to the start of this line
323
+ @context.move_to(x + linex, y + baseline - offset)
324
+
325
+ # draw the line on the canvas
326
+ @context.show_pango_layout_line(line)
327
+
328
+ break unless iter.next_line!
329
+ end
330
+
331
+ # return the y co-ord we finished on
332
+ return device_y_to_user_y(y + baseline - offset)
333
+ end
334
+
335
+
336
+ end
337
+ end
Binary file
@@ -101,7 +101,7 @@ context "The PDF::Wrapper class" do
101
101
  w = h = 200
102
102
  r = 5
103
103
  pdf = PDF::Wrapper.new
104
- pdf.rounded_rectangle(x,y,w,h,r)
104
+ pdf.rectangle(x,y,w,h,:radius => r)
105
105
 
106
106
  receiver = PDF::Reader::RegisterReceiver.new
107
107
  reader = PDF::Reader.string(pdf.render, receiver)
@@ -118,7 +118,7 @@ context "The PDF::Wrapper class" do
118
118
  r = 5
119
119
  w = 5
120
120
  pdf = PDF::Wrapper.new
121
- pdf.rounded_rectangle(x,y,w,h,r, :line_width => w)
121
+ pdf.rounded_rectangle(x,y,w,h, :radius => r, :line_width => w)
122
122
 
123
123
  receiver = PDF::Reader::RegisterReceiver.new
124
124
  reader = PDF::Reader.string(pdf.render, receiver)
@@ -134,7 +134,7 @@ context "The PDF::Wrapper class" do
134
134
  w = h = 200
135
135
  r = 5
136
136
  pdf = PDF::Wrapper.new
137
- pdf.rounded_rectangle(x,y,w,h,r, :fill_color => :red)
137
+ pdf.rounded_rectangle(x,y,w,h, :radius => r, :fill_color => :red)
138
138
 
139
139
  receiver = PDF::Reader::RegisterReceiver.new
140
140
  reader = PDF::Reader.string(pdf.render, receiver)
data/specs/spec_helper.rb CHANGED
@@ -25,6 +25,11 @@ class PDF::Wrapper
25
25
  public :load_libpixbuf
26
26
  public :load_libpango
27
27
  public :load_libpoppler
28
+ public :user_x_to_device_x
29
+ public :user_y_to_device_y
30
+ public :user_to_device_dist
31
+ public :device_x_to_user_x
32
+ public :device_y_to_user_y
28
33
  public :validate_color
29
34
  end
30
35
 
@@ -52,7 +57,7 @@ class PageSizeReceiver
52
57
 
53
58
  # Called when page parsing ends
54
59
  def begin_page(args)
55
- pages << args["MediaBox"] || args[:MediaBox]
60
+ pages << (args["MediaBox"] || args[:MediaBox])
56
61
  end
57
62
  end
58
63
 
@@ -0,0 +1,111 @@
1
+ # coding: utf-8
2
+
3
+ require File.dirname(__FILE__) + '/spec_helper'
4
+
5
+ context "The PDF::Wrapper class" do
6
+ specify "should be able to draw a table on the canvas using an array of data" do
7
+ pdf = PDF::Wrapper.new
8
+ data = [%w{data1 data2}, %w{data3 data4}]
9
+ pdf.table(data)
10
+
11
+ receiver = PageTextReceiver.new
12
+ reader = PDF::Reader.string(pdf.render, receiver)
13
+
14
+ receiver.content.first.include?("data1").should be_true
15
+ receiver.content.first.include?("data2").should be_true
16
+ receiver.content.first.include?("data3").should be_true
17
+ receiver.content.first.include?("data4").should be_true
18
+ end
19
+
20
+ specify "should be able to draw a table on the canvas using a PDF::Wrapper::Table object" do
21
+ pdf = PDF::Wrapper.new
22
+ table = PDF::Wrapper::Table.new do |t|
23
+ t.data = [%w{data1 data2}, %w{data3 data4}]
24
+ end
25
+
26
+ pdf.table(table)
27
+
28
+ receiver = PageTextReceiver.new
29
+ reader = PDF::Reader.string(pdf.render, receiver)
30
+
31
+ receiver.content.first.include?("data1").should be_true
32
+ receiver.content.first.include?("data2").should be_true
33
+ receiver.content.first.include?("data3").should be_true
34
+ receiver.content.first.include?("data4").should be_true
35
+ end
36
+
37
+ specify "should be able to draw a table on the canvas with no headings" do
38
+ pdf = PDF::Wrapper.new
39
+
40
+ table = PDF::Wrapper::Table.new do |t|
41
+ t.data = (1..50).collect { [1,2] }
42
+ t.headers = ["col1", "col2"]
43
+ t.show_headers = nil
44
+ end
45
+
46
+ pdf.table(table)
47
+
48
+ receiver = PageTextReceiver.new
49
+ reader = PDF::Reader.string(pdf.render, receiver)
50
+
51
+ receiver.content.first.include?("col1").should be_false
52
+ receiver.content.first.include?("col2").should be_false
53
+ end
54
+
55
+ specify "should be able to draw a table on the canvas with headers on the first page only" do
56
+ pdf = PDF::Wrapper.new
57
+
58
+ table = PDF::Wrapper::Table.new do |t|
59
+ t.data = (1..50).collect { [1,2] }
60
+ t.headers = ["col1", "col2"]
61
+ t.show_headers = :once
62
+ end
63
+
64
+ pdf.table(table)
65
+
66
+ receiver = PageTextReceiver.new
67
+ reader = PDF::Reader.string(pdf.render, receiver)
68
+
69
+ receiver.content[0].include?("col1").should be_true
70
+ receiver.content[0].include?("col2").should be_true
71
+ receiver.content[1].include?("col1").should be_false
72
+ receiver.content[1].include?("col2").should be_false
73
+ end
74
+
75
+ specify "should be able to draw a table on the canvas with headers on all pages" do
76
+ pdf = PDF::Wrapper.new
77
+
78
+ table = PDF::Wrapper::Table.new do |t|
79
+ t.data = (1..50).collect { [1,2] }
80
+ t.headers = ["col1", "col2"]
81
+ t.show_headers = :page
82
+ end
83
+
84
+ pdf.table(table)
85
+
86
+ receiver = PageTextReceiver.new
87
+ reader = PDF::Reader.string(pdf.render, receiver)
88
+
89
+ receiver.content[0].include?("col1").should be_true
90
+ receiver.content[0].include?("col2").should be_true
91
+ receiver.content[1].include?("col1").should be_true
92
+ receiver.content[1].include?("col2").should be_true
93
+ end
94
+
95
+ specify "should leave the cursor in the bottom left when adding a table" do
96
+ pdf = PDF::Wrapper.new
97
+ data = [%w{head1 head2},%w{data1 data2}]
98
+ pdf.table(data, :left => pdf.margin_left)
99
+ x,y = pdf.current_point
100
+ x.to_i.should eql(pdf.margin_left)
101
+ end
102
+
103
+ specify "should default to using as much available space when adding a table that isn't left aligned with the left margin" do
104
+ pdf = PDF::Wrapper.new
105
+ data = [%w{head1 head2},%w{data1 data2}]
106
+ pdf.table(data, :left => 100)
107
+ x,y = pdf.current_point
108
+ x.to_i.should eql(100)
109
+ end
110
+
111
+ end