Firmar PDF (Python o .Net10)

PowerBuilder · .NET · Python · Claude Code


En artículos anteriores os di mi versión del ejemplo de Appeon para llamar a Python desde PowerBuilder 2025 R2 con PyPb, a la que le añadí un runtime de Python embebido (sin instalar nada en el cliente) y la probé con Excel. Me quedé con ganas de llevarlo a algo de verdad útil, así que volví a uno de mis ejemplos clásicos: la firma digital de PDF en PowerBuilder (de 2022, con iText7 sobre .NET).

La idea era simple: añadir un tercer motor de firma en Python al ejemplo. Pero por el camino pasó algo mucho más gordo: resolvimos un error de .NET que llevaba años sin solución y que ni el soporte de Appeon ni el de iText supieron arreglarme en su día. Y digo «resolvimos» porque esto lo hice con Claude Code… siendo honestos, sobre todo lo hizo él. Os lo cuento.

Resumen rápido: el ejemplo pdfsign ahora firma de tres formas (consola .EXE, librería .NET en proceso y Python/pyHanko), elegibles con unos radio buttons. Y el viejo error «The type initializer for 'iText.IO.Util.ResourceUtil' threw an exception» en modo librería —que funcionaba en consola pero no como DLL consumida— por fin tiene explicación y arreglo. Todo ello ya sobre .NET 10 e iText7 9.6.0 (la librería forma parte de la migración de mis ejemplos a .NET 10 que os conté hace poco).

Tres motores de firma, un solo botón

El ejemplo quedó con un selector de método. El mismo botón «Firmar PDF» despacha a uno u otro motor:

Método Motor Cómo firma
ConsolaiText7 (.NET 10)Lanza TestNetPdfService.exe y captura su salida
.NET libreríaiText7 (.NET 10)Carga NetPdfService.dll en proceso (dotnetobject)
PythonpyHankoLlama a un script Python vía PyPb, con runtime embebido

El motor Python (pyHanko) es autocontenido: el Python 3.13 embeddable viaja junto a la app, igual que en el artículo de PyPb. El usuario final no instala absolutamente nada.

Sinceramente, el modo consola ya no tiene mucho sentido ahora que está el modo librería en el mismo proceso; lo mantengo en el ejemplo sobre todo a efectos de comparativa. Y ya que hablamos de iText7: recordad que es AGPL, así que si lo usáis en un proyecto propio tenéis que compartir las fuentes (o pagar la licencia comercial de Apryse/iText). Es justo el motivo por el que en este ejemplo también he sumado pyHanko, que es MIT.

El bug imposible: en consola sí, como librería no

Aquí está lo jugoso. Mi librería .NET de firma (iText7) funcionaba perfectamente como .exe de consola, pero al consumir la misma DLL en proceso desde PowerBuilder (como dotnetobject) reventaba siempre con:

The type initializer for 'iText.IO.Util.ResourceUtil' threw an exception.

La misma DLL. El mismo certificado. El mismo PDF. En consola, oro; como librería, error. En su momento hasta abrí una consulta en la comunidad de Appeon y nadie —ni Appeon ni la gente de iText— supo decirme por qué. Lo dejé por imposible… hasta ahora.

El falso culpable

Claude Code empezó por reproducirlo de verdad: montó un proyecto .NET con la versión de iText de entonces (8.0.5), investigó cómo funciona iText por dentro y vio que el constructor estático de ResourceUtil usa dos DLLs «de relleno» (Microsoft.DotNet.PlatformAbstractions y Microsoft.Extensions.DependencyModel). Quitándolas, reproducía el mismo mensaje exacto.

Parecía resuelto: «PowerBuilder no resuelve esas dependencias transitivas». Aplicamos un resolver de ensamblados… y seguía fallando. Ahí es donde la cosa se puso seria.

La clave fue no creerse el primer diagnóstico. El mensaje de iText es genérico: salta igual para cualquier fallo dentro de ese constructor. Nos estaba despistando.

El diagnóstico que lo destapó

Como yo no podía darle a Claude acceso a ejecutar PowerBuilder, hizo algo brillante: inyectó en la propia DLL un diagnóstico que, al firmar, forzaba la inicialización de iText y volcaba a un fichero de log la excepción interna real (no el mensaje genérico). Recompilé, firmé desde PB, y el log cantó:

location='...\DotNet\NetPdfService\NetPdfService.dll'
dir='...\DotNet\NetPdfService'
alc='DefaultAssemblyLoadContext'
preCargadas=True
    -> System.TypeInitializationException: ... 'iText.IO.Util.ResourceUtil' ...
    -> System.ArgumentException: The path is empty. (Parameter 'path')

Ahí estaba. Nada de DLLs que faltaban (cargaban bien). La causa real era otra:

