prawn-format 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 (39) hide show
  1. data/Manifest +37 -0
  2. data/Rakefile +31 -0
  3. data/examples/basic-formatting.rb +37 -0
  4. data/examples/christmas-carol.txt +717 -0
  5. data/examples/document.rb +61 -0
  6. data/examples/flowing.rb +24 -0
  7. data/examples/style-classes.rb +12 -0
  8. data/examples/syntax-highlighting.rb +31 -0
  9. data/examples/tags.rb +24 -0
  10. data/lib/prawn/format.rb +211 -0
  11. data/lib/prawn/format/effects/link.rb +30 -0
  12. data/lib/prawn/format/effects/underline.rb +32 -0
  13. data/lib/prawn/format/instructions/base.rb +62 -0
  14. data/lib/prawn/format/instructions/tag_close.rb +52 -0
  15. data/lib/prawn/format/instructions/tag_open.rb +95 -0
  16. data/lib/prawn/format/instructions/text.rb +89 -0
  17. data/lib/prawn/format/layout_builder.rb +113 -0
  18. data/lib/prawn/format/lexer.rb +222 -0
  19. data/lib/prawn/format/line.rb +99 -0
  20. data/lib/prawn/format/parser.rb +181 -0
  21. data/lib/prawn/format/state.rb +189 -0
  22. data/lib/prawn/format/text_object.rb +107 -0
  23. data/lib/prawn/format/version.rb +11 -0
  24. data/manual/html.rb +187 -0
  25. data/manual/include/basics.rb +6 -0
  26. data/manual/include/breaks.rb +13 -0
  27. data/manual/include/custom-tags.rb +10 -0
  28. data/manual/include/custom-tags2.rb +2 -0
  29. data/manual/include/indent.rb +4 -0
  30. data/manual/include/options.rb +15 -0
  31. data/manual/include/style-classes.rb +5 -0
  32. data/manual/manual.txt +101 -0
  33. data/manual/pdf.rb +204 -0
  34. data/prawn-format.gemspec +45 -0
  35. data/spec/layout_builder_spec.rb +27 -0
  36. data/spec/lexer_spec.rb +91 -0
  37. data/spec/parser_spec.rb +103 -0
  38. data/spec/spec_helper.rb +24 -0
  39. metadata +157 -0
