Globinours/app/Services/DocumentsService.php

537 lines
22 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
final class DocumentsService
{
private const ADOPTION_TEMPLATE = __DIR__ . '/../../resources/documents/CONTRAT ADOPTION V1.5.odt';
private const TEMPLATES = [
'adoption' => self::ADOPTION_TEMPLATE,
'abandon' => __DIR__ . '/../../resources/documents/ABANDON V1.0.odt',
'benevole' => __DIR__ . '/../../resources/documents/CONTRAT BENEVOLE V2.0.odt',
'fa' => __DIR__ . '/../../resources/documents/PROPOSITION FA V1.1.odt',
'pre_adoption' => __DIR__ . '/../../resources/documents/CERTIFICAT VISITE PRE-ADOPTION V1.1.odt',
];
public static function adoptionTemplate(): string
{
if (!is_file(self::ADOPTION_TEMPLATE)) {
throw new RuntimeException(t('service.document.adoption_missing'));
}
return self::ADOPTION_TEMPLATE;
}
public static function template(string $type): string
{
$path = self::TEMPLATES[$type] ?? null;
if (!$path || !is_file($path)) {
throw new RuntimeException(t('service.document.template_missing'));
}
return $path;
}
public static function generateAbandon(array $animal): string
{
return self::transformTemplate('abandon', 'abandon-' . self::safeName((string) $animal['name']), function (
DOMXPath $xp,
) use ($animal): void {
$sex = match ($animal['sex'] ?? 'U') {
'M' => 'Mâle',
'F' => 'Femelle',
default => 'Non renseigné',
};
$birth = trim((string) ($animal['birth_date'] ?? ''));
$age = '—';
if ($birth !== '') {
$born = new DateTimeImmutable($birth);
$now = new DateTimeImmutable();
$diff = $born->diff($now);
$age = $diff->y > 0 ? $diff->y . ' an' . ($diff->y > 1 ? 's' : '') : $diff->m . ' mois';
}
$chip = trim((string) ($animal['chip'] ?: $animal['chip_id'] ?? ''));
self::replaceParagraph($xp, 'Nom de lanimal', [
['Nom de lanimal : ', 'label'],
[(string) $animal['name'], 'value'],
['tab'],
['Sexe : ', 'label'],
[$sex, 'value'],
]);
self::replaceParagraph($xp, 'Race :', [
['Race : ', 'label'],
[(string) ($animal['breed'] ?? '—'), 'value'],
['tab'],
['Âge : ', 'label'],
[$age, 'value'],
['break'],
['Vaccination : ', 'label'],
['À vérifier', 'value'],
['tab'],
['Vermifugation : ', 'label'],
['À vérifier', 'value'],
['break'],
['Numéro de tatouage ou puce électronique : ', 'label'],
[$chip !== '' ? $chip : '—', 'value'],
]);
});
}
public static function generateContact(string $type, array $contact): string
{
if (!in_array($type, ['benevole', 'fa'], true)) {
throw new InvalidArgumentException(t('service.document.invalid_type'));
}
return self::transformTemplate($type, $type . '-' . self::safeName((string) $contact['name']), function (
DOMXPath $xp,
) use ($type, $contact): void {
$name = (string) $contact['name'];
$address = (string) ($contact['address'] ?? '');
$postal = (string) ($contact['postal_code'] ?? '');
$city = (string) ($contact['city'] ?? '');
$phone = (string) ($contact['phone'] ?? '');
$email = (string) ($contact['email'] ?? '');
if ($type === 'benevole') {
self::replaceParagraph(
$xp,
'Nom :',
[['Nom : ', 'label'], [$name, 'value'], ['tab'], ['Prénom : ', 'label'], ['', 'value']],
0,
);
self::replaceParagraph($xp, 'Adresse :', [['Adresse : ', 'label'], [$address, 'value']], 0);
self::replaceParagraph(
$xp,
'Code Postal :',
[['Code Postal : ', 'label'], [$postal, 'value'], ['tab'], ['Ville : ', 'label'], [$city, 'value']],
0,
);
self::replaceParagraph(
$xp,
'Numéro de téléphone',
[
['Numéro de téléphone : ', 'label'],
[$phone, 'value'],
['tab'],
['Adresse e-mail : ', 'label'],
[$email, 'value'],
],
0,
);
} else {
self::replaceParagraph($xp, 'NOM / Prénom :', [['NOM / Prénom : ', 'label'], [$name, 'value']], 0);
self::replaceParagraph($xp, 'Adresse :', [['Adresse : ', 'label'], [$address, 'value']], 0);
self::replaceParagraph(
$xp,
'Code postal :',
[['Code postal : ', 'label'], [$postal, 'value'], ['tab'], ['Ville : ', 'label'], [$city, 'value']],
0,
);
self::replaceParagraph(
$xp,
'Téléphone :',
[
['Téléphone : ', 'label'],
[$phone, 'value'],
['tab'],
['Adresse mail : ', 'label'],
[$email, 'value'],
],
0,
);
}
});
}
public static function generateAdoption(array $animal, ?array $adoption, ?array $vaccine): string
{
$dir = self::tempDir();
$name = self::safeName((string) $animal['name']);
$target = $dir . '/contrat-adoption-' . $name . '.odt';
if (!copy(self::adoptionTemplate(), $target)) {
throw new RuntimeException(t('service.document.copy_failed'));
}
$zip = new ZipArchive();
if ($zip->open($target) !== true) {
throw new RuntimeException(t('service.document.open_failed'));
}
$xml = $zip->getFromName('content.xml');
if ($xml === false) {
$zip->close();
throw new RuntimeException(t('service.document.content_invalid'));
}
$dom = new DOMDocument();
$dom->preserveWhiteSpace = true;
if (!$dom->loadXML($xml)) {
$zip->close();
throw new RuntimeException(t('service.document.xml_invalid'));
}
$xp = new DOMXPath($dom);
$xp->registerNamespace('text', 'urn:oasis:names:tc:opendocument:xmlns:text:1.0');
$xp->registerNamespace('office', 'urn:oasis:names:tc:opendocument:xmlns:office:1.0');
self::addGeneratedFieldStyles($dom, $xp);
$date = static fn(?string $value): string => $value ? date('d/m/Y', strtotime($value)) : '';
$sex = match ($animal['sex'] ?? 'U') {
'M' => '☒ Mâle ☐ Femelle',
'F' => '☐ Mâle ☒ Femelle',
default => '☐ Mâle ☐ Femelle',
};
$origin = trim((string) ($animal['breed'] ?? ''));
$chip = trim((string) ($animal['chip'] ?: $animal['chip_id'] ?? ''));
$testStatus =
'FIV ' .
self::testLabel((string) ($animal['fiv_status'] ?? 'unknown')) .
' / FeLV ' .
self::testLabel((string) ($animal['felv_status'] ?? 'unknown'));
$signatureDate = $date($adoption['adoption_date'] ?? date('Y-m-d'));
self::setSpan($xp, 'T26', (string) $animal['name']);
self::setLabeledSpan($xp, 'T28', 'Sexe : ', $sex);
self::setSpan($xp, 'T31', ' ' . $date($animal['birth_date'] ?? null));
self::setSpan($xp, 'T33', 'Robe : ', null, 'GeneratedField');
self::setSpan($xp, 'T34', (string) ($animal['color'] ?? ''));
self::setSpan($xp, 'T41', $origin !== '' ? $origin : '—');
self::setSpan($xp, 'T42', '');
self::setLabeledSpan($xp, 'T45', 'Puce électronique N° : ', $chip !== '' ? $chip : '—');
self::setLabeledSpan(
$xp,
'T49',
' : ',
(int) ($animal['sterilized'] ?? 0) === 1
? '☒ Effectuée ☐ À effectuer avant le :'
: '☐ Effectuée ☒ À effectuer avant le :',
);
self::setLabeledSpan($xp, 'T53', 'Date du test FIV-FELV : ', $testStatus, 'tab');
self::setLabeledSpan($xp, 'T55', 'Date de la dernière vaccination : ', $date($vaccine['done_date'] ?? null));
self::setLabeledSpan($xp, 'T56', 'Rappel à effectuer le : ', $date($vaccine['due_date'] ?? null), 'tab');
self::setSpan($xp, 'T98', (string) ($adoption['adopter_name'] ?? ''));
self::setSpan($xp, 'T101', (string) ($adoption['adopter_address'] ?? ''));
self::setSpan($xp, 'T104', (string) ($adoption['adopter_postal_code'] ?? ''));
self::setSpan($xp, 'T107', (string) ($adoption['adopter_city'] ?? ''));
self::setSpan($xp, 'T110', '');
self::setSpan($xp, 'T113', (string) ($adoption['adopter_phone'] ?? ''));
self::setSpan($xp, 'T116', (string) ($adoption['adopter_email'] ?? ''));
$signatureCity = AppSettings::get('association_city') ?: '—';
self::setLabeledSpan($xp, 'T155', 'Fait à : ', $signatureCity, null, 'GeneratedSignature');
self::setLabeledSpan($xp, 'T156', 'Le : ', $signatureDate, 'tab', 'GeneratedSignature');
self::setLabeledSpan($xp, 'T247', 'Fait à : ', $signatureCity, null, 'GeneratedSignature');
self::setLabeledSpan($xp, 'T253', 'Le : ', $signatureDate, 'tab', 'GeneratedSignature');
self::setSpan($xp, 'T283', (string) $animal['name']);
self::setLabeledSpan($xp, 'T367', 'Fait à : ', $signatureCity, null, 'GeneratedSignature');
self::setLabeledSpan($xp, 'T373', 'Le : ', $signatureDate, 'tab', 'GeneratedSignature');
foreach (
[
'T25',
'T30',
'T33',
'T36',
'T40',
'T45',
'T48',
'T52',
'T53',
'T55',
'T56',
'T97',
'T100',
'T103',
'T106',
'T109',
'T112',
'T115',
'T281',
]
as $labelStyle
) {
self::applySpanStyle($xp, $labelStyle);
}
$updated = $dom->saveXML();
if ($updated === false || !$zip->addFromString('content.xml', $updated)) {
$zip->close();
throw new RuntimeException(t('service.document.update_contract_failed'));
}
$zip->close();
return $target;
}
public static function toPdf(string $odtPath): string
{
$dir = dirname($odtPath);
$profile = $dir . '/lo-profile';
mkdir($profile, 0775, true);
$cmd = [
'/usr/bin/env',
'-u',
'DISPLAY',
'SAL_USE_VCLPLUGIN=svp',
'/usr/bin/libreoffice',
'-env:UserInstallation=' . self::fileUri($profile),
'--headless',
'--nologo',
'--nodefault',
'--nolockcheck',
'--nofirststartwizard',
'--convert-to',
'pdf',
'--outdir',
$dir,
$odtPath,
];
$pipes = [];
$process = proc_open($cmd, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
if (!is_resource($process)) {
throw new RuntimeException(t('service.document.libreoffice_failed'));
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$code = proc_close($process);
$pdf = $dir . '/' . pathinfo($odtPath, PATHINFO_FILENAME) . '.pdf';
if ($code !== 0 || !is_file($pdf)) {
throw new RuntimeException(t('service.document.pdf_failed', ['details' => trim($stdout . ' ' . $stderr)]));
}
return $pdf;
}
public static function cleanup(string $path): void
{
$dir = dirname($path);
if (!str_starts_with($dir, sys_get_temp_dir() . '/globinours-doc-')) {
return;
}
$items = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST,
);
foreach ($items as $item) {
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
}
rmdir($dir);
}
private static function setSpan(
DOMXPath $xp,
string $style,
string $value,
?string $prefix = null,
string $generatedStyle = 'GeneratedValue',
): void {
$nodes = $xp->query("//text:span[@text:style-name='$style']");
if (!$nodes || $nodes->length === 0) {
return;
}
$node = $nodes->item(0);
while ($node->firstChild) {
$node->removeChild($node->firstChild);
}
$node->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:text:1.0', 'text:style-name', $generatedStyle);
if ($prefix === 'tab') {
$node->appendChild(
$node->ownerDocument->createElementNS('urn:oasis:names:tc:opendocument:xmlns:text:1.0', 'text:tab'),
);
}
$node->appendChild($node->ownerDocument->createTextNode($value));
}
private static function setLabeledSpan(
DOMXPath $xp,
string $style,
string $label,
string $value,
?string $prefix = null,
string $labelStyle = 'GeneratedField',
): void {
$nodes = $xp->query("//text:span[@text:style-name='$style']");
if (!$nodes || $nodes->length === 0) {
return;
}
$node = $nodes->item(0);
while ($node->firstChild) {
$node->removeChild($node->firstChild);
}
$node->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:text:1.0', 'text:style-name', $labelStyle);
if ($prefix === 'tab') {
$node->appendChild(
$node->ownerDocument->createElementNS('urn:oasis:names:tc:opendocument:xmlns:text:1.0', 'text:tab'),
);
}
$node->appendChild($node->ownerDocument->createTextNode($label));
$valueSpan = $node->ownerDocument->createElementNS(
'urn:oasis:names:tc:opendocument:xmlns:text:1.0',
'text:span',
);
$valueSpan->setAttributeNS(
'urn:oasis:names:tc:opendocument:xmlns:text:1.0',
'text:style-name',
'GeneratedValue',
);
$valueSpan->appendChild($node->ownerDocument->createTextNode($value));
$node->appendChild($valueSpan);
}
private static function applySpanStyle(DOMXPath $xp, string $style, string $generatedStyle = 'GeneratedField'): void
{
$nodes = $xp->query("//text:span[@text:style-name='$style']");
if (!$nodes || $nodes->length === 0) {
return;
}
$nodes
->item(0)
->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:text:1.0', 'text:style-name', $generatedStyle);
}
private static function addGeneratedFieldStyles(DOMDocument $dom, DOMXPath $xp): void
{
$automatic = $xp->query('//office:automatic-styles')->item(0);
if (!$automatic) {
return;
}
self::appendTextStyle($dom, $automatic, 'GeneratedField', '11pt');
self::appendTextStyle($dom, $automatic, 'GeneratedValue', '11pt', true);
self::appendTextStyle($dom, $automatic, 'GeneratedSignature', '11pt');
}
private static function appendTextStyle(
DOMDocument $dom,
DOMNode $automatic,
string $name,
string $size,
bool $bold = false,
): void {
$style = $dom->createElementNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:style');
$style->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:name', $name);
$style->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:family', 'text');
$props = $dom->createElementNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:text-properties');
$props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:font-name', 'Open Sans');
$props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:font-name-asian', 'Open Sans');
$props->setAttributeNS(
'urn:oasis:names:tc:opendocument:xmlns:style:1.0',
'style:font-name-complex',
'Open Sans',
);
$props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0', 'fo:font-size', $size);
$weight = $bold ? 'bold' : 'normal';
$props->setAttributeNS(
'urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0',
'fo:font-weight',
$weight,
);
$props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:font-size-asian', $size);
$props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:font-size-complex', $size);
$props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:font-weight-asian', $weight);
$props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:font-weight-complex', $weight);
$style->appendChild($props);
$automatic->appendChild($style);
}
private static function transformTemplate(string $type, string $filename, callable $transform): string
{
$dir = self::tempDir();
$target = $dir . '/' . $filename . '.odt';
if (!copy(self::template($type), $target)) {
throw new RuntimeException(t('service.document.copy_failed'));
}
$zip = new ZipArchive();
if ($zip->open($target) !== true) {
throw new RuntimeException(t('service.document.open_failed'));
}
$xml = $zip->getFromName('content.xml');
if ($xml === false) {
$zip->close();
throw new RuntimeException(t('service.document.content_invalid'));
}
$dom = new DOMDocument();
$dom->preserveWhiteSpace = true;
if (!$dom->loadXML($xml)) {
$zip->close();
throw new RuntimeException(t('service.document.xml_invalid'));
}
$xp = new DOMXPath($dom);
$xp->registerNamespace('text', 'urn:oasis:names:tc:opendocument:xmlns:text:1.0');
$xp->registerNamespace('office', 'urn:oasis:names:tc:opendocument:xmlns:office:1.0');
self::addGeneratedFieldStyles($dom, $xp);
$transform($xp);
$updated = $dom->saveXML();
if ($updated === false || !$zip->addFromString('content.xml', $updated)) {
$zip->close();
throw new RuntimeException(t('service.document.update_failed'));
}
$zip->close();
return $target;
}
private static function replaceParagraph(DOMXPath $xp, string $startsWith, array $parts, int $occurrence = 0): void
{
$matches = [];
foreach ($xp->query('//text:p') as $paragraph) {
$plain = preg_replace('/\s+/u', ' ', str_replace("\u{00A0}", ' ', $paragraph->textContent));
if (str_starts_with(trim((string) $plain), $startsWith)) {
$matches[] = $paragraph;
}
}
$node = $matches[$occurrence] ?? null;
if (!$node) {
return;
}
while ($node->firstChild) {
$node->removeChild($node->firstChild);
}
foreach ($parts as $part) {
if ($part === ['tab']) {
$node->appendChild(
$node->ownerDocument->createElementNS('urn:oasis:names:tc:opendocument:xmlns:text:1.0', 'text:tab'),
);
continue;
}
if ($part === ['break']) {
$node->appendChild(
$node->ownerDocument->createElementNS(
'urn:oasis:names:tc:opendocument:xmlns:text:1.0',
'text:line-break',
),
);
continue;
}
[$text, $kind] = $part;
$span = $node->ownerDocument->createElementNS(
'urn:oasis:names:tc:opendocument:xmlns:text:1.0',
'text:span',
);
$span->setAttributeNS(
'urn:oasis:names:tc:opendocument:xmlns:text:1.0',
'text:style-name',
$kind === 'value' ? 'GeneratedValue' : 'GeneratedField',
);
$span->appendChild($node->ownerDocument->createTextNode($text));
$node->appendChild($span);
}
}
private static function testLabel(string $status): string
{
return match ($status) {
'neg' => 'négatif',
'pos' => 'positif',
default => 'non renseigné',
};
}
private static function safeName(string $name): string
{
$name = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $name) ?: 'chat';
return strtolower(trim(preg_replace('/[^a-zA-Z0-9]+/', '-', $name), '-')) ?: 'chat';
}
private static function tempDir(): string
{
$dir = sys_get_temp_dir() . '/globinours-doc-' . bin2hex(random_bytes(8));
if (!mkdir($dir, 0775, true)) {
throw new RuntimeException(t('service.document.temp_failed'));
}
return $dir;
}
private static function fileUri(string $path): string
{
return 'file://' . str_replace('%2F', '/', rawurlencode($path));
}
}