Files
termanager2/app/Services/Piantine/PiantinaPdfService.php
Francesco Picone 056d95cfcb Editor piantine: bozza automatica e pubblicazione con anteprima
- Il disegno è bozza e si salva da solo; i dati del territorio si
  modificano dall'editor e valgono subito nel gestionale.
- Un solo "Pubblica" (immagine + PDF), uguale in editor e gestione:
  anteprima preparata in coda su disco privato, conferma obbligatoria,
  rifiutata se nel frattempo è cambiato qualcosa di stampato.
- Stati Bozza / Pubblicata / Modifiche da pubblicare, calcolati
  sull'impronta di confine e dati stampati e sul PDF in uso.
- Gestione riscritta: stato, ricerca, filtri, storico paginato.
- Editor ridisegnato a tre zone, utilizzabile anche su tablet e telefono.
- Avvisi via evento (trait Avvisa + <x-avvisi>).
- .gitattributes: script shell sempre LF, altrimenti il container
  locale su Windows non parte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:08:34 +02:00

292 lines
10 KiB
PHP

<?php
namespace App\Services\Piantine;
use App\Models\Territorio;
use App\Models\TerritorioMappa;
use App\Models\TerritorioPdfVersione;
use App\Services\TerritorioThumbnailService;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
use setasign\Fpdi\Fpdi;
/**
* Compone il PDF del territorio e lo sostituisce nel gestionale conservando
* la versione precedente.
*
* Pagina 1 — la cartolina: il modulo ufficiale S-12 (resources/pdf/S-12_I.pdf,
* 146 x 94,4 mm) importato com'è, con Località e numero sulle righe
* punteggiate e i confini scritti nello spazio centrale.
*
* La "Località" del modulo è il nome della zona del territorio: è già lì, dice
* la stessa cosa, e si aggiorna in un posto solo quando la congregazione
* riorganizza il lavoro.
*
* Pagina 2 — la piantina, sola, al 98% della pagina. Il riquadro dell'S-12 è
* grande sette centimetri: una mappa di quartiere lì dentro non si legge, e
* chi percorre il territorio guarda quella, non il modulo.
*/
class PiantinaPdfService
{
/** Quanto della pagina occupa la piantina. */
private const OCCUPAZIONE = 0.98;
public function __construct(
private TerritorioThumbnailService $thumbnails,
) {
}
/**
* Il PDF completo, come stringa binaria.
*
* $immagine è il percorso assoluto di un'immagine diversa da quella
* salvata nella piantina: l'anteprima della pubblicazione compone il PDF
* con l'immagine appena generata, senza averla ancora resa ufficiale.
*/
public function genera(Territorio $territorio, TerritorioMappa $mappa, ?string $immagine = null): string
{
if ($immagine === null) {
if (! $mappa->image_path || ! Storage::disk('public')->exists($mappa->image_path)) {
throw new RuntimeException('Prima di generare il PDF va generata l\'immagine della piantina.');
}
$immagine = Storage::disk('public')->path($mappa->image_path);
}
$dimensioni = @getimagesize($immagine);
if ($dimensioni === false) {
throw new RuntimeException('L\'immagine della piantina non è leggibile: rigenerala.');
}
[$imgPxW, $imgPxH] = $dimensioni;
$pdf = new Fpdi();
$pdf->SetAutoPageBreak(false);
$pdf->setSourceFile(resource_path('pdf/S-12_I.pdf'));
$template = $pdf->importPage(1);
$misure = $pdf->getTemplateSize($template);
$w = $misure['width'];
$h = $misure['height'];
// ── Pagina 1: il modulo ────────────────────────────────────────────
$pdf->AddPage($misure['orientation'], [$w, $h]);
$pdf->useTemplate($template);
$pdf->SetTextColor(0, 0, 0);
// Intestazione: i valori si appoggiano sulle righe punteggiate del
// modulo. Posizioni in proporzione alla pagina, tarate sull'S-12.
$pdf->SetFont('Helvetica', 'B', 10);
$headerY = $h * 0.125;
$pdf->SetXY($w * 0.185, $headerY);
$pdf->Cell($w * 0.45, 5, $this->latin((string) $territorio->zona?->nome), 0, 0, 'L');
$pdf->SetXY($w * 0.875, $headerY);
$pdf->Cell($w * 0.10, 5, $this->latin($territorio->numero), 0, 0, 'C');
$this->scriviConfini($pdf, $territorio, $w, $h);
// ── Pagina 2: la piantina ──────────────────────────────────────────
$pdf->AddPage($misure['orientation'], [$w, $h]);
$boxW = $w * self::OCCUPAZIONE;
$boxH = $h * self::OCCUPAZIONE;
$scala = min($boxW / $imgPxW, $boxH / $imgPxH);
$imgW = $imgPxW * $scala;
$imgH = $imgPxH * $scala;
$pdf->Image(
$immagine,
($w - $imgW) / 2,
($h - $imgH) / 2,
$imgW,
$imgH,
$this->tipoImmagine($immagine)
);
return $pdf->Output('S');
}
/**
* I confini nello spazio che sul modulo è riservato alla piantina.
*
* Il corpo del testo si restringe finché non ci sta: i confini di un
* territorio vanno da due righe a venti, e una misura fissa o lascerebbe
* mezzo modulo vuoto o sborderebbe sulla dicitura stampata sotto.
*/
private function scriviConfini(Fpdi $pdf, Territorio $territorio, float $w, float $h): void
{
$testo = trim((string) $territorio->confini);
if ($testo === '') {
$pdf->SetFont('Helvetica', 'I', 9);
$pdf->SetTextColor(120, 120, 120);
$pdf->SetXY($w * 0.06, $h * 0.42);
$pdf->Cell($w * 0.88, 5, $this->latin('Confini non ancora descritti.'), 0, 0, 'C');
$pdf->SetTextColor(0, 0, 0);
return;
}
$boxX = $w * 0.06;
$boxY = $h * 0.20;
$boxW = $w * 0.88;
$boxH = $h * 0.53;
$righe = preg_split('/\R/', $testo) ?: [$testo];
foreach ([10, 9, 8, 7, 6, 5.5, 5] as $corpo) {
$interlinea = $corpo * 0.44;
$pdf->SetFont('Helvetica', '', $corpo);
if ($this->altezzaTesto($pdf, $righe, $boxW, $interlinea) <= $boxH) {
break;
}
}
$pdf->SetXY($boxX, $boxY);
foreach ($righe as $riga) {
$riga = trim($riga);
if ($riga === '') {
$pdf->Ln($interlinea * 0.6);
continue;
}
$pdf->SetX($boxX);
$pdf->MultiCell($boxW, $interlinea, $this->latin($riga), 0, 'L');
}
}
/** Quanto verrebbe alto il blocco dei confini a questo corpo. */
private function altezzaTesto(Fpdi $pdf, array $righe, float $larghezza, float $interlinea): float
{
$altezza = 0.0;
foreach ($righe as $riga) {
$riga = trim($riga);
if ($riga === '') {
$altezza += $interlinea * 0.6;
continue;
}
// GetStringWidth misura una riga sola: quante ne servono lo dice il
// rapporto con la larghezza disponibile, arrotondato per eccesso.
$larghezzaTesto = $pdf->GetStringWidth($this->latin($riga));
$altezza += max(1, ceil($larghezzaTesto / $larghezza)) * $interlinea;
}
return $altezza;
}
/** FPDF vuole sapere il formato quando l'estensione non basta. */
private function tipoImmagine(string $path): string
{
return strtolower(pathinfo($path, PATHINFO_EXTENSION)) === 'png' ? 'PNG' : 'JPEG';
}
/**
* Genera il PDF e lo rende quello ufficiale del territorio, archiviando
* l'eventuale PDF precedente nello storico versioni.
*/
public function sostituisci(Territorio $territorio, TerritorioMappa $mappa, string $origine = 'piantine'): void
{
$this->sostituisciConPdf($territorio, $this->genera($territorio, $mappa), $origine);
}
/**
* Rende ufficiale un PDF già composto, archiviando il precedente.
*
* Prima si scrive il nuovo file, poi si archivia il vecchio: nell'ordine
* inverso un errore di scrittura (disco pieno) lasciava pdf_path puntato
* a un file già spostato nello storico, e i link delle assegnazioni in 404.
*/
public function sostituisciConPdf(Territorio $territorio, string $binario, string $origine = 'piantine'): void
{
$disk = Storage::disk('public');
$nuovoPath = 'territori-pdf/piantina-' . $territorio->id . '-' . now()->format('Ymd-His') . '.pdf';
if (! $disk->put($nuovoPath, $binario)) {
throw new RuntimeException('Non è stato possibile scrivere il nuovo PDF sul disco: il PDF in vigore non è stato toccato.');
}
$this->archiviaCorrente($territorio, $origine);
$territorio->update([
'pdf_path' => $nuovoPath,
'thumbnail_path' => $this->thumbnails->generate($nuovoPath),
]);
activity()
->causedBy(auth()->user())
->performedOn($territorio)
->log('piantina_pdf_sostituito');
}
/**
* Rimette in vigore una versione archiviata; il PDF attuale finisce a sua
* volta nello storico, quindi il ripristino è sempre reversibile.
*/
public function ripristina(TerritorioPdfVersione $versione): void
{
$disk = Storage::disk('public');
$territorio = $versione->territorio;
if (! $territorio) {
throw new RuntimeException('La versione non appartiene più a nessun territorio.');
}
if (! $disk->exists($versione->pdf_path)) {
throw new RuntimeException('Il file di questa versione non esiste più sul disco.');
}
$this->archiviaCorrente($territorio, 'ripristino');
$nuovoPath = 'territori-pdf/ripristino-' . $territorio->id . '-' . now()->format('Ymd-His') . '.pdf';
$disk->move($versione->pdf_path, $nuovoPath);
$territorio->update([
'pdf_path' => $nuovoPath,
'thumbnail_path' => $this->thumbnails->generate($nuovoPath),
]);
$versione->delete();
activity()
->causedBy(auth()->user())
->performedOn($territorio)
->log('piantina_pdf_ripristinato');
}
/**
* Sposta il PDF attuale del territorio (se esiste) nello storico.
*/
private function archiviaCorrente(Territorio $territorio, string $origine): void
{
$disk = Storage::disk('public');
if (! $territorio->pdf_path || ! $disk->exists($territorio->pdf_path)) {
return;
}
$versionePath = 'territori-pdf-versioni/territorio-' . $territorio->id
. '-' . now()->format('Ymd-His') . '-' . substr(uniqid(), -5) . '.pdf';
$disk->makeDirectory('territori-pdf-versioni');
$disk->move($territorio->pdf_path, $versionePath);
TerritorioPdfVersione::create([
'territorio_id' => $territorio->id,
'pdf_path' => $versionePath,
'thumbnail_path' => $territorio->thumbnail_path,
'origine' => $origine,
'sostituito_il' => now(),
]);
}
/** FPDF ragiona in windows-1252: gli accenti UTF-8 vanno convertiti. */
private function latin(string $testo): string
{
$convertito = @iconv('UTF-8', 'windows-1252//TRANSLIT', $testo);
return $convertito !== false ? $convertito : $testo;
}
}