Artículo técnico

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

El PDF exportado pone cada límite de columna medio carácter a la izquierda de donde Excel lo dibuja, y ahora cada celda con ajuste de línea corta en un sitio distinto. 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 una excepción, los anchos almacenados hacen ida y vuelta 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 recuento 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 ppp, y da la conversión de un ancho almacenado de vuelta a píxeles como una expresión de truncamiento sobre la MDW. Para Calibri 11, que es lo que Excel entrega como estilo Normal, la MDW mide 7 píxeles. Pasa el ancho por defecto de 8.43 unidades por la fórmula de la especificación con MDW 7 y obtienes exactamente 64 píxeles, que son 48 puntos a 96 ppp. Esos son los números que el propio Excel reporta, así que son una comprobación útil: si tu conversión reproduce 8.43 unidades a 64 píxeles, la aritmética está bien y solo la entrada de MDW puede seguir estando mal

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

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

La cadena de conversión de ancho de columna de HotXLS en Delphi, alimentando el Max Digit Width medido de la fuente Normal del libro en la fórmula de la especificación para que un ancho almacenado de 8.43 unidades se convierta en 64 píxeles y luego 48 puntos
El ancho almacenado es un recuento de dígitos, así que la MDW medida de la fuente Normal es una entrada de la fórmula y no un detalle de estilo, y la ida y vuelta 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, y por eso se lee como un bug de renderizado y no de unidades. La MDW es un factor sobre el ancho, no un desplazamiento. Sube la MDW de 7 a 8 y la columna por defecto de 8.43 unidades pasa de 64 a 72 píxeles, un salto de 8 píxeles o 6 puntos en una columna. Diez columnas de eso y el borde derecho de la tabla se ha movido casi una pulgada. Los libros que tropiezan con esto son completamente ordinarios: cualquier cosa generada por una herramienta de informes 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 de MDW, lo que conviene recordar cuando construyes plantillas de informes con celdas combinadas. Shrink-to-fit compara el ancho de texto medido contra el mismo ancho de columna, así que una MDW equivocada también cambia qué celdas se encogen y cuánto. La misma familia de confusión de unidades aparece en anclas de dibujo, donde la geometría de imágenes y el escalado EMU tiene su propia cadena de conversión que equivocar

Dos reglas de columna de HotXLS comparadas, una medida con una 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 shrink-to-fit heredan el error
Como la MDW multiplica en lugar de desplazar, una sola medición equivocada mueve cada límite de columna, y las regiones combinadas y shrink-to-fit heredan la deriva sin que nada lance una excepción

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

HotXLS resuelve la MDW desde el propio libro en lugar de asumir una constante, y dos procedures 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; se restablece a Calibri 11 primero, así que un libro sin tabla de fuentes no puede heredar estado rancio 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 workbook de la hoja.
// Las hojas clásicas sin tabla de fuentes conservan el valor por defecto 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 procedure, PaginationMeasureMdW, le pide a GDI la extensión del único carácter '0' a través de GetTextExtentPoint32W sobre un canvas de bitmap fuera de pantalla compartido, recurre a tmAveCharWidth de GetTextMetricsW cuando la llamada de extensión falla, y recurre a DefaultMDW cuando no hay nada disponible. Su caché es de una sola ranura con clave (nombre, tamaño), lo que suena tosco hasta que miras 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 ausente?

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 de cálculo BIFF clásicas son el caso común: los formatos heredados no llevan ninguna bolsa de fuentes XLSX a la que fonts[0] pueda referirse, así que la guarda de tipo sale temprano y la 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 provocar una regresión en la salida de 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 anfitrión Windows con la fuente instalada. En un servicio o un agente de compilación sin cabeza, las métricas de texto de GDI generalmente aún se resuelven, pero una fuente que no está instalada en esa máquina la sustituye el font mapper y mides el sustituto. Nunca falla a gritos; devuelve un número plausible para la tipografía equivocada. Si las exportaciones del lado servidor deben coincidir con una referencia de escritorio, instala en el host de exportación las fuentes que nombran tus plantillas, o fija la fuente Normal antes de invocar la ruta de exportación PDF de la hoja

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

    // Fija la fuente Normal para que la MDW medida en este host sea la
    // contra la 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 se estrelló 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, hay que meterla en caché, y hacer caché dentro de una pasada de render es donde este trabajo sacó sangre. El bucle shrink-to-fit reduce el tamaño de fuente en incrementos de 0.5 pt y vuelve a medir tras cada paso, así que una sola celda puede llamar a PaginationMeasureTextWidth una docena de veces con la misma cadena, y el ajuste de línea la llama otra vez por línea candidata. Una memo con clave por nombre de fuente, tamaño y texto colapsa eso a una llamada GDI por cadena distinta, almacenada en un TStringList como pares nombre/valor

La otra caché añadida junto a ella no estaba tan ordenada. La pasada de render 5 resuelve la bolsa de fuentes por celda mediante FontIndex, y su memo usaba arrays dinámicos paralelos con un FontMemoCount mantenido a mano. La primera versión olvidó llamar a ResetFontMemo al inicio de cada página, así que el conteo siguió subiendo entre páginas mientras los arrays no, y el código escribió más allá del final de todos ellos. En Win32 eso garabateó en silencio en el heap adyacente y terminó; en Win64 lanzó una access violation en una escritura a 0x538 inmediatamente. La lección generalizable: una caché respaldada por arrays mantenida 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 ausente creciendo, y los arrays paralelos no

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

Comprobar tu propia conversión

No necesitas el componente para verificar nada de esto. Toma un libro cuya fuente Normal no sea Calibri 11, lee un ancho de <col width="..."/> y pásalo por la fórmula de la especificación dos veces, una con MDW 7 y otra con la MDW que tu renderizador realmente mide para esa fuente; si las respuestas difieren y tu salida coincide con la primera, has encontrado la deriva. La geometría de columnas es una de esas partes de un motor de hoja de cálculo que o es invisible o es lo único que alguien nota, y acertarla significa tratar la fuente Normal como una entrada de la maquetación y no como un detalle de estilo. Si construyes 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 de MDW, el modelo de paginación y la tubería PDF detrás de un único conjunto de clases VCL