Artículo técnico

Importación EMF en PDFlibPas: reglas de PolyDraw y Bezier

PDFlibPas, la librería de PDF de losLab para Delphi, convierte los registros Poly* de EMF en paths de PDF siguiendo la definición de cada registro en [MS-EMF]: un EMR_POLYBEZIER de 32 bits arranca en el punto 0, las polilíneas quedan abiertas y solo reciben stroke, PT_CLOSEFIGURE en EMR_POLYDRAW es un flag, y cada conteo de puntos se valida contra el tamaño del registro. Esas reglas llegaron entre v3.539.39, v3.539.41 y v3.539.43. Antes de ellas, el gráfico de un reporte podía salir de ImportEMFFromFile con una cuña rellena donde debía ir una línea de tendencia, una curva Bezier doblada hacia el punto de control equivocado, o un contorno cerrado al que le faltaba su último lado. Ninguno de estos casos levantaba un error, y las reglas valen para cualquier conversor de EMF a PDF en Delphi o parser de registros GDI

¿Por qué los registros Poly* de EMF fallan al convertir a PDF?

Los registros Poly* de EMF fallan porque cada uno carga parte de su significado fuera de sus puntos: si la figura queda abierta, si arranca en la posición actual, qué pluma y qué pincel aplican, y en qué parte del registro empiezan los puntos. Un enhanced metafile es una grabación de llamadas GDI contra un device context, así que un conversor tiene que reproducir ese estado del device context además de las coordenadas. PDF no tiene device context: tiene un path, un punto actual dentro de ese path, y un operador de pintado que decide entre stroke (S), fill (f) o ambos (B). Cada desajuste entre los dos modelos se convierte en una diferencia de render silenciosa

La familia Poly* además viene en dos anchos. Cada registro de 32 bits como EMR_POLYLINE tiene un gemelo de 16 bits como EMR_POLYLINE16 que guarda los puntos como pares de SmallInt. GDI normalmente graba la forma compacta cuando cada coordenada cabe, así que los handlers de 32 bits de un conversor pueden estar rotos durante años mientras los dibujos de prueba de todos los días nunca llegan a ellos. La auditoría más rápida es pasar los mismos puntos por ambos registros y comparar los paths resultantes. Los registros que cubre este artículo están todos en el grupo de registros de dibujo de [MS-EMF] (2.3.5 Drawing Record Types)

RegistroArranca en¿Cerrado?Posición actual
EMR_POLYBEZIERPunto 0NoNo se usa, no se actualiza
EMR_POLYLINEPunto 0No (solo pluma)No se usa, no se actualiza
EMR_POLYLINETOPosición actualNo (solo pluma)Se usa y se actualiza
EMR_POLYPOLYLINEPrimer punto de cada polilíneaNo (solo pluma)No se usa, no se actualiza
EMR_POLYDRAWPrimer PT_MOVETO, o posición actualSolo donde PT_CLOSEFIGURE está activoSe usa y se actualiza

¿Dónde arranca en realidad la curva de un EMR_POLYBEZIER?

La curva de un EMR_POLYBEZIER arranca en el punto 0, y solo los puntos desde el índice 1 en adelante se agrupan de a tres como punto de control, punto de control, punto final. Un registro con 7 puntos dibuja entonces dos segmentos cúbicos: el 0 es el arranque, el 1 al 3 forman el primer segmento, el 4 al 6 el segundo. El handler de 16 bits de PDFlibPas ya lo hacía así. El de 32 bits empezaba a agrupar en el punto 0, de modo que el punto de arranque se consumía como primer punto de control y cada segmento posterior quedaba corrido uno. La curva se dibujaba igual, solo que era la equivocada. Desde v3.539.41 ambos anchos abren el path con m en el punto 0 y emiten un c por cada terceto completo posterior

Diagrama de PDFlibPas de un registro EMR_POLYBEZIER con siete puntos donde el punto cero abre el path con m y los puntos uno a tres y cuatro a seis forman cada uno un segmento cúbico c, contrastando el handler de 32 bits corregido desde v3.539.41 con el agrupamiento viejo que consumía el punto de arranque como punto de control
El punto 0 es el punto de arranque y solo los tercetos completos posteriores se vuelven segmentos cúbicos, así que un PolyBezier de siete puntos se dibuja como m más dos operadores c

