Artículo técnico

Preflight de versión PDF en Delphi: reglas Measure RL y GEO

Un plano de planta exportado por un paquete CAD de arquitectura lleva típicamente un array /VP en su primera página con dos viewports, y cada viewport lleva una entrada /Measure para que un visor pueda leer metros directamente del plano. Una hoja de mapa GeoPDF tiene el mismo array /VP y la misma clave /Measure. El plano de planta es PDF 1.6 corriente. La hoja de mapa necesita PDF 1.7 más Adobe Extension Level 3. Una comprobación de versión que se detiene en el nombre de la clave exigirá la extensión para ambos, y al plano de planta lo rechazan al guardar con un mensaje sobre contenido geoespacial que nunca contuvo

Una clave /Measure, dos diccionarios distintos

El nombre de la clave te dice que un viewport es medible; solo el diccionario detrás te dice cómo. Los diccionarios Measure llegaron en PDF 1.6, e ISO 32000-1 §12.9 define el tipo rectilíneo: una cadena de razón de escala en /R, arrays de formato numérico en /X y /Y, y arrays adicionales para distancia (/D) y área (/A). La medición geoespacial llegó después, como parte de Adobe Extension Level 3 encima de PDF 1.7. Se marca con /Subtype /GEO y lleva un sistema de coordenadas geográfico en /GCS, un sistema de coordenadas de visualización opcional en /DCS, arrays de puntos emparejados en /GPTS y /LPTS, y unidades de visualización preferidas en /PDU — las estructuras que recorre leer viewports GeoPDF y arrays GPTS y LPTS en Delphi

El detalle donde la gente tropieza es el valor por defecto. La §12.9 da a /Subtype un valor por defecto de RL, y muchos exportadores CAD se aprovechan de eso y dejan la entrada fuera. Un comprobador que lee un subtype ausente como "desconocido, asume lo peor" convierte cada uno de esos planos en un documento geoespacial. Un comprobador que empareja por la clave /Measure nunca llega siquiera a la pregunta del subtype, porque el diccionario de viewport que sostiene la clave está un nivel por encima del diccionario Measure que la responde

Una clave /Measure, dos diccionarios en PDFlibPas: la medición rectilínea, con /RL o con el subtype omitido, solo necesita PDF 1.6, mientras que un diccionario geoespacial necesita 1.7 ExtensionLevel 3, así que el preflight decide por el contenido del diccionario Measure donde una regla a nivel de clave no puede distinguirlos
Un preflight que bloquea un archivo válido es peor que uno lento, porque quien llama recibe un diagnóstico autoritario sobre una feature que el documento nunca contuvo, y por eso la comprobación distintiva está un nivel más abajo

Cómo llega a un veredicto el preflight de guardado de PDF Library for Delphi

PDF Library for Delphi (PDFlibPas) ejecuta un preflight de versión dentro de SaveToFile antes de escribir ninguna salida. Visita cada objeto indirecto, descendiendo en diccionarios y arrays anidados, y prueba cada nodo contra una tabla de reglas de features. Cada regla nombra una versión mínima de PDF y, para features de extensión, un extension level de Adobe; una regla de extensión solo puede satisfacerla PDF 1.7 junto a una entrada /Extensions /ADBE coincidente en el catálogo. El objetivo contra el que comprueba es la versión del documento, o la versión que fijaste con LockSaveVersion, más el extension level que el catálogo declare. La primera regla que a la vez coincide y excede ese objetivo termina el guardado: SaveToFile devuelve 0 y LastErrorCode pasa a 602, el valor de PDFLIB_ERROR_VERSION_COMPLIANCE

Preflight de versión al guardar en PDFlibPas: SaveToFile comprueba cada objeto contra una tabla fija de reglas de features, cada una con una versión mínima de PDF y, para features de extensión, un extension level de Adobe que solo puede satisfacer PDF 1.7 con una entrada /Extensions coincidente, y la primera coincidencia que el objetivo no puede satisfacer detiene el guardado con error 602
Las claves 311, 312 y 313 de GetInformation convierten un rechazo en un diagnóstico, informando de la versión requerida, la feature que la disparó y el objetivo fijado, para que quien llama arregle el archivo o la regla en lugar de adivinar

