Artículo técnico

PDF Library for Delphi DLL, ActiveX, and dylib Bindings: Calling One PDF Engine from Any Language

Este es un problema que aparece en cuanto una biblioteca PDF sale de su lenguaje de origen. Tiene un enlace que funciona perfectamente desde C# en Windows. Necesita las mismas llamadas desde Python en macOS, así que copia el archivo de declaraciones de Windows, cambia el nombre del binario y lo ejecuta. Todos los símbolos se resuelven. La primera llamada devuelve datos sin sentido, la segunda falla con una violación de acceso y nada de su código PDF ha cambiado. El problema está una capa por debajo del PDF: las exportaciones de Windows usan la convención Stdcall, la dylib de macOS exporta las mismas funciones como Cdecl con un guion bajo inicial y una declaración de función externa que se equivoca en cualquiera de esos detalles corrompe la pila antes de abrir un solo documento

Toda esa clase de fallos procede de una decisión de diseño que conviene entender desde el inicio. PDF Library for Delphi, el motor PDF de código disponible de losLab para Delphi y C++Builder, envuelve todo su modelo de objetos en una sola clase de fachada plana, TPDFlib, y luego distribuye esa fachada en tres formatos binarios: una DLL de Windows con cerca de 1,250 funciones exportadas, un objeto de automatización COM/ActiveX y una dylib de macOS. La semántica PDF es idéntica en los tres. La parte que causa problemas vive en la ABI subyacente: convenciones de llamada, codificaciones de cadenas, propiedad de identificadores y qué lado tiene permitido liberar cada búfer

Una fachada, tres formatos binarios

Cada función pública de TPDFlib tiene una contraparte plana llamada DL más el nombre del método. LoadFromFile se convierte en DLLoadFromFile, Encrypt en DLEncrypt, NewSignProcessFromFile en DLNewSignProcessFromFile. El primer parámetro de casi todas las exportaciones es un InstanceID devuelto por DLCreateLibrary, que sustituye la referencia de objeto que normalmente mantendría un llamador Delphi. Interiorice pronto esa asignación. Significa que la referencia de la API Delphi también sirve como documentación para cualquier otro lenguaje: lo que la clase puede hacer, la DLL puede hacerlo bajo un nombre predecible, y puede leer una firma de método Pascal para conocer la llamada que necesita desde Python o C#

La compilación de Windows produce PDFlibDLL32.dll y PDFlibDLL64.dll; elija la que coincida con la arquitectura de bits de su proceso anfitrión, ya que un proceso Java o .NET de 64 bits no puede cargar la biblioteca de 32 bits sin importar cómo se vea la declaración

Diagrama de arquitectura de una fachada TPDFlib expuesta como DLL de Windows con convención Stdcall, un objeto de automatización ActiveX Safecall y una dylib de macOS con Cdecl
Los tres binarios comparten una única fachada PDF plana, pero difieren en la convención de llamada, el manejo de cadenas y los requisitos de registro

Windows: instancias Stdcall y los pares de funciones W/A

Cada exportación que recibe cadenas existe dos veces. Una versión amplia recibe PWideChar (UTF-16, el ajuste natural para .NET, Java y el c_wchar_p de Python), y una versión con sufijo A recibe PAnsiChar. Ambas tienen semántica idéntica y difieren solo en la codificación, que es precisamente lo que hace tan doloroso localizar una mezcla entre ellas: nada genera una excepción, nada devuelve un código de error, simplemente obtiene caracteres ilegibles en los metadatos o un falso "archivo no encontrado" para cualquier ruta que contenga un carácter fuera de ASCII básico. El primer error de codificación que un equipo encuentra de esta manera suele costar una tarde, porque el síntoma apunta a los datos y la causa está en la declaración

// Binding de Windows (PDFlibDLL64.dll): Stdcall, nombres de export tal cual
function DLCreateLibrary: Integer; stdcall;
  external 'PDFlibDLL64.dll' name 'DLCreateLibrary';
function DLReleaseLibrary(InstanceID: Integer): Integer; stdcall;
  external 'PDFlibDLL64.dll' name 'DLReleaseLibrary';
function DLLoadFromFile(InstanceID: Integer;
  FileName, Password: PWideChar): Integer; stdcall;
  external 'PDFlibDLL64.dll' name 'DLLoadFromFile';

// Binding de macOS: misma función, Cdecl, y un guion bajo delante del export
function DLCreateLibrary: Integer; cdecl;
  external 'PDFlibDylib.dylib' name '_DLCreateLibrary';

