File Coverage

blib/lib/Org/To/HTML.pm
Criterion Covered Total %
statement 86 188 45.7
branch 20 66 30.3
condition 9 36 25.0
subroutine 18 33 54.5
pod 3 22 13.6
total 136 345 39.4


line stmt bran cond sub pod time code
1             package Org::To::HTML;
2              
3 3     3   954304 use 5.010001;
  3         13  
4 3     3   17 use strict;
  3         9  
  3         97  
5 3     3   14 use vars qw($VERSION);
  3         5  
  3         151  
6 3     3   19 use warnings;
  3         6  
  3         154  
7 3     3   5275 use Log::ger;
  3         153  
  3         14  
8              
9 3     3   678 use Exporter 'import';
  3         5  
  3         101  
10 3     3   834 use File::Slurper qw(read_text write_text);
  3         25494  
  3         220  
11 3     3   1579 use HTML::Entities qw/encode_entities/;
  3         17441  
  3         253  
12 3     3   1722 use Org::Document;
  3         241603  
  3         156  
13              
14 3     3   25 use Moo;
  3         4  
  3         14  
15             with 'Org::To::Role';
16             extends 'Org::To::Base';
17              
18             our $AUTHORITY = 'cpan:PERLANCAR'; # AUTHORITY
19             our $DATE = '2024-12-21'; # DATE
20             our $DIST = 'Org-To-HTML'; # DIST
21             our $VERSION = '0.237'; # VERSION
22              
23             our @EXPORT_OK = qw(org_to_html);
24              
25             has naked => (is => 'rw');
26             has html_title => (is => 'rw');
27             has css_url => (is => 'rw');
28             has inline_images => (is => 'rw');
29              
30             our %SPEC;
31             $SPEC{org_to_html} = {
32             v => 1.1,
33             summary => 'Export Org document to HTML',
34             description => <<'MARKDOWN',
35              
36             This is the non-OO interface. For more customization, consider subclassing
37             Org::To::HTML.
38              
39             MARKDOWN
40             args => {
41             source_file => {
42             summary => 'Source Org file to export',
43             schema => ['str' => {}],
44             },
45             source_str => {
46             summary => 'Alternatively you can specify Org string directly',
47             schema => ['str' => {}],
48             },
49             target_file => {
50             summary => 'HTML file to write to',
51             schema => ['str' => {}],
52             description => <<'MARKDOWN',
53              
54             If not specified, HTML string will be returned.
55              
56             MARKDOWN
57             },
58             include_tags => {
59             summary => 'Include trees that carry one of these tags',
60             schema => ['array' => {of => 'str*'}],
61             description => <<'MARKDOWN',
62              
63             Works like Org's 'org-export-select-tags' variable. If the whole document
64             doesn't have any of these tags, then the whole document will be exported.
65             Otherwise, trees that do not carry one of these tags will be excluded. If a
66             selected tree is a subtree, the heading hierarchy above it will also be selected
67             for export, but not the text below those headings.
68              
69             MARKDOWN
70             },
71             exclude_tags => {
72             summary => 'Exclude trees that carry one of these tags',
73             schema => ['array' => {of => 'str*'}],
74             description => <<'MARKDOWN',
75              
76             If the whole document doesn't have any of these tags, then the whole document
77             will be exported. Otherwise, trees that do not carry one of these tags will be
78             excluded. If a selected tree is a subtree, the heading hierarchy above it will
79             also be selected for export, but not the text below those headings.
80              
81             exclude_tags is evaluated after include_tags.
82              
83             MARKDOWN
84             },
85             html_title => {
86             summary => 'HTML document title, defaults to source_file',
87             schema => ['str' => {}],
88             },
89             css_url => {
90             summary => 'Add a link to CSS document',
91             schema => ['str' => {}],
92             },
93             naked => {
94             summary => 'Don\'t wrap exported HTML with HTML/HEAD/BODY elements',
95             schema => ['bool' => {}],
96             },
97             ignore_unknown_settings => {
98             schema => 'bool',
99             },
100             inline_images => {
101             summary => 'If set to true, will make link to an image filename into an <img> element instead of <a>',
102             schema => 'bool',
103             default => 1,
104             },
105             },
106             };
107             sub org_to_html {
108 2     2 1 47425 my %args = @_;
109              
110 2         7 my $doc;
111 2 50       10 if ($args{source_file}) {
    0          
112             $doc = Org::Document->new(
113             from_string => scalar read_text($args{source_file}),
114             ignore_unknown_settings => $args{ignore_unknown_settings},
115 2         12 );
116             } elsif (defined($args{source_str})) {
117             $doc = Org::Document->new(
118             from_string => $args{source_str},
119             ignore_unknown_settings => $args{ignore_unknown_settings},
120 0         0 );
121             } else {
122 0         0 return [400, "Please specify source_file/source_str"];
123             }
124              
125             my $obj = ($args{_class} // __PACKAGE__)->new(
126             source_file => $args{source_file} // '(source string)',
127             include_tags => $args{include_tags},
128             exclude_tags => $args{exclude_tags},
129             css_url => $args{css_url},
130             naked => $args{naked},
131             html_title => $args{html_title},
132 2   50     52713 inline_images => $args{inline_images} // 1,
      50        
      50        
133             );
134              
135 2         2322 my $html = $obj->export($doc);
136             #$log->tracef("html = %s", $html);
137 2 50       35 if ($args{target_file}) {
138 0         0 write_text($args{target_file}, $html);
139 0         0 return [200, "OK"];
140             } else {
141 2         55 return [200, "OK", $html];
142             }
143             }
144              
145             sub export_document {
146 4     4 1 33 my ($self, $doc) = @_;
147              
148 4         8 $self->{_prev_elem_is_inline} = 0;
149              
150 4         7 my $html = [];
151 4 100       19 unless ($self->naked) {
152 1         52 push @$html, "<html>\n";
153 1   50     44 push @$html, (
154             "<!-- Generated by ".__PACKAGE__,
155             " version ".($VERSION // "?"),
156             " on ".scalar(localtime)." -->\n\n");
157              
158 1         5 push @$html, "<head>\n";
159              
160             {
161 1         3 my @title_settings = $doc->settings('TITLE');
  1         13  
162 1         39 my $title_from_setting;
163 1 50       4 $title_from_setting = $title_settings[0]->raw_arg
164             if @title_settings;
165 1   33     40 push @$html, "<title>",
      33        
      0        
166             ($self->html_title // $title_from_setting // $self->source_file // '(no title)'),
167             "</title>\n";
168             }
169              
170 1 50       7 if ($self->css_url) {
171 1         6 push @$html, (
172             "<link rel=\"stylesheet\" type=\"text/css\" href=\"",
173             $self->css_url, "\" />\n"
174             );
175             }
176 1         3 push @$html, "</head>\n\n";
177              
178 1         3 push @$html, "<body>\n";
179             }
180 4         8 push @$html, $self->export_elements(@{$doc->children});
  4         45  
181 4 100       58 unless ($self->naked) {
182 1         3 push @$html, "</body>\n\n";
183 1         30 push @$html, "</html>\n";
184             }
185              
186 4         23 join "", @$html;
187             }
188              
189             sub before_export_element {
190 21     21 1 33 my $self = shift;
191 21         69 my %args = @_;
192              
193             $self->{_prev_elem_is_inline} =
194 21 100 100     144 $args{elem}->can("is_inline") && $args{elem}->is_inline ? 1:0;
195             }
196              
197             sub export_block {
198 1     1 0 3 my ($self, $elem) = @_;
199             # currently all assumed to be <PRE>
200 1         15 join "", (
201             "<pre class=\"block block_", lc($elem->name), "\">",
202             encode_entities($elem->raw_content),
203             "</pre>\n\n"
204             );
205             }
206              
207             sub export_fixed_width_section {
208 1     1 0 3 my ($self, $elem) = @_;
209 1         6 join "", (
210             "<pre class=\"fixed_width_section\">",
211             encode_entities($elem->text),
212             "</pre>\n"
213             );
214             }
215              
216             sub export_comment {
217 0     0 0 0 my ($self, $elem) = @_;
218 0         0 join "", (
219             "<!-- ",
220             encode_entities($elem->_str),
221             " -->\n"
222             );
223             }
224              
225             sub export_drawer {
226 0     0 0 0 my ($self, $elem) = @_;
227             # currently not exported
228 0         0 '';
229             }
230              
231             sub export_footnote {
232 0     0 0 0 my ($self, $elem) = @_;
233             # currently not exported
234 0         0 '';
235             }
236              
237             sub export_headline {
238 5     5 0 11 my ($self, $elem) = @_;
239              
240 5         22 my @children = $self->_included_children($elem);
241              
242 5         46 join "", (
243             "<h" , $elem->level, ">",
244             $self->export_elements($elem->title),
245             "</h", $elem->level, ">\n\n",
246             $self->export_elements(@children)
247             );
248             }
249              
250             sub export_list {
251 0     0 0 0 my ($self, $elem) = @_;
252 0         0 my $tag;
253 0         0 my $type = $elem->type;
254 0 0       0 if ($type eq 'D') { $tag = 'dl' }
  0 0       0  
    0          
255 0         0 elsif ($type eq 'O') { $tag = 'ol' }
256 0         0 elsif ($type eq 'U') { $tag = 'ul' }
257             join "", (
258             "<$tag>\n",
259 0   0     0 $self->export_elements(@{$elem->children // []}),
  0         0  
260             "</$tag>\n\n"
261             );
262             }
263              
264             sub export_list_item {
265 0     0 0 0 my ($self, $elem) = @_;
266              
267 0         0 my $html = [];
268 0 0       0 if ($elem->desc_term) {
269 0         0 push @$html, "<dt>";
270             } else {
271 0         0 push @$html, "<li>";
272             }
273              
274 0 0       0 if ($elem->check_state) {
275 0         0 push @$html, "<strong>[", $elem->check_state, "]</strong>";
276             }
277              
278 0 0       0 if ($elem->desc_term) {
279 0         0 push @$html, $self->export_elements($elem->desc_term);
280 0         0 push @$html, "</dt>";
281 0         0 push @$html, "<dd>";
282             }
283              
284 0 0       0 push @$html, $self->export_elements(@{$elem->children}) if $elem->children;
  0         0  
285              
286 0 0       0 if ($elem->desc_term) {
287 0         0 push @$html, "</dd>\n";
288             } else {
289 0         0 push @$html, "</li>\n";
290             }
291              
292 0         0 join "", @$html;
293             }
294              
295             sub export_radio_target {
296 0     0 0 0 my ($self, $elem) = @_;
297             # currently not exported
298 0         0 '';
299             }
300              
301             sub export_setting {
302 1     1 0 29 my ($self, $elem) = @_;
303             # currently not exported
304 1         3 '';
305             }
306              
307             sub export_table {
308 0     0 0 0 my ($self, $elem) = @_;
309             join "", (
310             "<table border>\n",
311 0   0     0 $self->export_elements(@{$elem->children // []}),
  0         0  
312             "</table>\n\n"
313             );
314             }
315              
316             sub export_table_row {
317 0     0 0 0 my ($self, $elem) = @_;
318             join "", (
319             "<tr>",
320 0   0     0 $self->export_elements(@{$elem->children // []}),
  0         0  
321             "</tr>\n"
322             );
323             }
324              
325             sub export_table_cell {
326 0     0 0 0 my ($self, $elem) = @_;
327              
328             join "", (
329             "<td>",
330 0   0     0 $self->export_elements(@{$elem->children // []}),
  0         0  
331             "</td>"
332             );
333             }
334              
335             sub export_table_vline {
336 0     0 0 0 my ($self, $elem) = @_;
337             # currently not exported
338 0         0 '';
339             }
340              
341             sub __escape_target {
342 0     0   0 my $target = shift;
343 0         0 $target =~ s/[^\w]+/_/g;
344 0         0 $target;
345             }
346              
347             sub export_target {
348 0     0 0 0 my ($self, $elem) = @_;
349             # target
350 0         0 join "", (
351             "<a name=\"", __escape_target($elem->target), "\">"
352             );
353             }
354              
355             sub export_text {
356 8     8 0 18 my ($self, $elem) = @_;
357              
358 8         25 my $style = $elem->style;
359 8         9 my $tag;
360 8 50       103 if ($style eq 'B') { $tag = 'b' }
  0 50       0  
    50          
    50          
    50          
    50          
361 0         0 elsif ($style eq 'I') { $tag = 'i' }
362 0         0 elsif ($style eq 'U') { $tag = 'u' }
363 0         0 elsif ($style eq 'S') { $tag = 'strike' }
364 0         0 elsif ($style eq 'C') { $tag = 'code' }
365 0         0 elsif ($style eq 'V') { $tag = 'tt' }
366              
367 8         37 my $html = [];
368              
369 8 50       20 push @$html, "<$tag>" if $tag;
370 8         41 my $text = encode_entities($elem->text);
371 8         377 $text =~ s/\R\R+/\n\n<p>/g;
372 8 50       67 if ($self->{_prev_elem_is_inline}) {
373 8         28 $text =~ s/\A\R/ /;
374             }
375 8         27 $text =~ s/(?<=.)\R/ /g;
376 8         25 push @$html, $text;
377 8 50       31 push @$html, $self->export_elements(@{$elem->children}) if $elem->children;
  0         0  
378 8 50       17 push @$html, "</$tag>" if $tag;
379              
380 8         46 join "", @$html;
381             }
382              
383             sub export_time_range {
384 0     0 0   my ($self, $elem) = @_;
385              
386 0           encode_entities($elem->as_string);
387             }
388              
389             sub export_timestamp {
390 0     0 0   my ($self, $elem) = @_;
391              
392 0           encode_entities($elem->as_string);
393             }
394              
395             sub export_link {
396 0     0 0   require Filename::Type::Image;
397 0           require URI;
398              
399 0           my ($self, $elem) = @_;
400              
401 0           my $html = [];
402 0           my $link = $elem->link;
403 0           my $looks_like_image = Filename::Type::Image::check_image_filename(filename => $link);
404 0           my $inline_images = $self->inline_images;
405              
406 0 0 0       if ($inline_images && $looks_like_image) {
407             # TODO: extract to method e.g. settings
408 0           my $elem_settings;
409 0           my $s = $elem;
410 0           while (1) {
411 0           $s = $s->prev_sibling;
412 0 0 0       last unless $s && $s->isa("Org::Element::Setting");
413 0           $elem_settings->{ $s->name } = $s->raw_arg;
414             }
415             #use DD; dd $settings;
416 0           my $caption = $elem_settings->{CAPTION};
417              
418             # TODO: extract to method e.g. settings of Org::Document
419 0           my $doc_settings;
420 0           $s = $elem->document->children->[0];
421 0           while (1) {
422 0           $s = $s->next_sibling;
423 0 0 0       last unless $s && $s->isa("Org::Element::Setting");
424 0           $doc_settings->{ $s->name } = $s->raw_arg;
425             }
426             #use DD; dd $settings;
427 0           my $img_base = $doc_settings->{IMAGE_BASE};
428              
429 0 0         my $url = defined($img_base) ? URI->new($link)->abs(URI->new($img_base)) : $link;
430              
431 0 0         push @$html, "<figure>" if defined $caption;
432 0           push @$html, "<img src=\"";
433 0           push @$html, "$url";
434 0           push @$html, "\" />";
435 0           push @$html, "<figcaption>", encode_entities($caption), "</figcaption>";
436 0 0         push @$html, "</figure>" if defined $caption;
437             } else {
438 0           push @$html, "<a href=\"";
439 0           push @$html, $link;
440 0           push @$html, "\">";
441 0 0         if ($elem->description) {
442 0           push @$html, $self->export_elements($elem->description);
443             } else {
444 0           push @$html, $link;
445             }
446 0           push @$html, "</a>";
447             }
448              
449 0           join "", @$html;
450             }
451              
452             1;
453             # ABSTRACT: Export Org document to HTML
454              
455             __END__
456              
457             =pod
458              
459             =encoding UTF-8
460              
461             =head1 NAME
462              
463             Org::To::HTML - Export Org document to HTML
464              
465             =head1 VERSION
466              
467             This document describes version 0.237 of Org::To::HTML (from Perl distribution Org-To-HTML), released on 2024-12-21.
468              
469             =head1 SYNOPSIS
470              
471             use Org::To::HTML qw(org_to_html);
472              
473             # non-OO interface
474             my $res = org_to_html(
475             source_file => 'todo.org', # or source_str
476             #target_file => 'todo.html', # defaults return the HTML in $res->[2]
477             #html_title => 'My Todo List', # defaults to file name
478             #include_tags => [...], # default exports all tags.
479             #exclude_tags => [...], # behavior mimics emacs's include/exclude rule
480             #css_url => '/path/to/my/style.css', # default none
481             #naked => 0, # if set to 1, no HTML/HEAD/BODY will be output.
482             );
483             die "Failed" unless $res->[0] == 200;
484              
485             # OO interface
486             my $oeh = Org::To::HTML->new();
487             my $html = $oeh->export($doc); # $doc is Org::Document object
488              
489             =head1 DESCRIPTION
490              
491             Export Org format to HTML. To customize, you can subclass this module.
492              
493             A command-line utility L<org-to-html> is available in the distribution
494             L<App::OrgUtils>.
495              
496             Note that this module is just a simple exporter, for "serious" work you'll
497             probably want to use the exporting features or L<org-mode|http://orgmode.org>.
498              
499             =for Pod::Coverage ^(export_.+|before_.+|after_.+)$
500              
501             =head1 ATTRIBUTES
502              
503             =head2 naked => BOOL
504              
505             If set to true, export_document() will not output HTML/HEAD/BODY wrapping
506             element. Default is false.
507              
508             =head2 html_title => STR
509              
510             Title to use in TITLE HTML element, to override C<#+TITLE> setting in the Org
511             document. If unset and document does not have C<#+TITLE> setting, will default
512             to the name of the source file, or C<(source string)>.
513              
514             =head2 css_url => STR
515              
516             If set, export_document() will output a LINK element pointing to this CSS.
517              
518             =head2 inline_images => BOOL
519              
520             =head1 METHODS
521              
522             =head2 new(%args)
523              
524             =head2 $exp->export_document($doc) => HTML
525              
526             Export document to HTML.
527              
528             =head2 org_to_html
529              
530             =head1 FAQ
531              
532             =head2 Why would one want to use this instead of org-mode's built-in exporting features?
533              
534             This module might come in handy if you want to customize the Org-to-HTML
535             translation with Perl, for example when you want to customize the default HTML
536             title when there's no C<#+TITLE> setting, change translation of table element to
537             an ASCII table, etc.
538              
539             =head1 HOMEPAGE
540              
541             Please visit the project's homepage at L<https://metacpan.org/release/Org-To-HTML>.
542              
543             =head1 SOURCE
544              
545             Source repository is at L<https://github.com/perlancar/perl-Org-To-HTML>.
546              
547             =head1 SEE ALSO
548              
549             For more information about Org document format, visit http://orgmode.org/
550              
551             L<Org::Parser>
552              
553             L<org-to-html>
554              
555             =head1 AUTHOR
556              
557             perlancar <perlancar@cpan.org>
558              
559             =head1 CONTRIBUTORS
560              
561             =for stopwords Harald Jörg Steven Haryanto
562              
563             =over 4
564              
565             =item *
566              
567             Harald Jörg <Harald.Joerg@arcor.de>
568              
569             =item *
570              
571             Steven Haryanto <stevenharyanto@gmail.com>
572              
573             =back
574              
575             =head1 CONTRIBUTING
576              
577              
578             To contribute, you can send patches by email/via RT, or send pull requests on
579             GitHub.
580              
581             Most of the time, you don't need to build the distribution yourself. You can
582             simply modify the code, then test via:
583              
584             % prove -l
585              
586             If you want to build the distribution (e.g. to try to install it locally on your
587             system), you can install L<Dist::Zilla>,
588             L<Dist::Zilla::PluginBundle::Author::PERLANCAR>,
589             L<Pod::Weaver::PluginBundle::Author::PERLANCAR>, and sometimes one or two other
590             Dist::Zilla- and/or Pod::Weaver plugins. Any additional steps required beyond
591             that are considered a bug and can be reported to me.
592              
593             =head1 COPYRIGHT AND LICENSE
594              
595             This software is copyright (c) 2024 by perlancar <perlancar@cpan.org>.
596              
597             This is free software; you can redistribute it and/or modify it under
598             the same terms as the Perl 5 programming language system itself.
599              
600             =head1 BUGS
601              
602             Please report any bugs or feature requests on the bugtracker website L<https://rt.cpan.org/Public/Dist/Display.html?Name=Org-To-HTML>
603              
604             When submitting a bug or request, please include a test-file or a
605             patch to an existing test-file that illustrates the bug or desired
606             feature.
607              
608             =cut