Un código de error por sí solo no te dice qué arreglar. Tres claves de GetInformation llevan el resto: 311 es la versión que necesita la feature problemática (por ejemplo 1.7 ExtensionLevel 3), 312 es el nombre de la feature, y 313 es la versión a la que está fijado el objetivo de guardado, vacía cuando nada está fijado

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('floor-plan.pdf', '') <> 1 then
      raise Exception.Create('load failed');
    if Pdf.SaveToFile('floor-plan-out.pdf') <> 1 then
      if Pdf.LastErrorCode = PDFLIB_ERROR_VERSION_COMPLIANCE then
        // 311: versión requerida, 312: feature que la disparó,
        // 313: versión a la que está bloqueado el objetivo de guardado ('' sin bloqueo)
        Writeln('Needs ', Pdf.GetInformation(311),
          ' for ', Pdf.GetInformation(312),
          ', locked at [', Pdf.GetInformation(313), ']');
  finally
    Pdf.Free;
  end;
end;

Para contenido de medición la tabla sostiene tres reglas y deliberadamente ninguna regla sobre la clave /Measure desnuda. Una página con /VP o /UserUnit necesita PDF 1.6. Un diccionario /PtData necesita extension level 3. Un diccionario Measure necesita extension level 3 cuando dice /Subtype /GEO o contiene cualquiera de /GCS, /DCS, /GPTS, /LPTS o /PDU. Así un viewport cuyo diccionario Measure omite /Subtype y otro que deletrea /RL pasan ambos a 1.6, mientras que la misma página a 1.5 se rechaza por el /VP. Añade un array /GPTS y la respuesta pasa a ser 1.7 ExtensionLevel 3

El test de contenido es conservador a propósito. Un diccionario rectilíneo que además lleva un /GCS o /PDU perdido se trata como geoespacial. Preferimos rechazar ese archivo a dejarlo pasar: esas claves no significan nada en el modelo rectilíneo, así que quien las escribió era o una herramienta geoespacial o algo que dañó el archivo, y un visor consciente de GEO bien podría intentar interpretarlas. El cargador es igual de cuidadoso en la otra dirección. Su escaneo de features puede subir el número de versión del documento a 1.7, pero nunca escribe una declaración /Extensions en nombre de un archivo de entrada — declarar una extensión es una afirmación sobre el contenido, y solo tu código o las llamadas de autoría deberían hacerla

Comprobar los viewports de un plano antes de guardar

Cuando 312 nombra una feature geoespacial y estás seguro de que el archivo es un plano corriente, mira tú los diccionarios Measure. Las llamadas públicas de viewport bastan. GetMeasureDictCoordinateSystem lee /Subtype y devuelve 1 para RL, 2 para GEO, y 0 cuando la entrada está ausente — lo que según la §12.9 también significa rectilíneo. Los recuentos de arrays de puntos te dicen si hay datos de georreferenciación

// Reporta cada diccionario Measure del documento cargado.
// Todas las llamadas aquí solo leen; ninguna añade claves al archivo.
procedure ReportMeasureDicts(Pdf: TPDFlib);
var
  Page, VP, VPID, MD, Kind: Integer;
begin
  for Page := 1 to Pdf.PageCount do
  begin
    Pdf.SelectPage(Page);
    for VP := 1 to Pdf.GetPageViewPortCount do
    begin
      VPID := Pdf.GetPageViewPortID(VP);
      MD := Pdf.GetViewPortMeasureDict(VPID);
      if MD = 0 then
        Continue;
      Kind := Pdf.GetMeasureDictCoordinateSystem(MD); // 0 = omitido (RL), 1 = RL, 2 = GEO
      Writeln(Format('page %d viewport "%s": subtype %d, GPTS %d, LPTS %d',
        [Page, string(Pdf.GetViewPortName(VPID)), Kind,
         Pdf.GetMeasureDictGPTSCount(MD), Pdf.GetMeasureDictLPTSCount(MD)]));
    end;
  end;
end;

Dos llamadas faltan en ese bucle a propósito: GetMeasureDictGCSDict y GetMeasureDictDCSDict. Esas dos son accesores get-or-create construidos para autoría. Sobre un diccionario Measure sin entrada /GCS, GetMeasureDictGCSDict añade uno vacío y declara extension level 3 para el documento. Llámalo como sonda sobre un plano rectilíneo y el diccionario pasa a contener genuinamente /GCS; un guardado posterior fijado a 1.6 se rechaza, correctamente, por contenido que tu diagnóstico acaba de escribir. Si necesitas saber si existe un sistema de coordenadas, el subtype y los recuentos de arrays de puntos lo responden sin efectos secundarios

Fijar la salida a PDF 1.6

Una versión fijada mantiene honesta la salida cuando quien la consume tiene un techo. TPDFlib.LockSaveVersion acepta las cadenas '1.0' a '1.7' y devuelve 0 para cualquier otra cosa. Fija la versión del documento y evita que las llamadas del lado escritor la suban por su cuenta, pero el preflight de guardado sigue ejecutándose contra el valor fijado. Un plano CAD fijado a 1.6 se guarda limpio. Un GeoPDF de verdad fijado a 1.6 recibe 602, y esa es la respuesta que quieres, porque la alternativa es un archivo cuya cabecera promete menos de lo que su contenido requiere

