miércoles, 4 de marzo de 2015

Office 2007 en Mint 64 bits con Wine

En un desarrollo reciente me encontré nuevamente con una vieja necesidad: exportar a Word. Pero esta vez no podía simplemente utilizar alguna de las múltiples librerías para PHP disponibles, ya que no se trataba de generar un documento desde cero, sino de cargar uno ya existente, realizar ciertas modificaciones, y exportarlo. Además, ese documento preexistente utilizaba muchas características de Word que simplemente no funcionaban como debían en OpenOffice. En resumen, para probar los resultados de la conversión, necesitaba un Word auténtico, y no me interesaba trabajar en simultáneo en Windows y Linux. Así que me puse manos a la obra a instalar Office en Linux.

Antes que nada, a instalar Wine y algunos paquetes opcionales:

$ sudo apt-get install wine wine-mono0.0.8 gecko-mediaplayer

Al intentar instalar Office 2010 usando el comando wine directamente no tuve suerte: casi al terminar la instalación me dio un error. Es posible que hubiera podido resolverlo con lo que sé ahora, pero en ese momento decidí que Office 2007 también me era suficiente, por lo cual me puse a instalarlo.

La instalación transcurrió perfectamente, pero al ejecutar el Word instalado descubrí para mi sorpresa que no tenía ningún soporte a XML, lo que le impedía cargar archivos .docx (lo cual era prioritario para mí) y además fallaba al cerrarse. Investigando descubrí que no se trataba de algún problema con libxml2, sino que simplemente se debía a que lo había instalado en modo 64 bits, y por algún motivo, aunque luego se lance en 32 bits, las aplicaciones no lograban encontrar la librería adecuadamente.

La solución consistió en preparar un nuevo directorio de Wine, como si fuera un nuevo ordenador, en modo 32 bits (si el directorio, aquí /home/fer/prefix32, no existe, winecfg lo creará):

$ WINEARCH=win32 WINEPREFIX=/home/fer/prefix32 winecfg

Y luego ejecutar de nuevo el setup.exe con estos parámetros:

$ WINEARCH=win32 WINEPREFIX=/home/fer/prefix32 wine setup.exe

Entonces, ya podía ejecutar el Word con:

$ WINEARCH=win32 WINEPREFIX=/home/fer/prefix32 wine /home/fer/prefix32/.../winword.exe

Ahora bien, si al final agregaba como parámetro un nombre de archivo con ruta relativa, lo abría correctamente. Si, en cambio, usaba una ruta absoluta, no lo encontraba. La solución, agregar z: al principio, por ejemplo: 'z:/home/fer/archivo.docx'.

Pero esto no resultó suficiente para archivos con espacios. Wine convierte los espacios automáticamente en %20, el código de escape estándar, y entonces Word se queja de que le han pedido abrir un archivo inexistente. La solución consiste en nuevamente modificar la ruta, indicando que se la trate como una URL, mediante el prefijo 'file:///'. Así que, si queremos abrir '/home/fer/archivo con espacio.docx', debemos suministrar el argumento 'file:///z:/home/fer/archivo con espacio.docx'.

El siguiente paso fue crear un ejecutable para no tener que recordar todo esto. Debido a que voy mejor con PHP que con Bash, creé un archivo 'winword', con permisos de ejecución, y el siguiente contenido:

#!/usr/bin/php5
<?php

$app = '/home/fer/prefix32/drive_c/Program Files/Microsoft Office/Office12/WINWORD.EXE';
$wineroot = '/home/fer/prefix32';
$args = array_slice($argv,2);
$file = @$argv[1];
if( $file ) {
    $file = '\'file:///Z:'.$file.'\'';
}
$cmd = ("WINEARCH=win32 WINEPREFIX=$wineroot wine '$app' $file ".join(" ",$args));
passthru($cmd);

Luego, sólo quedaba hacer que Chrome abriera los docx con mi ejecutable. Lamentablemente, ya tenía asociada la extensión .docx con LibreOffice, y el navegador no ofrece ningún mecanismo para cambiar esto. La respuesta es abrir un explorador de carpetas, como Nautilus o Dolphin, buscar un archivo .docx, y con el menú contextual cambiar la asociación.

Espero que esto le haya servido de ayuda a alguien. Como mínimo me servirá a mí mismo para no tener que recordar todos estos pasos cuando cambie mi ordenador!

martes, 2 de abril de 2013

XLIFF merger

Me he encontrado con un proyecto de Symfony 1.4 que utilizaba más de un archivo de traducciones por cada aplicación. La situación se había vuelto insostenible al introducir Pootle en la ecuación, por lo cual desarrollé un pequeño script que combina dos o más archivos en el formato adecuado en uno solo (de hecho, la salida del mismo es por stdout, para mayor flexibilidad).

