En la segunda parte de este tutorial aprendimos como instalar las diferentes herramientas que utilizaremos para internacionalizar y localizar nuestras aplicaciones con gettext. También discutimos las diferencias entre catálogos, plantillas y las diferencias entre internacionalización y localización y porque es importante aplicarlas. En esta tercera y última parte haremos un ejercicio práctico para internacionalizar una pequeña aplicación de demo y localizarla en un par de lenguajes diferentes.
Como comenté en las otras publicaciones para este demo utilizaremos PHP pero en realidad gettext puede ser utilizado en otros lenguajes y en realidad la única diferencia es como instalar el soporte para gettext en nuestro lenguaje o ambiente favorito. La verdad es que estoy desarrollando una aplicación de tipo API en dicho lenguaje y mi propia necesidad de internacionalizarla es lo que me hizo considerar escribir este pequeño tutorial. Entonces, sin más preámbulo vamos a lo práctico.
Agregando soporte de gettext para PHP
Lo primero que vamos a hacer es asegurarnos que nuestra instalación de PHP tiene soporte para gettext. La mayoría de las distribuciones de Linux lo tendrán instalado por default aunque no necesariamente habilitado. Para el caso de Windows o macOS se puede utilizar el administrador de extensiones para habilitarla o deshabilitarla.
La manera más simple de verificar si tenemos instalada y habilitada la extensión es listar los módulos de php, el siguiente comando muestra dichas extensiones y filtrará la de gettext:
php -m | grep gettext
Si obtenemos en el resultado «gettext» entonces la extensión está habilitada. Si no es así podemos editar el archivo «.ini» correspondiente de php, en el caso de Fedora este archivo es:
/etc/php.d/20-gettext.ini
Dentro del archivo encontramos el siguiente contenido:
; Enable gettext extension module extension=gettext.so
La línea con la parte de extensión debe estar descomentada. Después de esto PHP debería tener habilitada la extensión.
Internacionalización (o preparando nuestro código)
Hemos hablado mucho ya de los conceptos pero ¿Cómo ajustamos nuestro código para internacionalizarlo? Pues en realidad es muy simple, solo hay que seguir cierto estándar al utilizar cadenas de texto dentro del código fuente que queremos que sea internacionalizado. Para nuestro demo lo primero que haremos será crear un directorio llamado php-gettext. Una vez creado el directorio creamos el típico archivo index.php con el siguiente contenido:
Como vemos en el ejemplo anterior tenemos una clase simple llamada Person con algunos métodos que imprimen mensajes. Obviamente en una aplicación del mundo real tendríamos mucho más código, clases, métodos, etc. pero para el objetivo de este demo con esto es más que suficiente. Cuando mandamos llamar este script veremos la siguiente salida:
# Hello my name is John Wayne and I'm 38 years which means I'm a grown up # That's it, good bye # # (running version 1.0.0)
GetText utiliza por default la función underscore o simplemente un guión bajo para indicar que deseamos traducir una cadena, es decir, si quisiéramos traducir la cadena de texto «Hello world» pasaríamos dicha cadena como argumento a la función:
echo _("Hello World!");
GetText se puede configurar para utilizar otros keywords o nombres de funciones adicional al guión bajo pero por el momento lo dejaremos así. Esto es en realidad casi todo lo que tenemos que hacer en nuestro código, es decir, asegurarnos que las partes que deseamos traducir utilicen este método predefinido. Es importante notar que si deseamos formatear la cadena de texto como en el caso de nuestro demo donde pasamos argumentos a la función sprintf debemos primero obtener el mensaje del catálogo con la función de underscore y posteriormente formatear ese resultado, es decir, primero mandamos llamar underscore y después sprintf pasando como primero argumento la salida de underscore. Un último paso es indicarle a gettext lo siguiente:
- El lenguaje o locale que deseamos usar durante la ejecución de la aplicación
- El nombre del catálogo o domain
- La ubicación de los catálogos que contienen las localizaciones o traducciones
Para ello agregamos a nuestro código las siguientes líneas al inicio del archivo:
<?php // Definir el lenguaje a usarse en tiempo de ejecución $language = 'en'; // Definir variables de entorno del sistema con este lenguaje putenv("LC_ALL=$language"); putenv("LC_LANG=$language"); setlocale(LC_ALL, $language); // Definir el nombre default de nuestro archivo de catálogo $domain = 'messages'; // Definir la ruta donde se encuentran los catálogos bindtextdomain($domain, "Locale"); textdomain($domain);
Definimos el lenguaje como inglés en la primera línea y posteriormente hacemos algunas llamadas a putenv para definir variables de entorno y setlocale para configurar el locale de la aplicación. Después definimos en la variable $domain el nombre que usaremos para los catálogos, puede ser cualquier cosa pero usualmente por convención se utiliza messages y, finalmente, en las últimas dos líneas configuramos la ruta donde se encuentran las localizaciones o catálogos con bindtextdomain y textdomain. En realidad lo único que tendríamos que cambiar a partir de este momento, si deseáramos cambiar el lenguaje de nuestra aplicación, sería la variable $language que podría estar leyéndose de alguna variable de entorno, valor de sesión o configuración propia de la aplicación.
Creando la plantilla y los catálogos
En nuestro ejemplo estamos utilizando como default el inglés en nuestros mensajes entonces, si decidimos soportar un par de lenguajes más, digamos Español y Francés, debemos crear catálogos para cada uno de estos lenguajes. Aunque ya hemos dejado listo nuestro código, es decir, ya está «internacionalizado» o preparado para traducirse aun nos faltan dichas traducciones, es decir, las localizaciones.
Como comenté, una de las ventajas de gettext es que no debemos hacer cambios al código, simplemente usando los estándares que discutimos con anterioridad podemos usar el comando xgettext para «inspeccionar» el código y generar una plantilla. Recordemos que la plantilla no contiene ninguna traducción sino que solo es un archivo base con todos los mensajes que serían localizados y a partir de este archivo creamos las localizaciones.
Antes de crear la plantilla y catálogos creamos un directorio en nuestro directorio base llamado Locale, que correspondería al «domain» que definimos anteriormente en nuestro código y dentro de cada uno otro directorio con el código ISO del lenguaje y finalmente en un último nivel un directorio en mayúsculas llamado LC_MESSAGES ya que gettext espera por convención encontrar este directorio de manera que tendríamos la siguiente estructura:
php-gettext/Locale/es/LC_MESSAGES php-gettext/Locale/fr/LC_MESSAGES
Considerando que queremos soportar Español y Francés.
Podemos utilizar las herramientas de línea de comando de gettext para generar los archivos pero es más fácil mantenerlos y actualizarlos con Poedit entonces, el siguiente paso es lanzar Poedit. Una vez abierto el programa nos vamos a la opción de menú Edit -> Preferences
Solo cambiamos el campo de nombre y de email, aunque esto no es necesario ayuda para identificar los archivos de traducciones. También nos aseguramos que la opción «Automatically compile MO file when saving» está habilitada. Una vez que cerramos este diálogo y volvemos a la pantalla principal vamos al menú File -> New y acto seguido se nos presentará un diálogo preguntándonos el lenguaje de la traducción, seleccionamos Inglés ya que esa será la base de nuestra plantilla o archivo .pot:
Una vez que damos aceptar tendremos una pantalla que nos dice que no hay traducciones. Esto es normal ya que no hemos configurado Poedit para indicarle donde está nuestro código fuente. Veremos la siguiente pantalla:
Vemos dos grandes botones, el segundo es el que nos interesa con la opción «Extract from sources«. Pero, antes de ello es sumamente importante guardar primero el archivo! entonces vamos a File -> Save As y guardamos el archivo con el nombre messages.pot en la ruta de Locale de nuestra aplicación, es decir:
# php-gettext/Locale
Noten la extensión del archivo, .pot, como he comentado ya varias veces los archivos .pot son de plantillas:
Una vez que guardamos el archivo podemos regresar a la pantalla principal y seleccionar la opción Extract from sources, una vez que seleccionamos dicho botón (el segundo) se nos muestra el siguiente diálogo:
Nos dirigimos al primer tab para configurar las opciones:
- Project name and version: el nombre del proyecto como tal.
- Language team: define el nombre del equipo o algún correo para identificar quien realiza la traducción, puramente informativo.
- Language: el lenguaje base que usa la aplicación, es decir, sin traducciones, en nuestro caso los mensajes en el código están en inglés.
- Charset: el formato de texto en el que se guardarán las traducciones, seleccionar UTF-8.
- Source code charset: el formato en el que están guardados los archivos de texto de nuestro código fuente, si utilizas cualquier editor moderno lo más probable es que sea UTF-8 también.
Después nos dirigimos al segundo tab del diálogo donde configuramos poedit para decirle en donde se encuentra el código fuente que vamos a traducir:
Este diálogo es un poco confuso pero simplemente hay que ir a la parte de paths y seleccionar el signo de mas, después la opción «Folders» y seleccionar el directorio base que contiene el código fuente ya que por default el base path será la ruta donde está guardado el archivo .pot y en realidad queremos inspeccionar todo el código que está un nivel más arriba. Si seleccionamos el folder adecuadamente deberá aparecer en el Base path la ruta a nuestro proyecto y en la parte de abajo de Paths un simple punto que indica el folder actual.
Una vez que seleccionamos OK o Aceptar se nos muestra la siguiente pantalla:
Podemos notar varias cosas:
- El lenguaje es detectado automáticamente, como PHP
- Hay 4 fuentes de texto o mensajes, es decir, todas las cadenas de texto dentro del código fuente donde usamos la función underscore
- Las cadenas de texto dentro del código fuente que no usan la función underscore son ignoradas
El siguiente paso es guardar el archivo. Simplemente vamos al menú File -> Save o bien presionamos Ctrl + S y cerramos el archivo. Si ahora abrimos cualquier editor de texto y seleccionamos el archivo que acabamos de guardar veremos lo siguiente:
Las líneas de color rosa no son mas que metadata o información particular de la plantilla como el nombre del proyecto, fecha, la persona que realiza la traducción, el software que se utilizó para generar el archivo, etc. La parte interesante comienza justo debajo de eso, veremos símbolos de «gato» con la ruta al archivo y la línea donde se encuentra el mensaje o la cadena de texto y en la siguiente línea la definición del lenguaje de dicho archivo que en este caso es PHP. Después hay dos líneas:
- msgid que define la cadena de texto fuente original que se debe traducir
- msgstr que no es otra cosa que la traducción misma
Pero ¿Por qué está vacío el msgstr de los mensajes? Recordemos que este es un archivo .POT, es decir, una plantilla que sirve como base para crear todas las demás traducciones. Este archivo tentativamente se guardaría en source control y se distribuiría a los colaboradores que deseen crear traducciones a otros lenguajes. Esto cobrará sentido en unos momentos.
Si volvemos a abrir el archivo .pot con poedit vemos lo siguiente:
Si ponemos atención al mensaje de la parte de abajo nos dice:
Los archivos POT son simplemente plantillas y no contienen ninguna traducción. Para crear una traducción, crea un nuevo archivo PO basado en la plantilla.
Ahora comienzan a tener sentido las plantillas. Seleccionamos el botón «Create New Translation» y acto seguido aparecerá un diálogo preguntándonos hacia que lenguaje queremos traducir esta plantilla, seleccionamos «Spanish»:
Como vemos en la última pantalla el archivo aun no ha sido guardado, vamos a File -> Save As y lo guardamos dentro de la carpeta de LC_MESSAGES para Español con el nombre «messages.po«, es decir la ruta debería ser:
php-gettext/Locale/es/LC_MESSAGES/messages.po
La razón por la cual debemos guardarlo como messages.po es porque si recuerdan en nuestro código indicamos el «domain» como «messages» así que gettext esperaría encontrar archivos .po de las traducciones con ese nombre para cada localización:
Una vez guardado el archivo podemos seleccionar la opción validate que nos mostrará el siguiente mensaje:
Y como bien nos indica poedit, el archivo es válido sin embargo hay 4 entradas que aun no se han traducido que de hecho es el 100%. Si ponemos atención, en la barra de estado de la aplicación nos indica el porcentaje y cantidad remanente de traducciones. Lo siguiente es poner manos a la obra y realizar las traducciones. Para agregarlas simplemente seleccionamos en la primera sección la cadena de texto a traducir y en la parte de abajo en Translation escribimos el equivalente en español. Es importante que si hacemos interpolación de cadenas tengamos cuidado de acomodar los caracteres especiales adecuadamente, de hecho para este ejercicio cometí un error a propósito en la primer traducción y después de seleccionar la opción de validación o «Validate» vemos lo siguiente:
En la cadena original hay tres reemplazos donde se hará interpolación sin embargo, en la traducción, solo hay dos es por ello que nos muestra dicho error. Una vez que nos aseguramos que todas las cadenas se tradujeron y que el validador pasa podemos guardar el archivo. Este generará automáticamente el archivo .mo que es el archivo compilado que leerá PHP.
Probando la localización
El siguiente paso es ir a nuestro código fuente y cambiar la línea que indica el lenguaje, recordemos que ya tenemos una traducción. Entonces, modificamos las siguientes líneas:
// Definir el lenguaje a usarse en tiempo de ejecución $language = 'es';
Y en lenguaje hemos definido «es» para obtener los mensajes en español, sin embargo, el ejecutar el demo vemos lo siguiente:
~: php test.php Hello my name is John Wayne and I'm 38 years which means I'm a grown up That's it, good bye! (running version 1.0.0)
Los mensajes siguen estando en inglés ¿Por qué? Bueno, resulta que los sistemas operativos definen en realidad la lista de locales que soportan, en el caso de Linux en particular podemos ver la lista de locales que tenemos en el sistema predefinidos con el siguiente comando:
~: locale -a
Esto nos dará una lista GIGANTE si tenemos muchos lenguajes instalados, por ejemplo, en mi caso, me da un total de 842. Lo más fácil entonces es filtrarlos ya que nos interesa saber que versiones del español tenemos instaladas:
~: locale -a | grep "es_"
El comando anterior arroja 64 resultados, que es más fácil de inspeccionar, esto quiere decir que mi instalación de Fedora soporta 64 variaciones del español, desde Argentina hasta Venezuela. Incluso, para varios sets de caracteres, por ejemplo, si busco los locales para México específicamente, usando el código ISO:
locale -a | grep "es_MX" es_MX es_MX.iso88591 es_MX.utf8
Hay tres variaciones para Español Mexicano; una default, una en formato iso88591 y finalmente otra en Unicode. ¿Por qué las diferencias? Bueno, el set de caracteres Unicode soporta muchísimos más caracteres que los demás, por ejemplo si utilizaramos emojis o algún otro tipo de caractér no estándar este no se desplegaría adecuadamente con iso-88591 o ASCII. De hecho si recuerdan, es el set de caracteres que seleccionamos en poedit que estaríamos usando.
Entonces ahora sabemos que de estos tres locales el que nos interesa es el identificado como «es_MX.utf8», procedemos a definirlo en nuestro código:
// Definir el lenguaje a usarse en tiempo de ejecución $language = 'es_MX.utf8';
Y ejecutamos de nuevo nuestra aplicación:
~: php test.php Hola mi nombre es John Wayne y tengo 38 años lo cual significa que soy un adulto Eso es todo, hasta luego! (running version 1.0.0)
Y voilá, ahora tenemos los mensajes traducidos. Noten que el mensaje final sigue estando en inglés y esto es a propósito pues en ningún momento usamos la función underscore para traducirlo en el código.
Manteniendo actualizados los catálogos
Después de nuestra primera versión decidimos que queremos traducir también el último mensaje que lanza la aplicación, bien. Entonces el siguiente paso es ajustar el código para que esa parte use también la función underscore:
public function printVersion() { echo sprintf(_("\n(running version %s)\n"), self::VERSION); }
Una vez que agregamos otro mensaje que debe ser traducido abrimos la plantilla principal en poedit, recordar que la plantilla principal es la base de todas las traducciones y que es la que debemos mantener actualizada. Cuando abrimos el archivo php-gettext/Locale/messages.pot presionamos el botón «Update», esto lo que hará es volver a inspeccionar el código en busca de nuevas cadenas o de las que hayan sido modificadas o incluso eliminadas y actualizará el catálogo:
Ahora si notamos hay 5 mensajes, no 4, es decir, el mensaje que nos dice que versión se ejecuta de la aplicación es ahora parte de los mensajes traducibles de la aplicación. Lo único que debemos hacer ahora es guardar el archivo y cerrarlo. Después, abrimos de nuevo la traducción en español, es decir el archivo php-gettext/Locale/es/LC_MESSAGES/messages.po. Una vez abierto el archivo necesitamos decirle a poedit que sincronice esa traducción con el catálogo, la manera más fácil es yendo al menú Catalog -> Update from POT file, esta opción permite seleccionar la plantilla contra la cual queremos sincronizar. El archivo que debemos seleccionar una vez abierto el diálogo es obviamente php-gettext/Locale/messages.pot, una vez abierto la pantalla muestra lo siguiente:
Como vemos en la lista de fuentes o mensajes aparece «(running version)» o el mensaje original pero en la columna de la derecha aparece en blanco, esto quiere decir que no hay traducción de ese mensaje para el lenguaje definido en este catálogo, es decir, en el de español. La barra de estado de abajo muestra información relevante también, nos dice que hay 4 cadenas traducidas de un total de 5, es decir solo el 80% de la aplicación está localizada y que queda 1 cadena de texto por traducir. Entonces lo que debemos hacer ahora es simplemente seleccionar esa cadena y traducirla en el recuadro Translation:
Después seleccionamos validar para asegurarnos que no hay errores y guardamos el archivo que, de nuevo, generará automaticamente el archivo .mo compilado, necesario para que PHP procese los mensajes. Si volvemos a ejecutar la aplicación tendremos lo siguiente:
~: php test.php Hola mi nombre es John Wayne y tengo 38 años lo cual significa que soy un adulto Eso es todo, hasta luego! (ejecutando versión 1.0.0)
El último mensaje de la aplicación está ahora traducido también.
Bonus, extra, prime!
Hasta ahora hemos completado todas las partes necesarias para internacionalizar y localizar la aplicación sin embargo, hay algunos extras que quisiera agregar.
Encapsulación
Aunque el código que tenemos es funcional se ve un poco feo, sobre todo por el uso del underscore y tener que estar formateando cada vez las cadenas de texto. Para mostrar un ejemplo práctico de lo que hablo veamos la siguiente línea:
echo sprintf(_("Hello my name is %s %s and I'm %s\n"), $this->firstName, $this->lastName, $age);
Este código aunque es legible no es muy intuitivo. ¿Que tal si en vez de eso pudieramos hacer esto?:
echo translate("Hello my name is %s %s and I'm %s\n", $this->firstName, $this->lastName, $age)
Esto nos permitiría tener un método más identificable. Para ello creamos el siguiente archivo:
php-gettext/i18n.php
Con el siguiente contenido:
Este es un simple archivo que utilizaremos para encapsular la funcionalidad de localización. Después simplemente hacemos un require o importamos el archivo al inicio:
require_once('i18n.php'); use function Helpers\I18n\translate;
Y cambiamos las referencias a la llamada del underscore function de gettext por ejemplo esto:
echo sprintf(_("Hello my name is %s %s and I'm %s\n"), $this->firstName, $this->lastName, $age);
Por esto:
echo translate("Hello my name is %s %s and I'm %s\n", $this->firstName, $this->lastName, $age);
Finalmente tendríamos nuestro archivo de la siguiente manera:
Como vemos ahora es mucho más legible y podemos utilizar el helper en cualquier otro archivo que lo necesitemos, como primer argumento se recibe la cadena que se va a traducir y seguido podemos pasar n cantidad de argumentos que corresponden a la interpolación que haremos.
Yendo más allá… keyword arguments
Algo que noté mientras programaba este helper para mi aplicación es que la función sprintf desgraciadamente no soporta argumentos nombrados o «keyword arguments» y es una de las cosas que extraño de Ruby on Rails para localizar ya que vuelve aun más legible el código. Me di a la tarea de mejorar el helper de manera que esto:
echo translate("Hello my name is %s %s and I'm %s\n", $this->firstName, $this->lastName, $age);
Se convierta en esto:
echo translate( "Hello my name is {firstName} {lastName} and I'm {age}\n", [ 'firstName' => $this->firstName, 'lastName' => $this->lastName, 'age' => $age ] );
Como vemos en la línea anterior podemos usar placeholders predefinidos con nombres específicos de manera que podamos identificar exactamente que variables vamos a rellenar y simplemente pasar como segundo parámetro un arreglo asociativo con dichos valores.
Para ello simplemente hice copy-paste de la clase que utilizo en mi aplicación que se encargará de esto. El código está en una clase ya que contiene validaciones y otros métodos más complejos por lo cual decidí encapsularlo:
Pueden simplemente copiar y pegar el archivo anterior en el i18n.php que ya tenían. Ahora, debido a que este archivo cambió y es una clase debemos modificar nuestro script, lo primero es cambiar las primeras líneas:
putenv("APP_LANG=es_MX.utf8"); require_once('i18n.php');
La primera define la variable de entorno APP_LANG que se usa dentro de la clase de translation y define el lenguaje, seguido requerimos el archivo como ya lo hacíamos. Después debemos cambiar las llamadas anteriores del método translate para que utilice el nuevo método de esta nueva clase ya que es un método estático, cambiar los placeholders de interpolación de «%s» a el nombre que querramos darle a la variable y finalmente pasar como segundo argumento el array con esos mapeos si es que existen, ya que no todas las cadenas se hará interpolación:
public function printInfo() { $age = $this->calculateAge(); echo I18n::translate( "Hello my name is {firstName} {lastName} and I'm {age}\n", [ 'firstName' => $this->firstName, 'lastName' => $this->lastName, 'age' => $age ] ); }
El restulado final del script sería el siguiente:
Pero existe un problema… si recordamos, gettext utiliza por default el keyword de función «_(» por una parte y por otra las cadenas de texto en la plantilla original aun tienen los placeholders «%s» y no los nombres de nuestras variables. Para solucionar esto abrimos la plantilla en poedit una vez abierta nos dirigimos al menú Catalog -> Properties y se nos muestra el diálogo con el que ya estábamos familiarizados, nos dirigimos ahora a la última pestaña o tab «Sources Keywords»:
Una vez que estamos en esta pantalla veremos una sección de «Additional keywords» con varios botones, seleccionamos el segundo para agregar un nuevo keyword o palabra clave que gettext deberá interpretar como una función de localización, que en nuestro caso nombramos «translate» así que la agregamos:
De esta manera la indicamos a gettext que utilice no solo el underscore sino la función llamada translate para hacer la introspección, damos OK o aceptar y finalmente se muestra la plantilla con los mensajes actualizados:
Guardamos el archivo y lo cerramos. Ahora que la plantilla está actualizada abrimos el archivo .po del catálogo en español es decir el archivo:
php-gettext/Locale/es/LC_MESSAGES/messages.po
Y seguimos el mismo procedimiento que hicimos anteriormente; Menú Catalog -> Update from POT file y seleccionamos la plantilla que recién actualizamos. Una vez que hagamos esto veremos que los mensajes del catálogo en español se muestran en un tono de color diferente como de warning o advertencia:
La razón es que ahora las traducciones no están sincronizadas y no concuerdan ya que las originales usabamos la interpolación con %s y ahora usamos los nombres de variables, simplemente vamos seleccionando cada mensaje y la ajustamos:
Guardamos el archivo y listo. El catálogo en español ahora concuerda con el uso de keyword arguments. Si volvemos a ejecutar nuestro script tenemos la siguiente salida:
~: php test.php Hola mi nombre es John Wayne y tengo 38 años lo cual significa que soy un adulto Eso es todo, hasta luego! (ejecutando versión 1.0.0)
Lo cual indica que la mejora al helper funciono.
Resúmen
Hubiera querido hacer más breve esta publicación pero decidí tomar muchas capturas de pantalla para poder ilustrar paso a paso como hacerlo. De hecho, leyendo puede parecer algo largo y tedioso pero una vez que entendemos los conceptos de gettext en realidad es bastante fácil utilizarlo y mantener al día tanto la plantilla como el catálogo. Aunque en realidad no es necesario utilizar una plantilla, ya que podríamos mantener cada archivo por separado haciendo introspección me parece que lo correcto es hacerlo de esta manera. Quizá para otra ocasión extienda este ejemplo utilizando solo los catálogos sin depender de la plantilla, también utilizando Poedit.
Pueden encontrar un demo completo de todo el ejemplo tanto en mi cuenta de Bitbucket como en Github con la versión final del código y traducciones en inglés y francés.
Sé el primero en comentar