File Coverage

blib/lib/EBook/Ishmael.pm
Criterion Covered Total %
statement 227 284 79.9
branch 74 118 62.7
condition 10 20 50.0
subroutine 35 39 89.7
pod 11 12 91.6
total 357 473 75.4


line stmt bran cond sub pod time code
1             package EBook::Ishmael;
2 2     2   504155 use 5.016;
  2         8  
3             our $VERSION = '2.03';
4 2     2   14 use strict;
  2         3  
  2         118  
5 2     2   14 use warnings;
  2         3  
  2         215  
6              
7 2     2   601 use Encode qw(find_encoding encode);
  2         21333  
  2         274  
8 2     2   17 use File::Basename;
  2         4  
  2         209  
9 2     2   13 use File::Path qw(remove_tree);
  2         4  
  2         128  
10 2     2   1050 use File::Temp qw(tempfile);
  2         23888  
  2         136  
11 2     2   1723 use Getopt::Long;
  2         32543  
  2         17  
12 2     2   1305 use JSON::PP;
  2         22394  
  2         190  
13 2     2   16 use List::Util qw(max);
  2         5  
  2         149  
14              
15 2     2   1653 use XML::LibXML;
  2         86994  
  2         15  
16              
17 2     2   1672 use EBook::Ishmael::EBook;
  2         15  
  2         354  
18 2     2   1255 use EBook::Ishmael::TextBrowserDump;
  2         9  
  2         174  
19 2     2   18 use EBook::Ishmael::Time qw(format_locale_time format_rfc3339_time);
  2         6  
  2         207  
20              
21             use constant {
22 2         10413 MODE_TEXT => 0,
23             MODE_META => 1,
24             MODE_ID => 2,
25             MODE_HTML => 3,
26             MODE_RAW_TIME => 4,
27             MODE_COVER => 5,
28             MODE_IMAGE => 6,
29 2     2   21 };
  2         4  
