Artículo técnico

Ancho de columna de Excel y Max Digit Width (MDW) en Delphi

El PDF exportado coloca cada límite de columna media letra a la izquierda de donde Excel lo dibuja, y cada celda con ajuste de línea ahora corta en otro punto. El ancho de columna de Excel no se mide en caracteres ni en puntos. Se mide en unidades de Max Digit Width (MDW) de la fuente Normal del libro, y HotXLS mide esa fuente con GDI antes de cada construcción de paginación. El modo de fallo es silencioso: nada lanza excepciones, los anchos guardados hacen round-trip byte a byte, y la geometría sigue desviada unos puntos porcentuales por columna hasta que la deriva acumulada empuja una tabla de una página a dos

¿En qué unidad se mide el ancho de columna de Excel?

Un ancho de columna en una hoja de cálculo es un conteo de caracteres de dígito de la fuente Normal del libro, no una medida absoluta. ECMA-376 §18.3.1.13 define el atributo width de <col> en términos del Maximum Digit Width de esa fuente a 96 dpi, y da la conversión de un ancho guardado de vuelta a píxeles como una expresión con truncamiento sobre el MDW. Para Calibri 11, que es lo que Excel entrega como estilo Normal, el MDW mide 7 píxeles. Pase el ancho por defecto de 8.43 unidades por la fórmula de la especificación con MDW 7 y obtiene exactamente 64 píxeles, que son 48 puntos a 96 dpi. Esos son los números que el propio Excel reporta, así que sirven como comprobación útil: si su conversión reproduce 8.43 unidades en 64 píxeles, la aritmética está bien y solo la entrada MDW puede seguir estando mal

const
  // Max digit width (MDW) de la fuente de cuerpo por defecto en píxeles a 96 dpi.
  // Calibri 11 mide 7 px, lo que reproduce los anchos de píxel exactos
  // que Excel guarda (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; // píxeles a 96 dpi -> puntos
  if Result < MinimumColumnWidth then
    Result := MinimumColumnWidth;
end;

HotXLS mantiene esa aritmética en exactamente una función, en la unidad lxPagination, así que hay un único lugar donde la regla puede estar mal. El + 5 es el relleno que Excel añade por líneas de cuadrícula y márgenes de celda, el * 0.75 convierte píxeles a 96 dpi en puntos PostScript, y el piso en MinimumColumnWidth existe para que una columna patológicamente estrecha aún deje una franja donde el renderizador pueda dibujar un borde. El punto de entrada público ColumnWidthToPoints conserva su vieja firma de un argumento y reenvía un MDW medido a esta función, que es lo que permitió aterrizar el cambio de comportamiento sin tocar ni un solo sitio de llamada

La cadena de conversión de ancho de columna de HotXLS en Delphi, alimentando la fórmula de la especificación con el Max Digit Width medido de la fuente Normal del libro, de modo que un ancho guardado de 8.43 unidades se vuelve 64 píxeles y luego 48 puntos
El ancho guardado es un conteo de dígitos, así que el MDW medido de la fuente Normal es una entrada de la fórmula y no un detalle de estilo, y el viaje de ida y vuelta de 8.43 a 64 a 48 comprueba la aritmética

Por qué una fuente Normal que no es Calibri mueve cada límite

La deriva es multiplicativa, por eso se lee como un bug de renderizado y no como un bug de unidades. El MDW es un factor sobre el ancho, no un offset. Empuje el MDW de 7 a 8 y la columna por defecto de 8.43 unidades pasa de 64 píxeles a 72, un salto de 8 píxeles u 6 puntos en una sola columna. Diez columnas de eso y el borde derecho de la tabla se movió casi una pulgada. Los libros que tropiezan con esto son completamente ordinarios: cualquier cosa generada por una herramienta de reportes que estampa Arial o Segoe UI en el estilo Normal, cualquier cosa guardada desde una plantilla de exportación de ERP, cualquier cosa que un cliente re-estilizó una vez y olvidó

Dos sistemas de maquetación relacionados heredan el error en lugar de causarlo. Las regiones combinadas suman los anchos en puntos de sus columnas miembro, así que una combinación que cabía en una página en Excel puede desbordarse tras la deriva del MDW, algo que vale la pena recordar cuando construye plantillas de reportes con celdas combinadas. El ajuste por reducción compara el ancho de texto medido contra el mismo ancho de columna, así que un MDW equivocado también cambia qué celdas se reducen y cuánto. La misma familia de confusión de unidades aparece en los anclajes de dibujo, donde la geometría de imágenes y el escalado EMU tienen su propia cadena de conversión que equivocar

Dos reglas de columna de HotXLS comparadas, una medida con un MDW de 7 píxeles y otra con 8, mostrando cómo el salto por columna de 64 a 72 píxeles se acumula a lo largo de diez columnas mientras las regiones combinadas y el ajuste por reducción heredan el error
Como el MDW multiplica en lugar de desplazar, una sola medición equivocada mueve cada límite de columna, y las regiones combinadas y el ajuste por reducción heredan la deriva sin que nada lance excepciones

Cómo mide HotXLS el MDW en tiempo de ejecución

HotXLS resuelve el MDW desde el propio libro en lugar de asumir una constante, y dos procedimientos hacen el trabajo. PaginationApplyNormalFont lee la fuente del estilo Normal del libro y corre al inicio de la construcción de paginación, antes de calcular cualquier geometría de columna; primero se restablece a Calibri 11, así que un libro sin tabla de fuentes no puede heredar estado añejo de una construcción anterior. La fuente del estilo Normal es fonts[0] en styles.xml, expuesta por el componente como Workbook.Fonts[0]

