| blib/lib/Text/HTML/Turndown.pm | |||
|---|---|---|---|
| Criterion | Covered | Total | % |
| statement | 170 | 172 | 98.8 |
| branch | 16 | 20 | 80.0 |
| condition | 13 | 18 | 72.2 |
| subroutine | 33 | 33 | 100.0 |
| pod | 1 | 14 | 7.1 |
| total | 233 | 257 | 90.6 |
| line | stmt | bran | cond | sub | pod | time | code |
|---|---|---|---|---|---|---|---|
| 1 | package Text::HTML::Turndown 0.12; | ||||||
| 2 | 6 | 6 | 1331262 | use 5.020; | |||
| 6 | 28 | ||||||
| 3 | 6 | 6 | 3987 | use Moo 2; | |||
| 6 | 55432 | ||||||
| 6 | 41 | ||||||
| 4 | 6 | 6 | 15362 | use experimental 'signatures'; | |||
| 6 | 24507 | ||||||
| 6 | 43 | ||||||
| 5 | 6 | 6 | 4911 | use stable 'postderef'; | |||
| 6 | 2907 | ||||||
| 6 | 42 | ||||||
| 6 | 6 | 6 | 735 | use Exporter 'import'; | |||
| 6 | 18 | ||||||
| 6 | 222 | ||||||
| 7 | 6 | 6 | 1643 | use XML::LibXML; | |||
| 6 | 123967 | ||||||
| 6 | 52 | ||||||
| 8 | 6 | 6 | 1395 | use List::Util 'reduce', 'max'; | |||
| 6 | 18 | ||||||
| 6 | 560 | ||||||
| 9 | 6 | 6 | 4388 | use List::MoreUtils 'first_index'; | |||
| 6 | 117159 | ||||||
| 6 | 54 | ||||||
| 10 | 6 | 6 | 9256 | use Carp 'croak'; | |||
| 6 | 27 | ||||||
| 6 | 470 | ||||||
| 11 | 6 | 6 | 3873 | use Module::Load 'load'; | |||
| 6 | 9661 | ||||||
| 6 | 47 | ||||||
| 12 | |||||||
| 13 | 6 | 6 | 4101 | use Text::HTML::Turndown::Rules; | |||
| 6 | 30 | ||||||
| 6 | 260 | ||||||
| 14 | 6 | 6 | 4094 | use Text::HTML::Turndown::Node; | |||
| 6 | 28 | ||||||
| 6 | 321 | ||||||
| 15 | 6 | 6 | 3791 | use Text::HTML::CollapseWhitespace 'collapseWhitespace'; | |||
| 6 | 22 | ||||||
| 6 | 601 | ||||||
| 16 | 6 | 6 | 3387 | use Text::HTML::ExtractInfo 'extract_info'; | |||
| 6 | 20 | ||||||
| 6 | 518 | ||||||
| 17 | 6 | 6 | 3431 | use Text::FrontMatter::YAML; | |||
| 6 | 133552 | ||||||
| 6 | 46281 | ||||||
| 18 | |||||||
| 19 | our @EXPORT_OK = ('html2markdown'); | ||||||
| 20 | |||||||
| 21 | =head1 NAME | ||||||
| 22 | |||||||
| 23 | Text::HTML::Turndown - convert HTML to Markdown | ||||||
| 24 | |||||||
| 25 | =head1 SYNOPSIS | ||||||
| 26 | |||||||
| 27 | use Text::HTML::Turndown; | ||||||
| 28 | my $convert = Text::HTML::Turndown->new(); | ||||||
| 29 | my $markdown = $convert->turndown(<<'HTML'); | ||||||
| 30 | Hello world! |
||||||
| 31 | HTML | ||||||
| 32 | # Hello world! | ||||||
| 33 | # ------------ | ||||||
| 34 | |||||||
| 35 | This is an adapation of the C |
||||||
| 36 | |||||||
| 37 | =cut | ||||||
| 38 | |||||||
| 39 | our %COMMONMARK_RULES = ( | ||||||
| 40 | paragraph => { | ||||||
| 41 | filter => 'p', | ||||||
| 42 | replacement => sub( $content, $node, $options, $context ) { | ||||||
| 43 | return "\n\n" . $content . "\n\n" | ||||||
| 44 | }, | ||||||
| 45 | }, | ||||||
| 46 | |||||||
| 47 | lineBreak => { | ||||||
| 48 | filter => 'br', | ||||||
| 49 | |||||||
| 50 | replacement => sub( $content, $node, $options, $context ) { | ||||||
| 51 | return $options->{br} . "\n" | ||||||
| 52 | } | ||||||
| 53 | }, | ||||||
| 54 | |||||||
| 55 | heading => { | ||||||
| 56 | filter => ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], | ||||||
| 57 | |||||||
| 58 | replacement => sub( $content, $node, $options, $context ) { | ||||||
| 59 | if( $node->nodeName !~ /\AH(\d)\z/i ) { | ||||||
| 60 | croak sprintf "Unknown node name '%s' for heading", $node->nodeName; | ||||||
| 61 | } | ||||||
| 62 | my $hLevel = $1; | ||||||
| 63 | |||||||
| 64 | if (($options->{headingStyle} // '') eq 'setext' && $hLevel < 3) { | ||||||
| 65 | my $underline = ($hLevel == 1 ? '=' : '-') x length($content); | ||||||
| 66 | return ( | ||||||
| 67 | "\n\n" . $content . "\n" . $underline . "\n\n" | ||||||
| 68 | ) | ||||||
| 69 | } else { | ||||||
| 70 | return "\n\n" . ('#'x $hLevel) . ' ' . $content . "\n\n" | ||||||
| 71 | } | ||||||
| 72 | } | ||||||
| 73 | }, | ||||||
| 74 | |||||||
| 75 | blockquote => { | ||||||
| 76 | filter => 'blockquote', | ||||||
| 77 | |||||||
| 78 | replacement => sub( $content, $node, $options, $context ) { | ||||||
| 79 | $content =~ s/^\n+|\n+$//g; | ||||||
| 80 | $content =~ s/^/> /gm; | ||||||
| 81 | return "\n\n" . $content . "\n\n" | ||||||
| 82 | } | ||||||
| 83 | }, | ||||||
| 84 | |||||||
| 85 | |||||||
| 86 | list => { | ||||||
| 87 | filter => ['ul', 'ol'], | ||||||
| 88 | |||||||
| 89 | replacement => sub( $content, $node, $options, $context ) { | ||||||
| 90 | my $parent = $node->parentNode; | ||||||
| 91 | if (uc $parent->nodeName eq 'LI' && $parent->lastChild->isEqual($node->_node)) { | ||||||
| 92 | return "\n" . $content | ||||||
| 93 | } else { | ||||||
| 94 | return "\n\n" . $content . "\n\n" | ||||||
| 95 | } | ||||||
| 96 | } | ||||||
| 97 | }, | ||||||
| 98 | |||||||
| 99 | listItem => { | ||||||
| 100 | filter => 'li', | ||||||
| 101 | |||||||
| 102 | replacement => sub( $content, $node, $options, $context ) { | ||||||
| 103 | my $prefix = $options->{bulletListMarker} . ' '; | ||||||
| 104 | my $parent = $node->parentNode; | ||||||
| 105 | if (uc $parent->nodeName eq 'OL') { | ||||||
| 106 | my $start = $parent->getAttribute('start'); | ||||||
| 107 | my @ch = grep { $_->nodeType == 1 } $parent->childNodes; | ||||||
| 108 | #my @ch = $parent->childNodes; | ||||||
| 109 | my $index = first_index { $_->isEqual($node->_node) } @ch; | ||||||
| 110 | $prefix = ($start ? $start + $index : $index + 1) . '. ' | ||||||
| 111 | } | ||||||
| 112 | $content =~ s/^\n+//; # remove leading newlines | ||||||
| 113 | $content =~ s/\n+$/\n/; # replace trailing newlines with just a single one | ||||||
| 114 | $content =~ s/\n/"\n" . (' ' x length($prefix))/gem; # indent | ||||||
| 115 | return ( | ||||||
| 116 | $prefix . $content . ($node->nextSibling && $content !~ /\n$/ ? "\n" : '') | ||||||
| 117 | ) | ||||||
| 118 | } | ||||||
| 119 | }, | ||||||
| 120 | |||||||
| 121 | indentedCodeBlock => { | ||||||
| 122 | filter => sub ($rule, $node, $options) { | ||||||
| 123 | return ( | ||||||
| 124 | $options->{codeBlockStyle} eq 'indented' && | ||||||
| 125 | uc $node->nodeName eq 'PRE' && | ||||||
| 126 | $node->firstNonBlankChild && | ||||||
| 127 | uc $node->firstNonBlankChild->nodeName eq 'CODE' | ||||||
| 128 | ) | ||||||
| 129 | }, | ||||||
| 130 | replacement => sub( $content, $node, $options, $context ) { | ||||||
| 131 | my @textChildren = $node->firstNonBlankChild->nonBlankChildNodes; | ||||||
| 132 | # Replace any with a newline: |
||||||
| 133 | @textChildren = map { | ||||||
| 134 | if( $_->nodeType == 3 ) { | ||||||
| 135 | $_->textContent =~ s/\n/\n /gr | ||||||
| 136 | } elsif( $_->nodeType == 1 and uc $_->nodeName eq 'BR' ) { | ||||||
| 137 | "\n " | ||||||
| 138 | } else { | ||||||
| 139 | # could be an HTML comment | ||||||
| 140 | '' | ||||||
| 141 | } | ||||||
| 142 | } @textChildren; | ||||||
| 143 | my $res = ( | ||||||
| 144 | join( '', | ||||||
| 145 | "\n\n ", | ||||||
| 146 | @textChildren, | ||||||
| 147 | "\n\n", | ||||||
| 148 | ) | ||||||
| 149 | ); | ||||||
| 150 | return $res; | ||||||
| 151 | }, | ||||||
| 152 | }, | ||||||
| 153 | |||||||
| 154 | fencedCodeBlock => { | ||||||
| 155 | filter => sub($rule, $node, $options) { | ||||||
| 156 | return ( | ||||||
| 157 | $options->{codeBlockStyle} eq 'fenced' && | ||||||
| 158 | uc $node->nodeName eq 'PRE' && | ||||||
| 159 | $node->firstNonBlankChild && | ||||||
| 160 | uc $node->firstNonBlankChild->nodeName eq 'CODE' | ||||||
| 161 | ) | ||||||
| 162 | }, | ||||||
| 163 | |||||||
| 164 | replacement => sub( $content, $node, $options, $context ) { | ||||||
| 165 | my $className = $node->firstChild->getAttribute('class') || ''; | ||||||
| 166 | (my $language) = ($className =~ /language-(\S+)/); | ||||||
| 167 | $language //= ''; | ||||||
| 168 | my @textChildren = $node->firstNonBlankChild->nonBlankChildNodes; | ||||||
| 169 | # Replace any with a newline: |
||||||
| 170 | @textChildren = map { | ||||||
| 171 | if( $_->nodeType == 3 ) { | ||||||
| 172 | $_->textContent | ||||||
| 173 | } elsif( $_->nodeType == 1 and uc $_->nodeName eq 'BR' ) { | ||||||
| 174 | "\n" | ||||||
| 175 | } else { | ||||||
| 176 | # could be an HTML comment | ||||||
| 177 | '' | ||||||
| 178 | } | ||||||
| 179 | } @textChildren; | ||||||
| 180 | if( @textChildren ) { | ||||||
| 181 | $textChildren[-1] =~ s/\n\z//; | ||||||
| 182 | } | ||||||
| 183 | |||||||
| 184 | my $fenceChar = substr( $options->{fence}, 0, 1 ); | ||||||
| 185 | my $fenceSize = 3; | ||||||
| 186 | my $fenceInCodeRegex = qr{^${fenceChar}{$fenceSize,}}; | ||||||
| 187 | for ($content =~ /($fenceInCodeRegex)/gm) { | ||||||
| 188 | if (length( $_ ) >= $fenceSize) { | ||||||
| 189 | $fenceSize = length( $_ ) + 1 | ||||||
| 190 | } | ||||||
| 191 | } | ||||||
| 192 | |||||||
| 193 | my $fence = $fenceChar x $fenceSize; | ||||||
| 194 | return ( | ||||||
| 195 | join '', | ||||||
| 196 | "\n\n" . $fence . $language . "\n", | ||||||
| 197 | @textChildren, | ||||||
| 198 | "\n" . $fence . "\n\n" | ||||||
| 199 | ) | ||||||
| 200 | } | ||||||
| 201 | }, | ||||||
| 202 | horizontalRule => { | ||||||
| 203 | filter => 'hr', | ||||||
| 204 | |||||||
| 205 | replacement => sub( $content, $node, $options, $context ) { | ||||||
| 206 | return "\n\n" . $options->{hr} . "\n\n" | ||||||
| 207 | } | ||||||
| 208 | }, | ||||||
| 209 | |||||||
| 210 | inlineLink => { | ||||||
| 211 | filter => sub ($rule, $node, $options) { | ||||||
| 212 | return ( | ||||||
| 213 | $options->{linkStyle} eq 'inlined' && | ||||||
| 214 | uc $node->nodeName eq 'A' && | ||||||
| 215 | $node->getAttribute('href') | ||||||
| 216 | ) | ||||||
| 217 | }, | ||||||
| 218 | |||||||
| 219 | replacement => sub( $content, $node, $options, $context ) { | ||||||
| 220 | my $href = $node->getAttribute('href'); | ||||||
| 221 | if ($href) { $href =~s/([\(\)])/\\$1/g }; | ||||||
| 222 | my $title = cleanAttribute($node->getAttribute('title')); | ||||||
| 223 | if ($title) { $title = ' "' . ( $title =~ s/"/\\"/gr ) . '"'; }; | ||||||
| 224 | |||||||
| 225 | # Don't emit a link if it has no content | ||||||
| 226 | # Content might be an image | ||||||
| 227 | if( length($content)) { | ||||||
| 228 | return "[$content]($href$title)" | ||||||
| 229 | } else { | ||||||
| 230 | return "" | ||||||
| 231 | } | ||||||
| 232 | } | ||||||
| 233 | }, | ||||||
| 234 | |||||||
| 235 | referenceLink => { | ||||||
| 236 | filter => sub ($rule, $node, $options) { | ||||||
| 237 | return ( | ||||||
| 238 | $options->{linkStyle} eq 'referenced' && | ||||||
| 239 | uc $node->nodeName eq 'A' && | ||||||
| 240 | $node->getAttribute('href') | ||||||
| 241 | ) | ||||||
| 242 | }, | ||||||
| 243 | |||||||
| 244 | replacement => sub( $content, $node, $options, $context ) { | ||||||
| 245 | my $href = $node->getAttribute('href'); | ||||||
| 246 | my $title = cleanAttribute($node->getAttribute('title')); | ||||||
| 247 | if ($title) { $title = ' "$title"' }; | ||||||
| 248 | my $replacement; | ||||||
| 249 | my $reference; | ||||||
| 250 | |||||||
| 251 | if( $options->{linkReferenceStyle} eq 'collapsed' ) { | ||||||
| 252 | $replacement = '[' . $content . '][]'; | ||||||
| 253 | $reference = '[' . $content . ']: ' . $href .$title; | ||||||
| 254 | |||||||
| 255 | } elsif( $options->{linkReferenceStyle} eq 'shortcut' ) { | ||||||
| 256 | $replacement = '[' . $content . ']'; | ||||||
| 257 | $reference = '[' . $content . ']: ' . $href .$title; | ||||||
| 258 | |||||||
| 259 | } else { | ||||||
| 260 | my $id = scalar $context->{references}->@* + 1; | ||||||
| 261 | $replacement = '[' . $content . '][' . $id . ']'; | ||||||
| 262 | $reference = '[' . $id . ']: ' . $href . $title; | ||||||
| 263 | } | ||||||
| 264 | |||||||
| 265 | push $context->{references}->@*, $reference; | ||||||
| 266 | return $replacement | ||||||
| 267 | }, | ||||||
| 268 | |||||||
| 269 | append => sub ($options, $context) { | ||||||
| 270 | my $references = ''; | ||||||
| 271 | if ($context->{references}->@*) { | ||||||
| 272 | $references = "\n\n" . join( "\n", $context->{references}->@* ) . "\n\n"; | ||||||
| 273 | $context->{references} = []; # Reset references | ||||||
| 274 | } | ||||||
| 275 | return $references | ||||||
| 276 | } | ||||||
| 277 | }, | ||||||
| 278 | |||||||
| 279 | |||||||
| 280 | emphasis => { | ||||||
| 281 | filter => ['em', 'i'], | ||||||
| 282 | |||||||
| 283 | replacement => sub( $content, $node, $options, $context ) { | ||||||
| 284 | if ($content !~ /\S/) { return '' }; | ||||||
| 285 | return $options->{emDelimiter} . $content . $options->{emDelimiter} | ||||||
| 286 | } | ||||||
| 287 | }, | ||||||
| 288 | |||||||
| 289 | strong => { | ||||||
| 290 | filter => ['strong', 'b'], | ||||||
| 291 | |||||||
| 292 | replacement => sub( $content, $node, $options, $context ) { | ||||||
| 293 | if ($content !~ /\S/) { return '' }; | ||||||
| 294 | return $options->{strongDelimiter} . $content . $options->{strongDelimiter} | ||||||
| 295 | } | ||||||
| 296 | }, | ||||||
| 297 | |||||||
| 298 | code => { | ||||||
| 299 | filter => sub ($rule, $node, $options) { | ||||||
| 300 | my $hasSiblings = $node->previousSibling || $node->nextSibling; | ||||||
| 301 | my $isCodeBlock = (uc $node->parentNode->nodeName eq 'PRE') && !$hasSiblings; | ||||||
| 302 | |||||||
| 303 | return ((uc $node->nodeName eq 'CODE') && !$isCodeBlock) | ||||||
| 304 | }, | ||||||
| 305 | |||||||
| 306 | replacement => sub( $content, $node, $options, $context ) { | ||||||
| 307 | if (!$content) { return '' }; | ||||||
| 308 | $content =~ s/\r?\n|\r/ /g; | ||||||
| 309 | my $extraSpace = $content =~ /^`|^ .*?[^ ].* $|`$/ ? ' ' : ''; | ||||||
| 310 | my $delimiter = '`'; | ||||||
| 311 | my @matches = $content =~ /`+/gm; | ||||||
| 312 | while (grep { $_ eq $delimiter } @matches) { | ||||||
| 313 | $delimiter .= '`'; | ||||||
| 314 | } | ||||||
| 315 | |||||||
| 316 | return $delimiter . $extraSpace . $content . $extraSpace . $delimiter; | ||||||
| 317 | } | ||||||
| 318 | }, | ||||||
| 319 | |||||||
| 320 | image => { | ||||||
| 321 | filter => 'img', | ||||||
| 322 | |||||||
| 323 | replacement => sub( $content, $node, $options, $context ) { | ||||||
| 324 | my $alt = Text::HTML::Turndown::->escape(cleanAttribute($node->getAttribute('alt'))); | ||||||
| 325 | my $src = $node->getAttribute('src') || ''; | ||||||
| 326 | my $title = cleanAttribute($node->getAttribute('title')); | ||||||
| 327 | my $titlePart = $title ? ' "' . $title . '"' : ''; | ||||||
| 328 | return $src ? "" : ""; | ||||||
| 329 | } | ||||||
| 330 | }, | ||||||
| 331 | ); | ||||||
| 332 | |||||||
| 333 | has 'rules' => ( | ||||||
| 334 | is => 'ro', | ||||||
| 335 | required => 1, | ||||||
| 336 | ); | ||||||
| 337 | |||||||
| 338 | has 'options' => ( | ||||||
| 339 | is => 'lazy', | ||||||
| 340 | default => sub { {} }, | ||||||
| 341 | ); | ||||||
| 342 | |||||||
| 343 | has 'html_parser' => ( | ||||||
| 344 | is => 'lazy', | ||||||
| 345 | default => sub { | ||||||
| 346 | return XML::LibXML->new(); | ||||||
| 347 | }, | ||||||
| 348 | ); | ||||||
| 349 | |||||||
| 350 | our %defaults = ( | ||||||
| 351 | rules => \%COMMONMARK_RULES, | ||||||
| 352 | headingStyle => 'setext', | ||||||
| 353 | hr => '* * *', | ||||||
| 354 | bulletListMarker => '*', | ||||||
| 355 | codeBlockStyle => 'indented', | ||||||
| 356 | fence => '```', | ||||||
| 357 | emDelimiter => '_', | ||||||
| 358 | strongDelimiter => '**', | ||||||
| 359 | linkStyle => 'inlined', | ||||||
| 360 | linkReferenceStyle => 'full', | ||||||
| 361 | br => ' ', | ||||||
| 362 | preformattedCode => undef, | ||||||
| 363 | blankReplacement => sub ($content, $node, $options, $context ) { | ||||||
| 364 | return $node->isBlock ? "\n\n" : "" | ||||||
| 365 | }, | ||||||
| 366 | keepReplacement => sub ($content, $node, $options, $context) { | ||||||
| 367 | return $node->isBlock ? "\n\n" . $node->toString . "\n\n" : $node->toString | ||||||
| 368 | }, | ||||||
| 369 | defaultReplacement => sub ($content, $node, $options, $context) { | ||||||
| 370 | return $node->isBlock ? "\n\n" . $content . "\n\n" : $content | ||||||
| 371 | } | ||||||
| 372 | ); | ||||||
| 373 | |||||||
| 374 | around BUILDARGS => sub( $orig, $class, %args ) { | ||||||
| 375 | |||||||
| 376 | my %options; | ||||||
| 377 | |||||||
| 378 | for my $k (sort keys %defaults) { | ||||||
| 379 | $options{ $k } = exists $args{ $k } ? delete $args{ $k } : $defaults{ $k }; | ||||||
| 380 | }; | ||||||
| 381 | $args{ options } = \%options; | ||||||
| 382 | $args{ rules } = Text::HTML::Turndown::Rules->new( options => \%options, rules => $options{ rules } ); | ||||||
| 383 | |||||||
| 384 | $args{ rules }->preprocess(sub($tree) { | ||||||
| 385 | return collapseWhitespace( | ||||||
| 386 | element => $tree, | ||||||
| 387 | isBlock => \&Text::HTML::Turndown::Node::_isBlock, | ||||||
| 388 | isVoid => \&Text::HTML::Turndown::Node::_isVoid, | ||||||
| 389 | (isPre => $options{preformattedCode} ? \&isPreOrCode : undef), | ||||||
| 390 | ); | ||||||
| 391 | }); | ||||||
| 392 | |||||||
| 393 | return $class->$orig(\%args); | ||||||
| 394 | }; | ||||||
| 395 | |||||||
| 396 | our @escapes = ( | ||||||
| 397 | [qr/\\/, 'q{\\\\\\\\}'], | ||||||
| 398 | [qr/\*/, 'q{\\\\*}'], | ||||||
| 399 | [qr/^-/, 'q{\\\\-}'], | ||||||
| 400 | [qr/^\+ /, 'q{\\\\+ }'], | ||||||
| 401 | [qr/^(=+)/, 'q{\\\\}.$1'], | ||||||
| 402 | [qr/^(#{1,6}) /, 'q{\\\\}.$1.q{ }'], | ||||||
| 403 | [qr/`/, 'q{\\\\`}'], | ||||||
| 404 | [qr/^~~~/, 'q{\\\\~~~}'], | ||||||
| 405 | [qr/\[/, 'q{\\\\[}'], | ||||||
| 406 | [qr/\]/, 'q{\\\\]}'], | ||||||
| 407 | [qr/^>/, 'q{\\\\>}'], | ||||||
| 408 | [qr/_/, 'q{\\\\_}'], | ||||||
| 409 | # Joplin uses this, but I wonder why there are underscores in the source HTML | ||||||
| 410 | # that should not be escaped?! | ||||||
| 411 | #[qr/(^|\p{Punctuation}|\p{Separator}|\p{Symbol})_(\P{Separator})/, '$1.q{\\\\_}.$2'], | ||||||
| 412 | [qr/^(\d+)\. /, '$1.q{\\. }'] | ||||||
| 413 | ); | ||||||
| 414 | |||||||
| 415 | 22 | 22 | 0 | 39 | sub keep( $self, $filter ) { | ||
| 22 | 43 | ||||||
| 22 | 35 | ||||||
| 22 | 33 | ||||||
| 416 | 22 | 110 | $self->rules->keep($filter); | ||||
| 417 | 22 | 71 | return $self | ||||
| 418 | } | ||||||
| 419 | |||||||
| 420 | 22 | 22 | 0 | 45 | sub preprocess( $self, $proc ) { | ||
| 22 | 53 | ||||||
| 22 | 38 | ||||||
| 22 | 38 | ||||||
| 421 | 22 | 190 | $self->rules->preprocess($proc); | ||||
| 422 | 22 | 240 | return $self | ||||
| 423 | } | ||||||
| 424 | |||||||
| 425 | 154 | 154 | 0 | 297 | sub addRule( $self, $name, $rule ) { | ||
| 154 | 229 | ||||||
| 154 | 242 | ||||||
| 154 | 220 | ||||||
| 154 | 214 | ||||||
| 426 | 154 | 616 | $self->rules->add( $name, $rule ); | ||||
| 427 | 154 | 1409 | return $self | ||||
| 428 | } | ||||||
| 429 | |||||||
| 430 | 295 | 295 | 0 | 8114 | sub escape( $self, $str ) { | ||
| 295 | 491 | ||||||
| 295 | 460 | ||||||
| 295 | 409 | ||||||
| 431 | return reduce( sub { | ||||||
| 432 | 3835 | 3835 | 13127 | $a =~ s/$b->[0]/$b->[1]/gee; | |||
| 70 | 4790 | ||||||
| 433 | 3835 | 5964 | $a | ||||
| 434 | 295 | 2596 | }, $str, @escapes ); | ||||
| 435 | } | ||||||
| 436 | |||||||
| 437 | 924 | 924 | 0 | 1432 | sub process( $self, $parentNode, $context ) { | ||
| 924 | 1455 | ||||||
| 924 | 1379 | ||||||
| 924 | 1401 | ||||||
| 924 | 1210 | ||||||
| 438 | return reduce( sub { | ||||||
| 439 | 1078 | 1078 | 37934 | my( $output ) = $a; | |||
| 440 | 1078 | 23045 | my $node = Text::HTML::Turndown::Node->new( _node => $b, options => $self->options ); | ||||
| 441 | |||||||
| 442 | 1078 | 69406 | my $replacement = ''; | ||||
| 443 | 1078 | 100 | 22125 | if( $node->nodeType == 3 ) { | |||
| 100 | |||||||
| 444 | #say sprintf '%s %s', $node->nodeName, ($node->isCode ? '1' : '0'); | ||||||
| 445 | |||||||
| 446 | 320 | 100 | 14975 | $replacement = $node->isCode ? $node->nodeValue : $self->escape($node->nodeValue); | |||
| 447 | |||||||
| 448 | } elsif( $node->nodeType == 1 ) { | ||||||
| 449 | 757 | 56178 | $replacement = $self->replacementForNode($node, $context); | ||||
| 450 | } | ||||||
| 451 | |||||||
| 452 | 1078 | 4704 | return _join( $output, $replacement ) | ||||
| 453 | 924 | 20855 | }, '', $parentNode->childNodes->@* ); | ||||
| 454 | } | ||||||
| 455 | |||||||
| 456 | |||||||
| 457 | 53 | 53 | 0 | 202 | sub isPreOrCode ($node) { | ||
| 53 | 55 | ||||||
| 53 | 56 | ||||||
| 458 | 53 | 66 | 269 | return uc($node->nodeName) eq 'PRE' || uc( $node->nodeName ) eq 'CODE' | |||
| 459 | } | ||||||
| 460 | |||||||
| 461 | 167 | 167 | 0 | 6187 | sub turndown( $self, $input ) { | ||
| 167 | 286 | ||||||
| 167 | 293 | ||||||
| 167 | 247 | ||||||
| 462 | 167 | 100 | 542 | if( ! ref $input ) { | |||
| 463 | 166 | 50 | 474 | if( $input eq '' ) { | |||
| 464 | 0 | 0 | return '' | ||||
| 465 | } | ||||||
| 466 | 166 | 3177 | $input = $self->html_parser->parse_html_string( $input, { recover => 2, encoding => 'UTF-8' }); | ||||
| 467 | }; | ||||||
| 468 | |||||||
| 469 | 167 | 50546 | for my $proc ($self->rules->_preprocess->@*) { | ||||
| 470 | 189 | 1686 | $input = $proc->($input); | ||||
| 471 | } | ||||||
| 472 | |||||||
| 473 | 167 | 2791 | my $context = { | ||||
| 474 | references => [], | ||||||
| 475 | }; | ||||||
| 476 | 167 | 557 | my $output = $self->process( $input, $context ); | ||||
| 477 | 167 | 3293 | return $self->postProcess( $output, $context ); | ||||
| 478 | } | ||||||
| 479 | |||||||
| 480 | 167 | 167 | 0 | 292 | sub postProcess( $self, $output, $context ) { | ||
| 167 | 258 | ||||||
| 167 | 333 | ||||||
| 167 | 240 | ||||||
| 167 | 230 | ||||||
| 481 | 2659 | 2659 | 3241 | $self->rules->forEach(sub($rule) { | |||
| 2659 | 3409 | ||||||
| 2659 | 3048 | ||||||
| 482 | 2659 | 50 | 4839 | if( ref $rule eq 'HASH' ) { | |||
| 483 | 2659 | 3862 | my $r = $rule->{append}; | ||||
| 484 | 2659 | 50 | 66 | 7668 | if( $r | ||
| 66 | |||||||
| 485 | && ref $r | ||||||
| 486 | && ref $r eq 'CODE' ) { | ||||||
| 487 | 167 | 3546 | $output = _join( $output, $r->($self->options, $context)); | ||||
| 488 | } | ||||||
| 489 | } | ||||||
| 490 | 167 | 1579 | }); | ||||
| 491 | |||||||
| 492 | 167 | 1305 | $output =~ s/^[\t\r\n]+//; | ||||
| 493 | 167 | 1127 | $output =~ s/[\t\r\n\s]+$//; | ||||
| 494 | |||||||
| 495 | 167 | 2213 | return $output; | ||||
| 496 | } | ||||||
| 497 | |||||||
| 498 | 757 | 757 | 0 | 1227 | sub replacementForNode( $self, $node, $context ) { | ||
| 757 | 1169 | ||||||
| 757 | 1149 | ||||||
| 757 | 1035 | ||||||
| 757 | 990 | ||||||
| 499 | 757 | 3839 | my $rule = $self->rules->forNode( $node ); | ||||
| 500 | 757 | 3039 | my $content = $self->process( $node, $context ); | ||||
| 501 | 757 | 34127 | my $whitespace = Text::HTML::Turndown::Node::flankingWhitespace($node, $self->options); | ||||
| 502 | |||||||
| 503 | 757 | 100 | 100 | 3597 | if( $whitespace->{leading} || $whitespace->{trailing}) { | ||
| 504 | 21 | 84 | $content =~s/^\s+//; | ||||
| 505 | 21 | 89 | $content =~s/\s+$//; | ||||
| 506 | } | ||||||
| 507 | my $res = ( | ||||||
| 508 | $whitespace->{leading} | ||||||
| 509 | . $rule->{replacement}->($content, $node, $self->options, $context) | ||||||
| 510 | . $whitespace->{trailing} | ||||||
| 511 | 757 | 14375 | ); | ||||
| 512 | 757 | 78083 | $res | ||||
| 513 | } | ||||||
| 514 | |||||||
| 515 | 1245 | 1245 | 1769 | sub _join ($output, $replacement) { | |||
| 1245 | 1996 | ||||||
| 1245 | 1694 | ||||||
| 1245 | 1539 | ||||||
| 516 | 1245 | 2467 | my $s1 = trimTrailingNewlines($output); | ||||
| 517 | 1245 | 2494 | my $s2 = trimLeadingNewlines($replacement); | ||||
| 518 | 1245 | 5298 | my $nls = max(length($output) - length($s1), length($replacement)- length($s2)); | ||||
| 519 | 1245 | 5648 | my $separator = substr( "\n\n", 0, $nls); | ||||
| 520 | |||||||
| 521 | 1245 | 9494 | return "$s1$separator$s2"; | ||||
| 522 | } | ||||||
| 523 | |||||||
| 524 | 34 | 34 | 0 | 1232 | sub cleanAttribute( $attribute ) { | ||
| 34 | 64 | ||||||
| 34 | 58 | ||||||
| 525 | 34 | 100 | 181 | (defined $attribute) ? $attribute =~ s/(\n+\s*)+/\n/gr : '' | |||
| 526 | } | ||||||
| 527 | |||||||
| 528 | 1245 | 1245 | 0 | 1607 | sub trimLeadingNewlines ($string) { | ||
| 1245 | 1691 | ||||||
| 1245 | 1494 | ||||||
| 529 | 1245 | 6305 | $string =~ s/^\n*//r; | ||||
| 530 | } | ||||||
| 531 | |||||||
| 532 | 1245 | 1245 | 0 | 1614 | sub trimTrailingNewlines ($string) { | ||
| 1245 | 2023 | ||||||
| 1245 | 1615 | ||||||
| 533 | # avoid match-at-end regexp bottleneck, see #370 | ||||||
| 534 | 1245 | 2239 | my $indexEnd = length($string); | ||||
| 535 | 1245 | 100 | 4072 | while ($indexEnd > 0 && substr( $string, $indexEnd-1, 1 ) eq "\n") { $indexEnd-- }; | |||
| 915 | 2509 | ||||||
| 536 | 1245 | 3238 | return substr( $string, 0, $indexEnd ) | ||||
| 537 | } | ||||||
| 538 | |||||||
| 539 | 88 | 88 | 0 | 5437 | sub use( $self, $plugin ) { | ||
| 88 | 189 | ||||||
| 88 | 177 | ||||||
| 88 | 127 | ||||||
| 540 | 88 | 50 | 33 | 282 | if( ref $plugin and ref $plugin eq 'ARRAY' ) { | ||
| 541 | 0 | 0 | $self->use( $_ ) for $plugin->@* | ||||
| 542 | } else { | ||||||
| 543 | 88 | 404 | load $plugin; | ||||
| 544 | 88 | 6896 | $plugin->install( $self ); | ||||
| 545 | } | ||||||
| 546 | } | ||||||
| 547 | |||||||
| 548 | 1; | ||||||
| 549 | |||||||
| 550 | =head1 FUNCTIONS | ||||||
| 551 | |||||||
| 552 | =head2 C<< html2markdown >> | ||||||
| 553 | |||||||
| 554 | use Text::HTML::Turndown 'html2markdown'; | ||||||
| 555 | my $frontmatter = html2markdown( $html, %frontmatter_defaults ); | ||||||
| 556 | say $frontmatter->document_string; | ||||||
| 557 | |||||||
| 558 | Converts an HTML string to a L |
||||||
| 559 | The HTML string is assumed to contain UTF-8 octets. | ||||||
| 560 | |||||||
| 561 | =cut | ||||||
| 562 | |||||||
| 563 | 1 | 1 | 1 | 201377 | sub html2markdown( $html, %keys ) { | ||
| 1 | 3 | ||||||
| 1 | 2 | ||||||
| 1 | 3 | ||||||
| 564 | 1 | 11 | my $tree = XML::LibXML->new->parse_html_string( | ||||
| 565 | $html, | ||||||
| 566 | { recover => 2, encoding => 'UTF-8' } | ||||||
| 567 | ); | ||||||
| 568 | |||||||
| 569 | 1 | 384 | my $frontmatter = extract_info( | ||||
| 570 | $tree, | ||||||
| 571 | #maybe url => $url, | ||||||
| 572 | ); | ||||||
| 573 | |||||||
| 574 | 1 | 14 | my $convert = Text::HTML::Turndown->new(); | ||||
| 575 | 1 | 43 | my $markdown = $convert->turndown($tree); | ||||
| 576 | |||||||
| 577 | 1 | 14 | my $tfm = Text::FrontMatter::YAML->new( | ||||
| 578 | frontmatter_hashref => { %keys, $frontmatter->%* }, | ||||||
| 579 | data_text => $markdown, | ||||||
| 580 | ); | ||||||
| 581 | |||||||
| 582 | 1 | 242 | return $tfm | ||||
| 583 | } | ||||||
| 584 | |||||||
| 585 | =head1 MARKDOWN FLAVOURS / FEATURES | ||||||
| 586 | |||||||
| 587 | =head1 COMPATIBILITY | ||||||
| 588 | |||||||
| 589 | This port aims to be compatible with the Javascript code and uses the same | ||||||
| 590 | test suite. But the original library does not pass its tests and the Joplin | ||||||
| 591 | part does not use the original tests. | ||||||
| 592 | |||||||
| 593 | =over 4 | ||||||
| 594 | |||||||
| 595 | =item Table headers | ||||||
| 596 | |||||||
| 597 | For Github flavoured markdown, Joplin aims to always force table headers in | ||||||
| 598 | markdown. This libary does not (yet). | ||||||
| 599 | |||||||
| 600 | =back | ||||||
| 601 | |||||||
| 602 | =head1 SEE ALSO | ||||||
| 603 | |||||||
| 604 | The original library (unmaintained): | ||||||
| 605 | |||||||
| 606 | L |
||||||
| 607 | |||||||
| 608 | The Joplin library (maintained): | ||||||
| 609 | |||||||
| 610 | L |
||||||
| 611 | |||||||
| 612 | L |
||||||
| 613 | |||||||
| 614 | =head1 REPOSITORY | ||||||
| 615 | |||||||
| 616 | The public repository of this module is | ||||||
| 617 | L |
||||||
| 618 | |||||||
| 619 | =head1 SUPPORT | ||||||
| 620 | |||||||
| 621 | The public support forum of this module is L |
||||||
| 622 | |||||||
| 623 | =head1 BUG TRACKER | ||||||
| 624 | |||||||
| 625 | Please report bugs in this module via the Github bug queue at | ||||||
| 626 | L |
||||||
| 627 | |||||||
| 628 | =head1 AUTHOR | ||||||
| 629 | |||||||
| 630 | Max Maischein C |
||||||
| 631 | |||||||
| 632 | =head1 COPYRIGHT (c) | ||||||
| 633 | |||||||
| 634 | Copyright 2025- by Max Maischein C |
||||||
| 635 | |||||||
| 636 | =head1 LICENSE | ||||||
| 637 | |||||||
| 638 | This module is released under the Artistic License 2.0. | ||||||
| 639 | |||||||
| 640 | =cut |