Para su propio parser: un conteo que no sea 1 más un múltiplo de 3 está mal formado, y los puntos sobrantes conviene ignorarlos en vez de coserlos a la curva

PolyDraw: PT_CLOSEFIGURE es un flag, no un tipo de punto

En EMR_POLYDRAW, PT_CLOSEFIGURE (valor 1) es un bit que se combina con PT_LINETO (2) o PT_BEZIERTO (4), así que un byte de tipo válido puede ser 3 o 5. El tipo de punto es el byte con ese bit enmascarado, y el flag significa cerrar la figura después del segmento que termina en ese punto. El handler viejo de PDFlibPas comparaba el byte contra valores sueltos en un case, así que los puntos de tipo 3 y 5 no coincidían con nada y se saltaban por completo. Un rectángulo dibujado con PolyDraw perdía su lado de cierre, y un terceto Bezier cuyo último punto traía el flag perdía ese punto, lo que desincronizaba todos los tercetos posteriores

Desde v3.539.39 el tipo se lee como Types[i] and not PT_CLOSEFIGURE, y el cierre se emite solo después de un segmento completo: tras la línea para un PT_LINETO cerrado, y tras el tercer punto de un grupo Bezier. Un archivo mal formado que ponga el flag en el primer o segundo punto de un terceto no cierra la figura antes de tiempo. Dos correcciones relacionadas salieron en la misma versión:

  • Cada PT_MOVETO del EMR_POLYDRAW16 de 16 bits reiniciaba el path entero, así que un registro con tres figuras conservaba solo la última; ahora el primer move arranca el path y los moves posteriores abren subpaths
  • Un registro PolyDraw que no empiece con PT_MOVETO arranca en la posición actual, como dice la definición del registro, en vez de escribir un operador l o c sin un m previo
Anatomía de PDFlibPas de un byte de tipo de EMR_POLYDRAW donde PT_CLOSEFIGURE es el bit de flag cero metido con OR en PT_LINETO o PT_BEZIERTO, así que los bytes de tipo válidos 3 y 5 deben enmascararse con and not PT_CLOSEFIGURE antes del dispatch; el case viejo se saltaba ambos bytes y las figuras cerradas perdían su último lado
Enmascare el flag de cierre antes del dispatch y emita el cierre solo después de una línea o un terceto Bezier completos, o PolyDraw se saltará puntos en silencio

¿Por qué una polilínea EMF jamás debe rellenarse en PDF?

Una polilínea EMF jamás debe rellenarse porque EMR_POLYLINE y EMR_POLYPOLYLINE son figuras abiertas que se dibujan solo con la pluma, y rellenar un path abierto en PDF lo cierra implícitamente. ISO 32000-1 §8.5.3 dice que los operadores de fill cierran cualquier subpath abierto antes de pintarlo. Un conversor que emita B o f para una polilínea de tres puntos pinta entonces un triángulo relleno con el color del pincel actual: la cuña rellena bajo la línea de tendencia de un gráfico. Antes de v3.539.41, PDFlibPas rellenaba ambos anchos de polilínea con el pincel, y el registro de 32 bits además se cerraba explícitamente. Hoy ambos anchos terminan solo con stroke, y la distinción de GDI se preserva: Polygon cierra y rellena, Polyline nunca lo hace

Comparación de PDFlibPas de una polilínea en V abierta exportada desde EMR_POLYLINE: un conversor correcto termina el path con el operador de stroke S e ignora el pincel seleccionado, mientras que emitir f o B cierra el subpath abierto implícitamente bajo ISO 32000-1 8.5.3 y pinta la cuña rellena, el bug clásico del gráfico
Un operador de fill cierra cualquier subpath abierto antes de pintarlo, así que las polilíneas deben terminar en S sin h, f ni B en el subpath

PolylineTo arranca en la posición actual

EMR_POLYLINETO dibuja desde la posición actual pasando por cada punto del registro, queda abierta, y deja la posición actual en el último punto. El handler viejo además traía un caso especial que apagaba la pluma cuando los primeros dos puntos compartían la coordenada y, y nada volvía a encenderla, así que todos los registros posteriores del archivo perdían su contorno. El estado de la pluma es asunto de EMR_SELECTOBJECT y EMR_CREATEPEN; un handler de registro de dibujo no tiene por qué tocarlo. Ese caso especial se eliminó en v3.539.41, y la forma de un punto del registro ya no lee más allá de sus propios puntos (corregido en v3.539.39)