Elija un ancho de caracteres por anfitrión y codifíquelo en el generador de enlaces. Una regla práctica: si el lenguaje anfitrión tiene cadenas UTF-16 nativas, enlace las versiones W en todas partes y no vuelva a tocar la familia A

macOS: mismos nombres, ABI diferente

La dylib exporta el mismo conjunto de funciones DL con dos cambios sistemáticos. La convención de llamada es Cdecl en lugar de Stdcall y cada nombre de exportación lleva un guion bajo inicial (_DLCreateLibrary, _DLLoadFromFile, etc.). Ambos cambios son puramente mecánicos, lo que los hace ideales para un enlace generado y peligrosos para una copia del archivo de Windows editada a mano. Mantenga una lista canónica de funciones y emita declaraciones por plataforma a partir de ella si sus herramientas lo permiten. Si omite esto, obtendrá exactamente la corrupción de pila descrita al comienzo de esta página, reproducida solo en la plataforma que su CI suele ejecutar menos

Anfitriones COM y ActiveX: Safecall y cargas Olevariant

Para VB.NET, C#, VBScript y anfitriones de automatización heredados, la compilación OCX envuelve la misma fachada en un objeto de automatización IDispatch, IPDFlibrary, con todos los métodos declarados Safecall. Esa convención cambia cómo le llegan los errores. Safecall traduce un fallo interno a un HRESULT COM, por lo que un llamador C# captura una excepción donde la DLL plana habría devuelto un entero silencioso que el llamador debía recordar comprobar. La misma operación, dos formas de error, según el binario que haya cargado

Los datos binarios siguen una segunda regla específica de COM. La interfaz de automatización no tiene parámetros de puntero. Todo lo binario, ya sean bytes de imagen de entrada o bytes PDF de salida, cruza el límite como un Olevariant mediante métodos como AddImageFromVariant y AppendToVariant. Convertir un arreglo de bytes en una variante es una sola línea en .NET. Intente entregarle un puntero sin procesar, pensando que de todos modos es el mismo proceso, y la capa de despacho rechazará o deformará la llamada. Un detalle más de registro causa problemas en los despliegues: el registro COM es por arquitectura de bits, por lo que un OCX registrado con el regsvr32 de 32 bits es invisible para un anfitrión de 64 bits. Esa discrepancia aparece como el famoso e inútil "class not registered" en la máquina del cliente, mucho después de que salió de la suya

Disciplina de identificadores: las instancias son dueñas de los documentos

La API plana funciona con identificadores enteros. DLCreateLibrary devuelve una instancia. Cargar un archivo devuelve un ID de documento dentro de esa instancia. Los procesos de firma, las listas de cadenas y los archivos de acceso directo devuelven cada uno sus propios identificadores enteros, todos limitados a la misma instancia. El ciclo de vida se ve igual desde cualquier anfitrión FFI; se muestra aquí en Pascal porque se lee con claridad:

var
  Inst, Doc: Integer;
begin
  Inst := DLCreateLibrary;                       // una instancia por worker thread
  try
    Doc := DLLoadFromFile(Inst, 'in.pdf', '');   // returns a DocumentID, 0 on failure
    if Doc <> 0 then
    begin
      DLEncrypt(Inst, 'owner-secret', 'user-secret', 3,
        DLEncodePermissions(Inst, 1, 0, 0, 0, 0, 0, 0, 1));
      DLSaveToFile(Inst, 'out.pdf');
    end;
  finally
    DLReleaseLibrary(Inst);                      // libera todos los documentos que posee la instancia
  end;
end;

De ese árbol de propiedad se derivan dos cosas. DLReleaseLibrary es la única llamada de limpieza que necesita estrictamente, ya que destruye de una vez todos los identificadores de documentos y procesos bajo la instancia. En un script corto, eso basta. En un servicio de larga ejecución se convierte en una fuga lenta con ceremonias adicionales, así que libere los documentos cuando termine con ellos en vez de dejarlos acumular hasta que la instancia muera. La instancia es también la unidad natural de aislamiento de hilos. Dé a cada hilo de trabajo su propio InstanceID y nunca comparta uno entre hilos sin bloqueo externo, por la misma razón por la que nunca compartiría un único objeto TPDFlib entre hilos

Las cadenas devueltas son prestadas, no propias

