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.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 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 = 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 ? "![$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 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 object.
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