File Coverage

blib/lib/EBook/Ishmael/EBook/Epub.pm
Criterion Covered Total %
statement 188 212 88.6
branch 51 92 55.4
condition 2 4 50.0
subroutine 25 25 100.0
pod 0 10 0.0
total 266 343 77.5


line stmt bran cond sub pod time code
1             package EBook::Ishmael::EBook::Epub;
2 17     17   236 use 5.016;
  17         47  
3             our $VERSION = '2.05';
4 17     17   71 use strict;
  17         33  
  17         315  
5 17     17   48 use warnings;
  17         21  
  17         631  
6              
7 17     17   74 use File::Basename;
  17         21  
  17         1071  
8 17     17   86 use File::Path qw(remove_tree);
  17         26  
  17         740  
9 17     17   64 use File::Spec;
  17         52  
  17         357  
10              
11 17     17   66 use XML::LibXML;
  17         26  
  17         89  
12              
13 17     17   2181 use EBook::Ishmael::EBook::Metadata;
  17         25  
  17         347  
14 17     17   57 use EBook::Ishmael::HTML qw(prepare_html);
  17         23  
  17         681  
15 17     17   63 use EBook::Ishmael::ImageID qw(mimetype_id);
  17         33  
  17         611  
16 17     17   6562 use EBook::Ishmael::Time qw(guess_time);
  17         45  
  17         1647  
17 17     17   111 use EBook::Ishmael::Unzip qw(unzip safe_tmp_unzip);
  17         19  
  17         31112  
