Artigo Técnico

Largeura de coluna do Excel e MDW em Delphi com HotXLS

O PDF exportado coloca cada limite de coluna meio caractere à esquerda de onde o Excel o desenha, e cada célula com quebra automática agora quebra em um lugar diferente. A largura de coluna do Excel não é medida em caracteres nem em pontos. Ela é medida em unidades de Max Digit Width (MDW) da fonte Normal da pasta de trabalho, e o HotXLS mede essa fonte com GDI antes de cada construção de paginação. O modo de falha é silencioso: nada lança exceção, as larguras armazenadas fazem round-trip byte a byte, e a geometria continua deslocada por alguns por cento por coluna até que o desvio acumulado empurre uma tabela de uma página para duas

Em qual unidade a largura de coluna do Excel é medida?

A largura de coluna em uma planilha é uma contagem de caracteres de dígito da fonte Normal da pasta de trabalho, não uma medida absoluta. A ECMA-376 §18.3.1.13 define o atributo width de <col> em termos do Maximum Digit Width dessa fonte a 96 dpi, e dá a conversão de uma largura armazenada de volta para pixels como uma expressão de truncamento sobre o MDW. Para o Calibri 11, que é o que o Excel distribui como estilo Normal, o MDW mede 7 pixels. Passe a largura padrão de 8.43 unidades pela fórmula da especificação com MDW 7 e você obtém exatamente 64 pixels, que são 48 pontos a 96 dpi. Esses são os números que o próprio Excel relata, então eles servem como uma verificação útil: se sua conversão reproduz 8.43 unidades em 64 pixels, a aritmética está certa e só a entrada do MDW ainda pode estar errada

const
  // Maximum digit width (MDW) da fonte de corpo padrão, em pixels a 96 dpi.
  // Calibri 11 mede 7 px, o que reproduz as larguras exatas em pixels
  // que o Excel armazena (8.43 unidades -> 64 px -> 48 pt).
  DefaultMDW = 7;
  MinimumColumnWidth = 24.0;

function ColumnWidthToPointsMdW(Value: Double; MdW: Integer): Double;
var
  Pixels: Integer;
begin
  if Value <= 0 then
    Value := 8.43;
  if MdW <= 0 then
    MdW := DefaultMDW;
  Pixels := Trunc(((256 * Value + Trunc(128 / MdW)) / 256) * MdW) + 5;
  Result := Pixels * 0.75; // pixels de 96 dpi -> pontos
  if Result < MinimumColumnWidth then
    Result := MinimumColumnWidth;
end;

O HotXLS mantém essa aritmética em exatamente uma função, na unit lxPagination, então há um único lugar onde a régua pode estar errada. O + 5 é o padding que o Excel adiciona para linhas de grade e margens de célula, o * 0.75 converte pixels de 96 dpi para pontos PostScript, e o piso em MinimumColumnWidth existe para que uma coluna patologicamente estreita ainda deixe uma faixa em que o renderizador possa desenhar uma borda. O ponto de entrada público ColumnWidthToPoints mantém sua antiga assinatura de um argumento e encaminha um MDW medido para esta função, e foi isso que permitiu que a mudança de comportamento aterrissasse sem tocar em um único call site

A cadeia de conversão de largura de coluna do HotXLS em Delphi, alimentando o Max Digit Width medido da fonte Normal da pasta de trabalho na fórmula da especificação, de modo que uma largura armazenada de 8.43 unidades se torne 64 pixels e depois 48 pontos
A largura armazenada é uma contagem de dígitos, então o MDW medido da fonte Normal é uma entrada da fórmula e não um detalhe de estilo, e o round trip de 8.43 para 64 para 48 verifica a aritmética

Por que uma fonte Normal que não é Calibri move cada limite

O desvio é multiplicativo, e é por isso que ele parece um bug de renderização em vez de um bug de unidades. O MDW é um fator sobre a largura, não um deslocamento. Empurre o MDW de 7 para 8 e a coluna padrão de 8.43 unidades vai de 64 pixels para 72, um salto de 8 pixels ou 6 pontos em uma coluna. Dez colunas disso e a borda direita da tabela se moveu quase uma polegada. Pastas de trabalho que disparam isso são inteiramente comuns: qualquer coisa gerada por uma ferramenta de relatório que carimba Arial ou Segoe UI no estilo Normal, qualquer coisa salva de um template de exportação de ERP, qualquer coisa que um cliente reformulou uma vez e esqueceu

