File Coverage

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 I
tag 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 that is not supported then file a bug report
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 and turns it back into HTML.
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 Eruz@bestpractical.comE
313              
314             =head1 LICENSE
315              
316             Under the same terms as perl itself.
317              
318             =cut
319              
320             1;