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>