Dois sistemas de layout relacionados herdam o erro em vez de causá-lo. Regiões mescladas somam as larguras em pontos de suas colunas membro, então uma mesclagem que cabia em uma página no Excel pode estourar após o desvio do MDW, o que vale lembrar quando você constrói templates de relatório com células mescladas. Shrink-to-fit compara a largura de texto medida com a mesma largura de coluna, então o MDW errado também muda quais células encolhem e o quanto encolhem. A mesma família de confusão de unidades aparece em âncoras de desenho, onde geometria de imagem e escala EMU tem sua própria cadeia de conversão para errar

Duas réguas de coluna do HotXLS comparadas, uma medida com MDW de 7 pixels e outra com 8, mostrando como o salto por coluna de 64 para 72 pixels se acumula ao longo de dez colunas enquanto regiões mescladas e shrink-to-fit herdam o erro
Como o MDW multiplica em vez de deslocar, uma medição errada move cada limite de coluna, e regiões mescladas e shrink-to-fit herdam o desvio sem que nada lance exceção

Como o HotXLS mede o MDW em tempo de execução

O HotXLS resolve o MDW a partir da própria pasta de trabalho em vez de assumir uma constante, e dois procedures fazem o trabalho. PaginationApplyNormalFont lê a fonte do estilo Normal da pasta de trabalho e roda no topo da construção de paginação, antes de qualquer geometria de coluna ser computada; ele redefine para Calibri 11 primeiro, então uma pasta de trabalho sem tabela de fontes não pode herdar estado obsoleto de uma construção anterior. A fonte do estilo Normal é fonts[0] em styles.xml, exposta pelo componente como Workbook.Fonts[0]

// Lê fonts[0] (a fonte do estilo Normal) da pasta de trabalho da worksheet.
// Worksheets clássicas sem tabela de fontes mantêm o padrão Calibri 11.
procedure PaginationApplyNormalFont(Worksheet: TObject);
var
  Sh: TXLSXWorksheet;
  Fnt: TXLSXFont;
begin
  PaginationNormalFontName := 'Calibri';
  PaginationNormalFontSize := 11;
  if not (Worksheet is TXLSXWorksheet) then
    Exit;
  Sh := TXLSXWorksheet(Worksheet);
  if (Sh.Workbook = nil) or (Sh.Workbook.Fonts.Count < 1) then
    Exit;
  Fnt := Sh.Workbook.Fonts[0];
  if Fnt.Name <> '' then
    PaginationNormalFontName := Fnt.Name;
  if Fnt.Size > 0 then
    PaginationNormalFontSize := Fnt.Size;
end;

O segundo procedure, PaginationMeasureMdW, pede ao GDI a extensão do único caractere '0' por meio de GetTextExtentPoint32W em um canvas de bitmap off-screen compartilhado, faz fallback para tmAveCharWidth de GetTextMetricsW quando a chamada de extensão falha, e faz fallback para DefaultMDW quando nenhum dos dois está disponível. Seu cache é um slot único chaveado por (name, size), o que soa grosseiro até você olhar o padrão de acesso: uma construção de paginação pede a mesma fonte Normal em cada coluna de cada página, então um slot tem uma taxa de acerto quase perfeita e custa três comparações por chamada

O que acontece sem tabela de fontes, sem GUI ou com fonte ausente?

O HotXLS degrada para a constante Calibri 11 em todos os casos em que a fonte Normal real não pode ser determinada, e o faz silenciosamente por design. Worksheets BIFF clássicas são o caso comum: os formatos legados não carregam um pool de fontes XLSX para fonts[0] referenciar, então o type guard sai cedo e o MDW padrão de 7 permanece. Isso não é uma correção, é o comportamento anterior preservado deliberadamente, para que adicionar medição ao caminho XLSX não pudesse regredir a saída do formato clássico

