File Coverage

blib/lib/OPC.pm
Criterion Covered Total %
statement 63 281 22.4
branch 0 96 0.0
condition 0 14 0.0
subroutine 21 46 45.6
pod 0 24 0.0
total 84 461 18.2


line stmt bran cond sub pod time code
1             package OPC;
2              
3 1     1   162413 use strict;
  1         3  
  1         39  
4 1     1   6 use feature 'say';
  1         2  
  1         87  
5 1     1   6 use utf8;
  1         2  
  1         6  
6 1     1   36 use Carp;
  1         2  
  1         49  
7 1     1   5 use File::Find;
  1         2  
  1         59  
8 1     1   7 use File::Basename;
  1         2  
  1         68  
9 1     1   512 use File::Copy;
  1         2233  
  1         57  
10 1     1   8 use Cwd;
  1         1  
  1         54  
11 1     1   6 use Encode;
  1         2  
  1         87  
12 1     1   698 use Archive::Zip qw/ :ERROR_CODES :CONSTANTS /;
  1         73626  
  1         154  
13 1     1   666 use XML::LibXML;
  1         31102  
  1         6  
14 1     1   613 use OPC::Root;
  1         2  
  1         30  
15 1     1   386 use OPC::Part;
  1         3  
  1         372  
16              
17             our $VERSION = '0.05';
18              
19             =head1 NAME
20              
21             OPC - API for low-level manipulations with packages in OPC format (ECMA-376 Part 2)
22              
23             =head1 SYNOPSIS
24              
25             my $Package = eval{ OPC->new( '/path/to/opc/package' ) };
26             if( $@ ) {
27             die "/path/to/opc/package is not a valid OPC package: $@";
28             }
29              
30             # Get part by name
31             my $Part1 = $Package->Part(name => '/part/name');
32              
33             # Get root node
34             my $Root = $Package->Root;
35              
36             # Get package related part with C 'http://my.own/custom/type'
37             my $Part2 = $Root->RelatedPart(type => 'http://my.own/custom/type');
38              
39             # Get list of parts related to some other part
40             my @PictureParts = $Part2>RelatedParts(type => 'http://my.own/type/for/pictures');
41              
42             =head1 DESCRIPTION
43              
44             See http://www.ecma-international.org/publications/standards/Ecma-376.htm
45              
46             =head1 AUTHOR
47              
48             Litres.ru Team
49             =cut
50              
51             my $XC = XML::LibXML::XPathContext->new();
52             my %FB3Namespaces = (
53             opcr => 'http://schemas.openxmlformats.org/package/2006/relationships',
54             opcct => 'http://schemas.openxmlformats.org/package/2006/content-types',
55             );
56             $XC->registerNs( $_ => $FB3Namespaces{$_} ) for keys %FB3Namespaces;
57              
58             sub new {
59 0     0 0   my( $Class, $PackagePath ) = @_;
60              
61 0 0         if( -d $PackagePath ) {
    0          
62             # Got path to directory. That means it's unpacked OPC
63 0           return $Class->FromDir( $PackagePath );
64             } elsif( -f $PackagePath ) {
65             # Got path to file. Probably that means it's zipped OPC
66 0           return $Class->FromZip( $PackagePath );
67             } else {
68 0           Carp::confess "Must specify path to zip package or directory. Path=[$PackagePath]";
69             }
70             }
71              
72             sub FromZip {
73 0     0 0   my( $Class, $ZipFilePath ) = @_;
74              
75 0           my $Zip = Archive::Zip->new();
76 0           my $ReadStatus = $Zip->read( $ZipFilePath );
77 0 0         unless( $ReadStatus == AZ_OK ) {
78 0 0         die $ReadStatus == AZ_FORMAT_ERROR ? "$ZipFilePath is not a valid ZIP archive" :
79             "Failed to open ZIP archive $ZipFilePath";
80             }
81              
82             # Проверка правильности метода сжатия (deflated или stored)
83              
84 0           for my $Member ( $Zip->members ) {
85 0 0         unless( grep $Member->compressionMethod == $_, COMPRESSION_STORED,
86             COMPRESSION_DEFLATED ) {
87              
88 0           die 'Item "'. $Member->fileName .'" uses unsupported compression method. '.
89             'Need "stored" or "deflated"';
90             }
91             }
92              
93 0           my( $ZipMemberNameByPartName, $PartNames );
94 0           ZIP_MEMBER_NAME: for my $ZipMemberName ( $Zip->memberNames ) {
95              
96             # Пытаемся получить имя части по имени элемента ZIP архива
97              
98 0           my $PartName = do {
99 1     1   7 use bytes; # чтобы lc действовал только на ASCII
  1         3  
  1         7  
100 0           lc "/$ZipMemberName";
101             };
102              
103             # Если получившееся имя части не соответствует правилам, то пропускаем такой элемент
104              
105             # Выполняем последнюю проверку на неэквивалетность ранее прочитанным именам частей
106             # и, если всё в порядке, запоминаем полученное имя части
107              
108 0 0         if( grep $PartName eq $_, @$PartNames ) {
109             # найдены части с эквивалетным названием. Согласно [M1.12] такого быть не должно
110 0           die "There several zip items with part name '$PartName' (OPC M1.12 violation)";
111             } else {
112 0           push @$PartNames, $PartName;
113 0           $ZipMemberNameByPartName->{ $PartName } = $ZipMemberName;
114             }
115             }
116              
117 0           return bless({
118             _is_zip => 1,
119             _physical => $Zip,
120             _physical_name_by_part_name => $ZipMemberNameByPartName,
121             _part_names => $PartNames,
122             }, $Class );
123             }
124              
125             sub FromDir {
126 0     0 0   my( $Class, $DirPath ) = @_;
127 0 0 0       if( !defined($DirPath) || !-d $DirPath ) {
128 0           Carp::confess 'Directory doesn\'nt exist';
129             }
130 0           $DirPath = Cwd::abs_path( $DirPath );
131             # delete trailing slash from directory name
132 0           $DirPath =~ s/\/$//g;
133 0           my $DirPathLength = length( $DirPath );
134              
135 0           my( $PhysicalNameByPartName, $PartNames );
136             File::Find::find( sub{
137 0 0   0     if( -f ) {
138 0           my $PartName = $File::Find::name;
139 0           $PartName = substr $PartName, $DirPathLength; # delete directory name from part name
140              
141 0           my $PartName = do {
142 1     1   300 use bytes; # for lc to be applied only to ASCII symbols
  1         3  
  1         3  
143 0           lc $PartName;
144             };
145              
146 0           push @$PartNames, $PartName;
147 0           $PhysicalNameByPartName->{$PartName} = $File::Find::name;
148             }
149 0           }, $DirPath );
150 0           return bless({
151             _is_zip => 0,
152             _physical => $DirPath,
153             _physical_name_by_part_name => $PhysicalNameByPartName,
154             _part_names => $PartNames,
155             }, $Class );
156             }
157              
158             sub GetPhysicalContents {
159 0     0 0   my( $self, $PhysicalName, %Param ) = @_;
160 0 0         my $IsBinary = exists $Param{binary} ? $Param{binary} : 0;
161            
162 0 0         if( $self->{_is_zip} ) {
163 0           return scalar $self->{_physical}->contents( $PhysicalName );
164             } else {
165 0           return do {
166 0 0         my $Layer = $IsBinary ? 'raw' : 'encoding(UTF-8)';
167 0           open my $fh, "<:$Layer", $PhysicalName;
168 0           local $/;
169 0           <$fh>;
170             }
171             }
172             }
173              
174             sub SetPhysicalContents {
175 0     0 0   my( $self, $PhysicalName, $NewContents, %Param ) = @_;
176 0 0         my $IsBinary = exists $Param{binary} ? $Param{binary} : 0;
177              
178 0 0         if( $self->{_is_zip} ) {
179 0           $self->{_physical}->contents( $PhysicalName, $NewContents );
180              
181             } else {
182 0 0 0       my $IsFileHandle = (ref($NewContents)
183             ? (ref($NewContents) eq 'GLOB'
184             || UNIVERSAL::isa($NewContents, 'GLOB')
185             || UNIVERSAL::isa($NewContents, 'IO::Handle'))
186             : (ref(\$NewContents) eq 'GLOB'));
187              
188 0 0         if( $IsFileHandle ) {
189             # new contents was given as file handle (prefered way)
190 0 0         File::Copy::copy $NewContents, $PhysicalName
191             or die "Can't update contents of $PhysicalName: $!";
192              
193             } else {
194             # new contents was given as string
195 0 0         my $Layer = $IsBinary ? 'raw' : 'encoding(UTF-8)';
196 0 0         open my $fh, ">:$Layer", $PhysicalName
197             or die "Can't open $PhysicalName for writing: $!";
198 0 0         print {$fh} $NewContents
  0            
199             or die "Can't update contents of $PhysicalName: $!";
200             }
201             }
202             }
203              
204             sub GetContentTypesXML {
205 0     0 0   my( $self ) = @_;
206              
207             my $PhysicalName = $self->{_is_zip}
208             ? '[Content_Types].xml'
209 0 0         : $self->{_physical}.'/'.'[Content_Types].xml';
210            
211 0           return $self->GetPhysicalContents( $PhysicalName );
212             }
213              
214             sub PartContents {
215 0     0 0   my( $self, $PartName ) = @_;
216 0           my $PhysicalName = $self->PhysicalNameByPartName( $PartName );
217 0 0         if( $PhysicalName ) {
218 0           my $IsBinary = $self->IsBinary($PartName);
219 0           return $self->GetPhysicalContents( $PhysicalName, binary => $IsBinary );
220             }
221 0           return undef;
222             }
223              
224             sub IsBinary {
225 0     0 0   my ($self, $PartName) = @_;
226 0           my $ContentType = $self->PartContentType( $PartName );
227 0 0         if( grep $ContentType eq $_, 'image/png', 'image/jpeg', 'image/gif' ) {
228 0           return 1;
229             }
230 0           return 0;
231             }
232              
233             sub SetContents {
234 0     0 0   my( $self, $PartName, $NewContents ) = @_;
235 0           my $PhysicalName = $self->PhysicalNameByPartName( $PartName );
236              
237             # there is no part with such a name - let's create one
238 0 0         unless( $PhysicalName ) {
239             # first check part name doesn't include . or .. - here we need only full part names
240 0 0         die "Can't set part contents. Name '$PartName' includes . or .."
241             if $PartName =~ /(^|\/)(.|..)(\/|$)/;
242              
243             # then correct part name
244 0           $PartName = do {
245 1     1   779 use bytes; # for lc to only affect ASCII symbols
  1         10  
  1         5  
246 0           lc $PartName;
247             };
248              
249             # creating physical part name
250 0 0         if( $self->{_is_zip} ) {
251 0           ( $PhysicalName = $PartName ) =~ s:^\/::; # removing leading slash
252             } else {
253 0           $PhysicalName = $self->{_physical}.$PartName;
254              
255             # ensure related directory exists
256 0           my $DirectoryName = File::Basename::dirname( $PartName );
257 0 0         unless( -d $self->{_physical}.$DirectoryName ) {
258 0           my $CurPath = $self->{_physical};
259 0           for (split /[\\\/]/,$DirectoryName){
260 0           $CurPath.="$_";
261 0 0         unless (-d $CurPath){
262 0   0       mkdir $CurPath || die "$CurPath: $!";
263             }
264 0           $CurPath.='/';
265             }
266             }
267             }
268 0           push @{$self->{_part_names}}, $PartName;
  0            
269 0           $self->{_physical_name_by_part_name}->{$PartName} = $PhysicalName;
270             }
271              
272 0           my $IsBinary = $self->IsBinary($PartName);
273 0           $self->SetPhysicalContents( $PhysicalName, $NewContents, binary => $IsBinary );
274             }
275              
276             sub PartContentType {
277 0     0 0   my( $self, $PartName ) = @_;
278              
279 0           $PartName = do {
280 1     1   350 use bytes; # A-Za-z are case insensitive
  1         2  
  1         5  
281 0           lc $PartName;
282             };
283              
284 0 0         unless( exists $self->{_content_type_by_part_name} ) {
285 0           my $CtXML = $self->GetContentTypesXML;
286 0 0         unless( $CtXML ) {
287 0           Carp::confess "Content Types part not found";
288             }
289 0           my $CtDoc = XML::LibXML->new()->parse_string( $CtXML );
290              
291 1     1   102 use bytes; # case insensitive a-zA-Z
  1         3  
  1         19  
292              
293             my %CtByExtension =
294 0           map { lc( $_->getAttribute('Extension') ) => $_->getAttribute('ContentType') }
  0            
295             $XC->findnodes( '/opcct:Types/opcct:Default', $CtDoc );
296              
297             my %CtByPartName =
298 0           map { lc( $_->getAttribute('PartName') ) => $_->getAttribute('ContentType') }
  0            
299             $XC->findnodes( '/opcct:Types/opcct:Override', $CtDoc );
300              
301 1     1   110 no bytes;
  1         15  
  1         7  
302              
303 0           $self->{_content_type_by_part_name} = {};
304              
305 0           for my $EachPartName ( $self->PartNames ) {
306 0           $EachPartName =~ /\.([^.]+)$/;
307 0           my $Extension = $1;
308 0 0         if( defined $CtByPartName{ $EachPartName } ) {
309 0           $self->{_content_type_by_part_name}->{$EachPartName} = $CtByPartName{ $EachPartName };
310             } else {
311 0           $self->{_content_type_by_part_name}->{$EachPartName} = $CtByExtension{ $Extension };
312             }
313             }
314             }
315              
316 0           return $self->{_content_type_by_part_name}->{$PartName};
317             }
318              
319             sub PhysicalNameByPartName {
320 0     0 0   my( $self, $PartName ) = @_;
321              
322 0           $PartName = do {
323 1     1   201 use bytes; # A-Za-z are case insensitive
  1         2  
  1         4  
324 0           lc $PartName;
325             };
326              
327             return
328             exists $self->{_physical_name_by_part_name}->{$PartName}
329 0 0         ? $self->{_physical_name_by_part_name}->{$PartName}
330             : undef;
331             }
332              
333             # DEPRECATED: alias for compatibility
334 0     0 0   sub ZipMemberNameByPartName { return PhysicalNameByPartName( @_ ) }
335              
336             sub PartNames {
337 0     0 0   my( $self ) = @_;
338 0           return @{ $self->{_part_names} };
  0            
339             }
340              
341             sub HasPart {
342 0     0 0   my( $self, $PartName ) = @_;
343            
344             # Check part existense by existense in physical package
345 0           return defined $self->PhysicalNameByPartName( $PartName );
346             }
347              
348             # Retrieve relations from rels file (can retrieve relations by type of by id)
349             sub Relations {
350 0     0 0   my( $self, $RelsPartName, %RelationParams ) = @_;
351              
352 0           my $Xml = $self->PartContents( $RelsPartName );
353              
354             # Get list of relation nodes
355 0           my $RelsDoc = XML::LibXML->load_xml( string => $Xml );
356              
357 0           my @RelationNodes = $self->RelationNodesFromDoc( $RelsDoc, %RelationParams );
358              
359 0           my @Relations;
360 0           for my $RelationNode ( @RelationNodes ) {
361 0           my %Relation = map { $_ => $RelationNode->getAttribute($_) } 'Id', 'Target', 'Type';
  0            
362              
363             # Calculate target absolute path
364 0           my( $Source ) = ( split '_rels/', $RelsPartName );
365 0           $Relation{TargetFullName} = FullPartNameFromRelative( $Relation{Target}, $Source );
366              
367 0           push @Relations, \%Relation;
368             }
369 0           return @Relations;
370             }
371              
372             sub RemoveRelations {
373 0     0 0   my( $self, $RelsPartName, %RelationParams ) = @_;
374              
375             # Get list of relation nodes for removement
376 0           my $RelsDoc = XML::LibXML->load_xml( string => $self->PartContents( $RelsPartName ));
377 0           my @RelationNodes = $self->RelationNodesFromDoc( $RelsDoc, %RelationParams );
378              
379             # Remove if there is something to remove. Otherwise just feel fine
380 0 0         if( @RelationNodes ) {
381 0           my( $RelsRoot ) = $XC->findnodes( '/opcr:Relationships', $RelsDoc );
382              
383 0           for my $RelationNode ( @RelationNodes ) {
384 0           $RelsRoot->removeChild( $RelationNode );
385             }
386              
387             # update contents in file
388 0           my $RelsXML = Encode::decode_utf8( $RelsDoc->toString );
389 0           $self->SetContents( $RelsPartName, $RelsXML );
390             }
391             }
392              
393             sub AddRelation {
394 0     0 0   my( $self, $RelsPartName, %RelationParams ) = @_;
395              
396 0           my $RelsDoc = XML::LibXML->load_xml( string => $self->PartContents( $RelsPartName ));
397 0           my( $RelsRoot ) = $XC->findnodes( '/opcr:Relationships', $RelsDoc );
398              
399 0           my $NewRelationNode = $RelsDoc->createElement( 'Relationship' );
400 0 0         $NewRelationNode->setAttribute( Type => $RelationParams{type} ) if $RelationParams{type};
401 0 0         $NewRelationNode->setAttribute( Target => $RelationParams{target} ) if $RelationParams{target};
402              
403 0           my @ExistingRelationIDs =
404             map $_->nodeValue,
405             $XC->findnodes( 'opcr:Relationship/@Id', $RelsRoot );
406              
407 0           my $RelationID;
408 0 0         if( $RelationParams{id} ) {
409 0           $RelationID = $RelationParams{id};
410              
411 0 0         if( grep $RelationID eq $_, @ExistingRelationIDs ) {
412 0           die "Can't add relation to $RelsPartName. Id '$RelationID' already exists"
413             }
414              
415             } else {
416              
417 0           my $i = 1;
418 0           while( 1 ) {
419 0           $RelationID = "rId$i";
420 0 0         last unless grep $RelationID eq $_, @ExistingRelationIDs;
421 0           $i++;
422             }
423             }
424 0           $NewRelationNode->setAttribute( Id => $RelationID );
425              
426 0           $RelsRoot->appendChild( $NewRelationNode );
427              
428             # update contents in file
429 0           my $RelsXML = Encode::decode_utf8( $RelsDoc->toString );
430 0           $self->SetContents( $RelsPartName, $RelsXML );
431             }
432              
433             sub CreateRelationsID {
434 0     0 0   my ( $self, $RelsPartName, %RelationParams ) = @_;
435 0           my $RelationID;
436              
437 0           my $RelsDoc = XML::LibXML->load_xml( string => $self->PartContents( $RelsPartName ));
438 0           my ( $RelsRoot ) = $XC->findnodes( '/opcr:Relationships', $RelsDoc );
439              
440 0           my @ExistingRelationIDs =
441             map $_->nodeValue,
442             $XC->findnodes( 'opcr:Relationship/@Id', $RelsRoot );
443              
444 0           my $i = 1;
445 0           while( 1 ) {
446 0           $RelationID = "rId$i";
447 0 0         last unless grep $RelationID eq $_, @ExistingRelationIDs;
448 0           $i++;
449             }
450              
451 0           return $RelationID;
452             }
453              
454             # Supplementary method: return relations nodes with given parameters
455             sub RelationNodesFromDoc {
456 0     0 0   my( $self, $RelsDoc, %RelationParams ) = @_;
457              
458 0 0 0       if( !$RelationParams{id} && !$RelationParams{type} ) {
459 0           Carp::confess 'No relation parameters given';
460             }
461              
462 0           my $Filter;
463 0 0         if( $RelationParams{type} ) {
464 0           $Filter = '@Type="'.$RelationParams{type}.'"';
465             }
466              
467 0 0         if( $RelationParams{id} ) {
468 0           $Filter = '@Id="'.$RelationParams{id}.'"';
469             }
470              
471 0           return $XC->findnodes(
472             "/opcr:Relationships/opcr:Relationship[$Filter]",
473             $RelsDoc );
474             }
475              
476             # Retrieve relations by type and return list of hashref
477             sub RelationsByType {
478 0     0 0   my( $self, $RelsPartName, $RelType ) = @_;
479 0           return $self->Relations( $RelsPartName, type => $RelType );
480             }
481              
482             # Извлекает связь заданного типа из rels части, что связь данного типа только одна,
483             # иначе выбрасывает фатальную ошибку
484             # Don't use RelationByType with $GetMulty param, use RelationsByType method instead
485             sub RelationByType {
486 0     0 0   my( $self, $RelsPartName, $RelType, $GetMulty ) = @_;
487              
488 0           my @Relations = $self->RelationsByType( $RelsPartName, $RelType );
489 0 0 0       if( !$GetMulty && @Relations > 1 ) {
490 0           die "Expected only one relation of type $RelType in $RelsPartName but found several";
491             }
492            
493 0 0         if ($GetMulty) {
    0          
494 0           return @Relations;
495             } elsif (@Relations) {
496 0           return %{$Relations[0]};
  0            
497             } else {
498 0           return ();
499             }
500             }
501              
502             # вспомогательная функция: получить полное имя части по относительной ссылке на неё
503             # и имени части, относительно которой ищем
504             sub FullPartNameFromRelative {
505 0     0 0   my $Name = shift;
506 0           my $Dir = shift;
507 0           $Dir =~ s:/$::; # remove trailing slash
508              
509 0 0         my $FullName = ( $Name =~ m:^/: ) ? $Name : # в $Name - полный путь
510             "$Dir/$Name"; # в $Name - относительная ссылка
511              
512 0           $FullName =~ s:^/::; # remove leading slash
513              
514 0           $FullName = do{
515 1     1   1428 use bytes; # A-Za-z are case insensitive
  1         2  
  1         4  
516 0           lc $FullName;
517             };
518              
519             # обрабатываем все . и .. в имени
520 0           my @CleanedSegments;
521 0           my @OriginalSegments = split m:/:, $FullName;
522 0           for my $Part ( @OriginalSegments ) {
523 0 0         if( $Part eq '.' ) {
    0          
524             # просто пропускаем
525             } elsif( $Part eq '..' ) {
526 0 0         if( @CleanedSegments > 0 ) {
527 0           pop @CleanedSegments;
528             } else {
529 0           die "/$FullName part name is invalid, because it's pointing out of FB3 root";
530             }
531             } else {
532 0           push @CleanedSegments, $Part;
533             }
534             }
535              
536 0           return '/'.( join '/', @CleanedSegments );
537             }
538              
539             sub Root {
540 0     0 0   my $self = shift;
541              
542 0           return OPC::Root->new( package => $self );
543             }
544              
545             sub Part {
546 0     0 0   my( $self, %Params ) = @_;
547 0           my $PartName = $Params{name};
548              
549 0           return OPC::Part->new( package => $self, name => $PartName )
550             }
551              
552             =head1 LICENSE AND COPYRIGHT
553              
554             Copyright (C) 2018 Litres.ru
555              
556             The GNU Lesser General Public License version 3.0
557              
558             OPC is free software: you can redistribute it and/or modify it
559             under the terms of the GNU Lesser General Public License as published by
560             the Free Software Foundation, either version 3.0 of the License.
561              
562             OPC is distributed in the hope that it will be useful, but
563             WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
564             or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
565             License for more details.
566              
567             Full text of License L.
568              
569             =cut
570              
571             1;