30              
31             # TODO: Temporary files are not cleaned up if ishmael is piped into another
32             # program like less
33              
34             # TODO: It would be nice if we had a way to automatically determine an ebook's
35             # encoding...
36              
37             $0 =~ s!^.*[/\\]!!;
38              
39             my $PRGNAM = 'ishmael';
40             my $PRGVER = $VERSION;
41              
42             my $HELP = <<"HERE";
43             $PRGNAM - $PRGVER
44              
45             Usage:
46             $0 [options] file [output]
47              
48             Options:
49             -d|--dumper= Specify dumper to use for formatting text
50             -e|--encoding= Print text output in specified encoding
51             -I|--file-encoding= Specify ebook character encoding
52             -f|--format= Specify ebook format
53             -w|--width= Specify output line width
54             -N|--no-network Disable fetching remove resources
55             -t|--text Dump formatted ebook text (default)
56             -H|--html Dump ebook HTML
57             -c|--cover Dump ebook cover image
58             -g|--image Dump ebook images
59             -i|--identify Identify ebook format
60             -m|--metadata[=
] Print ebook metadata
61             -r|--raw Dump the raw, unformatted ebook text
62              
63             -h|--help Print help message
64             -v|--version Print version/copyright info
65             HERE
66              
67             my $VERSION_MSG = <<"HERE";
68             $PRGNAM - $PRGVER
69              
70             Copyright (C) 2025-2026 Samuel Young
71              
72             This program is free software: you can redistribute it and/or modify
73             it under the terms of the GNU General Public License as published by
74             the Free Software Foundation, either version 3 of the License, or
75             (at your option) any later version.
76             HERE
77              
78             my $STDIN = '-';
79             my $STDOUT = '-';
80              
81             my %FORMAT_ALTS = (
82             'fb2' => 'fictionbook2',
83             'azw' => 'mobi',
84             'azw3' => 'kf8',
85             );
86              
87             my %META_MODES = map { $_ => 1 } qw(
88             ishmael json xml
89             );
90              
91             # Replace characters that cannot be encoded with empty strings.
92             my $ENC_SUBST = sub { q[] };
93              
94             # If reading from stdin, write stdin to temporary file and dump that.
95             sub _get_in_path {
96              
97 115     115   363 my $file = shift;
98              
99 115 50       379 if ($file eq $STDIN) {
100 0         0 return do {
101 0         0 my ($h, $p) = tempfile(UNLINK => 1);
102 0         0 binmode $h;
103 0         0 print { $h } do { local $/; };
  0         0  
  0         0  
  0         0  
104 0         0 close $h;
105 0         0 $p;
106             };
107             } else {
108 115         375 return $file;
109             }
110              
111             }
112              
113             sub _get_out {
114              
115 88     88   218 my $file = shift;
116              
117 88 50       294 if ($file ne $STDOUT) {
118 88 50       25137 open my $fh, '>', $file
119             or die "Failed to open $file for writing: $!\n";
120 88         599 return $fh;
121             } else {
122 0         0 return *STDOUT;
123             }
124              
125             }
126              
127             sub init {
128              
129 115     115 1 314504 my $class = shift;
130              
131             my $self = {
132             Ebook => undef,
133             Mode => MODE_TEXT,
134             Dumper => $ENV{ISHMAEL_DUMPER},
135             Encode => $ENV{ISHMAEL_ENCODING},
136 115         1359 FileEnc => undef,
137             Format => undef,
138             Output => undef,
139             Width => 80,
140             Meta => undef,
141             Network => 1,
142             };
143              
144 115         783 Getopt::Long::config('bundling');
145             GetOptions(
146             'dumper|d=s' => \$self->{Dumper},
147             'encoding|e=s' => \$self->{Encode},
148             'file-encoding|I=s' => \$self->{FileEnc},
149             'format|f=s' => \$self->{Format},
150             'width|w=i' => \$self->{Width},
151 115     115   176719 'no-network|N' => sub { $self->{Network} = 0 },
152 0     0   0 'text|t' => sub { $self->{Mode} = MODE_TEXT },
153 22     22   2613 'html|H' => sub { $self->{Mode} = MODE_HTML },
154 11     11   1592 'cover|c' => sub { $self->{Mode} = MODE_COVER },
155 11     11   1366 'image|g' => sub { $self->{Mode} = MODE_IMAGE },
156 11     11   1449 'identify|i' => sub { $self->{Mode} = MODE_ID },
157             'metadata|m:s' => sub {
158             # Some DWIMery that if the given argument is not a valid metadata
159             # format, assume the user meant for it be a file argument and put
160             # it back into @ARGV.
161 33     33   5583 $self->{Mode} = MODE_META;
162 33 50 33     293 if (!$_[1] or exists $META_MODES{ lc $_[1] }) {
163 33   50     238 $self->{Meta} = lc $_[1] || 'ishmael';
164             } else {
165 0         0 $self->{Meta} = 'ishmael';
166 0         0 unshift @ARGV, $_[1];
167             }
168             },
169 27     27   3646 'raw|r' => sub { $self->{Mode} = MODE_RAW_TIME },
170 0     0   0 'help|h' => sub { print $HELP; exit 0; },
  0         0  
171 0     0   0 'version|v' => sub { print $VERSION_MSG; exit 0; },
  0         0  
172 115 50       8441 ) or die "Error in command line arguments\n$HELP";
173              
174 115 50       16305 $self->{Ebook} = shift @ARGV or die $HELP;
175 115         327 $self->{Output} = shift @ARGV;
176              
177 115         446 $self->{Ebook} = _get_in_path($self->{Ebook});
178              
179 115 100       571 if ($self->{Mode} == MODE_COVER) {
    100          
180 11   33     55 $self->{Output} //= (fileparse($self->{Ebook}, qr/\.[^.]*/))[0] . '.-';
181             } elsif ($self->{Mode} == MODE_IMAGE) {
182 11   33     84 $self->{Output} //= (fileparse($self->{Ebook}, qr/\.[^.]*/))[0];
183             } else {
184 93   66     385 $self->{Output} //= $STDOUT;
185             }
186              
187 115 50       405 if (defined $self->{Format}) {
188              
189 0         0 $self->{Format} = lc $self->{Format};
190              
191 0 0       0 if (exists $FORMAT_ALTS{ $self->{Format} }) {
192 0         0 $self->{Format} = $FORMAT_ALTS{ $self->{Format} };
193             }
194              
195 0 0       0 unless (exists $EBOOK_FORMATS{ $self->{Format} }) {
196 0         0 die "$self->{Format} is not a recognized ebook format\n";
197             }
198              
199             }
200              
201 115 50 66     478 if (defined $self->{Encode} and not defined find_encoding($self->{Encode})) {
202 0         0 die "'$self->{Encode}' is an invalid character encoding\n";
203             }
204              
205 115 50 66     1045 if (defined $self->{FileEnc} and not defined find_encoding($self->{FileEnc})) {
206 0         0 die "'$self->{FileEnc}' is an invalid character encoding\n";
207             }
208              
209 115         532 bless $self, $class;
210              
211 115         816 return $self;
212              
213             }
214              
215             sub text {
216              
217 0     0 1 0 my $self = shift;
218              
219             my $ebook = EBook::Ishmael::EBook->new(
220             $self->{Ebook},
221             $self->{Format},
222             $self->{FileEnc},
223             $self->{Network},
224 0         0 );
225              
226 0         0 my $tmp = do {
227 0         0 my ($tf, $tp) = tempfile(UNLINK => 1);
228 0         0 close $tf;
229 0         0 $tp;
230             };
231              
232 0         0 $ebook->html($tmp);
233              
234 0         0 my $oh = _get_out($self->{Output});
235              
236 0 0       0 unless (defined $self->{Encode}) {
237 0         0 binmode $oh, ':utf8';
238             }
239              
240             my $dump = browser_dump(
241             $tmp,
242             {
243             browser => $self->{Dumper},
244             width => $self->{Width},
245             }
246 0         0 );
247              
248 0 0       0 if (defined $self->{Encode}) {
249 0         0 print { $oh } encode($self->{Encode}, $dump, $ENC_SUBST);
  0         0  
250             } else {
251 0         0 print { $oh } $dump;
  0         0  
252             }
253              
254 0 0       0 close $oh unless $self->{Output} eq $STDOUT;
255              
256 0         0 1;
257              
258             }
259              
260             sub meta {
261              
262 33     33 1 123 my $self = shift;
263              
264 33 100       164 if ($self->{Meta} eq 'ishmael') {
    100          
    50          
265 11         49 $self->meta_ishmael;
266             } elsif ($self->{Meta} eq 'json') {
267 11         43 $self->meta_json;
268             } elsif ($self->{Meta} eq 'xml') {
269 11         51 $self->meta_xml;
270             } else {
271 0         0 die "'$self->{Meta}' is not a valid metadata format\n";
272             }
273              
274 33         926 1;
275              
276             }
277              
278             sub meta_ishmael {
279              
280 11     11 1 21 my $self = shift;
281              
282             my $ebook = EBook::Ishmael::EBook->new(
283             $self->{Ebook},
284             $self->{Format},
285             $self->{FileEnc},
286             $self->{Network},
287 11         122 );
288              
289 11         32 my %meta = %{ $ebook->metadata->hash };
  11         89  
290 11 100       64 if (defined $meta{Created}) {
291 5         33 $meta{ Created } = format_locale_time($meta{Created});
292             }
293 11 100       759 if (defined $meta{Modified}) {
294 7         37 $meta{Modified} = format_locale_time($meta{Modified});
295             }
296              
297 11         719 my $oh = _get_out($self->{Output});
298 11         69 binmode $oh, ':utf8';
299              
300 11         62 my $klen = max(map { length } keys %meta) + 1;
  53         143  
301 11         73 for my $k (sort keys %meta) {
302 53 50       134 next if not defined $meta{$k};
303 53 100       140 if (ref $meta{ $k } eq 'ARRAY') {
304 15         25 printf { $oh } "%-*s %s\n", $klen, "$k:", join ", ", @{ $meta{$k} };
  15         33  
  15         153  
305             } else {
306 38         62 printf { $oh } "%-*s %s\n", $klen, "$k:", $meta{$k};
  38         192  
307             }
308             }
309              
310 11 50       1515 close $oh unless $self->{Output} eq $STDOUT;
311              
312 11         440 1;
313              
314             }
315              
316             sub meta_json {
317              
318 11     11 1 30 my $self = shift;
319              
320             my $ebook = EBook::Ishmael::EBook->new(
321             $self->{Ebook},
322             $self->{Format},
323             $self->{FileEnc},
324             $self->{Network},
325 11         114 );
326              
327 11         61 my $meta = $ebook->metadata->hash;
328 11 100       48 if (defined $meta->{ Created }) {
329 5         33 $meta->{ Created } = format_rfc3339_time($meta->{ Created });
330             }
331 11 100       1137 if (defined $meta->{ Modified }) {
332 7         40 $meta->{ Modified } = format_rfc3339_time($meta->{ Modified });
333             }
334              
335 11         1469 my $oh = _get_out($self->{Output});
336              
337 11         183 my $json = JSON::PP->new->utf8->pretty->canonical;
338 11         2044 print { $oh } $json->encode($meta);
  11         58  
339              
340 11 50       5853 close $oh unless $self->{Output} eq $STDOUT;
341              
342 11         498 1;
343              
344             }
345              
346             sub meta_xml {
347              
348 11     11 1 21 my $self = shift;
349              
350             my $ebook = EBook::Ishmael::EBook->new(
351             $self->{Ebook},
352             $self->{Format},
353             $self->{FileEnc},
354             $self->{Network},
355 11         115 );
356              
357 11         51 my $meta = $ebook->metadata->hash;
358 11 100       51 if (defined $meta->{Created}) {
359 5         29 $meta->{Created} = format_rfc3339_time($meta->{Created});
360             }
361 11 100       1167 if (defined $meta->{ Modified }) {
362 7         36 $meta->{ Modified } = format_rfc3339_time($meta->{Modified});
363             }
364              
365 11         1343 my $oh = _get_out($self->{Output});
366              
367 11         250 my $dom = XML::LibXML::Document->new('1.0', 'UTF-8');
368 11         147 my $root = XML::LibXML::Element->new('ishmael');
369 11         75 $dom->setDocumentElement($root);
370 11         340 $root->setAttribute('version', $PRGVER);
371 11         364 my $metan = $root->appendChild(
372             XML::LibXML::Element->new('metadata')
373             );
374              
375 11         56 for my $k (sort keys %$meta) {
376 53 50       1167 next if not defined $meta->{$k};
377 53         336 my $n = $metan->appendChild(
378             XML::LibXML::Element->new(lc $k)
379             );
380 53 100       170 if (ref $meta->{ $k } eq 'ARRAY') {
381 15         230 for my $i (@{ $meta->{$k} }) {
  15         42  
382 15         86 my $in = $n->appendChild(
383             XML::LibXML::Element->new('item')
384             );
385 15         41 $in->appendChild(
386             XML::LibXML::Text->new($i)
387             );
388             }
389             } else {
390             $n->appendChild(
391 38         717 XML::LibXML::Text->new($meta->{ $k })
392             );
393             }
394             }
395              
396 11         521 $dom->toFH($oh, 1);
397              
398 11 50       1674 close $oh unless $self->{Output} eq $STDOUT;
399              
400 11         88 1;
401              
402             }
403              
404             sub id {
405              
406 11     11 1 23 my $self = shift;
407              
408 11         72 my $id = ebook_id($self->{Ebook});
409              
410 11 50       319 say defined $id ? $id : "Could not identify format for $self->{Ebook}";
411              
412 11         36 1;
413              
414             }
415              
416             sub html {
417              
418 22     22 1 35 my $self = shift;
419              
420             my $ebook = EBook::Ishmael::EBook->new(
421             $self->{Ebook},
422             $self->{Format},
423             $self->{FileEnc},
424             $self->{Network},
425 22         224 );
426              
427 22         91 my $oh = _get_out($self->{Output});
428              
429 22 100       117 unless (defined $self->{Encode}) {
430 11         64 binmode $oh, ':utf8';
431             }
432              
433 22         208 my $html = $ebook->html;
434              
435 22 100       169 if (defined $self->{Encode}) {
436 11         28 say { $oh } encode($self->{Encode}, $html, $ENC_SUBST);
  11         153  
437             } else {
438 11         24 say { $oh } $html;
  11         3735  
439             }
440              
441 22 50       31882 close $oh unless $self->{Output} eq $STDOUT;
442              
443 22         1035 1;
444              
445             }
446              
447             sub raw {
448              
449 27     27 1 58 my $self = shift;
450              
451             my $ebook = EBook::Ishmael::EBook->new(
452             $self->{Ebook},
453             $self->{Format},
454             $self->{FileEnc},
455             $self->{Network},
456 27         284 );
457              
458 27         141 my $oh = _get_out($self->{Output});
459              
460 27 100       152 unless (defined $self->{Encode}) {
461 16         110 binmode $oh, ':utf8';
462             }
463              
464 27         196 my $raw = $ebook->raw;
465              
466 27 100       228 if (defined $self->{Encode}) {
467 11         29 say { $oh } encode($self->{Encode}, $raw, $ENC_SUBST);
  11         124  
468             } else {
469 16         42 say { $oh } $raw;
  16         4906  
470             }
471              
472 27 50       32893 close $oh unless $self->{Output} eq $STDOUT;
473              
474 27         1114 1;
475              
476             }
477              
478             sub cover {
479              
480 11     11 1 24 my $self = shift;
481              
482             my $ebook = EBook::Ishmael::EBook->new(
483             $self->{Ebook},
484             $self->{Format},
485             $self->{FileEnc},
486             $self->{Network},
487 11         118 );
488              
489 11 100       123 unless ($ebook->has_cover) {
490 5         130 say "$self->{Ebook} does not have a cover";
491 5         132 return;
492             }
493              
494 6         36 my ($cover, $format) = $ebook->cover;
495 6 50       26 if (not defined $cover) {
496 0         0 say "$self->{Ebook} does not have a cover";
497 0         0 return;
498             }
499              
500 6         29 $self->{Output} =~ s/\.-$/.$format/;
501              
502 6         28 my $oh = _get_out($self->{Output});
503 6         24 binmode $oh;
504 6         13 print { $oh } $cover;
  6         413  
505 6 50       4995 close $oh unless $self->{Output} eq $STDOUT;
506              
507 6         168 1;
508              
509             }
510              
511             sub image {
512              
513 11     11 0 27 my $self = shift;
514              
515 11 50       80 if ($self->{Output} eq $STDOUT) {
516 0         0 die "Cannot dump images to stdout\n";
517             }
518              
519             my $ebook = EBook::Ishmael::EBook->new(
520             $self->{Ebook},
521             $self->{Format},
522             $self->{FileEnc},
523             $self->{Network},
524 11         116 );
525              
526 11         106 my $num = $ebook->image_num;
527              
528 11 100       48 unless ($num) {
529 5         128 say "$self->{Ebook} has no images";
530 5         131 return;
531             }
532              
533 6         298 my $base = basename($self->{Output});
534 6         21 my $pad = length $num;
535              
536 6         11 my $mkdir = 0;
537              
538 6 50       181 unless (-d $self->{Output}) {
539             mkdir $self->{Output}
540 0 0       0 or die "Failed to mkdir $self->{Output}: $!\n";
541 0         0 $mkdir = 1;
542             }
543              
544 6         18 my @created;
545              
546             eval {
547 6         29 for my $i (0 .. $num - 1) {
548              
549 35         105 my $ii = $i + 1;
550              
551 35         222 my ($img, $format) = $ebook->image($i);
552 35 50       135 if (not defined $img) {
553 0         0 warn "Error dumping image #$i, skipping\n";
554 0         0 next;
555             }
556              
557 35         195 my $b = sprintf "%s-%0*d.%s", $base, $pad, $ii, $format;
558              
559 35         662 my $p = File::Spec->catfile($self->{Output}, $b);
560              
561 35 50       7620 open my $fh, '>', $p
562             or die "Failed to open $p for writing: $!\n";
563 35         164 binmode $fh;
564 35         77 print { $fh } $img;
  35         2137  
565 35         1348 close $fh;
566              
567 35         340 push @created, $p;
568              
569             }
570 6         34 1;
571 6 50       16 } or do {
572              
573 0         0 for my $c (@created) {
574 0         0 unlink $c;
575             }
576              
577 0 0       0 rmdir $self->{Output} if $mkdir;
578              
579 0         0 die $@;
580             };
581              
582 6 50       39 unless (@created) {
583 0 0       0 rmdir $self->{Output} if $mkdir;
584 0         0 die "Could not dump any images in $self->{Output}\n";
585             }
586              
587 6         1183 say $self->{Output};
588 6         36 for my $c (map { basename($_) } @created) {
  35         1014  
589 35         554 say " $c";
590             }
591              
592 6         158 1;
593              
594             }
595              
596             sub run {
597              
598 115     115 1 717 my $self = shift;
599              
600 115 50       780 if ($self->{Mode} == MODE_TEXT) {
    100          
    100          
    100          
    100          
    100          
    50          
601 0         0 $self->text;
602             } elsif ($self->{Mode} == MODE_META) {
603 33         131 $self->meta;
604             } elsif ($self->{Mode} == MODE_ID) {
605 11         53 $self->id;
606             } elsif ($self->{Mode} == MODE_HTML) {
607 22         76 $self->html;
608             } elsif ($self->{Mode} == MODE_RAW_TIME) {
609 27         117 $self->raw;
610             } elsif ($self->{Mode} == MODE_COVER) {
611 11         45 $self->cover;
612             } elsif ($self->{Mode} == MODE_IMAGE) {
613 11         73 $self->image;
614             }
615              
616 115         1783 1;
617              
618             }
619              
620             1;
621              
622              
623             =head1 NAME
624              
625             EBook::Ishmael - EBook dumper
626              
627             =head1 SYNOPSIS
628              
629             use EBook::Ishmael;
630              
631             my $ishmael = EBook::Ishmael->init();
632             $ishmael->run();
633              
634             =head1 DESCRIPTION
635              
636             B is the workhorse module for L. If you're looking for
637             user documentation, you should consult its manual instead of this (this is
638             developer documentation).
639              
640             =head1 METHODS
641              
642             =head2 $i = EBook::Ishmael->init()
643              
644             Reads C<@ARGV> and returns a blessed C object. Consult the
645             manual for L for a list of options that are available.
646              
647             =head2 $i->text()
648              
649             Dumps ebook file to text, default run mode.
650              
651             =head2 $i->meta()
652              
653             Dumps ebook metadata, C<--metadata> mode.
654              
655             =head2 $i->meta_ishmael()
656              
657             Dumps ebook metadata, C<--metadata=ishmael> mode.
658              
659             =head2 $i->meta_json()
660              
661             Dumps ebook metadata in JSON form, C<--metadata=json> mode.
662              
663             =head2 $i->meta_xml()
664              
665             Dumps ebook metadata in XML form, C<--metadata=xml> mode.
666              
667             =head2 $i->id()
668              
669             Identify the format of the given ebook, C<--identify> mode.
670              
671             =head2 $i->html()
672              
673             Dump the HTML-ified contents of a given ebook, C<--html> mode.
674              
675             =head2 $i->raw()
676              
677             Dump the raw, unformatted text contents of a given ebook, C<--raw> mode.
678              
679             =head2 $i->cover()
680              
681             Dump the binary data of the cover image of a given ebook, if one is present,
682             C<--cover> mode.
683              
684             =head2 $i->run()
685              
686             Runs L based on the parameters processed during C.
687              
688             =head1 AUTHOR
689              
690             Written by Samuel Young, Esamyoung12788@gmail.comE.
691              
692             This project's source can be found on its
693             L. Comments and pull
694             requests are welcome!
695              
696             =head1 COPYRIGHT
697              
698             Copyright (C) 2025-2026 Samuel Young
699              
700             This program is free software: you can redistribute it and/or modify
701             it under the terms of the GNU General Public License as published by
702             the Free Software Foundation, either version 3 of the License, or
703             (at your option) any later version.
704              
705             =head1 SEE ALSO
706              
707             L
708              
709             =cut