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 l’animal', [ ['Nom de l’animal : ', '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)); } }