Files
termanager2/app/Services/Piantine/PiantinaPdfService.php
Francesco Picone 9c37c1ad7b Piantine staccate dai territori, editor a schermo intero, restyle
Le piantine non dipendono piu' da un territorio esistente: territorio_id e'
nullable, la chiave esterna e' nullOnDelete invece di cascadeOnDelete, e `nome`
fa da etichetta finche' il collegamento manca. Si disegna guardando le strade e
si decide dopo di chi e' il disegno; se il territorio non c'e', lo si crea
dall'editor con numero, zona, tipologia e confini — zona e tipologia comprese,
create sul posto quando mancano.

Il cambio di cascata chiude anche una perdita di dati silenziosa: ogni import
XML svuota `territori` in blocco e finora si portava via tutte le piantine.

Il disegno passa su /piantine/editor, a schermo intero con telaio proprio: su
una mappa larga un terzo di schermo un confine si sbaglia, e finisce stampato.
/piantine resta la gestione — area, generazione, collegamenti, storico.

Cartolina S-12 reimpaginata: pagina 1 il modulo con la Localita' (che ora e' il
nome della zona, non un campo duplicato) e i confini scritti nello spazio
centrale, con il corpo del testo che si adatta alla lunghezza; pagina 2 la
piantina sola al 98%. Aggiunta l'anteprima, che compone il PDF al volo senza
toccare pdf_path ne' lo storico.

Immagine: JPEG invece di PNG (0,9 MB contro 5 per territorio), composta alle
tessere dello zoom piu' alto e ridotta con Lanczos, nelle proporzioni della
pagina cosi' riempie il foglio invece di lasciare fasce bianche. Il tratto del
confine e' espresso in pixel dell'immagine finale: si vede il confine e si legge
la via che ci passa sotto. L'attribuzione OSM si dimensiona con l'immagine,
come la ODbL richiede.

Rimossa la rigenerazione massiva: job, stato in cache e interfaccia.

Sistema visivo ripassato su tutta l'applicazione: le scale gray e indigo di
Tailwind sono ridefinite nei token, cosi' le 37 viste si riallineano insieme
invece di essere riscritte una per una. Grigi caldi, un accento solo con quattro
significati, carattere servito dall'applicazione e non da Google Fonts, via il
gradiente della testata. Aggiunto il link "salta al contenuto", che mancava.

Corretto un guasto d'ambiente che bloccava TUTTI gli upload in sviluppo: dalla
1.26 il fastcgi_params di nginx passa HTTP_HOST come $host, che perde la porta,
e ogni URL firmato smetteva di validare — l'endpoint di upload di Livewire
rispondeva 401.

294 test verdi.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 16:41:22 +02:00

270 lines
9.3 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. */
public function genera(Territorio $territorio, TerritorioMappa $mappa): string
{
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($mappa->image_path)
);
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
{
$binario = $this->genera($territorio, $mappa);
$disk = Storage::disk('public');
$this->archiviaCorrente($territorio, $origine);
$nuovoPath = 'territori-pdf/piantina-' . $territorio->id . '-' . now()->format('Ymd-His') . '.pdf';
$disk->put($nuovoPath, $binario);
$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;
}
}