blib/lib/HTML/Quoted.pm | |||
---|---|---|---|
Criterion | Covered | Total | % |
statement | 99 | 134 | 73.8 |
branch | 30 | 50 | 60.0 |
condition | 10 | 21 | 47.6 |
subroutine | 12 | 15 | 80.0 |
pod | 2 | 2 | 100.0 |
total | 153 | 222 | 68.9 |
line | stmt | bran | cond | sub | pod | time | code |
---|---|---|---|---|---|---|---|
1 | 3 | 3 | 70222 | use 5.008; | |||
3 | 10 | ||||||
3 | 120 | ||||||
2 | 3 | 3 | 15 | use strict; | |||
3 | 6 | ||||||
3 | 113 | ||||||
3 | 3 | 3 | 21 | use warnings; | |||
3 | 18 | ||||||
3 | 831 | ||||||
4 | |||||||
5 | package HTML::Quoted; | ||||||
6 | |||||||
7 | our $VERSION = '0.04'; | ||||||
8 | |||||||
9 | =head1 NAME | ||||||
10 | |||||||
11 | HTML::Quoted - extract structure of quoted HTML mail message | ||||||
12 | |||||||
13 | =head1 SYNOPSIS | ||||||
14 | |||||||
15 | use HTML::Quoted; | ||||||
16 | my $html = '...'; | ||||||
17 | my $struct = HTML::Quoted->extract( $html ); | ||||||
18 | |||||||
19 | =head1 DESCRIPTION | ||||||
20 | |||||||
21 | Parses and extracts quotation structure out of a HTML message. | ||||||
22 | Purpose and returned structures are very similar to | ||||||
23 | L |
||||||
24 | |||||||
25 | =head1 SUPPORTED FORMATS | ||||||
26 | |||||||
27 | Variouse MUAs use quite different approaches for quoting in mails. | ||||||
28 | |||||||
29 | Some use Itag and it's quite easy to parse. |
||||||
30 | |||||||
31 | Some wrap text into I tags and add '>' in the beginning of the |
||||||
32 | paragraphs. | ||||||
33 | |||||||
34 | Things gettign messier when it's an HTML reply on plain text mail | ||||||
35 | thread. | ||||||
36 | |||||||
37 | If B |
||||||
38 | via rt.cpan.org with as short as possible example. B |
||||||
39 | is even better. Test file with patch is the best. Not obviouse patches | ||||||
40 | without tests suck. | ||||||
41 | |||||||
42 | =head1 METHODS | ||||||
43 | |||||||
44 | =head2 extract | ||||||
45 | |||||||
46 | my $struct = HTML::Quoted->extract( $html ); | ||||||
47 | |||||||
48 | Takes a string with HTML and returns array reference. Each element | ||||||
49 | in the array either array or hash. For example: | ||||||
50 | |||||||
51 | |||||||
52 | [ | ||||||
53 | { 'raw' => 'Hi,' }, | ||||||
54 | { 'raw' => ' On date X wrote: ' }, |
||||||
55 | [ | ||||||
56 | { 'raw' => '' }, |
||||||
57 | { 'raw' => 'Hello,' }, | ||||||
58 | { 'raw' => ' How are you? ' }, |
||||||
59 | { 'raw' => '' } | ||||||
60 | ], | ||||||
61 | ... | ||||||
62 | ] | ||||||
63 | |||||||
64 | Hashes represent a part of the html. The following keys are | ||||||
65 | meaningful at the moment: | ||||||
66 | |||||||
67 | =over 4 | ||||||
68 | |||||||
69 | =item * raw - raw HTML | ||||||
70 | |||||||
71 | =item * quoter_raw, quoter - raw and decoded (entities are converted) quoter if block is prefixed with quoting characters | ||||||
72 | |||||||
73 | =back | ||||||
74 | |||||||
75 | =cut | ||||||
76 | |||||||
77 | sub extract { | ||||||
78 | 22 | 22 | 1 | 6716 | my $self = shift; | ||
79 | 22 | 270 | my $parser = HTML::Quoted::Parser->new( | ||||
80 | api_version => 3, | ||||||
81 | handlers => { | ||||||
82 | start_document => [handle_doc_start => 'self'], | ||||||
83 | end_document => [handle_doc_end => 'self'], | ||||||
84 | start => [handle_start => 'self, tagname, attr, attrseq, text'], | ||||||
85 | end => [handle_end => 'self, tagname, text'], | ||||||
86 | text => [handle_text => 'self, text, is_cdata'], | ||||||
87 | default => [handle_default => 'self, event, text'], | ||||||
88 | }, | ||||||
89 | ); | ||||||
90 | 22 | 1437 | $parser->empty_element_tags(1); | ||||
91 | 22 | 153 | $parser->parse($_[0]); | ||||
92 | 22 | 117 | $parser->eof; | ||||
93 | |||||||
94 | 22 | 243 | return $parser->{'html_quoted_parser'}{'result'}; | ||||
95 | } | ||||||
96 | |||||||
97 | =head2 combine_hunks | ||||||
98 | |||||||
99 | my $html = HTML::Quoted->combine_hunks( $arrayref_of_hunks ); | ||||||
100 | |||||||
101 | Takes the output of C |
||||||
102 | |||||||
103 | =cut | ||||||
104 | |||||||
105 | sub combine_hunks { | ||||||
106 | 13 | 13 | 1 | 21 | my ($self, $hunks) = @_; | ||
107 | |||||||
108 | join "", | ||||||
109 | 13 | 100 | 27 | map {; ref $_ eq 'HASH' ? $_->{raw} : $self->combine_hunks($_) } @$hunks; | |||
27 | 145 | ||||||
110 | } | ||||||
111 | |||||||
112 | package HTML::Quoted::Parser; | ||||||
113 | 3 | 3 | 22 | use base "HTML::Parser"; | |||
3 | 7 | ||||||
3 | 825346 | ||||||
114 | |||||||
115 | sub handle_doc_start { | ||||||
116 | 22 | 22 | 33 | my ($self) = @_; | |||
117 | 22 | 63 | my $meta = $self->{'html_quoted_parser'} = {}; | ||||
118 | 22 | 64 | my $res = $meta->{'result'} = [{}]; | ||||
119 | 22 | 49 | $meta->{'current'} = $res->[0]; | ||||
120 | 22 | 45 | $meta->{'stack'} = [$res]; | ||||
121 | 22 | 200 | $meta->{'in'} = { quote => 0, block => [0] }; | ||||
122 | } | ||||||
123 | |||||||
124 | sub handle_doc_end { | ||||||
125 | 22 | 22 | 42 | my ($self) = @_; | |||
126 | |||||||
127 | 22 | 47 | my $meta = $self->{'html_quoted_parser'}; | ||||
128 | 22 | 100 | 66 | 74 | pop @{ $meta->{'result'} } if ref $meta->{'result'}[-1] eq 'HASH' && !keys %{ $meta->{'result'}[-1] }; | ||
12 | 19 | ||||||
22 | 97 | ||||||
129 | 22 | 68 | $self->organize( $meta->{'result'} ); | ||||
130 | } | ||||||
131 | |||||||
132 | sub organize { | ||||||
133 | 26 | 26 | 34 | my ($self, $list) = @_; | |||
134 | |||||||
135 | 26 | 77 | my $prev = undef; | ||||
136 | 26 | 56 | foreach my $e ( splice @$list ) { | ||||
137 | 54 | 100 | 183 | if ( ref $e eq 'ARRAY' ) { | |||
100 | |||||||
50 | |||||||
138 | 4 | 11 | push @$list, $self->organize($e); | ||||
139 | 4 | 5 | $prev = undef; | ||||
140 | } | ||||||
141 | elsif ( $e->{'block'} ) { | ||||||
142 | 20 | 20 | push @$list, $e; | ||||
143 | 20 | 24 | $prev = undef; | ||||
144 | } | ||||||
145 | elsif ( defined $e->{'quoter'} ) { | ||||||
146 | 0 | 0 | 0 | 0 | if ( !$prev || $self->combine( $prev, $e ) ) { | ||
147 | 0 | 0 | push @$list, $prev = [ $e ]; | ||||
148 | } | ||||||
149 | } else { | ||||||
150 | 30 | 59 | push @$list, $e; | ||||
151 | 30 | 56 | $prev = undef; | ||||
152 | } | ||||||
153 | } | ||||||
154 | 26 | 76 | return $list; | ||||
155 | } | ||||||
156 | |||||||
157 | sub combine { | ||||||
158 | 0 | 0 | 0 | my ($self, $list, $e) = @_; | |||
159 | 0 | 0 | my ($last) = grep ref $_ eq 'HASH', reverse @$list; | ||||
160 | 0 | 0 | 0 | if ( $last->{'quoter'} eq $e->{'quoter'} ) { | |||
0 | |||||||
0 | |||||||
161 | 0 | 0 | push @$list, $e; | ||||
162 | 0 | 0 | return (); | ||||
163 | } | ||||||
164 | elsif ( rindex( $last->{'quoter'}, $e->{'quoter'}, 0) == 0 ) { | ||||||
165 | 0 | 0 | @$list = ( [@$list], $e ); | ||||
166 | 0 | 0 | return (); | ||||
167 | } | ||||||
168 | elsif ( rindex( $e->{'quoter'}, $last->{'quoter'}, 0) == 0 ) { | ||||||
169 | 0 | 0 | 0 | 0 | if ( ref $list->[-1] eq 'ARRAY' && !$self->combine( $list->[-1], $e ) ) { | ||
170 | 0 | 0 | return (); | ||||
171 | } | ||||||
172 | 0 | 0 | push @$list, [ $e ]; | ||||
173 | 0 | 0 | return (); | ||||
174 | } | ||||||
175 | else { | ||||||
176 | 0 | 0 | return $e; | ||||
177 | } | ||||||
178 | } | ||||||
179 | |||||||
180 | # XXX: p is treated as inline tag as it's groupping tag that | ||||||
181 | # can not contain blocks inside, use span for groupping | ||||||
182 | my %INLINE_TAG = map {$_ => 1 } qw( | ||||||
183 | a br span bdo map img | ||||||
184 | tt i b big small | ||||||
185 | em strong dfn code q | ||||||
186 | samp kbd var cite abbr acronym sub sup | ||||||
187 | p | ||||||
188 | ); | ||||||
189 | |||||||
190 | my %ENTITIES = ( | ||||||
191 | '>' => '>', | ||||||
192 | '>' => '>', | ||||||
193 | '>' => '>', | ||||||
194 | ); | ||||||
195 | |||||||
196 | my $re_amp = join '|', map "\Q$_\E", '>', grep $ENTITIES{$_} eq '>', keys %ENTITIES; | ||||||
197 | $re_amp = qr{$re_amp}; | ||||||
198 | my $re_quote_char = qr{[!#%=|:]}; | ||||||
199 | my $re_quote_chunk = qr{ $re_quote_char(?!\w) | \w*$re_amp+ }x; | ||||||
200 | my $re_quoter = qr{ $re_quote_chunk (?:[ \\t]* $re_quote_chunk)* }x; | ||||||
201 | |||||||
202 | sub handle_start { | ||||||
203 | 40 | 40 | 75 | my ($self, $tag, $attr, $attrseq, $text) = @_; | |||
204 | |||||||
205 | 40 | 74 | my $meta = $self->{'html_quoted_parser'}; | ||||
206 | 40 | 49 | my $stack = $meta->{'stack'}; | ||||
207 | |||||||
208 | 40 | 50 | 88 | if ( $meta->{'in'}{'br'} ) { | |||
209 | 0 | 0 | $meta->{'in'}{'br'} = 0; | ||||
210 | 0 | 0 | push @{ $stack->[-1] }, $meta->{'current'} = {}; | ||||
0 | 0 | ||||||
211 | } | ||||||
212 | |||||||
213 | 40 | 100 | 100 | 195 | if ( $tag eq 'blockquote' ) { | ||
100 | |||||||
100 | |||||||
214 | 4 | 11 | my $new = [{ quote => 1, block => 1 }]; | ||||
215 | 4 | 5 | push @{ $stack->[-1] }, $new; | ||||
4 | 7 | ||||||
216 | 4 | 6 | push @$stack, $new; # HACK: everything pushed into this | ||||
217 | 4 | 12 | $meta->{'current'} = $new->[0]; | ||||
218 | 4 | 4 | $meta->{'in'}{'quote'}++; | ||||
219 | 4 | 5 | push @{ $meta->{'in'}{'block'} }, 0; | ||||
4 | 7 | ||||||
220 | 4 | 9 | $meta->{'current'}{'raw'} .= $text; | ||||
221 | 4 | 4 | push @{ $stack->[-1] }, $meta->{'current'} = {}; | ||||
4 | 25 | ||||||
222 | } | ||||||
223 | elsif ( $tag eq 'br' && !$meta->{'in'}{'block'}[-1] ) { | ||||||
224 | 14 | 27 | $meta->{'current'}{'raw'} .= $text; | ||||
225 | 14 | 27 | my $line = $meta->{'current'}{'raw'}; | ||||
226 | 14 | 50 | 455 | if ( $line =~ /^\n*($re_quoter)/ ) { | |||
227 | 0 | 0 | $meta->{'current'}{'quoter_raw'} = $1; | ||||
228 | 0 | 0 | $meta->{'current'}{'quoter'} = $self->decode_entities( | ||||
229 | $meta->{'current'}{'quoter_raw'} | ||||||
230 | ); | ||||||
231 | } | ||||||
232 | 14 | 105 | $meta->{'in'}{'br'} = 1; | ||||
233 | } | ||||||
234 | elsif ( !$INLINE_TAG{ $tag } ) { | ||||||
235 | 18 | 100 | 100 | 53 | if ( !$meta->{'in'}{'block'}[-1] && keys %{ $meta->{'current'} } ) { | ||
12 | 51 | ||||||
236 | 4 | 4 | push @{ $stack->[-1] }, $meta->{'current'} = { raw => '' }; | ||||
4 | 22 | ||||||
237 | } | ||||||
238 | 18 | 28 | $meta->{'current'}{'block'} = 1; | ||||
239 | 18 | 33 | $meta->{'current'}{'raw'} .= $text; | ||||
240 | |||||||
241 | 18 | 100 | $meta->{'in'}{'block'}[-1]++; | ||||
242 | } | ||||||
243 | else { | ||||||
244 | 4 | 25 | $meta->{'current'}{'raw'} .= $text; | ||||
245 | } | ||||||
246 | } | ||||||
247 | |||||||
248 | sub handle_end { | ||||||
249 | 32 | 32 | 54 | my ($self, $tag, $text) = @_; | |||
250 | |||||||
251 | 32 | 40 | my $meta = $self->{'html_quoted_parser'}; | ||||
252 | 32 | 37 | my $stack = $meta->{'stack'}; | ||||
253 | |||||||
254 | 32 | 50 | 66 | 109 | if ( $meta->{'in'}{'br'} && $tag ne 'br' ) { | ||
255 | 0 | 0 | $meta->{'in'}{'br'} = 0; | ||||
256 | 0 | 0 | push @{ $stack->[-1] }, $meta->{'current'} = {} | ||||
0 | 0 | ||||||
257 | } | ||||||
258 | |||||||
259 | 32 | 49 | $meta->{'current'}{'raw'} .= $text; | ||||
260 | |||||||
261 | 32 | 100 | 116 | if ( $tag eq 'blockquote' ) { | |||
100 | |||||||
50 | |||||||
50 | |||||||
262 | 4 | 18 | pop @$stack; | ||||
263 | 4 | 4 | pop @{ $meta->{'in'}{'block'} }; | ||||
4 | 6 | ||||||
264 | 4 | 5 | push @{ $stack->[-1] }, $meta->{'current'} = {}; | ||||
4 | 8 | ||||||
265 | 4 | 19 | $meta->{'in'}{'quote'}--; | ||||
266 | } | ||||||
267 | elsif ( $tag eq 'br' ) { | ||||||
268 | 10 | 18 | $meta->{'in'}{'br'} = 0; | ||||
269 | 10 | 12 | push @{ $stack->[-1] }, $meta->{'current'} = {} | ||||
10 | 50 | ||||||
270 | } | ||||||
271 | elsif ( $tag eq 'p' ) { | ||||||
272 | 0 | 0 | push @{ $stack->[-1] }, $meta->{'current'} = {} | ||||
0 | 0 | ||||||
273 | } | ||||||
274 | elsif ( !$INLINE_TAG{ $tag } ) { | ||||||
275 | 18 | 23 | $meta->{'in'}{'block'}[-1]--; | ||||
276 | 18 | 100 | 35 | if ( $meta->{'in'}{'block'}[-1] ) { | |||
277 | 6 | 26 | $meta->{'current'}{'block'} = 1; | ||||
278 | } else { | ||||||
279 | 12 | 12 | push @{ $stack->[-1] }, $meta->{'current'} = {}; | ||||
12 | 59 | ||||||
280 | } | ||||||
281 | } | ||||||
282 | } | ||||||
283 | |||||||
284 | sub decode_entities { | ||||||
285 | 0 | 0 | 0 | my ($self, $string) = @_; | |||
286 | 0 | 0 | 0 | 0 | $string =~ s/(&(?:[a-z]+|#[0-9]|#x[0-9a-f]+);)/ $ENTITIES{$1} || $ENTITIES{lc $1} || $1 /ge; | ||
0 | 0 | ||||||
287 | 0 | 0 | return $string; | ||||
288 | } | ||||||
289 | |||||||
290 | sub handle_text { | ||||||
291 | 36 | 36 | 61 | my ($self, $text) = @_; | |||
292 | 36 | 53 | my $meta = $self->{'html_quoted_parser'}; | ||||
293 | 36 | 100 | 97 | if ( $meta->{'in'}{'br'} ) { | |||
294 | 2 | 3 | $meta->{'in'}{'br'} = 0; | ||||
295 | 2 | 3 | push @{ $meta->{'stack'}[-1] }, $meta->{'current'} = {}; | ||||
2 | 6 | ||||||
296 | } | ||||||
297 | 36 | 261 | $self->{'html_quoted_parser'}{'current'}{'raw'} .= $text; | ||||
298 | } | ||||||
299 | |||||||
300 | sub handle_default { | ||||||
301 | 0 | 0 | my ($self, $event, $text) = @_; | ||||
302 | 0 | my $meta = $self->{'html_quoted_parser'}; | |||||
303 | 0 | 0 | if ( $meta->{'in'}{'br'} ) { | ||||
304 | 0 | $meta->{'in'}{'br'} = 0; | |||||
305 | 0 | push @{ $meta->{'stack'}[-1] }, $meta->{'current'} = {}; | |||||
0 | |||||||
306 | } | ||||||
307 | 0 | $self->{'html_quoted_parser'}{'current'}{'raw'} .= $text; | |||||
308 | } | ||||||
309 | |||||||
310 | =head1 AUTHOR | ||||||
311 | |||||||
312 | Ruslan.Zakirov E |
||||||
313 | |||||||
314 | =head1 LICENSE | ||||||
315 | |||||||
316 | Under the same terms as perl itself. | ||||||
317 | |||||||
318 | =cut | ||||||
319 | |||||||
320 | 1; |