Artigo Técnico

PDFlibPas: regras EMF de PolyDraw, Polyline e Bezier

PDFlibPas, a biblioteca de PDF da losLab para Delphi, converte os records Poly* do EMF em paths de PDF seguindo a definição de cada record no [MS-EMF]: um EMR_POLYBEZIER de 32 bits começa no ponto 0, polylines ficam abertas e recebem só stroke, PT_CLOSEFIGURE em EMR_POLYDRAW é uma flag, e toda contagem de pontos é conferida contra o tamanho do record. Essas regras chegaram ao longo da v3.539.39, v3.539.41 e v3.539.43. Antes delas, um gráfico de relatório podia sair do ImportEMFFromFile com uma fatia preenchida onde deveria haver uma linha de tendência, uma curva Bezier dobrando para o control point errado, ou um contorno fechado faltando o último lado. Nada disso levantava erro, e as regras valem para qualquer conversor de EMF para PDF em Delphi ou parser de records GDI

Por que os records Poly* do EMF dão errado na conversão para PDF?

Os records Poly* do EMF dão errado porque cada um carrega parte do significado dele fora dos pontos: se a figura é aberta, se começa na posição atual, qual caneta e qual pincel se aplicam, e onde no record os pontos começam. Um enhanced metafile é uma gravação de chamadas GDI contra um device context, então o conversor precisa reproduzir o estado desse device context além das coordenadas. O PDF não tem device context. Ele tem um path, um current point dentro desse path, e um operador de pintura que decide entre stroke (S), fill (f) e ambos (B). Todo desencontro entre os dois modelos vira uma diferença de renderização silenciosa

A família Poly* também vem em duas larguras. Cada record de 32 bits como o EMR_POLYLINE tem um gêmeo de 16 bits como o EMR_POLYLINE16 que guarda os pontos como pares de SmallInt. O GDI normalmente grava a forma compacta quando todas as coordenadas cabem, então os handlers de 32 bits de um conversor podem ficar errados por anos enquanto desenhos de teste do dia a dia nunca chegam até eles. A auditoria mais rápida é passar os mesmos pontos pelos dois records e comparar os paths resultantes. Os records cobertos aqui estão todos no grupo de records de desenho do [MS-EMF] (2.3.5 Drawing Record Types)

RecordComeça emFechada?Posição atual
EMR_POLYBEZIERPonto 0NãoNão usada, não atualizada
EMR_POLYLINEPonto 0Não (só caneta)Não usada, não atualizada
EMR_POLYLINETOPosição atualNão (só caneta)Usada e atualizada
EMR_POLYPOLYLINEPrimeiro ponto de cada polylineNão (só caneta)Não usada, não atualizada
EMR_POLYDRAWPrimeiro PT_MOVETO, ou posição atualSó onde PT_CLOSEFIGURE está setadoUsada e atualizada

Onde uma curva EMR_POLYBEZIER realmente começa?

Uma curva EMR_POLYBEZIER começa no ponto 0, e só os pontos do índice 1 em diante são agrupados em trios como control point, control point, end point. Um record com 7 pontos desenha portanto dois segmentos cúbicos: 0 é o início, 1 a 3 formam o primeiro segmento, 4 a 6 formam o segundo. O handler de 16 bits do PDFlibPas já fazia isso. O handler de 32 bits começava a agrupar no ponto 0, então o start point era consumido como primeiro control point e todo segmento seguinte deslocava em um. A curva ainda renderizava, só que a errada. Desde a v3.539.41 ambas as larguras abrem o path com m no ponto 0 e emitem um c por trio completo depois dele

Diagrama do PDFlibPas de um record EMR_POLYBEZIER com sete pontos em que o ponto zero abre o path com m e os pontos um a três e quatro a seis formam cada um um segmento cúbico c, contrastando o handler de 32 bits corrigido desde a v3.539.41 com o agrupamento antigo que consumia o start point como control point
O ponto 0 é o start point e só os trios completos depois dele viram segmentos cúbicos, então um PolyBezier de sete pontos renderiza como m mais dois operadores c

Para o seu próprio parser: uma contagem que não é 1 mais múltiplo de 3 está malformada, e os pontos finais devem ser ignorados em vez de costurados numa curva

PolyDraw: PT_CLOSEFIGURE é uma flag, não um tipo de ponto

