File Coverage

blib/lib/EBook/Ishmael.pm
Criterion Covered Total %
statement 233 290 80.3
branch 75 120 62.5
condition 14 29 48.2
subroutine 36 40 90.0
pod 11 12 91.6
total 369 491 75.1


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