@@ -0,0 +1,99 @@
1
+ # encoding: utf-8
2
+
3
+ module Prawn
4
+ module Format
5
+
6
+ class Line
7
+ attr_reader :source
8
+ attr_reader :instructions
9
+
10
+ def initialize(instructions, hard_break)
11
+ # need to remember the "source" instructions, because lines can
12
+ # pushed back onto the stack en masse when flowing into boxes,
13
+ # if a line is discovered to not fit. Thus, a line must preserve
14
+ # all instructions it was originally given.
15
+
16
+ @source = instructions
17
+ @hard_break = hard_break
18
+ end
19
+
20
+ def instructions
21
+ @instructions ||= begin
22
+ instructions = source.dup
23
+
24
+ # ignore discardable items at the end of lines
25
+ instructions.pop while instructions.any? && instructions.last.discardable?
26
+
27
+ consolidate(instructions)
28
+ end
29
+ end
30
+
31
+ def spaces
32
+ @spaces ||= begin
33
+ spaces = instructions.inject(0) { |sum, instruction| sum + instruction.spaces }
34
+ [1, spaces].max
35
+ end
36
+ end
37
+
38
+ def hard_break?
39
+ @hard_break
40
+ end
41
+
42
+ def width
43
+ instructions.inject(0) { |sum, instruction| sum + instruction.width }
44
+ end
45
+
46
+ # distance from top of line to baseline
47
+ def ascent
48
+ instructions.map { |instruction| instruction.ascent }.max || 0
49
+ end
50
+
51
+ # distance from bottom of line to baseline
52
+ def descent
53
+ instructions.map { |instruction| instruction.descent }.min || 0
54
+ end
55
+
56
+ def height(include_blank=false)
57
+ instructions.map { |instruction| instruction.height(include_blank) }.max
58
+ end
59
+
60
+ def draw_on(document, state, options={})
61
+ return if instructions.empty?
62
+
63
+ format_state = instructions.first.state
64
+
65
+ case(options[:align])
66
+ when :left
67
+ state[:dx] = 0
68
+ when :center
69
+ state[:dx] = (state[:width] - width) / 2.0
70
+ when :right
71
+ state[:dx] = state[:width] - width
72
+ when :justify
73
+ state[:dx] = 0
74
+ state[:padding] = hard_break? ? 0 : (state[:width] - width) / spaces
75
+ state[:text].word_space(state[:padding])
76
+ end
77
+
78
+ state[:dy] -= ascent
79
+
80
+ state[:text].move_to(state[:dx], state[:dy])
81
+ state[:line] = self
82
+
83
+ document.save_font do
84
+ instructions.each { |instruction| instruction.draw(document, state, options) }
85
+ state[:pending_effects].each { |effect| effect.wrap(document, state) }
86
+ end
87
+
88
+ state[:dy] -= (options[:spacing] || 0) + (height - ascent)
89
+ end
90
+
91
+ private
92
+
93
+ def consolidate(list)
94
+ list.inject([]) { |l,i| i.accumulate(l) }
95
+ end
96
+ end
97
+
98
+ end
99
+ end
@@ -0,0 +1,181 @@
1
+ # encoding: utf-8
2
+
3
+ require 'prawn/format/instructions/text'
4
+ require 'prawn/format/instructions/tag_open'
5
+ require 'prawn/format/instructions/tag_close'
6
+ require 'prawn/format/lexer'
7
+ require 'prawn/format/line'
8
+ require 'prawn/format/state'
9
+
10
+ module Prawn
11
+ module Format
12
+
13
+ # The Parser class is used by the formatting subsystem to take
14
+ # the raw tokens from the Lexer class and wrap them in
15
+ # "instructions", which are then used by the LayoutBuilder to
16
+ # determine how each token should be rendered.
17
+ #
18
+ # The parser also ensures that tags are opened and closed
19
+ # consistently. It is not forgiving at all--if you forget to
20
+ # close a tag, the parser will raise an exception (TagError).
21
+ #
22
+ # It will also raise an exception if a tag is encountered with
23
+ # no style definition for it.
24
+ class Parser
25
+ # This is the exception that gets raised when the parser cannot
26
+ # process a particular tag.
27
+ class TagError < RuntimeError; end
28
+
29
+ attr_reader :document
30
+ attr_reader :tags
31
+ attr_reader :state
32
+
33
+ # Creates a new parser associated with the given +document+, and which
34
+ # will parse the given +text+. The +options+ may include either of two
35
+ # optional keys:
36
+ #
37
+ # * :tags is used to specify the hash of tags and their associated
38
+ # styles. Any tag not specified here will not be recognized by the
39
+ # parser, and will cause an error if it is encountered in +text+.
40
+ # * :styles is used to specify the mapping of style classes to their
41
+ # definitions. The keys should be symbols, and the values should be
42
+ # hashes. The values have the same format as for the :tags map.
43
+ # * :style is the default style for any text not otherwise wrapped by
44
+ # tags.
45
+ #
46
+ # Example:
47
+ #
48
+ # parser = Parser.new(@pdf, "<b class='ruby'>hello</b>",
49
+ # :tags => { :b => { :font_weight => :bold } },
50
+ # :styles => { :ruby => { :color => "red" } },
51
+ # :style => { :font_family => "Times-Roman" })
52
+ #
53
+ # See Format::State for a description of the supported style options.
54
+ def initialize(document, text, options={})
55
+ @document = document
56
+ @lexer = Lexer.new(text)
57
+ @tags = options[:tags] || {}
58
+ @styles = options[:styles] || {}
59
+
60
+ @state = State.new(document, :style => options[:style])
61
+ @lexer.verbatim = (@state.white_space == :pre)
62
+
63
+ @action = :start
64
+
65
+ @saved = []
66
+ @tag_stack = []
67
+ end
68
+
69
+ def verbatim?
70
+ @lexer.verbatim
71
+ end
72
+
73
+ # Returns the next instruction from the stream. If there are no more
74
+ # instructions in the stream (e.g., the end has been encountered), this
75
+ # returns +nil+.
76
+ def next
77
+ return @saved.pop if @saved.any?
78
+
79
+ case @action
80
+ when :start then start_parse
81
+ when :text then text_parse
82
+ else raise "BUG: unknown parser action: #{@action.inspect}"
83
+ end
84
+ end
85
+
86
+ # "Ungets" the given +instruction+. This makes it so the next call to
87
+ # +next+ will return +instruction+. This is useful for backtracking.
88
+ def push(instruction)
89
+ @saved.push(instruction)
90
+ end
91
+
92
+ # This is identical to +next+, except it does not consume the
93
+ # instruction. This means that +peek+ returns the instruction that will
94
+ # be returned by the next call to +next+. It is useful for testing
95
+ # the next instruction in the stream without advancing the stream.
96
+ def peek
97
+ save = self.next
98
+ push(save) if save
99
+ return save
100
+ end
101
+
102
+ # Returns +true+ if the end of the stream has been reached. Subsequent
103
+ # calls to +peek+ or +next+ will return +nil+.
104
+ def eos?
105
+ peek.nil?
106
+ end
107
+
108
+ private
109
+
110
+ def start_parse
111
+ instruction = nil
112
+ while (@token = @lexer.next)
113
+ case @token[:type]
114
+ when :text
115
+ @position = 0
116
+ instruction = text_parse
117
+ when :open
118
+ instruction = process_open_tag
119
+ when :close
120
+ raise TagError, "closing #{@token[:tag]}, but no tags are open" if @tag_stack.empty?
121
+ raise TagError, "closing #{@tag_stack.last[:tag]} with #{@token[:tag]}" if @tag_stack.last[:tag] != @token[:tag]
122
+
123
+ instruction = Instructions::TagClose.new(@state, @tag_stack.pop)
124
+ @state = @state.previous
125
+ else
126
+ raise ArgumentError, "[BUG] unknown token type #{@token[:type].inspect} (#{@token.inspect})"
127
+ end
128
+
129
+ if instruction
130
+ if instruction.start_verbatim?
131
+ @lexer.verbatim = true
132
+ elsif instruction.end_verbatim?
133
+ @lexer.verbatim = false
134
+ end
135
+
136
+ return instruction
137
+ end
138
+ end
139
+
140
+ return nil
141
+ end
142
+
143
+ def text_parse
144
+ if @token[:text][@position]
145
+ @action = :text
146
+ @position += 1
147
+
148
+ text = @token[:text][@position - 1]
149
+ if @state.white_space == :pre && text =~ /(?:\r\n|\r|\n)/
150
+ Instructions::TagClose.new(@state, { :style => { :display => :break }, :options => {} })
151
+ else
152
+ Instructions::Text.new(@state, text)
153
+ end
154
+ else
155
+ @action = :start
156
+ start_parse
157
+ end
158
+ end
159
+
160
+ def process_open_tag
161
+ @tag_stack << @token
162
+ raise TagError, "undefined tag #{@token[:tag]}" unless @tags[@token[:tag]]
163
+ @token[:style] = @tags[@token[:tag]].dup
164
+
165
+ (@token[:options][:class] || "").split(/\s/).each do |name|
166
+ @token[:style].update(@styles[name.to_sym] || {})
167
+ end
168
+
169
+ if @token[:style][:meta]
170
+ @token[:style][:meta].each do |key, value|
171
+ @token[:style][value] = @token[:options][key]
172
+ end
173
+ end
174
+
175
+ @state = @state.with_style(@token[:style])
176
+ Instructions::TagOpen.new(@state, @token)
177
+ end
178
+ end
179
+
180
+ end
181
+ end
@@ -0,0 +1,189 @@
1
+ # encoding: utf-8
2
+
3
+ module Prawn
4
+ module Format
5
+ class State
6
+ attr_reader :document
7
+ attr_reader :original_style, :style
8
+
9
+ def initialize(document, options={})
10
+ @document = document
11
+ @previous = options[:previous]
12
+
13
+ @original_style = (@previous && @previous.inheritable_style || {}).
14
+ merge(options[:style] || {})
15
+
16
+ compute_styles!
17
+
18
+ @style[:kerning] = font.has_kerning_data? unless @style.key?(:kerning)
19
+ end
20
+
21
+ def inheritable_style
22
+ @inheritable_style ||= begin
23
+ subset = original_style.dup
24
+ subset.delete(:meta)
25
+ subset.delete(:display)
26
+ subset.delete(:width)
27
+
28
+ # explicitly set font-size so that relative font-sizes don't get
29
+ # recomputed upon each nesting.
30
+ subset[:font_size] = font_size
31
+
32
+ subset
33
+ end
34
+ end
35
+
36
+ def kerning?
37
+ @style[:kerning]
38
+ end
39
+
40
+ def display
41
+ @style[:display] || :inline
42
+ end
43
+
44
+ def font_size
45
+ @style[:font_size] || 12
46
+ end
47
+
48
+ def font_family
49
+ @style[:font_family] || "Helvetica"
50
+ end
51
+
52
+ def font_style
53
+ @style[:font_style] || :normal
54
+ end
55
+
56
+ def font_weight
57
+ @style[:font_weight] || :normal
58
+ end
59
+
60
+ def color
61
+ @style[:color] || "000000"
62
+ end
63
+
64
+ def vertical_align
65
+ @style[:vertical_align] || 0
66
+ end
67
+
68
+ def text_decoration
69
+ @style[:text_decoration] || :none
70
+ end
71
+
72
+ def white_space
73
+ @style[:white_space] || :normal
74
+ end
75
+
76
+ def width
77
+ @style[:width] || 0
78
+ end
79
+
80
+ def font
81
+ @font ||= document.find_font(font_family, :style => pdf_font_style)
82
+ end
83
+
84
+ def pdf_font_style
85
+ if bold? && italic?
86
+ :bold_italic
87
+ elsif bold?
88
+ :bold
89
+ elsif italic?
90
+ :italic
91
+ else
92
+ :normal
93
+ end
94
+ end
95
+
96
+ def with_style(style)
97
+ self.class.new(document, :previous => self, :style => style)
98
+ end
99
+
100
+ def apply!(text_object, cookies)
101
+ if cookies[:color] != color
102
+ cookies[:color] = color
103
+ text_object.fill_color(color)
104
+ end
105
+
106
+ if cookies[:vertical_align] != vertical_align
107
+ cookies[:vertical_align] = vertical_align
108
+ text_object.rise(vertical_align)
109
+ end
110
+ end
111
+
112
+ def apply_font!(text_object, cookies, subset)
113
+ if cookies[:font] != [font_family, pdf_font_style, font_size, subset]
114
+ cookies[:font] = [font_family, pdf_font_style, font_size, subset]
115
+ font = document.font(font_family, :style => pdf_font_style)
116
+ font.add_to_current_page(subset)
117
+ text_object.font(font.identifier_for(subset), font_size)
118
+ end
119
+ end
120
+
121
+ def italic?
122
+ font_style == :italic
123
+ end
124
+
125
+ def bold?
126
+ font_weight == :bold
127
+ end
128
+
129
+ def previous(attr=nil, default=nil)
130
+ return @previous unless attr
131
+ return default unless @previous
132
+ return @previous.send(attr) || default
133
+ end
134
+
135
+ private
136
+
137
+ def compute_styles!
138
+ @style = @original_style.dup
139
+
140
+ evaluate_style(:font_size, 12, :current)
141
+ evaluate_style(:vertical_align, 0, font_size, :super => "+40%", :sub => "-30%")
142
+ evaluate_style(:width, 0, document.bounds.width)
143
+
144
+ @style[:color] = evaluate_color(@style[:color])
145
+ end
146
+
147
+ def evaluate_style(which, default, relative_to, mappings={})
148
+ current = previous(which, default)
149
+ relative_to = current if relative_to == :current
150
+ @style[which] = document.evaluate_measure(@style[which],
151
+ :em => @previous && @previous.font_size || 12,
152
+ :current => current, :relative => relative_to, :mappings => mappings) || default
153
+ end
154
+
155
+ HTML_COLORS = {
156
+ "aqua" => "00FFFF",
157
+ "black" => "000000",
158
+ "blue" => "0000FF",
159
+ "fuchsia" => "FF00FF",
160
+ "gray" => "808080",
161
+ "green" => "008000",
162
+ "lime" => "00FF00",
163
+ "maroon" => "800000",
164
+ "navy" => "000080",
165
+ "olive" => "808000",
166
+ "purple" => "800080",
167
+ "red" => "FF0000",
168
+ "silver" => "C0C0C0",
169
+ "teal" => "008080",
170
+ "white" => "FFFFFF",
171
+ "yellow" => "FFFF00"
172
+ }
173
+
174
+ def evaluate_color(color)
175
+ case color
176
+ when nil then nil
177
+ when /^\s*#?([a-f0-9]{3})\s*$/i then
178
+ return $1.gsub(/./, '\&0')
179
+ when /^\s*#?([a-f0-9]+)$\s*/i then
180
+ return $1
181
+ when /^\s*rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)\s*$/
182
+ return "%02x%02x%02x" % [$1.to_i, $2.to_i, $3.to_i]
183
+ else
184
+ return HTML_COLORS[color.strip.downcase]
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end