El código es rápido y el XML de salida se genera con una simple concatenación de cadenas, pero hace el trabajo. Espero que sea de ayuda. Está preparado para ser guardado en un archivo con permisos de ejecución.


#!/usr/bin/php
<?php

/**
 * (c) 2013, Fernando del Valle
 * Code licensed as GPL
 * Merges two or more XLIFF files, as the ones used by Pootle or Symfony.
 */

$files = @array_slice($argv,1);
if( count( $files ) < 2 ) {
    echo "Usage: xliff_merge.php file1 file2 [... filen]";
    exit(-1);
}

try {
        $dictionary = array();
        foreach( $files as $filename ) {
            if( !file_exists( $filename ) )
                throw new Exception("File '$filename' does not exist");
            $root = simplexml_load_file( $filename );
            $file = $root->file;
            if( !$file )
                throw new Exception("Bad format");
            $source = $file['source-language'];
            $target = $file['target-language'];
            $body = $file->body;
            $tunits = $body['trans-unit'];
            foreach( $body[0] as $tunit ) {
                $sstr = $tunit->source->__toString();
                $tstr = $tunit->target->__toString();
                $approved = @$tunit['approved'];
                if( !$sstr )
                    continue;
                $dictionary[ $sstr ] = array( 'target' => $tstr, 'approved' => $approved );
            }
        }
        $results = array();
        $results[] = "<?xml version='1.0' encoding='utf-8'?>";
        $results[] = "<xliff version=\"1.0\">";
        $results[] = "\t<file datatype=\"plaintext\" source-language=\"$source\" target-language=\"$target\" original=\"global\">";
        $results[] = "\t\t<body>";
        $c = 1;
        foreach( $dictionary as $source_string => $arr ) {
            $target_string = $arr['target'];
            $results[] = "\t\t\t<trans-unit id=\"$c\" ".($arr['approved'] ? 'approved="yes"' : '').">";
            $results[] = "\t\t\t\t<source>$source_string</source>";
            $results[] = "\t\t\t\t<target>$target_string</target>";
            $results[] = "\t\t\t</trans-unit>";
            $c++;
        }
        $results[] = "\t\t</body>";
        $results[] = "\t</file>";
        $results[] = "</xliff>";

        echo join( "\r\n", $results );
} catch (Exception $e) {
    echo "FATAL ERROR: {$e->getMessage()}";
    exit(-1);
}

viernes, 1 de junio de 2012

Apache2 IE Single Sign On con NTLM

He estado peleando con un sitio web que debe recoger las credenciales de un usuario ingresado en Active Directory de manera transparente (es decir, sin que ingrese nombre de usuario y contraseña). El navegador no es un problema, ya que es un entorno corporativo y sólo utilizan Internet Explorer.

Mi primer intento fue con un módulo de Perl, llamado AuthenNTLM. La configuración es extremadamente sencilla, sólo hay que recordar que el PDC debe estar indicado como nombre de máquina, sin el dominio:

/etc/apache2/conf.d/ntlm.conf
...
PerlAddVar ntdomain "dominio.com pdc" # y NO "dominio.com pdc.dominio.com"
...

También es importante que el sitio quede incluido en la configuración de Sitios Locales (o Local Intranet) de Internet Explorer, en la pestaña de seguridad de las preferencias, o si no el navegador no le enviará las credenciales al servidor.

Inmediatamente comenzó a funcionar en el ordenador en el que trabajo, y desde PHP pude acceder fácilmente a $_SERVER[´REMOTE_USER´] para averiguar el nombre de usuario del dominio.

El problema fue cuando lo probé en otro ordenador. Una y otra vez pedía el nombre de usuario y contraseña. En los logs de Apache, una y otra vez:

[error] Wrong password/user (rc=3/1/327681): ...

¿Cuál era el problema? El sistema operativo. Windows XP utiliza NTLMv1 (soportado por el módulo de Perl), mientras que Windows 7 se niega a usar nada menor a NTLMv2 (no soportado).

En realidad, se pueden cambiar las preferencias de Windows 7 para que acepte NTLMv1 (link). Pero según dicen, el protocolo era tan malo e inseguro que mejor no hacerlo. ¿Alternativas? Usar NTLM con Kerberos y Winbindd (pero mantener un servidor Winbindd puede ser un infierno) o probar con este módulo en Python que sí soporta NTLMv2: PyAuthenNTLM2

miércoles, 25 de junio de 2008

Truco para mostrar HTML en el WebBrowser

Este post trata de cómo mostrar HTML en el WebBrowser, haciendo que las URLs que aparezcan en dicho código HTML se procesen correctamente.

