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   320616 use 5.016;
  2         7  
3             our $VERSION = '2.05';
4 2     2   10 use strict;
  2         3  
  2         52  
5 2     2   9 use warnings;
  2         13  
  2         97  
6              
7 2     2   415 use Encode qw(find_encoding encode);
  2         12621  
  2         171  
8 2     2   24 use File::Basename;
  2         2  
  2         140  
9 2     2   9 use File::Path qw(remove_tree);
  2         3  
  2         78  
10 2     2   941 use File::Temp qw(tempfile);
  2         18207  
  2         132  
11 2     2   1339 use Getopt::Long;
  2         23024  
  2         9  
12 2     2   930 use JSON::PP;
  2         12424  
  2         177  
13 2     2   14 use List::Util qw(max);
  2         3  
  2         114  
14              
15 2     2   1520 use XML::LibXML;
  2         53749  
  2         10  
16              
17 2     2   1108 use EBook::Ishmael::EBook;
  2         20  
  2         211  
18 2     2   755 use EBook::Ishmael::TextBrowserDump;
  2         7  
  2         119  
19 2     2   14 use EBook::Ishmael::Time qw(format_locale_time format_rfc3339_time);
  2         3  
  2         99  
20              
21             use constant {
22 2         5658 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   7 };
  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   194 my $file = shift;
98              
99 115 50       243 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         237 return $file;
109             }
110              
111             }
112              
113             sub _get_out {
114              
115 88     88   133 my $file = shift;
116              
117 88 50       207 if ($file ne $STDOUT) {
118 88 50       8432 open my $fh, '>', $file
119             or die "Failed to open $file for writing: $!\n";
120 88         415 return $fh;
121             } else {
122 0         0 return *STDOUT;
123             }
124              
125             }
126              
127             sub init {
128              
129 115     115 1 161780 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         1049 FileEnc => undef,
137             Format => undef,
138             Output => undef,
139             Width => 80,
140             Meta => undef,
141             Network => 1,
142             };
143              
144 115         546 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   98644 'no-network|N' => sub { $self->{Network} = 0 },
152 0     0   0 'text|t' => sub { $self->{Mode} = MODE_TEXT },
153 22     22   1926 'html|H' => sub { $self->{Mode} = MODE_HTML },
154 11     11   1069 'cover|c' => sub { $self->{Mode} = MODE_COVER },
155 11     11   848 'image|g' => sub { $self->{Mode} = MODE_IMAGE },
156 11     11   883 '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   3057 $self->{Mode} = MODE_META;
162 33 50 33     201 if (!$_[1] or exists $META_MODES{ lc $_[1] }) {
163 33   50     157 $self->{Meta} = lc $_[1] || 'ishmael';
164             } else {
165 0         0 $self->{Meta} = 'ishmael';
166 0         0 unshift @ARGV, $_[1];
167             }
168             },
169 27     27   2087 '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       5213 ) or die "Error in command line arguments\n$HELP";
173              
174 115 50       10227 $self->{Ebook} = shift @ARGV or die $HELP;
175 115         245 $self->{Output} = shift @ARGV;
176              
177 115         300 $self->{Ebook} = _get_in_path($self->{Ebook});
178              
179 115 100       512 if ($self->{Mode} == MODE_COVER) {
    100          
180 11   33     37 $self->{Output} //= (fileparse($self->{Ebook}, qr/\.[^.]*/))[0] . '.-';
181             } elsif ($self->{Mode} == MODE_IMAGE) {
182 11   33     36 $self->{Output} //= (fileparse($self->{Ebook}, qr/\.[^.]*/))[0];
183             } else {
184 93   66     237 $self->{Output} //= $STDOUT;
185             }
186              
187 115 50       245 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     315 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     757 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         397 bless $self, $class;
210              
211 115         519 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 43 my $self = shift;
263              
264 33 100       104 if ($self->{Meta} eq 'ishmael') {
    100          
    50          
265 11         30 $self->meta_ishmael;
266             } elsif ($self->{Meta} eq 'json') {
267 11         39 $self->meta_json;
268             } elsif ($self->{Meta} eq 'xml') {
269 11         34 $self->meta_xml;
270             } else {
271 0         0 die "'$self->{Meta}' is not a valid metadata format\n";
272             }
273              
274 33         670 1;
275              
276             }
277              
278             sub meta_ishmael {
279              
280 11     11 1 17 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         82 );
288              
289 11         21 my %meta = %{ $ebook->metadata->hash };
  11         57  
