9 use List::Util qw(first);
 
  12 use SL::Locale::String qw(t8);
 
  14 use parent qw(Exporter);
 
  15 our @EXPORT_PROFILES = qw(PROFILE_FACTURX_EXTENDED PROFILE_XRECHNUNG);
 
  16 our @EXPORT_OK       = (@EXPORT_PROFILES);
 
  17 our %EXPORT_TAGS     = (PROFILES => \@EXPORT_PROFILES);
 
  19 use constant PROFILE_FACTURX_EXTENDED => 0;
 
  20 use constant PROFILE_XRECHNUNG        => 1;
 
  22 use constant RES_OK                              => 0;
 
  23 use constant RES_ERR_FILE_OPEN                   => 1;
 
  24 use constant RES_ERR_NO_XMP_METADATA             => 2;
 
  25 use constant RES_ERR_NO_XML_INVOICE              => 3;
 
  26 use constant RES_ERR_NOT_ZUGFERD                 => 4;
 
  27 use constant RES_ERR_UNSUPPORTED_ZUGFERD_VERSION => 5;
 
  29 our @customer_settings = (
 
  30   [ 0,                                  t8('Do not create Factur-X/ZUGFeRD invoices')                                    ],
 
  31   [ PROFILE_FACTURX_EXTENDED() * 2 + 1, t8('Create with profile \'Factur-X 1.0.05/ZUGFeRD 2.1.1 extended\'')             ],
 
  32   [ PROFILE_FACTURX_EXTENDED() * 2 + 2, t8('Create with profile \'Factur-X 1.0.05/ZUGFeRD 2.1.1 extended\' (test mode)') ],
 
  33   [ PROFILE_XRECHNUNG()        * 2 + 1, t8('Create with profile \'XRechnung 2.0.0\'')                                    ],
 
  34   [ PROFILE_XRECHNUNG()        * 2 + 2, t8('Create with profile \'XRechnung 2.0.0\' (test mode)')                        ],
 
  37 sub convert_customer_setting {
 
  38   my ($class, $customer_setting) = @_;
 
  40   return () if ($customer_setting <= 0) || ($customer_setting >= scalar(@customer_settings));
 
  43     profile   => int(($customer_setting - 1) / 2),
 
  44     test_mode => ($customer_setting - 1) % 2,
 
  48 sub _extract_zugferd_invoice_xml {
 
  50   my $names_dict = $doc->getValue($doc->getRootDict->{Names}) or return {};
 
  51   my $files_tree = $names_dict->{EmbeddedFiles}               or return {};
 
  52   my @agenda     = $files_tree;
 
  55   # Hardly ever more than single leaf, but...
 
  58     my $item = $doc->getValue(shift @agenda);
 
  61       my $kids = $doc->getValue($item->{Kids});
 
  65       my $nodes = $doc->getValue($item->{Names});
 
  66       my @names = map { $doc->getValue($_)} @$nodes;
 
  69         my ($k, $v)  = splice @names, 0, 2;
 
  70         my $ef_node  = $v->{EF};
 
  71         my $ef_dict  = $doc->getValue($ef_node);
 
  72         my $fnode    = (values %$ef_dict)[0];
 
  73         my $any_num  = $fnode->{value};
 
  74         my $obj_node = $doc->dereference($any_num);
 
  75         my $content  = $doc->decodeOne($obj_node->{value}, 0) // '';
 
  79         next if $content !~ m{<rsm:CrossIndustryInvoice};
 
  82         my $dom = eval { XML::LibXML->load_xml(string => $content) };
 
  83         return $content if $dom && ($dom->documentElement->nodeName eq 'rsm:CrossIndustryInvoice');
 
  91 sub _get_xmp_metadata {
 
  94   my $node = $doc->getValue($doc->getRootDict->{Metadata});
 
  95   if ($node && $node->{StreamData} && defined($node->{StreamData}->{value})) {
 
  96     return $node->{StreamData}->{value};
 
 102 sub extract_from_pdf {
 
 103   my ($self, $file_name) = @_;
 
 105   my $pdf_doc = CAM::PDF->new($file_name);
 
 109       result  => RES_ERR_FILE_OPEN(),
 
 110       message => $::locale->text('The file \'#1\' could not be opened for reading.', $file_name),
 
 114   my $xmp = _get_xmp_metadata($pdf_doc);
 
 117       result  => RES_ERR_NO_XMP_METADATA(),
 
 118       message => $::locale->text('The file \'#1\' does not contain the required XMP meta data.', $file_name),
 
 123     result  => RES_ERR_NO_XMP_METADATA(),
 
 124     message => $::locale->text('Parsing the XMP metadata failed.'),
 
 127   my $dom = eval { XML::LibXML->load_xml(string => $xmp) };
 
 129   return $bad if !$dom;
 
 131   my $xpc = XML::LibXML::XPathContext->new($dom);
 
 132   $xpc->registerNs('rdf', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#');
 
 136   foreach my $node ($xpc->findnodes('/x:xmpmeta/rdf:RDF/rdf:Description')) {
 
 137     my $ns = first { ref($_) eq 'XML::LibXML::Namespace' } $node->attributes;
 
 140     if ($ns->getData =~ m{urn:zugferd:pdfa:CrossIndustryDocument:invoice:2p0}) {
 
 141       $zugferd_version = 'zugferd:2p0';
 
 145     if ($ns->getData =~ m{urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0}) {
 
 146       $zugferd_version = 'factur-x:1p0';
 
 150     if ($ns->getData =~ m{zugferd|factur-x}i) {
 
 151       $zugferd_version = 'unsupported';
 
 156   if (!$zugferd_version) {
 
 158       result  => RES_ERR_NOT_ZUGFERD(),
 
 159       message => $::locale->text('The XMP metadata does not declare the Factur-X/ZUGFeRD data.'),
 
 163   if ($zugferd_version eq 'unsupported') {
 
 165       result  => RES_ERR_UNSUPPORTED_ZUGFERD_VERSION(),
 
 166       message => $::locale->text('The Factur-X/ZUGFeRD version used is not supported.'),
 
 170   my $invoice_xml = _extract_zugferd_invoice_xml($pdf_doc);
 
 172   if (!defined $invoice_xml) {
 
 174       result  => RES_ERR_NO_XML_INVOICE(),
 
 175       message => $::locale->text('The Factur-X/ZUGFeRD XML invoice was not found.'),
 
 181     metadata_xmp => $xmp,
 
 182     invoice_xml  => $invoice_xml,
 
 196 SL::ZUGFeRD - Helper functions for dealing with PDFs containing Factur-X/ZUGFeRD invoice data
 
 200     my $pdf  = '/path/to/my.pdf';
 
 201     my $info = SL::ZUGFeRD->extract_from_pdf($pdf);
 
 203     if ($info->{result} != SL::ZUGFeRD::RES_OK()) {
 
 204       # An error occurred; log message from parser:
 
 205       $::lxdebug->message(LXDebug::DEBUG1(), "Could not extract ZUGFeRD data from $pdf: " . $info->{message});
 
 209     # Parse & handle invoice XML:
 
 210     my $dom = XML::LibXML->load_xml(string => $info->{invoice_xml});
 
 217 =item C<extract_from_pdf> C<$file_name>
 
 219 Opens an existing PDF in the file system and tries to extract
 
 220 Factur-X/ZUGFeRD invoice data from it. First it'll parse the XMP
 
 221 metadata and look for the Factur-X/ZUGFeRD declaration inside. If the
 
 222 declaration isn't found or the declared version isn't 2p0, an error is
 
 225 Otherwise it'll continue to look through all embedded files in the
 
 226 PDF. The first embedded XML file with a root node of
 
 227 C<rsm:CrossCountryInvoice> will be returnd.
 
 229 Always returns a hash ref containing the key C<result>, a number that
 
 230 can be one of the following constants:
 
 234 =item C<RES_OK> (0): parsing was OK; the returned hash will also
 
 235 contain the keys C<xmp_metadata> and C<invoice_xml> which will contain
 
 236 the XML text of the metadata & the Factur-X/ZUGFeRD invoice.
 
 238 =item C<RES_ERR_…> (all values E<gt> 0): parsing failed; the hash will
 
 239 also contain a key C<message> which contains a human-readable
 
 240 information about what exactly failed.
 
 252 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>