Em EMR_POLYDRAW, PT_CLOSEFIGURE (valor 1) é um bit combinado com PT_LINETO (2) ou PT_BEZIERTO (4), então um byte de tipo válido pode ser 3 ou 5. O tipo do ponto é o byte com esse bit mascarado, e a flag significa fechar a figura depois do segmento que termina nesse ponto. O handler antigo do PDFlibPas comparava o byte com valores únicos num case, então pontos de tipo 3 e 5 não casavam com nada e eram pulados por completo. Um retângulo desenhado com PolyDraw perdia o lado que o fechava, e um trio Bezier cujo último ponto carregava a flag perdia esse ponto, o que empurrava todo trio seguinte para fora de sintonia

Desde a v3.539.39 o tipo é lido como Types[i] and not PT_CLOSEFIGURE, e o fechamento é emitido só depois de um segmento completo: depois da linha para um PT_LINETO fechado, e depois do terceiro ponto de um grupo Bezier. Um arquivo malformado que seta a flag no primeiro ou no segundo ponto de um trio não fecha a figura cedo demais. Duas correções relacionadas saíram na mesma release:

  • Cada PT_MOVETO no EMR_POLYDRAW16 de 16 bits reiniciava o path inteiro, então um record com três figuras mantinha só a última; agora o primeiro move inicia o path e os moves seguintes abrem subpaths
  • Um record PolyDraw que não começa com PT_MOVETO começa na posição atual, como a definição do record manda, em vez de escrever um operador l ou c sem m precedente
Anatomia no PDFlibPas de um byte de tipo de EMR_POLYDRAW em que PT_CLOSEFIGURE é o bit de flag zero operado com OR em PT_LINETO ou PT_BEZIERTO, então os bytes de tipo válidos 3 e 5 precisam ser mascarados com and not PT_CLOSEFIGURE antes do dispatch; o case antigo pulava os dois bytes e figuras fechadas perdiam o último lado
Mascare a flag de fechamento antes do dispatch e emita o fechamento só depois de uma linha ou de um trio Bezier completos, ou o PolyDraw descarta pontos em silêncio

Por que uma polyline EMF nunca deve ser preenchida em PDF?

Uma polyline EMF nunca deve ser preenchida porque EMR_POLYLINE e EMR_POLYPOLYLINE são figuras abertas desenhadas só com a caneta, e preencher um path aberto em PDF o fecha implicitamente. A ISO 32000-1 §8.5.3 declara que os operadores de fill fecham qualquer subpath aberto antes de pintá-lo. Um conversor que emite B ou f para uma polyline de três pontos pinta portanto um triângulo preenchido na cor do pincel atual: a fatia preenchida sob uma linha de tendência de gráfico. Antes da v3.539.41, o PDFlibPas preenchia ambas as larguras de polyline com o pincel, e o record de 32 bits ainda era fechado explicitamente. Hoje ambas as larguras terminam só com stroke, e a distinção do GDI é preservada: Polygon fecha e preenche, Polyline nunca faz isso

Comparação no PDFlibPas de uma polyline em V aberta exportada do EMR_POLYLINE: um conversor correto termina o path com o operador de stroke S e ignora o pincel selecionado, enquanto emitir f ou B fecha o subpath aberto implicitamente sob a ISO 32000-1 8.5.3 e pinta a fatia preenchida, o bug clássico de gráfico
Um operador de fill fecha qualquer subpath aberto antes de pintar, então polylines precisam terminar em S sem h, f ou B no subpath

PolylineTo começa na posição atual

EMR_POLYLINETO desenha da posição atual por todos os pontos do record, fica aberto e deixa a posição atual no último ponto. O handler antigo ainda continha um caso especial que desligava a caneta quando os dois primeiros pontos compartilhavam o y, e nada nunca a ligava de volta, então todo record seguinte do arquivo perdia o contorno. O estado da caneta pertence a EMR_SELECTOBJECT e EMR_CREATEPEN; um handler de record de desenho não tem nada a ver com mudá-lo. Esse caso especial saiu na v3.539.41, e a forma de um ponto só do record não lê mais além dos próprios pontos (corrigido na v3.539.39)

Os pontos do PolyPolyline começam depois do array de contagens

O EMR_POLYPOLYLINE de 32 bits guarda nPolys contagens e depois cptl pontos, e os pontos começam no offset de byte 32 + nPolys * 4. A armadilha está na RTL: a unit Windows declara TEMRPolyPolyline com aPolyCounts e aptl como arrays de um elemento, então aptl[0] é o primeiro ponto só quando nPolys é 1. Código que indexa aptl diretamente lê valores de contagem como coordenadas em todo record de múltiplas linhas. O handler antigo do PDFlibPas também dimensionava a checagem de limites com base nesse layout errado, então records válidos de múltiplas linhas eram rejeitados e os de linha única não desenhavam nada. Desde a v3.539.41 o PDFlibPas localiza o array de pontos a partir do offset calculado, do mesmo jeito que o handler de PolyPolygon dele sempre fez, e desenha cada polyline como o próprio subpath aberto dela com um stroke no final. Na v3.539.43 o gêmeo de 16 bits recebeu o mesmo tratamento; ele desenhava segmento por segmento, o que quebrava as junções de linha e ignorava um NULL_PEN selecionado

