File Coverage

blib/lib/EBook/Ishmael/EBook/Epub.pm
Criterion Covered Total %
statement 175 206 84.9
branch 49 90 54.4
condition 3 6 50.0
subroutine 23 23 100.0
pod 0 10 0.0
total 250 335 74.6


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