290 11 100       45 if (defined $meta{Created}) {
291 6         25 $meta{ Created } = format_locale_time($meta{Created});
292             }
293 11 100       711 if (defined $meta{Modified}) {
294 7         50 $meta{Modified} = format_locale_time($meta{Modified});
295             }
296              
297 11         526 my $oh = _get_out($self->{Output});
298 11         73 binmode $oh, ':utf8';
299              
300 11         47 my $klen = max(map { length } keys %meta) + 1;
  54         122  
301 11         73 for my $k (sort keys %meta) {
302 54 50       79 next if not defined $meta{$k};
303 54 100       83 if (ref $meta{ $k } eq 'ARRAY') {
304 15         16 printf { $oh } "%-*s %s\n", $klen, "$k:", join ", ", @{ $meta{$k} };
  15         23  
  15         82  
305             } else {
306 39         39 printf { $oh } "%-*s %s\n", $klen, "$k:", $meta{$k};
  39         147  
307             }
308             }
309              
310 11 50       875 close $oh unless $self->{Output} eq $STDOUT;
311              
312 11         283 1;
313              
314             }
315              
316             sub meta_json {
317              
318 11     11 1 13 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         77 );
326              
327 11         33 my $meta = $ebook->metadata->hash;
328 11 100       34 if (defined $meta->{ Created }) {
329 6         25 $meta->{ Created } = format_rfc3339_time($meta->{ Created });
330             }
331 11 100       894 if (defined $meta->{ Modified }) {
332 7         30 $meta->{ Modified } = format_rfc3339_time($meta->{ Modified });
333             }
334              
335 11         915 my $oh = _get_out($self->{Output});
336              
337 11         129 my $json = JSON::PP->new->utf8->pretty->canonical;
338 11         1303 print { $oh } $json->encode($meta);
  11         39  
339              
340 11 50       3822 close $oh unless $self->{Output} eq $STDOUT;
341              
342 11         295 1;
343              
344             }
345              
346             sub meta_xml {
347              
348 11     11 1 17 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         83 );
356              
357 11         36 my $meta = $ebook->metadata->hash;
358 11 100       31 if (defined $meta->{Created}) {
359 6         21 $meta->{Created} = format_rfc3339_time($meta->{Created});
360             }
361 11 100       806 if (defined $meta->{ Modified }) {
362 7         26 $meta->{ Modified } = format_rfc3339_time($meta->{Modified});
363             }
364              
365 11         896 my $oh = _get_out($self->{Output});
366              
367 11         181 my $dom = XML::LibXML::Document->new('1.0', 'UTF-8');
368 11         86 my $root = XML::LibXML::Element->new('ishmael');
369 11         67 $dom->setDocumentElement($root);
370 11         196 $root->setAttribute('version', $PRGVER);
371 11         216 my $metan = $root->appendChild(
372             XML::LibXML::Element->new('metadata')
373             );
374              
375 11         40 for my $k (sort keys %$meta) {
376 54 50       752 next if not defined $meta->{$k};
377 54         246 my $n = $metan->appendChild(
378             XML::LibXML::Element->new(lc $k)
379             );
380 54 100       71 if (ref $meta->{ $k } eq 'ARRAY') {
381 15         110 for my $i (@{ $meta->{$k} }) {
  15         25  
382 15         52 my $in = $n->appendChild(
383             XML::LibXML::Element->new('item')
384             );
385 15         20 $in->appendChild(
386             XML::LibXML::Text->new($i)
387             );
388             }
389             } else {
390             $n->appendChild(
391 39         402 XML::LibXML::Text->new($meta->{ $k })
392             );
393             }
394             }
395              
396 11         361 $dom->toFH($oh, 1);
397              
398 11 50       1279 close $oh unless $self->{Output} eq $STDOUT;
399              
400 11         63 1;
401              
402             }
403              
404             sub id {
405              
406 11     11 1 20 my $self = shift;
407              
408 11         69 my $id = ebook_id($self->{Ebook});
409              
410 11 50       431 say defined $id ? $id : "Could not identify format for $self->{Ebook}";
411              
412 11         30 1;
413              
414             }
415              
416             sub html {
417              
418 22     22 1 32 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         186 );
426              
427 22         73 my $oh = _get_out($self->{Output});
428              
429 22 100       70 unless (defined $self->{Encode}) {
430 11         60 binmode $oh, ':utf8';
431             }
432              
433 22         146 my $html = $ebook->html;
434              
435 22 100       140 if (defined $self->{Encode}) {
436 11         18 say { $oh } encode($self->{Encode}, $html, $ENC_SUBST);
  11         102  
437             } else {
438 11         21 say { $oh } $html;
  11         3282  
439             }
440              
441 22 50       16490 close $oh unless $self->{Output} eq $STDOUT;
442              
443 22         913 1;
444              
445             }
446              
447             sub raw {
448              
449 27     27 1 52 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         214 );
457              
458 27         92 my $oh = _get_out($self->{Output});
459              
460 27 100       85 unless (defined $self->{Encode}) {
461 16         75 binmode $oh, ':utf8';
462             }
463              
464 27         146 my $raw = $ebook->raw;
465              
466 27 100       187 if (defined $self->{Encode}) {
467 11         23 say { $oh } encode($self->{Encode}, $raw, $ENC_SUBST);
  11         92  
468             } else {
469 16         28 say { $oh } $raw;
  16         4349  
470             }
471              
472 27 50       16342 close $oh unless $self->{Output} eq $STDOUT;
473              
474 27         901 1;
475              
476             }
477              
478             sub cover {
479              
480 11     11 1 16 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         96 );
488              
489 11 100       63 unless ($ebook->has_cover) {
490 5         114 say "$self->{Ebook} does not have a cover";
491 5         118 return;
492             }
493              
494 6         26 my ($cover, $format) = $ebook->cover;
495 6 50       20 if (not defined $cover) {
496 0         0 say "$self->{Ebook} does not have a cover";
497 0         0 return;
498             }
499              
500 6         19 $self->{Output} =~ s/\.-$/.$format/;
501              
502 6         20 my $oh = _get_out($self->{Output});
503 6         15 binmode $oh;
504 6         11 print { $oh } $cover;
  6         251  