if Pdf.LockSaveVersion('1.6') <> 1 then
  raise Exception.Create('unsupported version string');
try
  if Pdf.SaveToFile('floor-plan-16.pdf') <> 1 then
    if Pdf.LastErrorCode = PDFLIB_ERROR_VERSION_COMPLIANCE then
      // Contenido real por encima de 1.6, por ejemplo un diccionario Measure GEO
      raise Exception.CreateFmt('Locked at 1.6 but %s needs %s',
        [string(Pdf.GetInformation(312)), string(Pdf.GetInformation(311))]);
finally
  Pdf.UnlockSaveVersion;
end;

Lee 311 y 312 antes de tocar la versión. Si la feature es contenido geoespacial genuino, tienes dos opciones honestas: dejar el documento sin fijar a 1.7 con la extensión declarada, o quitar los datos geoespaciales de la copia que envías a quien solo consume 1.6. Subir el fijado solo para que el error desaparezca es la opción a evitar. Esconde la pregunta real — ¿puede el lector aguas abajo manejar lo que estás enviando? Cuando construyes contenido geoespacial por la API, los setters SetMeasureDict* como SetMeasureDictCoordinateSystem y SetMeasureDictGPTSCount declaran ellos mismos extension level 3, así que los archivos escritos llegan al preflight ya etiquetados. El mismo hábito de comprobaciones acotadas y basadas en evidencia aparece en el preflight de modo autor PDF/E-1 para documentos de ingeniería, donde los planos CAD se enfrentan a un estándar de conformidad en lugar de a un número de versión

Por qué el escaneo de reglas lee la tabla in situ

El coste del preflight es aproximadamente nodos de objeto por reglas, y el recuento de nodos incluye cada número y nombre dentro de cada array. Eso hace que la sobrecarga por regla importe más de lo que parece. Cada regla de la tabla es un registro con dos campos AnsiString, la cadena de emparejamiento y el nombre de la feature. Copiar ese registro a una variable local hace que Delphi o FPC incrementen dos reference counts y liberen lo que la local sostenía antes. Hecho una vez por regla y por nodo, eso significa pagar la contabilidad de cadenas incluso para reglas que no aplican a la versión objetivo y se descartan en la línea siguiente

Escaneo de reglas en PDFlibPas: copiar un registro de regla que lleva campos de cadena ajusta reference counts en cada visita, mientras que pasar la entrada de la tabla de solo lectura como parámetro const la lee in situ sin tocar las cadenas
Leer la tabla de solo lectura in situ quita trabajo de reference counts del camino de emparejamiento de reglas, mientras que un guardado completo sigue pagando la detección de features, la decodificación de objetos y la serialización

La tabla de reglas se llena una vez en la inicialización de la unidad y nunca se modifica después, así que el escaneo entrega las entradas de la tabla a su código de emparejamiento mediante parámetros de registro const y las lee donde viven. Un parámetro de registro const se pasa por referencia, así que probar una regla no toca ninguno de los dos campos de cadena y los reference counts se quedan donde estaban. El efecto se concentra en el camino de emparejamiento de reglas. Un guardado real también decodifica objetos, ejecuta la detección de features diferida y serializa la salida, y esas etapas no las toca cómo se pasan las reglas. El orden de reglas, los umbrales y el diagnóstico de primer fallo en 311 y 312 no dependen de esto, y ningún resultado de regla se cachea entre guardados, así que un documento que editas y guardas dos veces se comprueba entero las dos veces

El mismo patrón merece revisarse en tu propio código Delphi dondequiera que un bucle recorre una tabla de registros que contienen cadenas, interfaces o arrays dinámicos:

type
  TMyRule = record
    Name: string;      // campo gestionado: las copias cuestan trabajo de reference count
    Threshold: Integer;
  end;

// código propio del lector, mostrado como patrón
function Applies(const Rule: TMyRule; Value: Integer): Boolean;
begin
  Result := Value >= Rule.Threshold;  // const: sin copia, sin tráfico de refcount
end;

// En el bucle caliente, pasa la entrada de la tabla directamente en lugar
// de asignarla primero a un registro local
for I := 0 to High(Rules) do
  if Applies(Rules[I], V) then
    Inc(Hits);

Viewports de medición, diccionarios geoespaciales, fijado de versión y la comprobación de conformidad al guardar viajan todos en PDF Library for Delphi, disponible para proyectos Delphi, C++Builder y Lazarus