Artículo técnico

Importar anotaciones FDF en Delphi: el cero silencioso

Antes de la v3.539.30, TPDFlib.ImportAnnotationsFromFDFString en losLab PDF Library devolvía la cantidad de entradas de anotaciones FDF que había parseado sin agregar ninguna al documento: cada entrada se contaba, cada entrada se descartaba. Desde la v3.539.30 el importador de FDF lee las claves en cualquier orden, parsea /Rect de forma correcta e 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 de arranque equivocado produjo una falla silenciosa perfecta, qué otros tres defectos se escondían detrás, y cómo chequear una importación por su cuenta en vez de fiarse del valor de retorno

El escenario es de lo más corriente. Un revisor marca un contrato, los comentarios viajan como archivo FDF (Acrobat le dice 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 job se pone verde, y el PDF de salida no tiene ni un comentario. Nada lanzó excepción, nada avisó, y el número parecía creíble 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 independientemente del trabajo que dice estar reportando

¿Por qué ImportAnnotationsFromFDFString reportaba éxito sin agregar nada?

El importador leía cada /Subtype como un string vacío, y el helper que crea la anotación salía temprano ante un subtype vacío mientras el llamador incrementaba el resultado de todas formas. El buscador de claves devolvía la posición inmediatamente después de /Subtype, que es el whitespace previo al valor. ReadName arrancaba en ese espacio y se detenía en el primer carácter de whitespace, o sea que se detenía antes de leer nada. AddAnnotationToPage se niega a construir una anotación sin subtype, lo cual aislado es la elección defensiva correcta, pero era un procedure sin valor de retorno, y el Inc(Result) quedaba afuera. Cada guard era razonable por sí solo; juntos convirtieron «nada funcionó» en «todo funcionó». El fix hace que ReadName se salte el whitespace, exija el / inicial de un name object de PDF y se detenga en cualquier delimitador, incluidos [, ( y ), así que /Subtype/Text y /Subtype /Text producen ambos Text

ImportAnnotationsFromFDFString de PDFlibPas encontraba /Subtype, arrancaba ReadName en el whitespace posterior a la clave de modo que devolvía un nombre vacío, AddAnnotationToPage salía por el subtype ausente, y el llamador incrementaba el resultado de todas formas, reportando siete comentarios importados sin agregar ninguno al documento
Cada guard era razonable aislado; juntos convirtieron nada funcionó en todo funcionó, y por eso el valor de retorno jamás debe ser lo único que un test de importación chequea

El valor de retorno merecía cuidado incluso después de ese fix. Hasta la v3.539.39, ImportAnnotationsFromFDFString seguía incrementando su resultado por cada diccionario bien formado del array /Annots, incluidas las entradas cuyo /Page base 0 quedaba fuera de rango o cuyo /Subtype faltaba, y en ambos casos la entrada se saltaba. Desde PDFlibPas v3.539.40, ImportAnnotationsFromFDFString y ImportAnnotationsFromFDF devuelven la cantidad de anotaciones realmente agregadas, igual que la importación de XFDF: el helper de FDF AddAnnotationToPage ahora devuelve un Boolean y el contador solo se mueve si hay éxito. Medir el documento sigue siendo el chequeo más fuerte, porque también vale en versiones viejas, 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 la 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 solo el subtype habría destapado tres bugs más en la misma función, cada uno invisible únicamente porque ninguna anotación llegaba jamás a una página. Primero, ReadNumber recibía su posición como parámetro de valor, así que leer los cuatro números de /Rect en secuencia leía el mismo punto cuatro veces, y encima no se saltaba el [ de apertura, así que en la práctica no leía nada. Segundo, FindKey compartía un único cursor de avance 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; una vez que el cursor había pasado /Contents, la búsqueda de /Page y /Rect corría más allá de la entrada actual y o no encontraba nada o matcheaba las claves de la siguiente anotación. La librería no podía leer su propia salida. 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 de FDF que asuma un orden de claves está mal por construcción, produzca el archivo la herramienta que la produzca

El importador reparado acota primero cada entrada. FindDictEnd camina desde el << de apertura hasta su >> correspondiente, rastreando diccionarios anidados y saltándose los cuerpos de literal string con sus escapes de backslash, así 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 fin, lo que vuelve irrelevante el orden de las claves y evita que una anotación tome prestado el /Page de otra. El match de clave además acepta un delimitador inmediatamente después del nombre, porque /Contents(Hi) es tan válido como /Contents (Hi), mientras que la regla de frontera de palabra evita que /Subj matchee el arranque de /Subtype y que /T matchee /Type. ReadNumber ahora recibe su posición como parámetro var, se salta el whitespace y el [, y parsea con PLTryStrToFloatInvariant, que falla suave ante un token malformado en lugar de lanzar excepción. 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

FindDictEnd de PDFlibPas ahora acota cada anotación FDF desde su << de apertura hasta el >> correspondiente, así que cada búsqueda de clave reinicia en el inicio de la entrada y se detiene en su fin, y ReadNumber recibe una posición var, se salta el corchete y parsea con PLTryStrToFloatInvariant
El cursor compartido no podía leer el propio export de la librería: apenas pasaba /Contents, las búsquedas de /Page y /Rect se metían en las claves de la siguiente anotación, así que al orden de las claves ya no se le permite importar

¿Por qué los round-trips de FDF desplazaban cada anotación por su propia altura?

El viejo exportador escribía el rectángulo en un 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 librería, el espacio que controla SetOrigin, y los serializaba como [L T L+W T+H]. El importador, una vez que funcionó, escribía esos cuatro valores de vuelta tal cual como rectángulo de PDF, así que el borde superior caía donde correspondía a la esquina inferior izquierda y cada round-trip movía la anotación hacia arriba su propia altura. El exportador ahora copia los números de /Rect de la propia anotación, tres decimales, separador punto, sin exponente, y solo cae al rectángulo calculado cuando el array guardado falta o no tiene cuatro números

PDFlibPas serializaba el /Rect de FDF como left, top, width, height en coordenadas de dibujo, así que reimportar esos cuatro números como llx lly urx ury hacía caer el borde superior donde correspondía a la esquina inferior izquierda y movía cada anotación hacia arriba su propia altura en cada round-trip
El exportador ahora copia los números /Rect de la propia anotación — tres decimales, separador punto, sin exponente — y el test de regresión compara un segundo export byte a byte con el primero

El test de regresión que deja esto clavado vale la pena copiar, porque hace aserciones sobre el documento y sobre un segundo export, no sobre el valor de retorno del importador. Fíjese en que el conteo esperado es 2: AddNoteAnnotation crea una anotación Text más su Popup, y ambas viajan. El test además corre el export y el import bajo un separador decimal 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 := ',';   // simule un escritorio alemán o francés
    try
      FDF := Source.ExportAnnotationsToFDFString;   // igual escribe /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é transporta el camino de 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 links de popup y los appearance streams no son parte de esta ruta, y el exportador se salta las anotaciones Widget porque los form fields pertenecen a los métodos de form data. El mapa general de qué data viaja por cuál método está en el panorama de intercambio de form data FDF, XFDF y XFA, y si necesita inspeccionar qué llegó realmente, 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 coma de exports viejos?

Para FDF la respuesta es inequívoca: una coma no es un delimitador en la sintaxis de 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 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 cual coincide con ISO 32000-1 §7.3.3: los números de 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 la v3.539.28 y anteriores, en un sistema con locale 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 la v3.539.29 ambas direcciones son invariantes, y la forma legado la reconoce XFDFNormalizeLegacyDecimals solo cuando el atributo parte por whitespace en exactamente la cantidad esperada de tokens (cuatro para rect, uno para opacity y width) y cada token tiene la forma dígitos-coma-dígitos. Un rect estándar nunca matchea: o es un solo token con tres comas, o son tokens que terminan en coma. dashes se deja tranquilo a propósito, porque 4,2 podría ser dos longitudes de dash o un 4.2 legado, y ninguna regla puede distinguirlos

const
  // Claves fuera del orden del exportador, más decimales coma de un export viejo con locale coma
  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');
    // Re-exportado como XFDF con decimales punto: rect="10.500 20.250 40.750 60.125"
    Writeln(Lib.ExportAnnotationsToXFDFString);
  finally
    Lib.Free;
  end;
end;

¿Qué debería comprobar en realidad un test de importación de anotaciones?

Un test de importación útil hace aserciones sobre el estado del documento destino, nunca solo sobre lo que el importador dice de sí mismo. Nada en la suite de tests chequeaba AnnotationCount después de una importación FDF, y el valor de retorno, el único número que alguien miraba, era justamente el número que el bug dejó intacto. Tres aserciones habrían cazado cada defecto descrito aquí: el conteo de anotaciones en la página esperada, un campo leído de vuelta vía GetAnnotType o GetAnnotContentsEx, y un segundo export comparado byte a byte con el primero. La misma disciplina aplica a cualquier API que reescriba la estructura del documento en bloque, incluida la consolidación de campos descrita en fusionar form fields duplicados: chequea el árbol resultante, no un total devuelto. Los métodos de anotaciones FDF y XFDF, con sus variantes de archivo y de string, vienen en el losLab PDF Library for Delphi and C++Builder, y la v3.539.30 o posterior es la versión a correr si los comentarios tienen que sobrevivir al viaje, la v3.539.40 o posterior si el conteo devuelto debe coincidir con lo que se agregó