File Coverage

blib/lib/PDF/FacturX/Embed.pm
Criterion Covered Total %
statement 43 169 25.4
branch 3 58 5.1
condition 0 21 0.0
subroutine 12 22 54.5
pod 2 2 100.0
total 60 272 22.0


line stmt bran cond sub pod time code
1             package PDF::FacturX::Embed;
2 5     5   127031 use strict;
  5         12  
  5         247  
3 5     5   38 use warnings;
  5         10  
  5         323  
4 5     5   616 use utf8;
  5         365  
  5         39  
5 5     5   215 use Exporter 'import';
  5         10  
  5         262  
6 5     5   2722 use File::Temp qw(tempfile tempdir);
  5         47633  
  5         454  
7 5     5   38 use File::Basename qw(basename);
  5         11  
  5         434  
8 5     5   87 use File::Spec;
  5         12  
  5         175  
9 5     5   3094 use POSIX qw(strftime);
  5         37236  
  5         42  
10 5     5   11465 use Encode qw(encode_utf8);
  5         21253  
  5         368  
11 5     5   6502 use PDF::Builder;
  5         1709966  
  5         348  
12 5     5   527 use File::ShareDir qw(dist_dir);
  5         28140  
  5         15676  
13              
14             our $VERSION = '0.01';
15              
16             our @EXPORT_OK = qw(embed_xml_pdfa3 find_icc_profile);
17              
18             ###############################################################################
19             # embed_xml_pdfa3 — prend un PDF source + un XML Factur-X et produit un
20             # PDF/A-3 avec :
21             # - l'XML embarqué comme « associated file » (AFRelationship = /Data)
22             # - les métadonnées XMP Factur-X (pdfaid + fx)
23             # - un OutputIntent sRGB (requis par PDF/A)
24             #
25             # Pilote Ghostscript (>= 10.x) via un fichier .ps pdfmark temporaire.
26             #
27             # Usage :
28             # use PDF::FacturX::Embed qw(embed_xml_pdfa3);
29             # my ($ok, $msg) = embed_xml_pdfa3(
30             # pdf_in => 'src.pdf',
31             # xml => $xml_string, # chaîne Perl (caractères), UTF-8 interne
32             # pdf_out => 'dst.pdf',
33             # profile => 'basic', # minimum|basicwl|basic|en16931
34             # title => 'Facture FA-2026-0042',
35             # author => 'Acme SARL',
36             # creator => 'PDF::FacturX',
37             # tmp_dir => '/path/to/tmp', # optionnel, défaut System tmp
38             # gs => 'gs', # optionnel, défaut 'gs'
39             # icc_path => '/path.icc', # optionnel, auto-détection sinon
40             # on_warning => sub { warn @_ },# optionnel, capture warnings PDF::Builder
41             # );
42             # die $msg unless $ok;
43             ###############################################################################
44              
45             my %CONFORMANCE = (
46             minimum => 'MINIMUM',
47             basicwl => 'BASIC WL',
48             basic => 'BASIC',
49             en16931 => 'EN 16931',
50             );
51              
52             sub embed_xml_pdfa3 {
53 0     0 1 0 my (%opt) = @_;
54 0 0       0 my $pdf_in = $opt{pdf_in} or die "pdf_in requis\n";
55 0 0       0 my $xml = $opt{xml}; defined $xml or die "xml requis\n";
  0         0  
56 0 0       0 my $pdf_out = $opt{pdf_out} or die "pdf_out requis\n";
57 0   0     0 my $profile = $opt{profile} || 'basic';
58 0 0       0 die "Profil inconnu: $profile\n" unless exists $CONFORMANCE{$profile};
59              
60 0 0       0 -r $pdf_in or die "PDF source introuvable : $pdf_in\n";
61              
62 0   0     0 my $icc_path = $opt{icc_path} || find_icc_profile();
63 0 0 0     0 die "Profil ICC sRGB introuvable. Fournir icc_path ou installer ghostscript.\n"
64             unless $icc_path && -r $icc_path;
65              
66 0   0     0 my $gs = $opt{gs} || 'gs';
67              
68             # Répertoire temporaire jetable. tmp_dir est optionnel : par défaut on
69             # passe par File::Temp (système). File::Temp->newdir nettoie à la sortie
70             # de portée, contrairement à tempdir(CLEANUP => 1) qui ne nettoie qu'à
71             # la mort du processus (gênant dans les workers longue durée).
72 0         0 my $tmpdir_obj;
73 0 0       0 if ($opt{tmp_dir}) {
74 0 0       0 -d $opt{tmp_dir} or die "tmp_dir introuvable : $opt{tmp_dir}\n";
75 0         0 $tmpdir_obj = File::Temp->newdir('fx_XXXX', DIR => $opt{tmp_dir});
76             }
77             else {
78 0         0 $tmpdir_obj = File::Temp->newdir('fx_XXXX');
79             }
80 0         0 my $tmpdir = "$tmpdir_obj"; # stringification → chemin
81 0         0 my $ps_fp = File::Spec->catfile($tmpdir, 'pdfmark.ps');
82              
83             # Le XML et le XMP sont inlinés dans le pdfmark PostScript en chaînes
84             # Perl unicode ; `encode_utf8` n'est appliqué qu'UNE fois à la fin
85             # avant écriture. Mélanger octets + unicode dans le heredoc provoque
86             # un double-encodage silencieux (ex: « Poséidon » → « Poséidon »).
87              
88 0         0 my $now = strftime('%Y-%m-%dT%H:%M:%S', localtime());
89 0         0 my $mod_date = strftime('%Y%m%d%H%M%S', localtime());
90              
91             my $xmp = _build_xmp(
92             profile => $profile,
93             title => $opt{title} // 'Invoice',
94             author => $opt{author} // '',
95 0   0     0 creator => $opt{creator} // 'PDF::FacturX',
      0        
      0        
96             date => $now,
97             );
98              
99 0         0 my $pdfmark = _build_pdfmark(
100             xml => $xml,
101             xmp => $xmp,
102             icc_path => $icc_path,
103             mod_date => $mod_date,
104             );
105 0 0       0 open my $pfh, '>:raw', $ps_fp or die "open $ps_fp: $!";
106 0         0 print $pfh encode_utf8($pdfmark);
107 0         0 close $pfh;
108              
109 0         0 my @cmd = (
110             $gs,
111             '-dPDFA=3',
112             '-dPDFACompatibilityPolicy=1',
113             '-dBATCH', '-dNOPAUSE', '-dQUIET',
114             # NOSAFER : nécessaire pour que gs lise les fichiers référencés
115             # dans le pdfmark (ICC, XML, XMP).
116             '-dNOSAFER',
117             '-sColorConversionStrategy=RGB',
118             '-sProcessColorModel=DeviceRGB',
119             '-sDEVICE=pdfwrite',
120             "-sOutputFile=$pdf_out",
121             $ps_fp,
122             $pdf_in,
123             );
124              
125 0         0 my $stderr_fp = File::Spec->catfile($tmpdir, 'gs.err');
126 0         0 my $rc = system(join(' ', map { _shq($_) } @cmd) . " 2>$stderr_fp");
  0         0  
127 0         0 my $err = '';
128 0 0       0 if (open my $efh, '<:raw', $stderr_fp) {
129 0         0 local $/;
130 0         0 $err = <$efh>;
131 0         0 close $efh;
132             }
133              
134 0 0       0 if ($rc != 0) {
135 0         0 return (0, "Ghostscript a échoué (code " . ($rc >> 8) . ") : $err");
136             }
137 0 0       0 unless (-s $pdf_out) {
138 0         0 return (0, "Ghostscript n'a rien produit : $err");
139             }
140              
141             # Post-traitement : gs ne sait pas poser un Metadata stream avec
142             # /Type /Metadata /Subtype /XML (PDF/A-3 §6.6.2.1). On ouvre le PDF
143             # produit avec PDF::Builder et on pose le XMP via xml_metadata().
144 0         0 my ($ok, $msg) = _set_xmp_metadata($pdf_out, $xmp, $opt{on_warning});
145 0 0       0 return (0, "PDF::Builder xml_metadata KO : $msg") unless $ok;
146              
147 0         0 return (1, "OK (" . (-s $pdf_out) . " octets)");
148             }
149              
150             sub _set_xmp_metadata {
151 0     0   0 my ($pdf_path, $xmp_bytes, $on_warning) = @_;
152              
153             # PDF::Builder open() peut imprimer des messages de version sur STDOUT.
154             # Dans des contextes embedded (Dancer, mod_perl) ce flux peut polluer
155             # la sortie HTTP. On capture STDOUT/STDERR le temps de l'appel et on
156             # relaie via le callback $on_warning.
157 0         0 my $buf = '';
158 0         0 my ($saved_out, $saved_err);
159 0 0       0 open $saved_out, '>&', \*STDOUT or return (0, "dup stdout: $!");
160 0 0       0 open $saved_err, '>&', \*STDERR or return (0, "dup stderr: $!");
161 0         0 close STDOUT;
162 0         0 close STDERR;
163             open STDOUT, '>', \$buf
164 0 0       0 or do { _restore_fds($saved_out, $saved_err); return (0, "muzzle stdout: $!") };
  0         0  
  0         0  
165             open STDERR, '>', \$buf
166 0 0       0 or do { _restore_fds($saved_out, $saved_err); return (0, "muzzle stderr: $!") };
  0         0  
  0         0  
167              
168 0         0 my ($ok, $msg) = (1, 'xmp set');
169             eval {
170 0         0 my $pdf = PDF::Builder->open($pdf_path);
171 0         0 $pdf->xml_metadata($xmp_bytes);
172 0         0 $pdf->saveas($pdf_path);
173 0         0 1;
174 0 0       0 } or do { ($ok, $msg) = (0, "PDF::Builder: $@") };
  0         0  
175              
176 0         0 _restore_fds($saved_out, $saved_err);
177              
178 0 0 0     0 if (length $buf && ref $on_warning eq 'CODE') {
179 0         0 $on_warning->("[PDF::FacturX::Embed] PDF::Builder: $buf");
180             }
181 0         0 return ($ok, $msg);
182             }
183              
184             sub _restore_fds {
185 0     0   0 my ($saved_out, $saved_err) = @_;
186 0         0 close STDOUT;
187 0         0 open STDOUT, '>&', $saved_out;
188 0         0 close STDERR;
189 0         0 open STDERR, '>&', $saved_err;
190 0         0 close $saved_out;
191 0         0 close $saved_err;
192             }
193              
194             ###############################################################################
195             # find_icc_profile — cherche un profil ICC sRGB :
196             # 1. Dans les emplacements standards de Ghostscript (homebrew, /usr/share)
197             # 2. Sinon, repli sur le sRGB embarqué dans share/icc/sRGB.icc
198             ###############################################################################
199             sub find_icc_profile {
200 1     1 1 276748 my @bases = (
201             '/opt/homebrew/Cellar/ghostscript',
202             '/usr/local/Cellar/ghostscript',
203             '/opt/homebrew/share/ghostscript',
204             '/usr/local/share/ghostscript',
205             '/usr/share/ghostscript',
206             );
207 1         6 my @names = qw(default_rgb.icc sRGB2014.icc sRGB.icc srgb.icc);
208              
209 1         5 for my $base (@bases) {
210 5 50       599 next unless -d $base;
211 0         0 for my $name (@names) {
212 0         0 my @hits = _find_file_shallow($base, $name, 6);
213 0 0       0 return $hits[0] if @hits;
214             }
215             }
216              
217             # Repli : profil ICC embarqué dans la dist
218 1         7 my $share_dir;
219 1 50       3 eval { $share_dir = dist_dir('PDF-FacturX'); 1 } or do {
  1         9  
  1         217  
220 0         0 my $here = __FILE__;
221 0         0 my @parts = File::Spec->splitpath($here);
222 0         0 my $dir = $parts[1];
223 0         0 $share_dir = File::Spec->catdir($dir, '..', '..', '..', 'share');
224             };
225 1         39 my $bundled = File::Spec->catfile($share_dir, 'icc', 'sRGB.icc');
226 1 50       37 return $bundled if -r $bundled;
227              
228 0           return undef;
229             }
230              
231             sub _find_file_shallow {
232 0     0     my ($dir, $name, $max_depth) = @_;
233 0 0         return () if $max_depth < 0;
234 0 0         opendir(my $dh, $dir) or return ();
235 0 0         my @entries = grep { $_ ne '.' && $_ ne '..' } readdir $dh;
  0            
236 0           closedir $dh;
237 0           my @out;
238 0           for my $e (@entries) {
239 0           my $p = File::Spec->catfile($dir, $e);
240 0 0         if (-d $p) {
    0          
241 0           push @out, _find_file_shallow($p, $name, $max_depth - 1);
242             }
243             elsif ($e eq $name) {
244 0           push @out, $p;
245             }
246             }
247 0           return @out;
248             }
249              
250             ###############################################################################
251             # pdfmark PostScript — injecte OutputIntent, XML embarqué, AF, Names/EF.
252             # Le XMP est posé après coup par PDF::Builder (gs ne sait pas écrire un
253             # stream /Type /Metadata /Subtype /XML compatible PDF/A-3 §6.6.2.1).
254             ###############################################################################
255             sub _build_pdfmark {
256 0     0     my (%a) = @_;
257 0           my $icc_path = _ps_string($a{icc_path});
258 0           my $mod_ps = _ps_string("D:$a{mod_date}");
259 0           my $xml_ps = _ps_string_bytes($a{xml});
260              
261 0           return <<"PS";
262             %!PS-Adobe-3.0
263              
264             % ── OutputIntent sRGB (requis par PDF/A) ──────────────────────────────
265             [/_objdef {icc_PDFA} /type /stream /OBJ pdfmark
266             [{icc_PDFA} << /N 3 /Alternate /DeviceRGB >> /PUT pdfmark
267             [{icc_PDFA} $icc_path (r) file /PUT pdfmark
268             [/_objdef {OutputIntent_PDFA} /type /dict /OBJ pdfmark
269             [{OutputIntent_PDFA} <<
270             /Type /OutputIntent
271             /S /GTS_PDFA1
272             /DestOutputProfile {icc_PDFA}
273             /OutputConditionIdentifier (sRGB)
274             /Info (sRGB IEC61966-2.1)
275             >> /PUT pdfmark
276             [{Catalog} << /OutputIntents [{OutputIntent_PDFA}] >> /PUT pdfmark
277              
278             % ── XML Factur-X embarqué ─────────────────────────────────────────────
279             % Deux /PUT séparés : le dict en premier pour poser /Type /Subtype,
280             % puis le contenu en STRING inline (pas `file` : `file` refait le dict
281             % à zéro et on perd /Type /Subtype — bug PDF/A-3 6.8 test 1).
282             [/_objdef {fxXML} /type /stream /OBJ pdfmark
283             [{fxXML}
284             << /Type /EmbeddedFile
285             /Subtype (text/xml) cvn
286             /Params << /ModDate $mod_ps >>
287             >> /PUT pdfmark
288             [{fxXML} $xml_ps /PUT pdfmark
289              
290             % ── FileSpec (AFRelationship = /Data) ──────────────────────────────────
291             [/_objdef {fxFS} /type /dict /OBJ pdfmark
292             [{fxFS} <<
293             /Type /Filespec
294             /F (factur-x.xml)
295             /UF (factur-x.xml)
296             /Desc (Factur-X Invoice)
297             /AFRelationship /Data
298             /EF << /F {fxXML} /UF {fxXML} >>
299             >> /PUT pdfmark
300              
301             % ── Catalog : /AF + /Names /EmbeddedFiles ──────────────────────────────
302             [{Catalog} <<
303             /AF [{fxFS}]
304             /Names <<
305             /EmbeddedFiles <<
306             /Names [(factur-x.xml) {fxFS}]
307             >>
308             >>
309             >> /PUT pdfmark
310             PS
311             }
312              
313             ###############################################################################
314             # XMP — namespaces pdfaid, fx (Factur-X), dc, xmp + extension schema PDF/A-3.
315             ###############################################################################
316             sub _build_xmp {
317 0     0     my (%a) = @_;
318 0           my $conformance = $CONFORMANCE{$a{profile}};
319 0           my $title = _xml_esc($a{title});
320 0           my $author = _xml_esc($a{author});
321 0           my $creator = _xml_esc($a{creator});
322 0           my $date = _xml_esc($a{date});
323              
324 0           return <<"XMP";
325            
326            
327            
328              
329            
330             xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/">
331             3
332             B
333            
334              
335            
336             xmlns:fx="urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#">
337             INVOICE
338             factur-x.xml
339             1.0
340             $conformance
341            
342              
343            
344             xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/"
345             xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#"
346             xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#">
347            
348            
349            
350             Factur-X PDFA Extension Schema
351             urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#
352             fx
353            
354            
355            
356             DocumentFileName
357             Text
358             external
359             Name of the embedded XML invoice file
360            
361            
362             DocumentType
363             Text
364             external
365             INVOICE
366            
367            
368             Version
369             Text
370             external
371             Factur-X version
372            
373            
374             ConformanceLevel
375             Text
376             external
377             Factur-X conformance level
378            
379            
380            
381            
382            
383            
384            
385              
386            
387             xmlns:dc="http://purl.org/dc/elements/1.1/">
388             application/pdf
389             $title
390             $author
391            
392              
393            
394             xmlns:xmp="http://ns.adobe.com/xap/1.0/">
395             $creator
396             $date
397             $date
398            
399              
400            
401            
402            
403             XMP
404             }
405              
406             # Encode une chaîne texte courte (ASCII) en literal PostScript (…).
407             sub _ps_string {
408 0     0     my ($s) = @_;
409 0           $s =~ s/\\/\\\\/g;
410 0           $s =~ s/\(/\\(/g;
411 0           $s =~ s/\)/\\)/g;
412 0           return "($s)";
413             }
414              
415             # Encode des octets arbitraires (UTF-8 multi-octets, etc.) en literal PostScript.
416             sub _ps_string_bytes {
417 0     0     my ($bytes) = @_;
418 0           $bytes =~ s/\\/\\\\/g;
419 0           $bytes =~ s/\(/\\(/g;
420 0           $bytes =~ s/\)/\\)/g;
421 0           return "($bytes)";
422             }
423              
424             sub _xml_esc {
425 0     0     my ($s) = @_;
426 0   0       $s //= '';
427 0           $s =~ s/&/&/g;
428 0           $s =~ s/
429 0           $s =~ s/>/>/g;
430 0           $s =~ s/"/"/g;
431 0           return $s;
432             }
433              
434             # Shell-quote pour construire la commande gs (évite les surprises sur les paths).
435             sub _shq {
436 0     0     my ($s) = @_;
437 0 0         return $s unless $s =~ /[^\w.\/=:-]/;
438 0           $s =~ s/'/'\\''/g;
439 0           return "'$s'";
440             }
441              
442             1;
443              
444             __END__