A caneta e o pincel default, e os brackets de path

Duas regras de estado completam as correções de polyline na v3.539.43:

  • Um device context GDI recém-criado já tem BLACK_PEN e WHITE_BRUSH selecionados, então um metafile que desenha sem nenhum EMR_SELECTOBJECT ainda desenha contornos pretos; o conversor começava sem caneta e sem fill e escrevia n (fecha o path, não pinta nada) para esses records
  • Dentro de um bracket BeginPath / EndPath, uma Polyline não usa nem atualiza a posição atual, então precisa abrir um subpath novo no primeiro ponto dela em vez de conectar com a figura anterior, e nada pode ser pintado até o bracket receber stroke ou fill

Construindo um arquivo de teste EMF com TMetafileCanvas

O jeito mais rápido de conferir um conversor contra essas regras é gravar as três chamadas arriscadas num único enhanced metafile com TMetafileCanvas. O desenho abaixo grava as curvas com um pincel vazado e depois seleciona um pincel amarelo sólido para a polyline de propósito: um conversor correto precisa ignorar esse pincel para a polyline, então qualquer amarelo no PDF de saída é um bug. PolyDraw não tem wrapper de TCanvas, então é chamado pela API do Windows com o handle do canvas, usando bytes de tipo 3 e 5 para exercitar a flag de fechamento

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

procedure BuildPolyTestEmf(const FileName: string);
const
  // Um quadrado fechado (3 = LINETO + CLOSEFIGURE), depois uma
  // figura Bezier fechada cujo último trio de controle termina em 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;    // só contornos para as curvas
      // O ponto 0 é o início; 1..3 e 4..6 são dois 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 aberta com pincel sólido selecionado: recebe stroke, nunca é
      // fechada num triângulo amarelo
      Canvas.Brush.Style := bsSolid;
      Canvas.Brush.Color := clYellow;
      Canvas.Polyline([Point(20, 240), Point(120, 160), Point(220, 240)]);
    finally
      Canvas.Free;   // encerra a gravação
    end;
    Mf.SaveToFile(FileName);
  finally
    Mf.Free;
  end;
end;

Como essas coordenadas cabem num SmallInt, o GDI normalmente vai gravar as variantes de 16 bits. Para alcançar os handlers de 32 bits você precisa de um produtor que os escreva, ou de records construídos à mão. Arquivos feitos à mão têm armadilha própria: o TMetafile.LoadFromStream da VCL trata o stream como EMF só quando o comprimento restante é estritamente maior que o TEnhMetaHeader de 108 bytes. Um EMF minimalista escrito à mão com header curto, ou um vazio de exatamente 108 bytes, é tomado por WMF e rejeitado com "Metafile is not valid". Escreva sempre o header completo de 108 bytes, incluindo os campos de extensão, antes dos seus records de teste

Importando o EMF para um PDF com o PDFlibPas

O PDFlibPas importa um EMF com ImportEMFFromFile ou ImportEMFFromStream, que retornam um image ID não zero em caso de sucesso e 0 em caso de falha. GeneralOptions = 0 mantém o path vetorial de que este artigo trata; 1 rasteriza o metafile num bitmap. FontOptions = 1 adiciona as fontes do metafile como fontes TrueType não embutidas. A variante de stream rebobina o stream para a posição 0 antes de carregar, então passe um stream que contenha só o 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);              // origem no topo à esquerda para DrawImage
    PDF.SetMeasurementUnits(0);    // pontos
    // FontOptions 1 = adiciona fontes como TrueType não embutidas
    // GeneralOptions 0 = importação vetorial, 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 um EMF, ImageWidth / ImageHeight são o tamanho do frame em pontos
    PDF.DrawImage(36, 36, PDF.ImageWidth, PDF.ImageHeight);

    // A página só invoca o 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;