18              
19             my $MAGIC = pack 'C4', ( 0x50, 0x4b, 0x03, 0x04 );
20             my $CONTAINER = File::Spec->catfile(qw/META-INF container.xml/);
21              
22             my $DCNS = "http://purl.org/dc/elements/1.1/";
23              
24             # This module only supports EPUBs with a single rootfile. The standard states
25             # there can be multiple rootfiles, but I have yet to encounter one that does.
26              
27             # TODO: Make heuristic more precise.
28             # If file uses zip magic bytes, assume it to be an EPUB.
29             sub heuristic {
30              
31 115     115 0 165 my $class = shift;
32 115         160 my $file = shift;
33 115         143 my $fh = shift;
34              
35 115 100       333 return 0 if $file =~ /\.zip$/;
36              
37 103         500 read $fh, my $mag, 4;
38              
39 103         268 return $mag eq $MAGIC;
40             }
41              
42             # Set _rootfile based on container.xml's rootfile@full-path attribute.
43             sub _get_rootfile {
44              
45 10     10   39 my $self = shift;
46              
47             my $dom = XML::LibXML->load_xml(
48             location => $self->{_container},
49             no_network => !$self->{Network},
50 10         107 );
51 10         3335 my $ns = $dom->documentElement->namespaceURI;
52              
53 10         37 my $xpc = XML::LibXML::XPathContext->new($dom);
54 10         356 $xpc->registerNs('container', $ns);
55              
56 10         43 my ($rf) = $xpc->findnodes(
57             '/container:container' .
58             '/container:rootfiles' .
59             '/container:rootfile[1]' .
60             '/@full-path'
61             );
62              
63 10 50       497 unless (defined $rf) {
64 0         0 die "Could not find root file in EPUB $self->{Source}\n";
65             }
66              
67 10         132 $self->{_rootfile} = File::Spec->catfile($self->{_unzip}, $rf->value());
68              
69 10         101 return $self->{_rootfile};
70              
71             }
72              
73             # Reads metadata and _spine from rootfile.
74             sub _read_rootfile {
75              
76 10     10   16 my $self = shift;
77              
78 10         362 $self->{_contdir} = dirname($self->{_rootfile});
79              
80             my $dom = XML::LibXML-> load_xml(
81             location => $self->{_rootfile},
82             no_network => !$self->{Network},
83 10         41 );
84 10         3331 my $ns = $dom->documentElement->namespaceURI;
85              
86 10         30 my $xpc = XML::LibXML::XPathContext->new($dom);
87 10         180 $xpc->registerNs('package', $ns);
88 10         32 $xpc->registerNs('dc', $DCNS);
89              
90 10         28 my ($vernode) = $xpc->findnodes(
91             '/package:package/@version'
92             );
93 10 50       313 if (defined $vernode) {
94 10         47 $self->{_version} = $vernode->getValue;
95             }
96              
97 10         24 my ($meta) = $xpc->findnodes(
98             '/package:package/package:metadata'
99             );
100              
101 10         221 my ($manif) = $xpc->findnodes(
102             '/package:package/package:manifest'
103             );
104              
105 10         191 my ($spine) = $xpc->findnodes(
106             '/package:package/package:spine'
107             );
108              
109 10 50       182 unless (defined $meta) {
110 0         0 die "EPUB $self->{Source} is missing metadata in rootfile\n";
111             }
112              
113 10 50       21 unless (defined $manif) {
114 0         0 die "EPUB $self->{Source} is missing manifest in rootfile\n";
115             }
116              
117 10 50       25 unless (defined $spine) {
118 0         0 die "EPUB $self->{Source} is missing spine in rootfile\n";
119             }
120              
121 10         24 for my $dc ($xpc->findnodes('./dc:*', $meta)) {
122              
123 70         562 my $name = $dc->nodeName =~ s/^dc://r;
124 70         168 my $text = $dc->textContent();
125              
126 70         150 $text =~ s/\s+/ /g;
127              
128 70 100       206 if ($name eq 'contributor') {
    100          
    100          
    50          
    100          
    100          
    50          
    50          
    50          
129 10         74 $self->{Metadata}->add_contributor($text);
130             } elsif ($name eq 'creator') {
131 10         35 $self->{Metadata}->add_author($text);
132             } elsif ($name eq 'date') {
133 10         18 my $t = eval { guess_time($text) };
  10         38  
134 10 50       496 if (defined $t) {
135 10         32 $self->{Metadata}->set_created($t);
136             }
137             } elsif ($name eq 'description') {
138 0         0 $self->{Metadata}->set_description($text);
139             } elsif ($name eq 'identifier') {
140 20         51 $self->{Metadata}->set_id($text);
141             } elsif ($name eq 'language') {
142 10         33 $self->{Metadata}->set_language($text);
143             } elsif ($name eq 'publisher') {
144 0         0 $self->{Metadata}->add_contributor($text);
145             } elsif ($name eq 'subject') {
146 0         0 $self->{Metadata}->add_genre($text);
147             } elsif ($name eq 'title') {
148 10         46 $self->{Metadata}->set_title($text);
149             }
150              
151             }
152              
153 10         30 for my $itemref ($xpc->findnodes('./package:itemref', $spine)) {
154              
155 40 50       1055 my $id = $itemref->getAttribute('idref') or next;
156              
157 40 50       365 my ($item) = $xpc->findnodes(
158             "./package:item[\@id=\"$id\"]", $manif
159             ) or next;
160              
161 40 50 50     1407 unless (($item->getAttribute('media-type') // '') eq 'application/xhtml+xml') {
162 0         0 next;
163             }
164              
165 40 50       309 my $href = $item->getAttribute('href') or next;
166              
167 40         477 $href = File::Spec->catfile($self->{_contdir}, $href);
168              
169 40 50       549 next unless -f $href;
170              
171 40         54 push @{ $self->{_spine} }, $href;
  40         131  
172              
173             }
174              
175             # Get list of images
176 10         82 for my $item ($xpc->findnodes('./package:item', $manif)) {
177 80         571 my $mime = $item->getAttribute('media-type');
178 80 50       440 next if not defined $mime;
179              
180 80         155 my $format = mimetype_id($mime);
181 80 100       129 next if not defined $format;
182              
183 10 50       19 my $href = $item->getAttribute('href') or next;
184 10         112 $href = File::Spec->catfile($self->{_contdir}, $href);
185 10 50       109 next if not -f $href;
186              
187 10         19 push @{ $self->{_images} }, [ $href, $format ];
  10         41  
188              
189             }
190              
191 10         19 my ($covmeta) = $xpc->findnodes('./package:meta[@name="cover"]', $meta);
192             # Put if code in own block so that we can last out of it.
193 10 50       759 if (defined $covmeta) {{
194 10 50       12 my $covcont = $covmeta->getAttribute('content') or last;
  10         50  
195 10 50       87 my ($covitem) = $xpc->findnodes("./package:item[\@id=\"$covcont\"]", $manif)
196             or last;
197 10 50       303 my $covhref = $covitem->getAttribute('href') or last;
198 10 50       70 my $covmime = $covitem->getAttribute('media-type') or last;
199 10 50       69 my $format = mimetype_id($covmime) or last;
200 10         67 my $covpath = File::Spec->catfile($self->{_contdir}, $covhref);
201 10 50       122 last unless -f $covpath;
202 10         39 $self->{_cover} = [ $covpath, $format ];
203             }}
204              
205 10         88 return 1;
206              
207             }
208              
209             sub new {
210              
211 10     10 0 15 my $class = shift;
212 10         14 my $file = shift;
213 10         13 my $enc = shift;
214 10   50     21 my $net = shift // 1;
215              
216 10         67 my $self = {
217             Source => undef,
218             Metadata => EBook::Ishmael::EBook::Metadata->new,
219             Network => $net,
220             _unzip => undef,
221             _container => undef,
222             _rootfile => undef,
223             # Directory where _rootfile is, as that is where all of the "content"
224             # files are.
225             _contdir => undef,
226             # List of content files in order specified by spine.
227             _spine => [],
228             _cover => undef,
229             _images => [],
230             _version => undef,
231             };
232              
233 10         19 bless $self, $class;
234              
235 10         30 $self->read($file);
236              
237 10 50       40 if (not defined $self->{Metadata}->title) {
238 0         0 $self->{Metadata}->set_title((fileparse($file, qr/\.[^.]*/))[0]);
239             }
240              
241 10 50       22 if (defined $self->{_version}) {
242 10         39 $self->{Metadata}->set_format('EPUB ' . $self->{_version});
243             } else {
244 0         0 $self->{Metadata}->set_format('EPUB');
245             }
246              
247 10         39 return $self;
248              
249             }
250              
251             sub read {
252              
253 10     10 0 11 my $self = shift;
254 10         14 my $src = shift;
255              
256 10         42 my $tmpdir = safe_tmp_unzip;
257              
258 10         4365 unzip($src, $tmpdir);
259              
260 10         260 $self->{Source} = File::Spec->rel2abs($src);
261 10         46 $self->{_unzip} = $tmpdir;
262              
263 10         73 $self->{_container} = File::Spec->catfile($self->{_unzip}, $CONTAINER);
264              
265 10 50       147 unless (-f $self->{_container}) {
266 0         0 die "$src is an invalid EPUB file: does not have a META-INF/container.xml\n";
267             }
268              
269 10         41 $self->_get_rootfile();
270 10         36 $self->_read_rootfile();
271              
272 10         369 return 1;
273              
274             }
275              
276             sub html {
277              
278 3     3 0 519 my $self = shift;
279 3         7 my $out = shift;
280              
281             my $html = join '', map {
282              
283             my $dom = XML::LibXML->load_xml(
284             location => $_,
285             no_network => !$self->{Network},
286 12         4176 );
287 12         5786 my $ns = $dom->documentElement->namespaceURI;
288              
289 12         34 my $xpc = XML::LibXML::XPathContext->new($dom);
290 12         190 $xpc->registerNs('html', $ns);
291              
292 12 50       30 my ($body) = $xpc->findnodes('/html:html/html:body')
293             or next;
294 12         386 prepare_html($body);
295              
296 12         27 map { $_->toString } $body->childNodes;
  1830         5526  
297              
298 3         6 } @{ $self->{_spine} };
  3         11  
299              
300 3 50       841 if (defined $out) {
301 0 0       0 open my $fh, '>', $out
302             or die "Failed to open $out for writing: $!\n";
303 0         0 binmode $fh, ':utf8';
304 0         0 print { $fh } $html;
  0         0  
305 0         0 close $fh;
306 0         0 return $out;
307             } else {
308 3         20 return $html;
309             }
310              
311             }
312              
313             sub raw {
314              
315 2     2 0 5 my $self = shift;
316 2         3 my $out = shift;
317              
318             my $raw = join "\n\n", map {
319              
320             my $dom = XML::LibXML->load_xml(
321             location => $_,
322             no_network => !$self->{Network},
323 8         242 );
324 8         3639 my $ns = $dom->documentElement->namespaceURI;
325              
326 8         22 my $xpc = XML::LibXML::XPathContext->new($dom);
327 8         129 $xpc->registerNs('html', $ns);
328              
329 8 50       22 my ($body) = $xpc->findnodes('/html:html/html:body')
330             or next;
331 8         278 prepare_html($body);
332              
333 8         235 $body->textContent;
334              
335 2         4 } @{ $self->{_spine} };
  2         6  
336              
337 2 50       123 if (defined $out) {
338 0 0       0 open my $fh, '>', $out
339             or die "Failed to open $out for writing: $!\n";
340 0         0 binmode $fh, ':utf8';
341 0         0 print { $fh } $raw;
  0         0  
342 0         0 close $fh;
343 0         0 return $out;
344             } else {
345 2         10 return $raw;
346             }
347              
348             }
349              
350             sub metadata {
351              
352 9     9 0 10466 my $self = shift;
353              
354 9         39 return $self->{Metadata};
355              
356             }
357              
358             sub has_cover {
359              
360 4     4 0 6 my $self = shift;
361              
362 4         14 return defined $self->{_cover};
363              
364             }
365              
366             sub cover {
367              
368 2     2 0 845 my $self = shift;
369              
370 2 50       6 return (undef, undef) if not $self->has_cover;
371              
372 2 50       96 open my $fh, '<', $self->{_cover}[0]
373             or die "Failed to open $self->{_cover}[0] for reading: $!\n";
374 2         7 binmode $fh;
375 2         4 my $img = do { local $/; readline $fh };
  2         10  
  2         92  
376 2         19 close $fh;
377              
378 2         14 return ($img, $self->{_cover}[1]);
379              
380             }
381              
382             sub image_num {
383              
384 4     4 0 779 my $self = shift;
385              
386 4         6 return scalar @{ $self->{_images} };
  4         14  
387              
388             }
389              
390             sub image {
391              
392 2     2 0 5 my $self = shift;
393 2         3 my $n = shift;
394              
395 2 50       6 if ($n >= $self->image_num) {
396 0         0 return (undef, undef);
397             }
398              
399 2 50       75 open my $fh, '<', $self->{_images}[$n][0]
400             or die "Failed to open $self->{_images}[$n][0] for reading: $!\n";
401 2         6 binmode $fh;
402 2         3 my $img = do { local $/ = undef; readline $fh };
  2         9  
  2         80  
403 2         18 close $fh;
404              
405 2         16 return ($img, $self->{_images}[$n][1]);
406              
407             }
408              
409             DESTROY {
410              
411 10     10   605 my $self = shift;
412              
413 10 50       15322 remove_tree($self->{_unzip}, { safe => 1 }) if -d $self->{_unzip};
414              
415             }
416              
417             1;