Los puntos de PolyPolyline empiezan después del array de conteos

El EMR_POLYPOLYLINE de 32 bits guarda nPolys conteos y luego cptl puntos, y los puntos empiezan en el offset de byte 32 + nPolys * 4. La trampa está en la RTL: la unidad Windows declara TEMRPolyPolyline con aPolyCounts y aptl como arrays de un elemento, así que aptl[0] es el primer punto solo cuando nPolys vale 1. Código que indexa aptl directo lee valores de conteo como coordenadas en cada registro multilínea. El handler viejo de PDFlibPas además dimensionaba su chequeo de límites sobre ese layout equivocado, así que rechazaba registros multilínea válidos y los de una sola línea no dibujaban nada. Desde v3.539.41 PDFlibPas localiza el array de puntos desde el offset calculado, como siempre hizo su handler de PolyPolygon, y dibuja cada polilínea como su propio subpath abierto con un solo stroke al final. En v3.539.43 el gemelo de 16 bits recibió el mismo tratamiento; venía dibujando segmento por segmento, lo que rompía las uniones de línea e ignoraba un NULL_PEN seleccionado

La pluma y el pincel por defecto, y los brackets de path

Dos reglas de estado completan las correcciones de polilínea en v3.539.43:

  • Un device context GDI recién creado ya trae BLACK_PEN y WHITE_BRUSH seleccionados, así que un metafile que dibuja sin ningún EMR_SELECTOBJECT sigue dibujando contornos negros; el conversor arrancaba sin pluma ni fill y escribía n (fin de path, no pintar nada) para esos registros
  • Dentro de un bracket BeginPath / EndPath, una Polyline ni usa ni actualiza la posición actual, así que debe abrir un subpath nuevo en su primer punto en vez de conectarse a la figura anterior, y nada se puede pintar hasta que el bracket reciba stroke o fill

Cómo armar un archivo EMF de prueba con TMetafileCanvas

La forma más rápida de probar un conversor contra estas reglas es grabar las tres llamadas riesgosas en un solo enhanced metafile con TMetafileCanvas. El dibujo de abajo graba las curvas con un pincel hueco y después selecciona a propósito un pincel amarillo sólido para la polilínea: un conversor correcto debe ignorar ese pincel para la polilínea, así que cualquier amarillo en el PDF de salida es un bug. PolyDraw no tiene wrapper en TCanvas, así que se llama vía la API de Windows con el handle del canvas, usando bytes de tipo 3 y 5 para ejercitar el flag de cierre

uses
  Winapi.Windows, System.Types, Vcl.Graphics;

procedure BuildPolyTestEmf(const FileName: string);
const
  // Un cuadrado cerrado (3 = LINETO + CLOSEFIGURE), luego una figura
  // Bezier cuyo último terceto de control termina con 5 = BEZIERTO + CLOSEFIGURE
  DrawPts: array[0..7] of TPoint = (
    (X: 300; Y: 40), (X: 380; Y: 40), (X: 380; Y: 120), (X: 300; Y: 120),
    (X: 420; Y: 120), (X: 440; Y: 40), (X: 520; Y: 40), (X: 540; Y: 120));
  DrawTypes: array[0..7] of Byte = (
    PT_MOVETO, PT_LINETO, PT_LINETO, PT_LINETO or PT_CLOSEFIGURE,
    PT_MOVETO, PT_BEZIERTO, PT_BEZIERTO, PT_BEZIERTO or PT_CLOSEFIGURE);
var
  Mf: TMetafile;
  Canvas: TMetafileCanvas;
begin
  Mf := TMetafile.Create;
  try
    Mf.Enhanced := True;
    Mf.Width := 600;
    Mf.Height := 260;
    Canvas := TMetafileCanvas.Create(Mf, 0);
    try
      Canvas.Pen.Color := clNavy;
      Canvas.Pen.Width := 2;
      Canvas.Brush.Style := bsClear;    // solo contornos para las curvas
      // El punto 0 es el arranque; 1..3 y 4..6 son dos segmentos cúbicos
      Canvas.PolyBezier([Point(20, 120), Point(60, 20), Point(100, 220),
        Point(140, 120), Point(180, 20), Point(220, 220), Point(260, 120)]);
      PolyDraw(Canvas.Handle, DrawPts[0], DrawTypes[0], Length(DrawPts));
      // V abierta con pincel sólido seleccionado: se traza, nunca se cierra
      // en un triángulo amarillo
      Canvas.Brush.Style := bsSolid;
      Canvas.Brush.Color := clYellow;
      Canvas.Polyline([Point(20, 240), Point(120, 160), Point(220, 240)]);
    finally
      Canvas.Free;   // termina la grabación
    end;
    Mf.SaveToFile(FileName);
  finally
    Mf.Free;
  end;