El problema surge cuando queremos descargar una página por "fuera" del WebBrowser (generando un GET o un POST, por ejemplo), y luego queremos mostrar el HTML recibido en esta ventana, a modo de registro de lo sucedido.

El WebBrowser provee al menos tres formas de cargar una página:
- .Navigate( url ): carga la URL indicada y la muestra.
- .Url = url: también carga la URL indicada y la muestra.
- .DocumentText = cadena_html: procesa el árbol DOM y lo muestra.

Para nuestro caso, sólo nos sirve la tercera opción. No queremos que se descargue dos veces la misma página, sino que nos interesa descargarla una vez y luego "alimentar" al WebBrowser con su HTML.

Surge un primer problema: debemos esperar a que la página termine de cargar. Podemos bloquearnos de la siguiente manera, siendo _embeddedBrowser nuestro WebBrowser:

do {
System.Threading.Thread.Sleep(50);
Application.DoEvents();
} while (_embeddedBrowser.ReadyState != WebBrowserReadyState.Complete || _embeddedBrowser.IsBusy);

Ahora surge otro problema, que está relacionado con las URLs relativas. Una página web puede mostrar imágenes, cargar hojas de estilo CSS y cargar scripts desde otros archivos del servidor web. El enlace a dichos archivos no necesita estar del todo completo. Así, si nuestra página se encuentra en http://www.blogger.com/china/index.html, podemos utilizar una imagen ubicada en http://www.blogger.com/logo.jpg de las siguientes maneras:

- src="http://www.blogger.com/logo.jpg"
- src="/logo.jpg"
- src="../logo.jpg"

Es complicado corregir en el HTML cada una de estas situaciones (sobre todo cuando el "/index.html" tampoco es obligatorio). Sin embargo, sin algún truco, sólo los contenidos indicados de la primera manera (la "completa") se mostrarán.

Después de experimentar con diferentes soluciones, entre las cuales incluyo modificar el HTML haciendo un parser, encontré que la mejor manera era generar un árbol DOM (simplificando, una estructura XML) con la página, agregar un tag 'BASE href="url de la página"' dentro del tag HEAD y volver a mostrarla.

La forma más directa de obtener el árbol DOM resultó ser a partir del mismo WebBrowser. Si nos quedamos esperando de la siguiente manera (aquí el WebBrowser es wb):

wb.DocumentText = contents;
do
{
System.Threading.Thread.Sleep(50);
Application.DoEvents();
} while ((wb.ReadyState != WebBrowserReadyState.Loaded) && (wb.ReadyState != WebBrowserReadyState.Complete));

Pronto podremos modificar el árbol DOM:

HtmlElement head = wb.Document.GetElementsByTagName("head")[0];
if (head != null)
{
if (wb.Document.GetElementsByTagName("base").Count == 0)
{
HtmlElement b = wb.Document.CreateElement("base");
b.SetAttribute("href",
sourceUrl);
head.AppendChild(b);
}
}

La contra de este procedimiento es que vemos el mismo HTML dos veces: una vez procesado con los links incorrectos, y otra con los links correctos. Si queremos evitar este parpadeo, ninguno de los métodos del WebBrowser parece ayudar, ni siquiera haciendo un .Visible = false. La forma más sencilla es:

- crear un WebBrowser falso
- asignarle el HTML con .DocumentText
- esperar a que se genere el DOM
- modificar el DOM para agregarle un tag 'base href="..."'
- recuperar el HTML de este WebBrowser falso
- asignar este HTML al WebBrowser real
- esperar a que se termine de cargar la página

Otra consideración hace a la interactividad. Probablemente nos interese hacer un .AllowNavigation = false, para que el usuario no pueda hacer click en el navegador. Sin embargo, las páginas no se cargan si esta propiedad no está en true. Así que deberemos habilitarla temporalmente.

El código final es el siguiente, donde contents es el HTML descargado y sourceUrl es la dirección desde donde se lo descargó. Por motivos de espacio, he quitado todas las comprobaciones de error.

public void showHtml(string contents, string sourceUrl)
{
WebBrowser wb = new WebBrowser();
wb.DocumentText = contents;
do
{
System.Threading.Thread.Sleep(50);
Application.DoEvents();
} while ((wb.ReadyState != WebBrowserReadyState.Loaded) && (wb.ReadyState != WebBrowserReadyState.Complete));

HtmlElement head = wb.Document.GetElementsByTagName("head")[0];
HtmlElement b = wb.Document.CreateElement("base");
b.SetAttribute("href", sourceUrl);
head.AppendChild(b);

_embeddedBrowser.AllowNavigation = true;
_embeddedBrowser.DocumentText = wb.Document.GetElementsByTagName("html")[0].OuterHtml;
wb = null;

do
{
System.Threading.Thread.Sleep(50);
Application.DoEvents();
} while (_embeddedBrowser.ReadyState != WebBrowserReadyState.Complete || _embeddedBrowser.IsBusy);

_embeddedBrowser.AllowNavigation = false;
}