// Lee fonts[0] (la fuente del estilo Normal) del libro de la hoja de cálculo.
// Las hojas clásicas sin tabla de fuentes conservan el default 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;

El segundo procedimiento, PaginationMeasureMdW, le pide a GDI la extensión del carácter único '0' mediante GetTextExtentPoint32W sobre un canvas de mapa de bits compartido fuera de pantalla, recae en tmAveCharWidth de GetTextMetricsW cuando la llamada de extensión falla, y recae en DefaultMDW cuando no hay nada disponible. Su caché es una sola ranura con clave (name, size), lo que suena tosco hasta que mira el patrón de acceso: una construcción de paginación pide la misma fuente Normal en cada columna de cada página, así que una ranura tiene una tasa de aciertos casi perfecta y cuesta tres comparaciones por llamada

¿Qué pasa sin tabla de fuentes, sin GUI o con una fuente faltante?

HotXLS decae a la constante Calibri 11 en todos los casos en que la fuente Normal real no puede determinarse, y lo hace en silencio por diseño. Las hojas BIFF clásicas son el caso común: los formatos antiguos no llevan un pool de fuentes XLSX al que fonts[0] pueda referirse, así que el guard de tipo sale temprano y el MDW por defecto de 7 se mantiene. Eso no es un arreglo, es el comportamiento anterior preservado deliberadamente, de modo que añadir medición a la ruta XLSX no pudiera regresar la salida del formato clásico

La dependencia de GDI es la advertencia honesta. La medición corre contra un contexto de dispositivo de Windows, así que la ruta asume un equipo Windows con la fuente instalada. En un servicio o un agente de build sin interfaz, las métricas de texto de GDI generalmente aún se resuelven, pero una fuente que no está instalada en esa máquina es sustituida por el font mapper y usted mide el sustituto. Nunca falla estridentemente; devuelve un número plausible para el tipo de letra equivocado. Si las exportaciones del lado servidor deben coincidir con una referencia de escritorio, instale en el equipo de exportación las fuentes que nombran sus plantillas, o fije la fuente Normal antes de invocar la ruta de exportación PDF de hojas de cálculo

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

    // Fije la fuente Normal para que el MDW medido en este equipo sea el
    // contra el que se diseñó la maquetación, no un sustituto del 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;

Cachés de medición, y la que crasheó en Win64

Una vez que la medición de texto es un viaje de ida y vuelta a GDI en lugar de una multiplicación, tiene que cachearse, y cachear dentro de una pasada de render es donde este trabajo sacó sangre. El ciclo de ajuste por reducción baja el tamaño de fuente en incrementos de 0.5 pt y vuelve a medir tras cada paso, así que una celda puede llamar PaginationMeasureTextWidth una docena de veces con la misma cadena, y el ajuste de línea la llama de nuevo por cada línea candidata. Un memo con clave de nombre de fuente, tamaño y texto colapsa eso en una llamada GDI por cadena distinta, almacenado en un TStringList como pares nombre/valor

La otra caché añadida junto a ella no fue tan ordenada. La pasada de render 5 resuelve el pool de fuentes por celda mediante FontIndex, y su memo usaba arreglos dinámicos paralelos con un FontMemoCount mantenido a mano. La primera versión olvidó llamar ResetFontMemo al inicio de cada página, así que el conteo siguió subiendo entre páginas mientras los arreglos no, y el código escribió más allá del final de todos ellos. En Win32 eso garabateó silenciosamente en el heap contiguo y terminó; en Win64 lanzó una violación de acceso al escribir en 0x538 de inmediato. La lección generalizable: una caché respaldada por arreglos y guardada en una variable a nivel de unidad debe restablecerse en la entrada de cada pasada que la use, porque una lista de cadenas o un diccionario perdona un restablecimiento faltante creciendo, y los arreglos paralelos no

Cómo HotXLS resuelve la fuente Normal del libro, mide su Max Digit Width a través de GDI con dos repliegues y cachea el resultado, junto a los dos memos de pasadas de render y la regla de restablecimiento que una caché de arreglos paralelos necesita
El MDW se resuelve desde el libro y se mide con GDI una vez por fuente, luego se cachea por clave, mientras los memos de pasadas de render muestran por qué una caché de arreglos paralelos debe restablecerse en la entrada de cada pasada

Comprobar su propia conversión

Usted no necesita el componente para verificar nada de esto. Tome un libro cuya fuente Normal no sea Calibri 11, lea un ancho de <col width="..."/>, y páselo dos veces por la fórmula de la especificación, una con MDW 7 y otra con el MDW que su renderizador realmente mide para esa fuente; si las respuestas difieren y su salida coincide con la primera, encontró la deriva. La geometría de columnas es una de esas partes de un motor de hojas de cálculo que o es invisible o es lo único que todos notan, y acertar significa tratar la fuente Normal como una entrada de la maquetación y no como un detalle de estilo. Si construye aplicaciones Delphi o C++Builder que leen, escriben, renderizan e imprimen libros de Excel sin Office instalado, el componente Excel HotXLS para Delphi maneja la medición del MDW, el modelo de paginación y la pipeline PDF detrás de un solo conjunto de clases VCL