505 6 50       1271 close $oh unless $self->{Output} eq $STDOUT;
506              
507 6         116 1;
508              
509             }
510              
511             sub image {
512              
513 11     11 0 18 my $self = shift;
514              
515 11 50       36 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         74 );
525              
526 11         71 my $num = $ebook->image_num;
527              
528 11 100       29 unless ($num) {
529 5         112 say "$self->{Ebook} has no images";
530 5         119 return;
531             }
532              
533 6         199 my $base = basename($self->{Output});
534 6         16 my $pad = length $num;
535              
536 6         8 my $mkdir = 0;
537              
538 6 50       95 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         11 my @created;
545              
546             eval {
547 6         21 for my $i (0 .. $num - 1) {
548              
549 35         50 my $ii = $i + 1;
550              
551 35         137 my ($img, $format) = $ebook->image($i);
552 35 50       74 if (not defined $img) {
553 0         0 warn "Error dumping image #$i, skipping\n";
554 0         0 next;
555             }
556              
557 35         135 my $b = sprintf "%s-%0*d.%s", $base, $pad, $ii, $format;
558              
559 35         354 my $p = File::Spec->catfile($self->{Output}, $b);
560              
561 35 50       7434 open my $fh, '>', $p
562             or die "Failed to open $p for writing: $!\n";
563 35         131 binmode $fh;
564 35         49 print { $fh } $img;
  35         1256  
565 35         787 close $fh;
566              
567 35         200 push @created, $p;
568              
569             }
570 6         21 1;
571 6 50       11 } 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       17 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         370 say $self->{Output};
588 6         21 for my $c (map { basename($_) } @created) {
  35         527  
589 35         421 say " $c";
590             }
591              
592 6         94 1;
593              
594             }
595              
596             sub run {
597              
598 115     115 1 547 my $self = shift;
599              
600 115 50       622 if ($self->{Mode} == MODE_TEXT) {
    100          
    100          
    100          
    100          
    100          
    50          
601 0         0 $self->text;
602             } elsif ($self->{Mode} == MODE_META) {
603 33         115 $self->meta;
604             } elsif ($self->{Mode} == MODE_ID) {
605 11         49 $self->id;
606             } elsif ($self->{Mode} == MODE_HTML) {
607 22         69 $self->html;
608             } elsif ($self->{Mode} == MODE_RAW_TIME) {
609 27         77 $self->raw;
610             } elsif ($self->{Mode} == MODE_COVER) {
611 11         39 $self->cover;
612             } elsif ($self->{Mode} == MODE_IMAGE) {
613 11         35 $self->image;
614             }
615              
616 115         1375 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