| 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; |