A dependência de GDI é a ressalva honesta. A medição roda contra um device context do Windows, então o caminho assume um host Windows com a fonte instalada. Em um serviço ou build agent headless, as métricas de texto do GDI geralmente ainda resolvem, mas uma fonte que não está instalada naquela máquina é substituída pelo font mapper e você acaba medindo o substituto. Nunca falha ruidosamente; retorna um número plausível para o typeface errado. Se exportações do lado do servidor precisam corresponder a uma referência de desktop, instale no host de exportação as fontes que seus templates nomeiam, ou fixe a fonte Normal antes de invocar o caminho de exportação PDF de worksheet

var
  Book: TXLSXWorkbook;
  Exporter: TXLSPDFExport;
begin
  Book := TXLSXWorkbook.Create;
  Exporter := TXLSPDFExport.Create;
  try
    Book.Open('quarterly-report.xlsx');

    // Fixa a fonte Normal para que o MDW medido neste host seja aquele
    // contra o qual o layout foi projetado, não um substituto do font mapper.
    if Book.Fonts.Count > 0 then
    begin
      Book.Fonts[0].Name := 'Calibri';
      Book.Fonts[0].Size := 11;
    end;

    Exporter.UseWorksheetPageSetup := True;
    Exporter.SaveAsPDF(Book, 'quarterly-report.pdf');
  finally
    Exporter.Free;
    Book.Free;
  end;
end;

Caches de medição, e o que travou no Win64

Uma vez que a medição de texto é um round trip de GDI em vez de uma multiplicação, ela precisa ser cacheada, e cachear dentro de um render pass é onde este trabalho derramou sangue. O loop shrink-to-fit reduz o tamanho da fonte em incrementos de 0.5 pt e mede novamente após cada passo, então uma célula pode chamar PaginationMeasureTextWidth uma dúzia de vezes com a mesma string, e o word wrap a chama de novo por linha candidata. Um memo chaveado por nome de fonte, tamanho e texto colapsa isso para uma chamada GDI por string distinta, armazenado em uma TStringList como pares nome/valor

O outro cache adicionado junto não foi tão arrumado. O render pass 5 resolve o pool de fontes por célula por FontIndex, e seu memo usava dynamic arrays paralelos com um FontMemoCount mantido à mão. A primeira versão esqueceu de chamar ResetFontMemo no início de cada página, então a contagem continuou subindo entre páginas enquanto os arrays não subiam, e o código escreveu além do fim de todos eles. No Win32 isso rabiscava silenciosamente no heap adjacente e terminava; no Win64 levantou um access violation imediatamente, em uma escrita para 0x538. A lição generalizável: um cache suportado por array mantido em uma variável de nível de unit deve ser redefinido na entrada de cada pass que o usa, porque uma string list ou um dicionário perdoa uma redefinição ausente crescendo, e arrays paralelos não

Como o HotXLS resolve a fonte Normal da pasta de trabalho, mede seu Max Digit Width por meio do GDI com dois fallbacks e cacheia o resultado, ao lado dos dois memos de render pass e da regra de redefinição de que um cache de arrays paralelos precisa
O MDW é resolvido a partir da pasta de trabalho e medido com GDI uma vez por fonte, depois cacheado por chave, enquanto os memos de render pass mostram por que um cache de arrays paralelos precisa ser redefinido na entrada de cada pass

Verificando sua própria conversão

Você não precisa do componente para verificar nada disso. Pegue uma pasta de trabalho cuja fonte Normal não seja Calibri 11, leia uma largura de <col width="..."/>, e passe pela fórmula da especificação duas vezes, uma com MDW 7 e uma com o MDW que seu renderizador realmente mede para essa fonte; se as respostas diferirem e sua saída corresponder à primeira, você encontrou o desvio. A geometria de coluna é uma daquelas partes de um motor de planilha que ou é invisível ou é a única coisa que todos notam, e acertá-la significa tratar a fonte Normal como uma entrada do layout em vez de um detalhe de estilo. Se você constrói aplicações Delphi ou C++Builder que leem, escrevem, renderizam e imprimem pastas de trabalho do Excel sem o Office instalado, o componente Excel Delphi HotXLS cuida da medição do MDW, do modelo de paginação e do pipeline de PDF por trás de um único conjunto de classes VCL