Publier Globinours 1.0.0-rc.3

This commit is contained in:
Alexandre NOEL 2026-09-03 12:39:15 +02:00
commit 9a2b4068da
325 changed files with 38230 additions and 20 deletions

View file

@ -0,0 +1,537 @@
<?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));
}
}