En resumen, el WebBrowser está lleno de trucos, por ser amables. En otras palabras, podríamos decir que su interfaz es bastante caprichosa. Si alguien encuentra una solución mejor para estos problemas agradecería que la enviara:
- hacer que un WebBrowser cargue un HTML sin mostrarlo inmediatamente
- generar un árbol DOM completo sin utilizar un WebBrowser
- realizar el proceso sin tener que permitir .AllowNavigation en ningún momento
- modificar todas las URLs partiendo de una cadena HTML y una URL de base sin usar un WebBrowser para nada

Espero que esto le sirva de ayuda a alguien.

Nota posterior:

El tag BASE se inserta al final de la sección HEAD. Si hubiera referencias anteriores, por ejemplo, tags de SCRIPT externos, el navegador no esperará e intentará cargarlos antes de llegar al base path. Esto quiere decir que no encontrará los archivos con los scripts. Hay dos soluciones:
1 - Evitar que aparezcan popups con los errores en los scripts, haciendo .ScriptsErrorsSuppressed = true
2 - En vez de insertar un hijo al HEAD, insertar un hermano al primer hijo, de la siguiente manera:

if (head.Children.Count > 0)
{
head.Children[0].InsertAdjacentElement( HtmlElementInsertionOrientation.BeforeBegin, baseTag);
}
else
{
head.AppendChild(baseTag);
}

viernes, 20 de junio de 2008

Cómo modificar la cantidad de filas en un TableLayoutPanel

Cuando creamos con el diseñador un TableLayoutPanel podemos indicar la cantidad de filas y columnas que deseamos tener. Pero esta cantidad puede cambiar durante la ejecución del programa, y sobre esto trata este post.

Para nuestro ejemplo, supondremos que tenemos un TableLayoutPanel llamado "panel" con dos filas, y queremos agregar una tercera. Agregar el control es fácil:

Label lbl = new Label();
lbl.setText( "Etiqueta de ejemplo" );
panel.Controls.Add( lbl, 0, 2 ); // Primera columna, tercera fila


Sin embargo, no veremos la tercera fila todavía. De nada sirve hacer panel.Rows = 3. El problema reside en la lista de RowStyles que contiene el layout: sigue creyendo que tiene dos filas, cada una con la altura indicada en el diseñador, y ocupando todo el espacio visual del Layout. Tendremos que regenerarlas, por ejemplo, haciendo:

panel.RowStyles.Clear();
for( int i=0; i != 3; ++i ) { // 3 filas
RowStyle rs = new RowStyle();
rs.Height = 20; // 20 píxeles
panel.RowStyles.Add( rs );
}


En realidad, no es necesario establecer manualmente una altura para cada fila. El siguiente código hace que se recalculen las alturas automáticamente:

panel.RowStyles.Clear();
for( int i=0; i != 3; ++i ) { // 3 filas
panel.RowStyles.Add( new RowStyle() );
}

Cómo embeber un archivo XML en Visual C#

Agregar un archivo XML a un proyecto es fácil: basta con hacer botón derecho, Agregar, Nuevo elemento..., Archivo XML. Aparecerá un editor donde podemos rellenar nuestro propio archivo.

Leerlo es un poco más complicado. A diferencia de Qt, que utiliza un prefijo, aquí debemos realizar los siguientes pasos:
- En las propiedades del archivo, tenemos que cambiar la Acción de compilación a "Recurso incrustado" (embedded resource)
- También en las propiedades, debemos indicar un espacio de nombres (namespace), generalmente el mismo que utiliza nuestra aplicación. Dejamos la opción de copiar en "No copiar", ya que precisamente lo que queremos es un recurso incrustado, no archivos dando vueltas por el directorio de instalación.
- En el menú Proyecto, hacemos click en Agregar elemento existente..., cambiamos el tipo para que muestre todo tipo de archivos, y agregamos el XML que hemos creado. Este es el paso más tonto de todos.
- Lo leemos con la siguiente secuencia (como DOM), si el archivo se llama "archivo.xml":

Stream str = this.GetType().Assembly.GetManifestResourceStream( "espacio_de_nombres.archivo.xml" );
XmlDocument doc = new XmlDocument();
doc.Load(str);

Notas:
- Debemos resistir la tentación de hacer un .GetManifestResourceInfo("namespace.archivo.xml").Filename. Devuelve una cadena nula.

Más información:
http://support.microsoft.com/kb/324567/en-us/