end;

Como estas coordenadas caben en un SmallInt, GDI normalmente guardará las variantes de 16 bits. Para llegar a los handlers de 32 bits necesita un productor que los escriba, o registros armados a mano. Los archivos hechos a mano traen su propia trampa: TMetafile.LoadFromStream de la VCL solo trata el stream como un EMF cuando la longitud restante es estrictamente mayor que el TEnhMetaHeader de 108 bytes. Un EMF minimalista escrito a mano con header corto, o uno vacío de exactamente 108 bytes, se toma por un WMF y se rechaza con "Metafile is not valid". Escriba siempre el header completo de 108 bytes, campos de extensión incluidos, antes de sus registros de prueba

Importar el EMF a un PDF con PDFlibPas

PDFlibPas importa un EMF con ImportEMFFromFile o ImportEMFFromStream, que devuelven un ID de imagen distinto de cero en caso de éxito y 0 en caso de falla. GeneralOptions = 0 conserva el path vectorial del que trata este artículo; 1 rasteriza el metafile a un bitmap. FontOptions = 1 agrega las fuentes del metafile como fuentes TrueType no embebidas. La variante por stream rebobina el stream a la posición 0 antes de cargar, así que pase un stream que contenga solo el metafile

uses
  System.SysUtils, PDFlibrary;

procedure EmfToPdf(const EmfFile, PdfFile: WideString);
var
  PDF: TPDFlib;
  ImageID: Integer;
  PageOps: AnsiString;
begin
  PDF := TPDFlib.Create;
  try
    PDF.SetOrigin(1);              // origen arriba a la izquierda para DrawImage
    PDF.SetMeasurementUnits(0);    // puntos
    // FontOptions 1 = agrega las fuentes como TrueType no embebidas
    // GeneralOptions 0 = import vectorial, 1 = bitmap
    ImageID := PDF.ImportEMFFromFile(EmfFile, 1, 0);
    if ImageID = 0 then
      raise Exception.Create('The metafile could not be imported');
    PDF.SelectImage(ImageID);
    // Para un EMF, ImageWidth / ImageHeight son el tamaño del frame en puntos
    PDF.DrawImage(36, 36, PDF.ImageWidth, PDF.ImageHeight);

    // La página solo invoca el form importado: q ... cm /Name Do Q
    PageOps := PDF.GetPageContentToString;
    if Pos(AnsiString(' Do'), PageOps) = 0 then
      raise Exception.Create('Expected a form XObject invocation');

    if PDF.SaveToFile(PdfFile) <> 1 then
      raise Exception.Create('The PDF could not be saved');
  finally
    PDF.Free;
  end;
end;

Una importación vectorial de EMF se convierte en un form XObject, así que GetPageContentToString devuelve solo la secuencia de save, transform, Do y restore. Los operadores m, l, c, h y S producidos a partir de los registros Poly* viven en el stream del form XObject, que está comprimido. Para auditarlos, descomprima el archivo guardado en un inspector de objetos PDF y lea el stream del form: para el archivo de prueba de arriba debería ver la polilínea terminar en S sin ningún h antes, un h en cada flag de cierre de las figuras PolyDraw, y ningún f ni B en esos subpaths. DrawImage además escala un EMF importado de forma uniforme por el menor entre Width y Height, así que el dibujo conserva su relación de aspecto aunque la caja que le pase no la respete

Para targets de Free Pascal, vea cómo compila el importador vectorial de EMF de PDFlibPas bajo Free Pascal; la semántica de los registros es la misma dondequiera que compile el importador

¿Cómo debe tratar un parser de EMF los conteos de puntos que vienen del archivo?