Uma importação vetorial de EMF vira um form XObject, então o GetPageContentToString retorna só a sequência de save, transform, Do e restore. Os operadores m, l, c, h e S produzidos a partir dos records Poly* vivem no stream do form XObject, que é comprimido. Para auditar, descomprima o arquivo salvo num inspetor de objetos PDF e leia o stream do form: para o arquivo de teste acima você deve ver a polyline terminar em S sem nenhum h antes, um h a cada flag de fechamento nas figuras PolyDraw, e nenhum f ou B em nenhum desses subpaths. O DrawImage também escala um EMF importado uniformemente pelo menor entre Width e Height, então o desenho mantém o aspect ratio dele mesmo que a caixa que você passar não bata com ele

Para alvos Free Pascal, veja como o importador vetorial de EMF do PDFlibPas compila sob Free Pascal; a semântica dos records é a mesma onde quer que o importador compile

Como um parser de EMF deve tratar contagens de pontos vindas do arquivo?

Um parser de EMF deve tratar toda contagem de pontos como input não confiável e conferi-la contra o tamanho do record antes de copiar um único ponto. O EnumEnhMetaFile só garante que cada nSize de record fica dentro do arquivo. Ele não confere se o cptl bate com o nSize, então um handler que copia cptl pontos com Move vai ler os records seguintes, ou passar do fim do metafile, quando a contagem for forjada ou estiver corrompida. Desde a v3.539.39 o PDFlibPas confere header fixo mais contagem vezes bytes por ponto contra o nSize para PolyDraw, PolyBezier, PolyBezierTo, Polyline, PolylineTo e Polygon em ambas as larguras, com um byte extra por ponto para os bytes de tipo do PolyDraw. Para os records PolyPoly as contagens por figura também precisam somar no máximo o total declarado, e figuras de zero pontos são puladas

A mesma checagem é curta o bastante para copiar para o seu próprio parser. Esta versão valida um EMR_POLYPOLYLINE de 32 bits e retorna um ponteiro para o array real de pontos dele:

uses
  Winapi.Windows;

// Retorna nil a menos que o record realmente contenha os pontos que declara.
// Os pontos começam depois do array de contagens: 32 + nPolys * 4 bytes à
// frente, e não em aptl[0], que a RTL declara como array de um 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;                         // contagem forjada ou truncada
  Total := 0;
  Count := @P^.aPolyCounts[0];    // caminha por ponteiro: [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;                         // as figuras pedem mais pontos do que existem
  Result := PPoint(NativeUInt(Rec) + NativeUInt(PointsOffset));
end;

O teste de pontos que cabem roda primeiro, então o array de contagens já é sabido estar dentro do record antes de o loop caminhar por ele. A aritmética é Int64 porque nPolys * 4 e cptl * 8 calculados em 32 bits podem dar wrap around e passar na comparação

Referência rápida: regras Poly* do EMF para conversão de EMF para PDF

  • EMR_POLYBEZIER: o ponto 0 é o start point; agrupe a partir do ponto 1 em trios; corrigido no record de 32 bits na v3.539.41
  • EMR_POLYLINE / EMR_POLYPOLYLINE: figuras abertas, stroke com S, nunca h, f ou B, porque o fill do PDF fecha subpaths abertos
  • EMR_POLYLINETO: comece na posição atual, fique aberto, atualize a posição atual, nunca mexa no estado da caneta
  • EMR_POLYPOLYLINE de 32 bits: os pontos começam no byte 32 + nPolys * 4, não em aptl[0]
  • EMR_POLYDRAW: mascare PT_CLOSEFIGURE antes do dispatch, feche depois do segmento completo, comece na posição atual quando o primeiro ponto não for PT_MOVETO
  • O estado default do device context é BLACK_PEN mais WHITE_BRUSH; v3.539.43 e posteriores o respeitam
  • Dentro de BeginPath / EndPath, cada polyline abre o próprio subpath dela e nada é pintado até o bracket ser usado
  • Valide todo cptl / cpts contra o nSize em aritmética de 64 bits antes de copiar pontos
  • EMFs de teste construídos à mão precisam do header completo de 108 bytes, ou o TMetafile.LoadFromStream os lê como WMF

Se os seus relatórios passam por outro componente, a mesma semântica de records se aplica; o import vetorial de EMF e WMF do HotPDF cobre como esse componente transforma pincéis de gradiente e hatch em patterns de PDF, e vetores, shaders e gradientes no PDFlibPas cobre desenhar as mesmas formas diretamente pela API da biblioteca em vez de passar por um metafile

O PDFlibPas v3.539.43 ou posterior inclui todas as regras acima. Detalhes e downloads de teste estão na página de produto da biblioteca de PDF PDFlibPas para Delphi