| blib/lib/Text/Amuse/Preprocessor/HTML.pm | |||
|---|---|---|---|
| Criterion | Covered | Total | % |
| statement | 141 | 146 | 96.5 |
| branch | 70 | 80 | 87.5 |
| condition | 36 | 41 | 87.8 |
| subroutine | 14 | 14 | 100.0 |
| pod | 2 | 2 | 100.0 |
| total | 263 | 283 | 92.9 |
| line | stmt | bran | cond | sub | pod | time | code |
|---|---|---|---|---|---|---|---|
| 1 | package Text::Amuse::Preprocessor::HTML; | ||||||
| 2 | |||||||
| 3 | 12 | 12 | 208185 | use strict; | |||
| 12 | 53 | ||||||
| 12 | 351 | ||||||
| 4 | 12 | 12 | 66 | use warnings; | |||
| 12 | 26 | ||||||
| 12 | 266 | ||||||
| 5 | 12 | 12 | 709 | use utf8; | |||
| 12 | 47 | ||||||
| 12 | 86 | ||||||
| 6 | # use Data::Dumper; | ||||||
| 7 | require Exporter; | ||||||
| 8 | |||||||
| 9 | our @ISA = qw(Exporter); | ||||||
| 10 | |||||||
| 11 | # Items to export into callers namespace by default. Note: do not export | ||||||
| 12 | # names by default without a very good reason. Use EXPORT_OK instead. | ||||||
| 13 | # Do not simply export all your public functions/methods/constants. | ||||||
| 14 | |||||||
| 15 | our @EXPORT_OK = qw( html_to_muse html_file_to_muse ); | ||||||
| 16 | |||||||
| 17 | our $VERSION = '0.67'; | ||||||
| 18 | |||||||
| 19 | =encoding utf8 | ||||||
| 20 | |||||||
| 21 | =head1 NAME | ||||||
| 22 | |||||||
| 23 | Text::Amuse::Preprocessor::HTML - HTML importer | ||||||
| 24 | |||||||
| 25 | =head1 DESCRIPTION | ||||||
| 26 | |||||||
| 27 | This module tries its best to convert the HTML into an acceptable | ||||||
| 28 | Muse string. It's not perfect, though, and some manual adjustment is | ||||||
| 29 | needed if there are tables or complicated structures. | ||||||
| 30 | |||||||
| 31 | =head1 SYNOPSIS | ||||||
| 32 | |||||||
| 33 | use utf8; | ||||||
| 34 | use Text::Amuse::Preprocessor::HTML qw/html_to_muse/; | ||||||
| 35 | my $html = ' Your text here... & " ò àùć ' |
||||||
| 36 | my $muse = html_to_muse($html); | ||||||
| 37 | |||||||
| 38 | =cut | ||||||
| 39 | |||||||
| 40 | 12 | 12 | 7543 | use IO::HTML qw/html_file/; | |||
| 12 | 158339 | ||||||
| 12 | 816 | ||||||
| 41 | 12 | 12 | 7325 | use HTML::PullParser; | |||
| 12 | 85570 | ||||||
| 12 | 484 | ||||||
| 42 | 12 | 12 | 6239 | use Text::Amuse::Utils; | |||
| 12 | 11225 | ||||||
| 12 | 26635 | ||||||
| 43 | |||||||
| 44 | sub _preserve { | ||||||
| 45 | 59 | 59 | 1859 | my %keeptag = ( | |||
| 46 | "em" => [[""], [""]], | ||||||
| 47 | "i" => [[""], [""]], | ||||||
| 48 | "u" => [[""], [""]], | ||||||
| 49 | "strong" => [[""], [""]], | ||||||
| 50 | "b" => [[""], [""]], | ||||||
| 51 | "blockquote" => ["\n\n", "\n"], |
||||||
| 52 | "ol" => ["\n\n", "\n\n"], | ||||||
| 53 | "ul" => ["\n\n", "\n\n"], | ||||||
| 54 | "li" => { ol => [ " 1. ", "\n\n"], | ||||||
| 55 | ul => [ " - ", "\n\n"], | ||||||
| 56 | }, | ||||||
| 57 | "code" => [[""], [""]], |
||||||
| 58 | "a" => [[ "[[" ] , [ "]]" ]], | ||||||
| 59 | "pre" => [ "\n |
||||||
| 60 | table => ["\n\n", "\n\n"], | ||||||
| 61 | "tr" => ["\n ", "" ], | ||||||
| 62 | "td" => [[" "], [" | "] ], | ||||||
| 63 | "th" => [[ " "], [" || "] ], | ||||||
| 64 | "dd" => ["\n\n", "\n\n"], | ||||||
| 65 | "dt" => ["\n***** ", "\n\n" ], | ||||||
| 66 | "h1" => ["\n* ", "\n\n"], | ||||||
| 67 | "h2" => ["\n* ", "\n\n"], | ||||||
| 68 | "h3" => ["\n** ", "\n\n"], | ||||||
| 69 | "h4" => ["\n*** ", "\n\n"], | ||||||
| 70 | "h5" => ["\n**** ", "\n\n"], | ||||||
| 71 | "h6" => ["\n***** ", "\n\n"], | ||||||
| 72 | "sup" => [[""], [""]], | ||||||
| 73 | "sub" => [[""], [""]], | ||||||
| 74 | "strike" => [[" |
||||||
| 75 | "del" => [[" |
||||||
| 76 | "p" => ["\n\n", "\n\n"], | ||||||
| 77 | "br" => ["\n ", "\n"], |
||||||
| 78 | "div" => ["\n\n", "\n\n"], | ||||||
| 79 | "center" => ["\n\n |
||||||
| 80 | "right" => ["\n\n |
||||||
| 81 | ); | ||||||
| 82 | 59 | 774 | return %keeptag; | ||||
| 83 | } | ||||||
| 84 | |||||||
| 85 | =head1 FUNCTIONS | ||||||
| 86 | |||||||
| 87 | =head2 html_to_muse($html_decoded_text) | ||||||
| 88 | |||||||
| 89 | The first argument must be a decoded string with the HTML text. | ||||||
| 90 | Returns the L |
||||||
| 91 | |||||||
| 92 | =head2 html_file_to_muse($html_file) | ||||||
| 93 | |||||||
| 94 | The first argument must be a filename. | ||||||
| 95 | |||||||
| 96 | =cut | ||||||
| 97 | |||||||
| 98 | sub html_to_muse { | ||||||
| 99 | 39 | 39 | 1 | 20083 | my ($rawtext, $opts) = @_; | ||
| 100 | 39 | 50 | 116 | return unless defined $rawtext; | |||
| 101 | # pack the things like hello there with space. Be careful | ||||||
| 102 | # with recursions. | ||||||
| 103 | 39 | 99 | return _html_to_muse(\$rawtext, $opts); | ||||
| 104 | } | ||||||
| 105 | |||||||
| 106 | sub html_file_to_muse { | ||||||
| 107 | 20 | 20 | 1 | 24619 | my ($text, $opts) = @_; | ||
| 108 | 20 | 50 | 303 | die "$text is not a file" unless (-f $text); | |||
| 109 | 20 | 93 | return _html_to_muse(html_file($text), $opts); | ||||
| 110 | } | ||||||
| 111 | |||||||
| 112 | sub _html_to_muse { | ||||||
| 113 | 59 | 59 | 6077 | my ($text, $options) = @_; | |||
| 114 | 59 | 100 | 305 | $options ||= {}; | |||
| 115 | 59 | 142 | my %preserved = _preserve(); | ||||
| 116 | 59 | 356 | my $is_rtl = Text::Amuse::Utils::lang_code_is_rtl($options->{lang}); | ||||
| 117 | 59 | 100 | 684 | if ($is_rtl) { | |||
| 118 | 2 | 5 | delete $preserved{right}; | ||||
| 119 | } | ||||||
| 120 | 59 | 292 | my %opts = ( | ||||
| 121 | start => '"S", tagname, attr', | ||||||
| 122 | end => '"E", tagname', | ||||||
| 123 | text => '"T", dtext', | ||||||
| 124 | empty_element_tags => 1, | ||||||
| 125 | marked_sections => 1, | ||||||
| 126 | unbroken_text => 1, | ||||||
| 127 | ignore_elements => [qw(script style)], | ||||||
| 128 | ); | ||||||
| 129 | 59 | 100 | 207 | if (ref($text) eq 'SCALAR') { | |||
| 50 | |||||||
| 130 | 39 | 80 | $opts{doc} = $text; | ||||
| 131 | } | ||||||
| 132 | elsif (ref($text) eq 'GLOB') { | ||||||
| 133 | 20 | 46 | $opts{file} = $text; | ||||
| 134 | } | ||||||
| 135 | else { | ||||||
| 136 | 0 | 0 | die "Nor a ref, nor a file!"; | ||||
| 137 | } | ||||||
| 138 | |||||||
| 139 | 59 | 50 | 307 | my $p = HTML::PullParser->new(%opts) or die $!; | |||
| 140 | 59 | 7356 | my @textstack; | ||||
| 141 | my @spanpile; | ||||||
| 142 | 59 | 0 | my @lists; | ||||
| 143 | 59 | 0 | my @parspile; | ||||
| 144 | 59 | 139 | my @tagpile = ('root'); | ||||
| 145 | 59 | 93 | my $current = ''; | ||||
| 146 | 59 | 158 | while (my $token = $p->get_token) { | ||||
| 147 | 1548 | 25701 | my $type = shift @$token; | ||||
| 148 | # starttag? | ||||||
| 149 | 1548 | 100 | 3371 | if ($type eq 'S') { | |||
| 100 | |||||||
| 50 | |||||||
| 150 | 498 | 812 | my $tag = shift @$token; | ||||
| 151 | 498 | 955 | push @tagpile, $tag; | ||||
| 152 | 498 | 846 | $current = $tag; | ||||
| 153 | 498 | 759 | my $attr = shift @$token; | ||||
| 154 | # see if processing of span or font are needed | ||||||
| 155 | 498 | 100 | 66 | 2698 | if (($tag eq 'span') or ($tag eq 'font')) { | ||
| 100 | 100 | ||||||
| 100 | 100 | ||||||
| 156 | 71 | 149 | $tag = _span_process_attr($attr); | ||||
| 157 | 71 | 134 | push @spanpile, $tag; | ||||
| 158 | } | ||||||
| 159 | elsif (($tag eq "ol") or ($tag eq "ul")) { | ||||||
| 160 | 6 | 43 | push @lists, $tag; | ||||
| 161 | } | ||||||
| 162 | elsif (($tag eq 'p') or ($tag eq 'div')) { | ||||||
| 163 | 117 | 341 | $tag = _pars_process_attr($tag, $attr, { rtl => $is_rtl }); | ||||
| 164 | 117 | 284 | push @parspile, $tag; | ||||
| 165 | } | ||||||
| 166 | # see if we want to skip it. | ||||||
| 167 | 498 | 100 | 100 | 1545 | if ((defined $tag) && (exists $preserved{$tag})) { | ||
| 168 | |||||||
| 169 | # is it a list? | ||||||
| 170 | 330 | 100 | 757 | if (ref($preserved{$tag}) eq "HASH") { | |||
| 171 | # does it have a parent? | ||||||
| 172 | 18 | 50 | 57 | if (my $parent = $lists[$#lists]) { | |||
| 173 | push @textstack, "\n", | ||||||
| 174 | " " x $#lists, | ||||||
| 175 | 18 | 58 | $preserved{$tag}{$parent}[0]; | ||||
| 176 | } else { | ||||||
| 177 | push @textstack, "\n", | ||||||
| 178 | 0 | 0 | $preserved{$tag}{ul}[0]; | ||||
| 179 | } | ||||||
| 180 | } | ||||||
| 181 | # no? ok | ||||||
| 182 | else { | ||||||
| 183 | 312 | 691 | push @textstack, $preserved{$tag}[0]; | ||||
| 184 | } | ||||||
| 185 | } | ||||||
| 186 | 498 | 100 | 100 | 2296 | if ((defined $tag) && | ||
| 100 | |||||||
| 187 | ($tag eq 'a') && | ||||||
| 188 | (my $href = $attr->{href})) { | ||||||
| 189 | 19 | 80 | push @textstack, [ $href, "][" ]; | ||||
| 190 | } | ||||||
| 191 | } | ||||||
| 192 | |||||||
| 193 | # stoptag? | ||||||
| 194 | elsif ($type eq 'E') { | ||||||
| 195 | 478 | 747 | $current = ''; | ||||
| 196 | 478 | 748 | my $tag = shift @$token; | ||||
| 197 | 478 | 752 | my $expected = pop @tagpile; | ||||
| 198 | 478 | 100 | 983 | if ($expected ne $tag) { | |||
| 199 | 11 | 1005 | warn "tagpile mismatch: $expected, $tag\n"; | ||||
| 200 | } | ||||||
| 201 | |||||||
| 202 | 478 | 100 | 66 | 2399 | if (($tag eq 'span') or ($tag eq 'font')) { | ||
| 100 | 100 | ||||||
| 100 | 100 | ||||||
| 203 | 71 | 110 | $tag = pop @spanpile; | ||||
| 204 | } | ||||||
| 205 | elsif (($tag eq "ol") or ($tag eq "ul")) { | ||||||
| 206 | 6 | 9 | $tag = pop @lists; | ||||
| 207 | } | ||||||
| 208 | elsif (($tag eq 'p') or ($tag eq 'div')) { | ||||||
| 209 | 118 | 100 | 238 | if (@parspile) { | |||
| 210 | 117 | 189 | $tag = pop @parspile | ||||
| 211 | } | ||||||
| 212 | } | ||||||
| 213 | |||||||
| 214 | 478 | 100 | 100 | 1708 | if ($tag && (exists $preserved{$tag})) { | ||
| 215 | 329 | 100 | 719 | if (ref($preserved{$tag}) eq "HASH") { | |||
| 216 | 18 | 50 | 57 | if (my $parent = $lists[$#lists]) { | |||
| 217 | 18 | 92 | push @textstack, $preserved{$tag}{$parent}[1]; | ||||
| 218 | } else { | ||||||
| 219 | 0 | 0 | push @textstack, $preserved{$tag}{ul}[1]; | ||||
| 220 | } | ||||||
| 221 | } else { | ||||||
| 222 | 311 | 932 | push @textstack, $preserved{$tag}[1]; | ||||
| 223 | } | ||||||
| 224 | } | ||||||
| 225 | } | ||||||
| 226 | # regular text | ||||||
| 227 | elsif ($type eq 'T') { | ||||||
| 228 | 572 | 849 | my $line = shift @$token; | ||||
| 229 | # Word &C. (and CKeditor), love the no-break space. | ||||||
| 230 | # but preserve it it's only whitespace in the line. | ||||||
| 231 | 572 | 1245 | $line =~ s/\r//gs; | ||||
| 232 | 572 | 1078 | $line =~ s/\t/ /gs; | ||||
| 233 | # at the beginning of the tag | ||||||
| 234 | 572 | 100 | 1514 | if ($current =~ m/^(p|div)$/) { | |||
| 235 | 79 | 100 | 356 | if ($line =~ m/\A\s*([\x{a0} ]+)\s*\z/) { | |||
| 236 | 22 | 36 | $line = "\n \n"; |
||||
| 237 | } | ||||||
| 238 | } | ||||||
| 239 | 572 | 1004 | $line =~ s/\x{a0}/ /gs; | ||||
| 240 | # remove leading spaces from these tags | ||||||
| 241 | 572 | 100 | 1232 | if ($current =~ m/^(h[1-6]|li|ul|ol|p|div)$/) { | |||
| 242 | 116 | 403 | $line =~ s/^\s+//gms; | ||||
| 243 | } | ||||||
| 244 | 572 | 100 | 1116 | if ($current ne 'pre') { | |||
| 245 | 565 | 1907 | push @textstack, [ $line ]; | ||||
| 246 | } | ||||||
| 247 | else { | ||||||
| 248 | 7 | 55 | push @textstack, $line; | ||||
| 249 | } | ||||||
| 250 | } else { | ||||||
| 251 | 0 | 0 | warn "which type? $type??\n" | ||||
| 252 | } | ||||||
| 253 | } | ||||||
| 254 | 59 | 831 | my @current_text; | ||||
| 255 | my @processed; | ||||||
| 256 | 59 | 135 | while (@textstack) { | ||||
| 257 | 1286 | 2083 | my $text = shift(@textstack); | ||||
| 258 | 1286 | 100 | 2214 | if (ref($text)) { | |||
| 259 | 852 | 1941 | push @current_text, @$text; | ||||
| 260 | } | ||||||
| 261 | else { | ||||||
| 262 | 434 | 816 | push @processed, _merge_text_lines(\@current_text); | ||||
| 263 | 434 | 1118 | push @processed, $text; | ||||
| 264 | } | ||||||
| 265 | } | ||||||
| 266 | 59 | 136 | push @processed, _merge_text_lines(\@current_text); | ||||
| 267 | 59 | 260 | my $full = join("", @processed); | ||||
| 268 | 59 | 444 | $full =~ s/\n\n\n+/\n\n/gs; | ||||
| 269 | 59 | 1363 | return $full; | ||||
| 270 | } | ||||||
| 271 | |||||||
| 272 | sub _cleanup_text_block { | ||||||
| 273 | 325 | 325 | 499 | my $parsed = shift; | |||
| 274 | 325 | 50 | 637 | return '' unless defined $parsed; | |||
| 275 | # here we are inside a single text block. | ||||||
| 276 | 325 | 2292 | $parsed =~ s/\s+/ /gs; | ||||
| 277 | # print "<<<$parsed>>>\n"; | ||||||
| 278 | # clean the footnotes. | ||||||
| 279 | 325 | 886 | $parsed =~ s!\[ | ||||
| 280 | \[ | ||||||
| 281 | \#\w+ # the anchor | ||||||
| 282 | \] | ||||||
| 283 | \[ | ||||||
| 284 | (<(sup|strong|em)>|\[)? # sup or [ | ||||||
| 285 | \[* | ||||||
| 286 | (\d+) # the number | ||||||
| 287 | \]* | ||||||
| 288 | ((sup|strong|em)>|\])? # sup or ] | ||||||
| 289 | \] # close | ||||||
| 290 | \] # close | ||||||
| 291 | ![$3]!gx; | ||||||
| 292 | |||||||
| 293 | # add a newline if missing | ||||||
| 294 | # unless ($parsed =~ m/\n\z/) { | ||||||
| 295 | # $parsed .= "\n"; | ||||||
| 296 | # } | ||||||
| 297 | 325 | 477 | my $recursion = 0; | ||||
| 298 | 325 | 66 | 1539 | while (($parsed =~ m!( |<[^/]+?> )!) && ($recursion < 20)) { | |||
| 299 | 41 | 260 | $parsed =~ s!( +)()!$2$1!g; | ||||
| 300 | 41 | 292 | $parsed =~ s!(<[^/]*?>)( +)!$2$1!g; | ||||
| 301 | 41 | 250 | $recursion++; | ||||
| 302 | } | ||||||
| 303 | # empty links artifacts. | ||||||
| 304 | 325 | 680 | $parsed =~ s/\[\[\]\]//g; | ||||
| 305 | 325 | 1984 | $parsed =~ s/\s+/ /gs; | ||||
| 306 | 325 | 917 | $parsed =~ s/\A\s+//; | ||||
| 307 | 325 | 1198 | $parsed =~ s/\s+\z//; | ||||
| 308 | 325 | 623 | $parsed =~ s/^\*/ */gm; | ||||
| 309 | # print ">>>$parsed<<<\n"; | ||||||
| 310 | 325 | 746 | return $parsed; | ||||
| 311 | } | ||||||
| 312 | |||||||
| 313 | sub _span_process_attr { | ||||||
| 314 | 71 | 71 | 107 | my $attr = shift; | |||
| 315 | 71 | 97 | my $tag; | ||||
| 316 | 71 | 202 | my @attrsvalues = values %$attr; | ||||
| 317 | 71 | 100 | 588 | if (grep(/italic/i, @attrsvalues)) { | |||
| 100 | |||||||
| 318 | 8 | 16 | $tag = "em"; | ||||
| 319 | } | ||||||
| 320 | elsif (grep(/bold/i, @attrsvalues)) { | ||||||
| 321 | 8 | 36 | $tag = "strong"; | ||||
| 322 | } | ||||||
| 323 | else { | ||||||
| 324 | 55 | 109 | $tag = undef; | ||||
| 325 | } | ||||||
| 326 | 71 | 144 | return $tag; | ||||
| 327 | } | ||||||
| 328 | |||||||
| 329 | sub _pars_process_attr { | ||||||
| 330 | 117 | 117 | 262 | my ($tag, $attr, $opts) = @_; | |||
| 331 | # warn Dumper($attr); | ||||||
| 332 | 117 | 100 | 267 | if (my $style = $attr->{style}) { | |||
| 333 | 25 | 100 | 137 | if ($style =~ m/text-align:\s*center/i) { | |||
| 334 | 5 | 11 | $tag = 'center'; | ||||
| 335 | } | ||||||
| 336 | 25 | 100 | 100 | 142 | if (!$opts->{rtl} and $style =~ m/text-align:\s*right/i) { | ||
| 337 | 7 | 15 | $tag = 'right'; | ||||
| 338 | } | ||||||
| 339 | 25 | 100 | 97 | if ($style =~ m/padding-left:\s*\d/si) { | |||
| 340 | 2 | 5 | $tag = 'blockquote' | ||||
| 341 | } | ||||||
| 342 | } | ||||||
| 343 | 117 | 100 | 253 | if (my $align = $attr->{align}) { | |||
| 344 | 2 | 50 | 7 | if ($align =~ m/center/i) { | |||
| 345 | 0 | 0 | $tag = 'center'; | ||||
| 346 | } | ||||||
| 347 | 2 | 50 | 33 | 18 | if (!$opts->{rtl} and $align =~ m/right/i) { | ||
| 348 | 2 | 7 | $tag = 'right'; | ||||
| 349 | } | ||||||
| 350 | } | ||||||
| 351 | 117 | 271 | return $tag; | ||||
| 352 | } | ||||||
| 353 | |||||||
| 354 | sub _merge_text_lines { | ||||||
| 355 | 493 | 493 | 676 | my $lines = shift; | |||
| 356 | 493 | 100 | 1038 | return '' unless @$lines; | |||
| 357 | 325 | 741 | my $text = join ('', @$lines); | ||||
| 358 | 325 | 624 | @$lines = (); | ||||
| 359 | 325 | 570 | return _cleanup_text_block($text); | ||||
| 360 | } | ||||||
| 361 | |||||||
| 362 | 1; | ||||||
| 363 | |||||||
| 364 | |||||||
| 365 | =head1 AUTHOR, LICENSE, ETC., | ||||||
| 366 | |||||||
| 367 | See L |
||||||
| 368 | |||||||
| 369 | =cut | ||||||
| 370 | |||||||
| 371 | # Local Variables: | ||||||
| 372 | # tab-width: 8 | ||||||
| 373 | # cperl-indent-level: 2 | ||||||
| 374 | # End: |