Un parser de EMF debe tratar cada conteo de puntos como entrada no confiable y validarlo contra el tamaño del registro antes de copiar un solo punto. EnumEnhMetaFile solo garantiza que el nSize de cada registro quede dentro del archivo. No chequea que cptl concuerde con nSize, así que un handler que copie cptl puntos con Move leerá los registros siguientes, o más allá del final del metafile, cuando el conteo esté falsificado o corrupto. Desde v3.539.39 PDFlibPas valida header fijo más conteo por bytes por punto contra nSize para PolyDraw, PolyBezier, PolyBezierTo, Polyline, PolylineTo y Polygon en ambos anchos, con un byte extra por punto para los bytes de tipo de PolyDraw. En los registros PolyPoly los conteos por figura además deben sumar como máximo el total declarado, y las figuras de cero puntos se saltan

El mismo chequeo es tan corto que puede copiarlo a su propio parser. Esta versión valida un EMR_POLYPOLYLINE de 32 bits y devuelve un puntero a su array de puntos real:

uses
  Winapi.Windows;

// Devuelve nil salvo que el registro realmente contenga los puntos que declara.
// Los puntos empiezan tras el array de conteos: a 32 + nPolys * 4 bytes,
// no en aptl[0], que la RTL declara como array de un elemento
function PolyPolylinePoints(Rec: PEnhMetaRecord): PPoint;
var
  P: PEMRPolyPolyline;
  Count: PDWORD;
  PointsOffset, Total: Int64;
  I: Cardinal;
begin
  Result := nil;
  if (Rec^.iType <> EMR_POLYPOLYLINE) or (Rec^.nSize < 32) then
    Exit;
  P := PEMRPolyPolyline(Rec);
  if P^.nPolys = 0 then
    Exit;
  PointsOffset := 32 + Int64(P^.nPolys) * SizeOf(DWORD);
  if PointsOffset + Int64(P^.cptl) * SizeOf(TPoint) > Rec^.nSize then
    Exit;                         // conteo falsificado o truncado
  Total := 0;
  Count := @P^.aPolyCounts[0];    // recorrer por puntero: [0..0] dispara range checks
  for I := 1 to P^.nPolys do
  begin
    Inc(Total, Count^);
    Inc(Count);
  end;
  if Total > P^.cptl then
    Exit;                         // las figuras reclaman más puntos de los que hay
  Result := PPoint(NativeUInt(Rec) + NativeUInt(PointsOffset));
end;

El test de que los puntos caben corre primero, así que el array de conteos se sabe dentro del registro antes de que el loop lo recorra. La aritmética es Int64 porque nPolys * 4 y cptl * 8 calculados en 32 bits pueden dar la vuelta y pasar la comparación

Referencia rápida: reglas de los Poly* de EMF para la conversión de EMF a PDF

  • EMR_POLYBEZIER: el punto 0 es el punto de arranque; agrupe desde el punto 1 de a tres; corregido para el registro de 32 bits en v3.539.41
  • EMR_POLYLINE / EMR_POLYPOLYLINE: figuras abiertas, stroke con S, nunca h, f ni B, porque el fill de PDF cierra los subpaths abiertos
  • EMR_POLYLINETO: arrancar en la posición actual, quedar abierta, actualizar la posición actual, jamás tocar el estado de la pluma
  • EMR_POLYPOLYLINE de 32 bits: los puntos empiezan en el byte 32 + nPolys * 4, no en aptl[0]
  • EMR_POLYDRAW: enmascare PT_CLOSEFIGURE antes del dispatch, cierre después del segmento completo, y arranque en la posición actual cuando el primer punto no sea PT_MOVETO
  • El estado por defecto del device context es BLACK_PEN más WHITE_BRUSH; v3.539.43 y posteriores lo respetan
  • Dentro de BeginPath / EndPath, cada polilínea abre su propio subpath y nada se pinta hasta que el bracket se usa
  • Valide cada cptl / cpts contra nSize en aritmética de 64 bits antes de copiar puntos
  • Los EMF de prueba hechos a mano necesitan el header completo de 108 bytes, o TMetafile.LoadFromStream los lee como WMF

Si sus reportes pasan por otro componente, la semántica de los registros es la misma; la importación vectorial de EMF y WMF de HotPDF cubre cómo ese componente convierte pinceles de gradiente y hatch en patterns de PDF, y gráficos vectoriales, shaders y gradientes en PDFlibPas cubre cómo dibujar las mismas formas directo con la API de la librería en vez de vía un metafile

PDFlibPas v3.539.43 o posterior incluye todas las reglas de arriba. Los detalles y las descargas de prueba están en la página del producto de la librería PDFlibPas Delphi PDF