<?php
/**
* Membership card PDF generation.
*
* @package TonItaliaRegistration
*/
defined( 'ABSPATH' ) || exit;
use setasign\Fpdi\Fpdi;
/**
* Class TON_Reg_Membership_Card
*/
class TON_Reg_Membership_Card {
const OPTION_TEMPLATES = 'ton_reg_card_templates';
const OPTION_ACTIVE_YEAR = 'ton_reg_card_active_year';
const SUBFOLDER = 'tessere';
const CONTEXT_ADMISSION = 'admission';
const CONTEXT_BATCH = 'batch';
const CONTEXT_MANUAL = 'manual';
/**
* @return bool
*/
public static function is_available() {
return is_readable( TON_REG_PLUGIN_DIR . 'vendor/autoload.php' );
}
/**
* Bootstrap PDF library.
*/
private static function load_library() {
if ( ! self::is_available() ) {
return new WP_Error( 'no_library', __( 'Libreria PDF non disponibile.', 'ton-italia-registration' ) );
}
require_once TON_REG_PLUGIN_DIR . 'vendor/autoload.php';
return true;
}
/**
* @return bool
*/
public static function current_user_can_manage_cards() {
return current_user_can( 'manage_options' );
}
/**
* @return void
*/
public static function assert_can_manage_cards() {
if ( ! self::current_user_can_manage_cards() ) {
wp_die( esc_html__( 'Non autorizzato.', 'ton-italia-registration' ), 403 );
}
}
/**
* @return int
*/
public static function get_active_year() {
$year = (int) get_option( self::OPTION_ACTIVE_YEAR, 0 );
if ( $year < 2000 || $year > 2100 ) {
$year = (int) gmdate( 'Y' );
}
return $year;
}
/**
* @param int $year Year.
* @return void
*/
public static function set_active_year( $year ) {
update_option( self::OPTION_ACTIVE_YEAR, (int) $year );
}
/**
* @return array<int,array<string,mixed>>
*/
public static function get_all_templates() {
$templates = get_option( self::OPTION_TEMPLATES, array() );
return is_array( $templates ) ? $templates : array();
}
/**
* @param int $year Year.
* @return array<string,mixed>
*/
public static function get_template_config( $year ) {
$templates = self::get_all_templates();
$config = $templates[ (int) $year ] ?? array();
return wp_parse_args( $config, self::default_layout() );
}
/**
* @return array<string,mixed>
*/
public static function default_layout() {
return array(
'template_attachment_id' => 0,
'name_x' => 42.5,
'name_y' => 118.0,
'font_family' => 'Helvetica',
'font_style' => '',
'font_size' => 14,
'text_color' => '#000000',
'uppercase' => true,
'align' => 'C',
'cell_width' => 80,
);
}
/**
* @param int $year Year.
* @param array<string,mixed> $config Config.
*/
public static function save_template_config( $year, $config ) {
$year = (int) $year;
$templates = self::get_all_templates();
$current = $templates[ $year ] ?? array();
$templates[ $year ] = array_merge( self::default_layout(), $current, $config );
update_option( self::OPTION_TEMPLATES, $templates );
}
/**
* Upload a PDF card template to the media library.
*
* Uses wp_handle_upload + wp_insert_attachment without image metadata generation,
* which avoids fatal errors from Imagick/GD on some hosts when processing PDFs.
*
* @param array<string,mixed>|null $file Uploaded file array from $_FILES.
* @param int $year Template year.
* @return int|WP_Error Attachment ID.
*/
public static function upload_template_pdf( $file, $year ) {
if ( ! self::current_user_can_manage_cards() ) {
return new WP_Error( 'forbidden', __( 'Non autorizzato.', 'ton-italia-registration' ) );
}
if ( ! is_array( $file ) || empty( $file['tmp_name'] ) ) {
if ( is_array( $file ) && isset( $file['error'] ) && UPLOAD_ERR_NO_FILE !== (int) $file['error'] ) {
return new WP_Error( 'upload', self::upload_error_message( (int) $file['error'] ) );
}
return new WP_Error( 'no_file', __( 'Nessun file selezionato.', 'ton-italia-registration' ) );
}
require_once ABSPATH . 'wp-admin/includes/file.php';
$check = wp_check_filetype( $file['name'], array( 'pdf' => 'application/pdf' ) );
if ( empty( $check['ext'] ) || 'pdf' !== $check['ext'] ) {
return new WP_Error( 'mime', __( 'Il template deve essere un file PDF.', 'ton-italia-registration' ) );
}
$upload = wp_handle_upload(
$file,
array(
'test_form' => false,
'mimes' => array(
'pdf' => 'application/pdf',
),
)
);
if ( ! empty( $upload['error'] ) ) {
return new WP_Error( 'upload', $upload['error'] );
}
$attachment_id = wp_insert_attachment(
array(
'post_mime_type' => $upload['type'],
'post_title' => sprintf(
/* translators: %d: year */
__( 'Template tessera %d', 'ton-italia-registration' ),
(int) $year
),
'post_content' => '',
'post_status' => 'inherit',
'guid' => $upload['url'],
),
$upload['file']
);
if ( is_wp_error( $attachment_id ) ) {
wp_delete_file( $upload['file'] );
return $attachment_id;
}
wp_update_attachment_metadata( $attachment_id, array() );
return (int) $attachment_id;
}
/**
* @param int $code PHP upload error code.
* @return string
*/
private static function upload_error_message( $code ) {
$messages = array(
UPLOAD_ERR_INI_SIZE => __( 'Il file supera il limite di upload del server.', 'ton-italia-registration' ),
UPLOAD_ERR_FORM_SIZE => __( 'Il file supera il limite consentito.', 'ton-italia-registration' ),
UPLOAD_ERR_PARTIAL => __( 'Upload incompleto. Riprova.', 'ton-italia-registration' ),
UPLOAD_ERR_NO_FILE => __( 'Nessun file selezionato.', 'ton-italia-registration' ),
UPLOAD_ERR_NO_TMP_DIR => __( 'Cartella temporanea mancante sul server.', 'ton-italia-registration' ),
UPLOAD_ERR_CANT_WRITE => __( 'Impossibile scrivere il file sul server.', 'ton-italia-registration' ),
UPLOAD_ERR_EXTENSION => __( 'Upload bloccato da un\'estensione PHP.', 'ton-italia-registration' ),
);
return $messages[ $code ] ?? __( 'Errore nel caricamento del template.', 'ton-italia-registration' );
}
/**
* @param int $registration_id Registration ID.
* @param int $year Year.
* @param array<string,mixed> $args Args.
* @return int|WP_Error Card ID.
*/
public static function generate_for_registration( $registration_id, $year, $args = array() ) {
$defaults = array(
'context' => self::CONTEXT_MANUAL,
'force_regenerate' => false,
'created_by' => get_current_user_id(),
'require_admin' => true,
);
$args = wp_parse_args( $args, $defaults );
if ( $args['require_admin'] && ! self::current_user_can_manage_cards() ) {
return new WP_Error( 'forbidden', __( 'Non autorizzato.', 'ton-italia-registration' ) );
}
$row = TON_Reg_Database::get( $registration_id );
if ( ! $row ) {
return new WP_Error( 'not_found', __( 'Iscrizione non trovata.', 'ton-italia-registration' ) );
}
if ( 'admitted' !== $row->status || ! empty( $row->anonymized_at ) ) {
return new WP_Error( 'not_admitted', __( 'La tessera può essere generata solo per soci ammessi.', 'ton-italia-registration' ) );
}
$year = (int) $year;
$config = self::get_template_config( $year );
if ( empty( $config['template_attachment_id'] ) ) {
return new WP_Error( 'no_template', __( 'Template PDF non configurato per questo anno.', 'ton-italia-registration' ) );
}
$existing = TON_Reg_Membership_Cards::get_by_registration_year( $registration_id, $year );
if ( $existing && empty( $args['force_regenerate'] ) ) {
return (int) $existing->id;
}
$library = self::load_library();
if ( is_wp_error( $library ) ) {
return $library;
}
$pdf_path = self::build_pdf_file( $row, $year, $config );
if ( is_wp_error( $pdf_path ) ) {
return $pdf_path;
}
$attachment_id = self::save_pdf_attachment( $row, $year, $pdf_path );
// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
@unlink( $pdf_path );
if ( is_wp_error( $attachment_id ) ) {
return $attachment_id;
}
if ( $existing ) {
if ( ! empty( $existing->attachment_id ) && (int) $existing->attachment_id !== (int) $attachment_id ) {
wp_delete_attachment( (int) $existing->attachment_id, true );
}
TON_Reg_Membership_Cards::update(
(int) $existing->id,
array(
'attachment_id' => (int) $attachment_id,
'template_attachment_id' => (int) $config['template_attachment_id'],
'layout_snapshot' => wp_json_encode( $config ),
'generation_context' => sanitize_key( $args['context'] ),
'created_at' => current_time( 'mysql', true ),
'created_by' => (int) $args['created_by'],
)
);
return (int) $existing->id;
}
$card_id = TON_Reg_Membership_Cards::insert(
array(
'registration_id' => (int) $registration_id,
'user_id' => (int) ( $row->user_id ?? 0 ) ?: null,
'year' => $year,
'attachment_id' => (int) $attachment_id,
'template_attachment_id' => (int) $config['template_attachment_id'],
'layout_snapshot' => wp_json_encode( $config ),
'generation_context' => sanitize_key( $args['context'] ),
'created_by' => (int) $args['created_by'],
)
);
if ( ! $card_id ) {
wp_delete_attachment( (int) $attachment_id, true );
return new WP_Error( 'db_error', __( 'Impossibile salvare la tessera nel database.', 'ton-italia-registration' ) );
}
return (int) $card_id;
}
/**
* @param int $registration_id Registration ID.
* @param string $old_status Previous status.
* @param string $new_status New status.
* @return int|WP_Error|null Card ID, error, or null if skipped.
*/
public static function maybe_generate_on_admission( $registration_id, $old_status, $new_status ) {
if ( 'admitted' === $old_status || 'admitted' !== $new_status ) {
return null;
}
$year = self::get_active_year();
if ( TON_Reg_Membership_Cards::get_by_registration_year( $registration_id, $year ) ) {
return null;
}
return self::generate_for_registration(
$registration_id,
self::get_active_year(),
array(
'context' => self::CONTEXT_ADMISSION,
'require_admin' => true,
)
);
}
/**
* @param object $row Registration row.
* @param int $year Year.
* @param array<string,mixed> $config Layout config.
* @return string|WP_Error Temp file path.
*/
private static function build_pdf_file( $row, $year, $config ) {
$template_path = get_attached_file( (int) $config['template_attachment_id'] );
if ( ! $template_path || ! is_readable( $template_path ) ) {
return new WP_Error( 'template_missing', __( 'File template PDF non trovato.', 'ton-italia-registration' ) );
}
try {
$pdf = new Fpdi();
$pdf->setSourceFile( $template_path );
$tpl_id = $pdf->importPage( 1 );
$size = $pdf->getTemplateSize( $tpl_id );
$orientation = ( $size['width'] > $size['height'] ) ? 'L' : 'P';
$pdf->AddPage( $orientation, array( $size['width'], $size['height'] ) );
$pdf->useTemplate( $tpl_id );
$family = self::sanitize_font_family( (string) $config['font_family'] );
$style = self::sanitize_font_style( (string) $config['font_style'] );
$size_pt = max( 6, min( 72, (float) $config['font_size'] ) );
$pdf->SetFont( $family, $style, $size_pt );
list( $r, $g, $b ) = self::parse_hex_color( (string) $config['text_color'] );
$pdf->SetTextColor( $r, $g, $b );
self::write_name_on_pdf( $pdf, $row, $config, $size_pt );
$tmp = wp_tempnam( 'ton-card-' . $year );
if ( ! $tmp ) {
return new WP_Error( 'temp_file', __( 'Impossibile creare file temporaneo.', 'ton-italia-registration' ) );
}
$pdf->Output( 'F', $tmp );
return $tmp;
} catch ( Exception $e ) {
return new WP_Error( 'pdf_error', $e->getMessage() );
}
}
/**
* @param object $row Registration row.
* @param int $year Year.
* @param string $path Temp PDF path.
* @return int|WP_Error Attachment ID.
*/
private static function save_pdf_attachment( $row, $year, $path ) {
TON_Reg_Documents::ensure_member_folder( $row );
TON_Reg_Documents::ensure_subfolder( $row, self::SUBFOLDER );
$filename = sprintf( 'Tessera-%d.pdf', (int) $year );
$title = sprintf(
/* translators: 1: year, 2: full name */
__( 'Tessera %1$d — %2$s', 'ton-italia-registration' ),
(int) $year,
$row->nome . ' ' . $row->cognome
);
return TON_Reg_Documents::sideload_file(
$row,
$path,
$filename,
$title,
self::SUBFOLDER,
array(
'_ton_reg_registration_id' => (int) $row->id,
'_ton_reg_document_type' => 'tessera',
'_ton_reg_card_year' => (int) $year,
)
);
}
/**
* Write member name on the PDF, splitting onto two lines when needed.
*
* @param Fpdi $pdf PDF instance.
* @param object $row Registration row.
* @param array<string,mixed> $config Layout config.
* @param float $size_pt Font size in points.
*/
private static function write_name_on_pdf( $pdf, $row, $config, $size_pt ) {
$parts = self::get_name_parts( $row, $config );
$nome = $parts['nome'];
$cognome = $parts['cognome'];
$full_name = trim( $nome . ( $cognome ? ' ' . $cognome : '' ) );
$align = in_array( $config['align'], array( 'L', 'C', 'R' ), true ) ? $config['align'] : 'C';
$cell_width = max( 0, (float) $config['cell_width'] );
$x = (float) $config['name_x'];
$y = (float) $config['name_y'];
$use_two_lines = false;
if ( $cell_width > 0 && '' !== $cognome ) {
$full_width = $pdf->GetStringWidth( $full_name );
if ( $full_width > $cell_width ) {
$use_two_lines = true;
}
}
if ( $use_two_lines ) {
$line_height = max( 4.0, $size_pt * 0.45 );
$pdf->SetXY( $x, $y );
$pdf->Cell( $cell_width, $line_height, $nome, 0, 2, $align );
$pdf->SetX( $x );
$pdf->Cell( $cell_width, $line_height, $cognome, 0, 0, $align );
return;
}
$pdf->SetXY( $x, $y );
$pdf->Cell( $cell_width, 0, $full_name, 0, 0, $align );
}
/**
* @param object $row Registration row.
* @param array<string,mixed> $config Layout config.
* @return array{nome:string,cognome:string}
*/
private static function get_name_parts( $row, $config ) {
return array(
'nome' => self::format_name_part( (string) ( $row->nome ?? '' ), $config ),
'cognome' => self::format_name_part( (string) ( $row->cognome ?? '' ), $config ),
);
}
/**
* @param string $text Name part.
* @param array<string,mixed> $config Layout config.
* @return string
*/
private static function format_name_part( $text, $config ) {
$text = trim( $text );
if ( ! empty( $config['uppercase'] ) ) {
if ( function_exists( 'mb_strtoupper' ) ) {
$text = mb_strtoupper( $text, 'UTF-8' );
} else {
$text = strtoupper( $text );
}
}
return $text;
}
/**
* @param object $row Registration row.
* @param array<string,mixed> $config Layout config.
* @return string
*/
private static function get_display_name( $row, $config ) {
$parts = self::get_name_parts( $row, $config );
return trim( $parts['nome'] . ' ' . $parts['cognome'] );
}
/**
* @param string $hex Hex color.
* @return array{0:int,1:int,2:int}
*/
private static function parse_hex_color( $hex ) {
$hex = ltrim( trim( $hex ), '#' );
if ( 3 === strlen( $hex ) ) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
if ( 6 !== strlen( $hex ) || ! ctype_xdigit( $hex ) ) {
return array( 0, 0, 0 );
}
return array(
(int) hexdec( substr( $hex, 0, 2 ) ),
(int) hexdec( substr( $hex, 2, 2 ) ),
(int) hexdec( substr( $hex, 4, 2 ) ),
);
}
/**
* @param string $family Font family.
* @return string
*/
private static function sanitize_font_family( $family ) {
$allowed = array( 'Helvetica', 'Times', 'Courier', 'Arial' );
return in_array( $family, $allowed, true ) ? ( 'Arial' === $family ? 'Helvetica' : $family ) : 'Helvetica';
}
/**
* @param string $style Font style.
* @return string
*/
private static function sanitize_font_style( $style ) {
$allowed = array( '', 'B', 'I', 'BI', 'IB' );
$style = strtoupper( $style );
if ( 'IB' === $style ) {
$style = 'BI';
}
return in_array( $style, $allowed, true ) ? $style : '';
}
/**
* @param int $card_id Card ID.
* @return bool|WP_Error
*/
public static function current_user_can_download( $card_id ) {
$card = TON_Reg_Membership_Cards::get( $card_id );
if ( ! $card ) {
return new WP_Error( 'not_found', __( 'Tessera non trovata.', 'ton-italia-registration' ) );
}
$row = TON_Reg_Database::get( (int) $card->registration_id );
if ( self::current_user_can_manage_cards() ) {
return true;
}
if ( ! is_user_logged_in() ) {
return new WP_Error( 'login', __( 'Accedi per scaricare la tessera.', 'ton-italia-registration' ) );
}
$user_row = TON_Reg_Database::get_for_wp_user( wp_get_current_user() );
if ( ! $user_row || (int) $user_row->id !== (int) $card->registration_id ) {
return new WP_Error( 'forbidden', __( 'Non autorizzato.', 'ton-italia-registration' ) );
}
if ( ! $row || 'admitted' !== $row->status || ! empty( $row->anonymized_at ) ) {
return new WP_Error( 'forbidden', __( 'Non autorizzato.', 'ton-italia-registration' ) );
}
return true;
}
/**
* @param int $card_id Card ID.
* @return void
*/
public static function stream_download( $card_id ) {
$can = self::current_user_can_download( $card_id );
if ( is_wp_error( $can ) ) {
wp_die( esc_html( $can->get_error_message() ), 403 );
}
$card = TON_Reg_Membership_Cards::get( $card_id );
$path = get_attached_file( (int) $card->attachment_id );
if ( ! $path || ! is_readable( $path ) ) {
wp_die( esc_html__( 'File non trovato.', 'ton-italia-registration' ), 404 );
}
$filename = basename( $path );
nocache_headers();
header( 'Content-Type: application/pdf' );
header( 'Content-Disposition: attachment; filename="' . sanitize_file_name( $filename ) . '"' );
header( 'Content-Length: ' . (string) filesize( $path ) );
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
readfile( $path );
exit;
}
/**
* @param string $context Generation context key.
* @return string
*/
public static function context_label( $context ) {
$labels = array(
self::CONTEXT_ADMISSION => __( 'Ammissione', 'ton-italia-registration' ),
self::CONTEXT_BATCH => __( 'Batch annuale', 'ton-italia-registration' ),
self::CONTEXT_MANUAL => __( 'Manuale', 'ton-italia-registration' ),
);
return $labels[ $context ] ?? $context;
}
}