Files
2026-11-11 15:04:01 +02:00

73 lines
6.3 KiB
PHP

<?php
namespace App\Modules\Services;
/** Deterministic text-only PDF v1. Stored bytes, never regenerated from live records. */
final class InvoicePdf
{
public const VERSION='sgf-test-invoice-text-v2';
public static function lines(array $s): array
{
if(($s['calculation_policy']??null)===MultiLineInvoiceCalculation::POLICY)return MultiLineInvoicePdf::lines($s);
$lines=[['title','Factura de serviço - demonstração isolada'],['text',$s['series_code'].' / '.$s['number']],['text','Emissão: '.$s['issued_local'].' ('.$s['timezone'].')'],['section','Emitente']];
foreach(['issuer'=>'Emitente','client'=>'Cliente'] as $key=>$title) {
if($key==='client') $lines[]=['section',$title];
$party=$s['document'][$key]; $lines[]=['text',$party['name']];
foreach(['nuit'=>'NUIT','address'=>'Morada','locality'=>'Localidade','country'=>'País'] as $field=>$label) $lines[]=['text',$label.': '.($party['data'][$field] ?? 'Não indicado')];
}
$p=$s['document']['prepared']; $line=$s['document']['data']['lines'][0];
$lines[]=['section','Serviço e período'];
foreach(['service_name'=>'Serviço','description'=>'Descrição','quantity'=>'Quantidade','unit_price'=>'Preço sem IVA'] as $key=>$label) if(!empty($line[$key]) || ($line[$key] ?? null)==='0') $lines[]=['text',$label.': '.$line[$key]];
$lines[]=['text','Unidade: '.($p['units'][0] ?? 'Não indicada')];
$lines[]=['text','Período: '.($p['period_start'] ?? 'Não indicado').' a '.($p['period_end'] ?? 'Não indicado')];
$lines[]=['text','Condições: '.($p['payment_terms'] ?? 'Não indicadas')];
$lines[]=['text','Vencimento proposto: '.($p['proposed_due_date'] ?? 'Não indicado')];
$lines[]=['section','Valores - '.$s['calculation']['currency']];
foreach(['base_exact'=>'Base exacta','vat_exact'=>'IVA exacto','vat_document_proposed'=>'IVA documental proposto','discarded_difference'=>'Diferença preservada','total_document_proposed'=>'Total proposto'] as $key=>$label) $lines[]=['text',$label.': '.$s['calculation'][$key]];
$lines[]=['text','Perfil: '.$s['profile_identifier'].' v'.$s['profile_version'].' | IVA '.$s['rate_percent'].'%'];
$lines[]=['text','Exigibilidade simulada: '.$s['exigibility_date'].' | Período: '.$s['assessment_period']];
foreach(['contacts'=>'Contactos','observations'=>'Observações'] as $key=>$label) if(!empty($s['document'][$key])) { $lines[]=['section',$label]; $lines[]=['text',$s['document'][$key]]; }
foreach($s['document']['payments'] as $payment) { $lines[]=['section','Instruções de pagamento']; foreach(['label'=>'Designação','bank'=>'Banco','holder'=>'Titular','account_number'=>'Conta','nib'=>'NIB','iban'=>'IBAN','swift'=>'SWIFT','bank_code'=>'Código bancário','currency'=>'Moeda','instructions'=>'Instruções'] as $key=>$label) if(isset($payment['data'][$key]) && $payment['data'][$key]!=='') $lines[]=['text',$label.': '.$payment['data'][$key]]; }
$lines[]=['section','Origem e limites']; $lines[]=['text','Rascunho #'.$s['draft_id'].' v'.$s['document']['draft_version'].' | Regra temporal #'.$s['rule_id'].' v'.$s['rule_version']];
$lines[]=['text','Sem recebimento, comunicação à AT ou lançamento contabilístico.'];
return $lines;
}
public static function supports(array $lines): bool
{
foreach($lines as [, $text]) if(@iconv('UTF-8','Windows-1252',$text)===false || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/',$text)) return false;
return true;
}
public function render(array $s): string
{
$lines=$s['document_lines'] ?? self::lines($s); abort_unless(self::supports($lines),422,'Texto fora do conjunto de caracteres suportado pelo PDF v1; não foi emitido.');
$rows=[];
foreach($lines as [$kind,$text]) {
foreach(explode("\n",str_replace("\r",'', $text)) as $paragraph) {
// <=50 glyphs even for the widest Helvetica letters; fixed A4 margins.
$words=preg_split('/\s+/u',$paragraph); $wrapped='';
foreach($words as $word) foreach(mb_str_split($word,50) as $part) {
if(mb_strlen($wrapped.($wrapped===''?'':' ').$part)>50) { $rows[]=[$kind,$wrapped]; $wrapped=$part; }
else $wrapped.=($wrapped===''?'':' ').$part;
}
if($wrapped!=='') $rows[]=[$kind,$wrapped];
}
}
$pages=array_chunk($rows,44);
$objects=[]; $add=function($text)use(&$objects){$objects[]=$text; return count($objects);};
$add(''); $add(''); $font=$add('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>'); $bold=$add('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>'); $kids=[];
$escape=function($s) { return str_replace(['\\','(',')'],['\\\\','\\(','\\)'],iconv('UTF-8','Windows-1252',$s)); };
foreach($pages as $n=>$rows) {
$stream="0.48 0.19 0.12 rg\nBT /F2 12 Tf 42 799 Td (".$escape('TESTE — SEM VALIDADE FISCAL').") Tj ET\n"; $y=766;
foreach($rows as [$kind,$text]) { $f=$kind==='text'?'F1':'F2'; $size=$kind==='title'?13:10; $color=$kind==='section'?'0.09 0.34 0.28':'0.12 0.21 0.23'; $stream.="$color rg\nBT /$f $size Tf 42 $y Td (".$escape($text).") Tj ET\n"; $y-=15; }
$stream.="0.4 0.4 0.4 rg\nBT /F1 8 Tf 42 30 Td (".$escape('TESTE — SEM VALIDADE FISCAL | Página '.($n+1).' / '.count($pages)).") Tj ET\n";
$content=$add('<< /Length '.strlen($stream).">>\nstream\n".$stream.'endstream');
$kids[]=$add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 $font 0 R /F2 $bold 0 R >> >> /Contents $content 0 R >>");
}
$objects[0]='<< /Type /Catalog /Pages 2 0 R >>'; $objects[1]='<< /Type /Pages /Count '.count($kids).' /Kids ['.implode(' ',array_map(fn($id)=>"$id 0 R",$kids)).'] >>';
$pdf="%PDF-1.4\n%\xE2\xE3\xCF\xD3\n"; $offset=[0];
foreach($objects as $i=>$obj) { $offset[]=strlen($pdf); $pdf.=($i+1)." 0 obj\n$obj\nendobj\n"; }
$xref=strlen($pdf); $pdf.='xref'."\n0 ".(count($objects)+1)."\n0000000000 65535 f \n";
foreach(array_slice($offset,1) as $pos) $pdf.=sprintf('%010d 00000 n ',$pos)."\n";
return $pdf.'trailer'."\n<< /Size ".(count($objects)+1).' /Root 1 0 R >>'."\nstartxref\n$xref\n%%EOF\n";
}
}