File Coverage

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.11;
2 5     5   971734 use 5.020;
  5         18  
3 5     5   2765 use Moo 2;
  5         35940  
  5         30  
4 5     5   9492 use experimental 'signatures';
  5         16931  
  5         27  
5 5     5   4599 use stable 'postderef';
  5         1863  
  5         25  
6 5     5   431 use Exporter 'import';
  5         8  
  5         145  
7 5     5   1529 use XML::LibXML;
  5         78999  
  5         34  
8 5     5   757 use List::Util 'reduce', 'max';
  5         14  
  5         324  
9 5     5   2906 use List::MoreUtils 'first_index';
  5         76056  
  5         30  
10 5     5   5294 use Carp 'croak';
  5         8  
  5         336  
11 5     5   2193 use Module::Load 'load';
  5         5863  
  5         29  
12              
13 5     5   2420 use Text::HTML::Turndown::Rules;
  5         23  
  5         191  
14 5     5   2972 use Text::HTML::Turndown::Node;
  5         61  
  5         243  
15 5     5   2933 use Text::HTML::CollapseWhitespace 'collapseWhitespace';
  5         17  
  5         430  
16 5     5   2553 use Text::HTML::ExtractInfo 'extract_info';
  5         15  
  5         390  
17 5     5   3630 use Text::FrontMatter::YAML;
  5         100357  
  5         39081  
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 libraries.
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 = 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 ? "![$alt]($src$titlePart)" : "";
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 62 sub keep( $self, $filter ) {
  22         34  
  22         35  
  22         29  
416 22         89 $self->rules->keep($filter);
417 22         64 return $self
418             }
419              
420 22     22 0 40 sub preprocess( $self, $proc ) {
  22         38  
  22         31  
  22         33  
421 22         106 $self->rules->preprocess($proc);
422 22         198 return $self
423             }
424              
425 154     154 0 226 sub addRule( $self, $name, $rule ) {
  154         199  
  154         189  
  154         191  
  154         180  
426 154         503 $self->rules->add( $name, $rule );
427 154         1140 return $self
428             }
429              
430 282     282 0 8161 sub escape( $self, $str ) {
  282         433  
  282         470  
  282         421  
431             return reduce( sub {
432 3666     3666   12286 $a =~ s/$b->[0]/$b->[1]/gee;
  68         7556  
433 3666         5543 $a
434 282         2370 }, $str, @escapes );
435             }
436              
437 913     913 0 1294 sub process( $self, $parentNode, $context ) {
  913         1263  
  913         1210  
  913         1203  
  913         1227  
438             return reduce( sub {
439 1066     1066   36379 my( $output ) = $a;
440 1066         22291 my $node = Text::HTML::Turndown::Node->new( _node => $b, options => $self->options );
441              
442 1066         66280 my $replacement = '';
443 1066 100       21314 if( $node->nodeType == 3 ) {
    100          
444             #say sprintf '%s %s', $node->nodeName, ($node->isCode ? '1' : '0');
445              
446 317 100       17304 $replacement = $node->isCode ? $node->nodeValue : $self->escape($node->nodeValue);
447              
448             } elsif( $node->nodeType == 1 ) {
449 748         51956 $replacement = $self->replacementForNode($node, $context);
450             }
451              
452 1066         4635 return _join( $output, $replacement )
453 913         19253 }, '', $parentNode->childNodes->@* );
454             }
455              
456              
457 53     53 0 329 sub isPreOrCode ($node) {
  53         93  
  53         76  
458 53   66     511 return uc($node->nodeName) eq 'PRE' || uc( $node->nodeName ) eq 'CODE'
459             }
460              
461 165     165 0 6332 sub turndown( $self, $input ) {
  165         268  
  165         288  
  165         286  
462 165 100       513 if( ! ref $input ) {
463 164 50       497 if( $input eq '' ) {
464 0         0 return ''
465             }
466 164         3469 $input = $self->html_parser->parse_html_string( $input, { recover => 2, encoding => 'UTF-8' });
467             };
468              
469 165         50979 for my $proc ($self->rules->_preprocess->@*) {
470 187         1847 $input = $proc->($input);
471             }
472              
473 165         2840 my $context = {
474             references => [],
475             };
476 165         527 my $output = $self->process( $input, $context );
477 165         3367 return $self->postProcess( $output, $context );
478             }
479              
480 165     165 0 251 sub postProcess( $self, $output, $context ) {
  165         262  
  165         310  
  165         245  
  165         232  
481 2629     2629   3342 $self->rules->forEach(sub($rule) {
  2629         3383  
  2629         3189  
482 2629 50       4894 if( ref $rule eq 'HASH' ) {
483 2629         3824 my $r = $rule->{append};
484 2629 50 66     8076 if( $r
      66        
485             && ref $r
486             && ref $r eq 'CODE' ) {
487 165         3908 $output = _join( $output, $r->($self->options, $context));
488             }
489             }
490 165         1530 });
491              
492 165         1345 $output =~ s/^[\t\r\n]+//;
493 165         1189 $output =~ s/[\t\r\n\s]+$//;
494              
495 165         2236 return $output;
496             }
497              
498 748     748 0 1176 sub replacementForNode( $self, $node, $context ) {
  748         1122  
  748         1059  
  748         1077  
  748         945  
499 748         9976 my $rule = $self->rules->forNode( $node );
500 748         2465 my $content = $self->process( $node, $context );
501 748         28616 my $whitespace = Text::HTML::Turndown::Node::flankingWhitespace($node, $self->options);
502              
503 748 100 100     3507 if( $whitespace->{leading} || $whitespace->{trailing}) {
504 20         78 $content =~s/^\s+//;
505 20         101 $content =~s/\s+$//;
506             }
507             my $res = (
508             $whitespace->{leading}
509             . $rule->{replacement}->($content, $node, $self->options, $context)
510             . $whitespace->{trailing}
511 748         13685 );
512 748         79059 $res
513             }
514              
515 1231     1231   1715 sub _join ($output, $replacement) {
  1231         1985  
  1231         1776  
  1231         1683  
516 1231         2300 my $s1 = trimTrailingNewlines($output);
517 1231         2474 my $s2 = trimLeadingNewlines($replacement);
518 1231         5053 my $nls = max(length($output) - length($s1), length($replacement)- length($s2));
519 1231         2765 my $separator = substr( "\n\n", 0, $nls);
520              
521 1231         9057 return "$s1$separator$s2";
522             }
523              
524 32     32 0 984 sub cleanAttribute( $attribute ) {
  32         56  
  32         39  
525 32 100       116 (defined $attribute) ? $attribute =~ s/(\n+\s*)+/\n/gr : ''
526             }
527              
528 1231     1231 0 1522 sub trimLeadingNewlines ($string) {
  1231         1732  
  1231         1644  
529 1231         6311 $string =~ s/^\n*//r;
530             }
531              
532 1231     1231 0 1714 sub trimTrailingNewlines ($string) {
  1231         1818  
  1231         1504  
533             # avoid match-at-end regexp bottleneck, see #370
534 1231         2113 my $indexEnd = length($string);
535 1231   100     4100 while ($indexEnd > 0 && substr( $string, $indexEnd-1, 1 ) eq "\n") { $indexEnd-- };
  907         2529  
536 1231         3105 return substr( $string, 0, $indexEnd )
537             }
538              
539 88     88 0 1261 sub use( $self, $plugin ) {
  88         156  
  88         142  
  88         122  
540 88 50 33     264 if( ref $plugin and ref $plugin eq 'ARRAY' ) {
541 0         0 $self->use( $_ ) for $plugin->@*
542             } else {
543 88         314 load $plugin;
544 88         7956 $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 object.
559             The HTML string is assumed to contain UTF-8 octets.
560              
561             =cut
562              
563 1     1 1 246470 sub html2markdown( $html, %keys ) {
  1         2  
  1         3  
  1         3  
564 1         14 my $tree = XML::LibXML->new->parse_html_string(
565             $html,
566             { recover => 2, encoding => 'UTF-8' }
567             );
568              
569 1         379 my $frontmatter = extract_info(
570             $tree,
571             #maybe url => $url,
572             );
573              
574 1         13 my $convert = Text::HTML::Turndown->new();
575 1         63 my $markdown = $convert->turndown($tree);
576              
577 1         15 my $tfm = Text::FrontMatter::YAML->new(
578             frontmatter_hashref => { %keys, $frontmatter->%* },
579             data_text => $markdown,
580             );
581              
582 1         224 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