Cuando PowerBuilder hospeda el CLR de .NET, deja AppDomain.CurrentDomain.BaseDirectory VACÍO ("").

El constructor de iText hace Directory.GetFiles(BaseDirectory, "*.dll") y solo comprueba si es null, no si es cadena vacía. Resultado: Directory.GetFiles("")«The path is empty» → revienta el inicializador. En consola no pasa, porque ahí BaseDirectory es la carpeta del .exe. Misma DLL, distinto entorno de hospedaje.

La solución (de una línea)

Con la causa identificada, el arreglo es minúsculo: un constructor estático en la clase de firma que, antes de que iText arranque, le da a .NET un BaseDirectory válido (la carpeta de la propia DLL):

static PdfService()
{
    string dir = System.IO.Path.GetDirectoryName(typeof(PdfService).Assembly.Location) ?? "";
    if (string.IsNullOrEmpty(AppContext.BaseDirectory) && dir != "")
        AppContext.SetData("APP_CONTEXT_BASE_DIRECTORY", dir.EndsWith("\\") ? dir : dir + "\\");
}

Y se acabó. Recompilé, firmé desde PB en modo librería y el log dijo ResourceUtil init OK. Lo validó además montando un AssemblyLoadContext que simula el hospedaje de PowerBuilder: con la DLL vieja, error; con la nueva, firma correcta. Reproducible y demostrado.

Y una buena noticia para rematar: este ejemplo ya va sobre .NET 10 e iText7 9.6.0 (forma parte de la migración de mis librerías que os conté en el artículo anterior), y el arreglo sigue siendo igual de válido. La causa era el hospedaje del CLR bajo PowerBuilder, no la versión de iText, así que el constructor estático nos protege lo mismo en iText 8 que en iText 9.

Con la explicación ya clara, cerré aquel hilo de la comunidad de Appeon años después de abrirlo, y de paso he publicado el ejemplo completo con la explicación en inglés en el CodeExchange de Appeon, para que le llegue también a la comunidad internacional.

Lo increíble de Claude Code

Quiero detenerme aquí, porque es lo que más me ha impresionado. Esto no fue «escríbeme una función». Fue un problema que el soporte oficial de dos empresas no supo resolver, y se solucionó en una sola sesión gracias a una forma de trabajar que, sinceramente, yo solo no habría tenido:

  • Reprodujo el fallo desde cero con la versión exacta de iText.
  • Investigó el funcionamiento interno de iText para entender qué hacía realmente el constructor.
  • No se conformó con el primer diagnóstico plausible (las DLLs) y desconfió del mensaje genérico.
  • Inyectó instrumentación en la DLL para sacar la excepción interna real desde dentro de PowerBuilder, sorteando que él no podía ejecutar PB.
  • Validó el arreglo simulando el hospedaje de PB con un AssemblyLoadContext a medida.

Eso es razonamiento de ingeniería de verdad: hipótesis, experimento, refutación y prueba. Claude Code no «adivina» código: investiga. Y resolvió en una tarde algo que llevaba años marcado como «imposible» en mi lista. El mérito, que quede claro, es suyo; yo puse el PowerBuilder y las ganas.

Si tienes uno de esos bugs que nadie te sabe explicar —de esos que dejas «para nunca»— dale una oportunidad a Claude Code para que lo investigue de verdad. A mí me ha tachado uno de la lista que daba por perdido.

Un detalle observado: Python va un pelín más lento

Comparando los tres métodos, la firma con Python es algo más lenta que las de iText. Tiene sentido si miras el recorrido: la llamada sale de PowerBuilder → pasa por el puente .NET (PyPb) → y finalmente llega a Python, que es donde pyHanko hace el trabajo. Son varios saltos entre entornos. No es un problema —para firmar documentos sueltos ni se nota— pero es justo mencionarlo: a cambio te llevas la potencia de todo el ecosistema Python sin instalar nada.

De propina: firma legal (PAdES + sello de tiempo + LTV)

Ya puestos, subimos los tres motores a PAdES (el formato de firma para PDF de eIDAS), con sello de tiempo de una autoridad gratuita y, cuando hay conexión, LTV (validación a largo plazo). Y con una cascada con red de seguridad: si no hay internet, en vez de fallar, la firma degrada sola a un nivel inferior pero siempre se produce.


Artículos relacionados

Este artículo está hecho a cuatro manos con Claude Code 🤖 (bueno… siendo sinceros, tres manos suyas y una mía sujetando el café ☕). Si tenéis por ahí algún bug marcado como «imposible», ya sabéis: contádmelo en los comentarios, que le echamos un pulso 💪.

¡Nos vemos en el próximo artículo! 🚀

Comentarios