Las funciones que devuelven texto, como DLGetPageText, entregan un PWideChar o PAnsiChar que apunta a un búfer que posee y recicla la instancia de la biblioteca. El contrato es: copie de inmediato, nunca libere

Cronología de PDF Library for Delphi que contrasta copiar de inmediato un puntero prestado de DLGetPageText frente a retenerlo hasta que la biblioteca recicle el búfer subyacente
Los punteros char devueltos toman prestado un almacenamiento que la instancia recicla, así que la copia debe ocurrir antes de la siguiente llamada a la biblioteca
var
  P: PWideChar;
  PageText: string;
begin
  P := DLGetPageText(Inst, 7);   // pointer into a library-owned buffer
  PageText := P;                 // copia ahora; una llamada posterior puede reusar el buffer
end;

En C#, eso significa convertir el IntPtr en una cadena administrada antes de la siguiente llamada a la biblioteca. En Python ctypes, significa extraer inmediatamente la cadena amplia del puntero. Mantenga el puntero sin procesar entre llamadas y habrá escrito un error que supera todas las pruebas unitarias y luego falla la primera vez que dos solicitudes se superponen en producción, porque la segunda llamada recicló el búfer que la primera aún estaba leyendo. La misma regla de propiedad funciona en la otra dirección para los callbacks registrados mediante DLSetProgressCallback. Cualquier puntero que la biblioteca entregue a su callback es válido solo durante el cuerpo de ese callback, y el propio objeto callback debe seguir vivo (anclado, en un anfitrión con recolección de basura) mientras la instancia pueda invocarlo. Un delegado recolectado a mitad de trabajo es la causa clásica de la violación de acceso "aleatoria" que aparece en un enlace .NET que funcionó sin problemas durante meses

Incorpore una prueba de humo al propio enlace y ejecútela antes de distribuir cualquier conjunto de declaraciones generadas. Ejercite una llamada de cada categoría que suele revelar errores de ABI: una función sin parámetros como DLCreateLibrary para demostrar que la convención es correcta, una función con cadena de entrada alimentada con una ruta de caracteres no ASCII para demostrar que la codificación es correcta, una función con cadena de salida para demostrar que el manejo del búfer prestado es correcto y una operación que falle intencionalmente para observar cómo llega el error a su anfitrión. Son quince minutos de trabajo y detectan los errores de convención de llamada y codificación que, de otro modo, llegarían meses después como un volcado de fallo de un cliente

Cuadrícula de dos por dos de PDF Library for Delphi con pruebas de humo de enlace que cubren convención de llamada, codificación de cadenas, búferes prestados y manifestación de fallos
Cuatro sondas baratas detectan fallos de convención, codificación y propiedad antes de que las declaraciones generadas lleguen a la computadora de un cliente

El caso concreto de Python ctypes

Python ctypes es el enlace que veo escrito manualmente con más frecuencia y hace sencillo demostrar la separación entre plataformas. En Windows, cargue la biblioteca con ctypes.WinDLL para que ctypes aplique Stdcall, enlace las funciones W sin sufijo y declare cada parámetro de cadena como c_wchar_p. En macOS, cárguela con ctypes.CDLL para Cdecl, conserve la lista de funciones idéntica y resuelva los nombres sin el guion bajo inicial. La mayoría de las capas FFI, incluido ctypes, resuelven por usted la convención del guion bajo en macOS, pero esa es la única suposición que debe confirmar con una llamada resuelta antes de generar cientos de declaraciones sobre ella

Dos preguntas de despliegue acompañan el trabajo de enlace y tienen respuestas claras. La DLL simple no necesita registro: regsvr32 solo se aplica a la compilación ActiveX y la DLL se distribuye mediante copia de archivo, que es la principal razón para preferirla en servicios y contenedores de Windows donde prefiere no tocar el registro. La seguridad de hilos se reduce a la regla ya indicada: una instancia por hilo. El identificador de instancia contiene cada parte del estado mutable que el motor sigue, el documento seleccionado, las opciones de representación, la configuración de extracción, por lo que dos hilos que comparten una instancia entrelazan el estado del otro incluso cuando cada llamada individual devuelve éxito

Una vez que un enlace es sólido, las operaciones al otro lado son exactamente las que los artículos de Delphi cubren en profundidad, incluida la aplicación y auditoría del cifrado PDF y la extracción de texto e imágenes de documentos existentes

Las descargas binarias para las tres capas de integración se distribuyen con la biblioteca; consulte la página del producto PDF Library for Delphi para conocer las ediciones y licencias