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/