Hasta v3.539.30, TPDFlib.ImportAnnotationsFromFDFString de losLab PDF Library devolvía el número de entradas de anotaciones FDF que había parseado sin añadir ninguna al documento: cada entrada se contaba, cada entrada se descartaba. Desde v3.539.30 el importador FDF lee las claves en cualquier orden, parsea /Rect correctamente y de forma independiente del locale, y el exportador correspondiente escribe el /Rect real de la anotación, así que un export, un import y un segundo export producen FDF idéntico byte a byte. El resto de esta nota explica cómo un offset inicial equivocado produjo un fallo silencioso perfecto, qué otros tres defectos se escondían detrás y cómo comprobar una importación por su cuenta en lugar de fiarse del valor de retorno
El escenario es de lo más corriente. Un revisor anota un contrato, los comentarios viajan en un archivo FDF (Acrobat lo llama Export Comments), y su servicio en Delphi los fusiona en una copia limpia con ImportAnnotationsFromFDF. La llamada devuelve 7, el log dice «7 comments imported», el trabajo se pone en verde, y el PDF de salida no tiene ni un solo comentario. Nada lanzó una excepción, nada avisó, y el número parecía plausible porque era el conteo real de entradas del archivo. Esa es la peor forma que puede tomar un bug: una función cuya única señal de éxito es un contador que se calcula al margen del trabajo que dice estar reportando
¿Por qué ImportAnnotationsFromFDFString reportaba éxito sin añadir nada?
El importador leía cada /Subtype como una cadena vacía, y el helper que crea la anotación aborta a la primera con un subtype vacío, mientras que quien lo llama incrementa el resultado de todos modos. El buscador de claves devolvía la posición inmediatamente posterior a /Subtype, que es el espacio en blanco antes del valor. ReadName arrancaba en ese espacio y se detenía en el primer carácter de espacio en blanco, así que se paraba antes de leer nada. AddAnnotationToPage se niega a construir una anotación sin subtype, que aislada es la elección defensiva correcta, pero era un procedure sin valor de retorno, y el Inc(Result) vivía fuera. Cada guard era razonable por separado; juntos convertían «no funcionó nada» en «funcionó todo». El fix hace que ReadName se salte el espacio en blanco, exija la / inicial de un name object de PDF y se pare en cualquier delimitador, incluidos [, ( y ), de modo que /Subtype/Text y /Subtype /Text devuelvan ambos Text
Ese valor de retorno merecía cuidados incluso después del fix. Hasta v3.539.39, ImportAnnotationsFromFDFString seguía incrementando su resultado por cada diccionario bien formado del array /Annots, incluidas entradas cuyo /Page 0-based quedaba fuera de rango o cuyo /Subtype faltaba, dos casos que se saltan. Desde PDFlibPas v3.539.40, ImportAnnotationsFromFDFString y ImportAnnotationsFromFDF devuelven el número de anotaciones realmente añadidas, como la importación XFDF: el helper FDF AddAnnotationToPage devuelve ahora un Boolean y el contador solo se mueve si hay éxito. Medir el documento sigue siendo la comprobación más fuerte, porque también vale en versiones anteriores, así que el sketch de abajo compara AnnotationCount en cada página antes y después de la importación
function TotalAnnotations(Lib: TPDFlib): Integer;
var
Page, Saved: Integer;
begin
Result := 0;
Saved := Lib.SelectedPage;
for Page := 1 to Lib.PageCount do
if Lib.SelectPage(Page) = 1 then
Inc(Result, Lib.AnnotationCount); // por página seleccionada, widgets incluidos
Lib.SelectPage(Saved);
end;
var
Lib: TPDFlib;
Before, Reported, Added: Integer;
begin
Lib := TPDFlib.Create;
try
Lib.LoadFromFile('contract.pdf', '');
Before := TotalAnnotations(Lib);
Reported := Lib.ImportAnnotationsFromFDF('review-comments.fdf');
Added := TotalAnnotations(Lib) - Before;
if Added <> Reported then // iguales desde v3.539.40
Writeln(Format('Importer reported %d, %d landed on a page', [Reported, Added]));
Lib.SaveToFile('contract-reviewed.pdf');
finally
Lib.Free;
end;
end;
Tres defectos más detrás del primero
Arreglar el subtype a secas habría destapado otros tres bugs en la misma función, cada uno invisible solo porque ninguna anotación llegaba jamás a una página. Primero, ReadNumber recibía su posición como parámetro por valor, así que leer los cuatro números del /Rect en secuencia leía el mismo punto cuatro veces, y además no se saltaba el [ de apertura, así que en la práctica no leía nada. Segundo, FindKey compartía un único cursor hacia adelante entre todas las búsquedas. El exportador escribe /Subtype, /Rect, /Page, /Contents, /T, /Subj, pero el importador buscaba en el orden /Subtype, /Contents, /T, /Subj, /Page, /Rect; en cuanto el cursor pasaba /Contents, la búsqueda de /Page y /Rect seguía más allá de la entrada actual y o no encontraba nada o emparejaba las claves de la anotación siguiente. La library no podía leer su propio output. Tercero, los números pasaban por PLStrToFloat, que sigue el separador decimal del sistema. ISO 32000-1 §12.7.7 define FDF como sintaxis de objetos PDF, y las claves de diccionario en PDF no tienen orden (§7.3.7), así que cualquier parser FDF que asuma un orden de claves está mal por construcción, venga el archivo de la herramienta que venga
El importador reparado acota primero cada entrada. FindDictEnd camina desde el << de apertura hasta su >> correspondiente, contando los diccionarios anidados y saltándose los cuerpos de literal string con sus escapes de barra invertida, de modo que un >> dentro de un comentario como (see section >> 4) no puede cortar la entrada antes de tiempo. Cada búsqueda de clave arranca entonces en el inicio de la propia entrada y queda limitada a su final, lo que vuelve irrelevante el orden de las claves y evita que una anotación tome prestado el /Page de otra. La comparación de claves acepta además un delimitador justo detrás del nombre, porque /Contents(Hi) es tan válido como /Contents (Hi), mientras que la regla de frontera de palabra impide que /Subj empareje con el comienzo de /Subtype o que /T lo haga con /Type. ReadNumber recibe ahora su posición como parámetro var, se salta el espacio en blanco y el [, y parsea con PLTryStrToFloatInvariant, que falla suavemente ante un token malformado en lugar de lanzar. Si alguno de los cuatro números del rectángulo falla, los cuatro caen a cero en lugar de producir un rectángulo leído a medias
¿Por qué los round-trips FDF desplazaban cada anotación su propia altura?
El exportador antiguo escribía el rectángulo en el modelo de coordenadas equivocado. El /Rect de una anotación es [llx lly urx ury] en default user space (ISO 32000-1 §12.5.2, con los rectángulos definidos en §7.9.5), y FDF lleva el mismo array. ExportAnnotationsToFDFString, sin embargo, llamaba a GetAnnotRectEx, que reporta Left, Top, Width y Height en las coordenadas de dibujo de la library, el espacio que controla SetOrigin, y los serializaba como [L T L+W T+H]. El importador, una vez que funcionaba, escribía esos cuatro valores tal cual como rectángulo PDF, así que el borde superior aterrizaba donde correspondía a la esquina inferior izquierda y cada round trip subía la anotación su propia altura. El exportador copia ahora los números del /Rect de la propia anotación, tres decimales, separador punto, sin exponente, y solo recurre al rectángulo calculado cuando el array guardado falta o no tiene cuatro números
El test de regresión que fija esto merece ser copiado, porque afirma sobre el documento y sobre un segundo export, no sobre el valor de retorno del importador. Fíjese en el conteo esperado de 2: AddNoteAnnotation crea una anotación Text más su Popup, y ambas viajan. El test ejecuta además el export y el import bajo un separador decimal con coma, que es donde vive la otra mitad de esta historia
var
Source, Target: TPDFlib;
FDF: AnsiString;
OldSep: Char;
begin
Source := TPDFlib.Create;
Target := TPDFlib.Create;
try
Source.NewPages(1); // ahora dos páginas
Source.SelectPage(2);
Source.AddNoteAnnotation(50.5, 60.25, 0, 80, 80, 120, 60,
'Reviewer', 'Check this', 0.25, 0.5, 0.75, 0);
Target.NewPages(1);
OldSep := FormatSettings.DecimalSeparator;
FormatSettings.DecimalSeparator := ','; // simula un escritorio alemán o francés
try
FDF := Source.ExportAnnotationsToFDFString; // sigue escribiendo /Rect [50.5 ...
Target.ImportAnnotationsFromFDFString(FDF);
finally
FormatSettings.DecimalSeparator := OldSep;
end;
Target.SelectPage(2);
Assert(Target.AnnotationCount = 2); // la nota y su popup
Assert(Target.GetAnnotType(1) = 'Text');
Assert(Target.ExportAnnotationsToFDFString = Source.ExportAnnotationsToFDFString);
finally
Target.Free;
Source.Free;
end;
end;
Tenga claro qué lleva la vía FDF. El importador reconstruye cada entrada como un diccionario con /Type, /Subtype, /Rect, /Contents, /T y /Subj; el color, los flags, el estilo de borde, los enlaces popup y los appearance streams no forman parte de esta ruta, y el exportador se salta las anotaciones Widget porque los campos de formulario pertenecen a los métodos de form-data. El mapa general de qué datos viajan por qué método está en la visión de conjunto del intercambio de datos de formulario FDF, XFDF y XFA, y si necesita inspeccionar qué llegó de verdad, los lectores por índice como GetAnnotType, GetAnnotTitle y GetAnnotContentsEx están cubiertos en introspección de outlines, anotaciones y actions
¿Cómo leer archivos FDF y XFDF con decimales con coma de exports antiguos?
Para FDF la respuesta es inequívoca: una coma no es un delimitador en la sintaxis PDF, así que un token numérico que contiene exactamente una coma y ningún punto solo puede ser un decimal escrito en una máquina con locale de coma. Versiones anteriores sí escribían archivos así, por ejemplo /Rect [10,500 20,250 40,750 60,125], y el nuevo ReadNumber convierte esa única coma en punto antes de parsear. Un token con dos comas, o con una coma y un punto, se rechaza en lugar de adivinar. El lector tampoco consume notación de exponente, lo que casa con ISO 32000-1 §7.3.3: los números PDF nunca la usan
XFDF es más delicado, porque en atributos XML la coma es el separador. El XFDF estándar (ISO 19444-1) escribe rect="50.5,80.25,70.75,100.125" y dashes="4,2", mientras que v3.539.28 y anteriores, en un sistema con locale de coma, escribían rect="50,500 80,250 70,750 100,125" y opacity="0,600", y además fallaban con EConvertError al leer un opacity="0.6" estándar. Desde v3.539.29 ambas direcciones son invariantes, y la forma legada la reconoce XFDFNormalizeLegacyDecimals solo cuando el atributo parte por espacios en blanco en exactamente el número de tokens esperado (cuatro para rect, uno para opacity y width) y cada token tiene la forma dígitos-coma-dígitos. Un rect estándar nunca encaja: o es un token con tres comas o son tokens que acaban en coma. dashes se deja deliberadamente tranquilo, porque 4,2 puede ser dos longitudes de guion o un 4.2 legado, y ninguna regla sabe distinguirlos
const
// Claves fuera del orden del exportador, más decimales con coma de un export antiguo con coma en el locale
LegacyFDF: AnsiString = '%FDF-1.2'#10'1 0 obj'#10'<< /FDF << /Annots ['#10 +
'<< /Rect [10,500 20,250 40,750 60,125] /Page 0 /Contents (First) ' +
'/Subtype /Text /T (Alpha) /Type /Annot >>'#10 +
'] >> >>'#10'endobj'#10'trailer'#10'<< /Root 1 0 R >>'#10'%%EOF'#10;
var
Lib: TPDFlib;
begin
Lib := TPDFlib.Create; // un documento nuevo tiene una página
try
Lib.ImportAnnotationsFromFDFString(LegacyFDF);
Assert(Lib.AnnotationCount = 1);
Assert(Lib.GetAnnotTitle(1) = 'Alpha');
// Reexportado como XFDF con decimales con punto: rect="10.500 20.250 40.750 60.125"
Writeln(Lib.ExportAnnotationsToXFDFString);
finally
Lib.Free;
end;
end;
¿Qué debe afirmar de verdad un test de importación de anotaciones?
Un test de importación útil afirma sobre el estado del documento destino, nunca solo sobre lo que el importador cuenta de sí mismo. Nada en la suite de tests comprobaba AnnotationCount tras una importación FDF, y el valor de retorno, el único número que alguien miraba, era justo el número que el bug dejó intacto. Tres afirmaciones habrían cazado cada defecto descrito aquí: el conteo de anotaciones en la página esperada, un campo leído de vuelta con GetAnnotType o GetAnnotContentsEx, y un segundo export comparado byte a byte con el primero. La misma disciplina vale para cualquier API que reescriba la estructura del documento en bloque, incluida la consolidación de campos descrita en la fusión de campos de formulario duplicados: revise el árbol resultante, no un total devuelto. Los métodos de anotaciones FDF y XFDF, con sus variantes de archivo y de cadena, vienen en losLab PDF Library for Delphi y C++Builder, y v3.539.30 o superior es la versión que le hace falta si los comentarios tienen que sobrevivir al viaje, v3.539.40 o superior si el conteo devuelto debe coincidir con lo añadido