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.
- data/Manifest +37 -0
- data/Rakefile +31 -0
- data/examples/basic-formatting.rb +37 -0
- data/examples/christmas-carol.txt +717 -0
- data/examples/document.rb +61 -0
- data/examples/flowing.rb +24 -0
- data/examples/style-classes.rb +12 -0
- data/examples/syntax-highlighting.rb +31 -0
- data/examples/tags.rb +24 -0
- data/lib/prawn/format.rb +211 -0
- data/lib/prawn/format/effects/link.rb +30 -0
- data/lib/prawn/format/effects/underline.rb +32 -0
- data/lib/prawn/format/instructions/base.rb +62 -0
- data/lib/prawn/format/instructions/tag_close.rb +52 -0
- data/lib/prawn/format/instructions/tag_open.rb +95 -0
- data/lib/prawn/format/instructions/text.rb +89 -0
- data/lib/prawn/format/layout_builder.rb +113 -0
- data/lib/prawn/format/lexer.rb +222 -0
- data/lib/prawn/format/line.rb +99 -0
- data/lib/prawn/format/parser.rb +181 -0
- data/lib/prawn/format/state.rb +189 -0
- data/lib/prawn/format/text_object.rb +107 -0
- data/lib/prawn/format/version.rb +11 -0
- data/manual/html.rb +187 -0
- data/manual/include/basics.rb +6 -0
- data/manual/include/breaks.rb +13 -0
- data/manual/include/custom-tags.rb +10 -0
- data/manual/include/custom-tags2.rb +2 -0
- data/manual/include/indent.rb +4 -0
- data/manual/include/options.rb +15 -0
- data/manual/include/style-classes.rb +5 -0
- data/manual/manual.txt +101 -0
- data/manual/pdf.rb +204 -0
- data/prawn-format.gemspec +45 -0
- data/spec/layout_builder_spec.rb +27 -0
- data/spec/lexer_spec.rb +91 -0
- data/spec/parser_spec.rb +103 -0
- data/spec/spec_helper.rb +24 -0
- 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
|