Artículo técnico

Cursor de filas pull para XLS, XLSX, ODS y CSV en Delphi

HotXLS lee fuentes .xls, .xlsx, .xlsm, .ods, CSV y TSV a través de un único cursor de filas pull, TXLSRowCursor, cuyos FindFirst y FindNext avanzan una fila lógica a la vez mientras solo esa fila permanece en memoria. Una máquina de estados de seis valores separa before-first de EOF, cancelado y fallido, y el antiguo lector de callbacks es ahora un adaptador sobre el mismo cursor

El escenario les resultará familiar a quienes hayan publicado una función de importación. Llega un .xlsx de 200 MB, conectan un manejador OnCell y el primer requisito después de "leerlo" es "detenerse tras las primeras cien contabilizaciones revertidas". Ahora la forma del código trabaja contra ustedes: el bucle vive dentro de la biblioteca, el manejador debe activar un flag, cada callback posterior sigue disparándose hasta que el parser lo nota, y el estado acumulado — cuántas coincidencias hasta ahora, qué columna coincidió, qué hacer después — tiene que vivir en campos de una clase que existe solo para darle al callback un lugar donde sentarse. Nada de eso es un problema de parseo. Es un problema de flujo de control, y es justo el que un cursor pull elimina

Lo que un callback push realmente cuesta con 200 MB

Push invierte el control, y esa inversión es justo lo que un llamador que filtra o cruza fuentes no puede permitirse. Con una API de callbacks la biblioteca es dueña del bucle, así que el llamador no puede usar Break, no puede intercalar dos fuentes, no puede entregar el lector a una rutina que espera ser manejada, y no puede expresar "mirar la fila siguiente antes de decidir" sin buffering. El costo no está en el throughput — una vía de callbacks SAX bien escrita transmite sin problemas — está en que cada consumidor no trivial termina creando su propia pequeña máquina de estados para simular el bucle que no le dejaron escribir. Multiplique eso por cuatro formatos de archivo, cada uno con su propio punto de entrada de escaneo históricamente, y la semántica de filtrado, fórmulas y errores empieza a divergir entre ellos, que es precisamente la divergencia que HotXLS se propuso cerrar

¿Cómo cambia un cursor pull su código de llamada?

Les devuelve el bucle, y con él el flujo de control Pascal de siempre. TXLSRowCursor.Open acepta un nombre de archivo o un TStream, detecta el formato, carga las cadenas compartidas y los metadatos de estilo de fecha una sola vez, y selecciona la hoja 1. SelectSheet (1-based) o SelectSheetByName re-apunta a otra hoja de cálculo y reinicia el cursor a before-first. FindFirst y FindNext se posicionan entonces en la siguiente fila con datos — las filas sin celdas decodificables se saltan, así que RowIndex puede saltar — y la fila actual se expone como CellCount, Cells[] y ValueByCol[], todos 1-based en el eje de columnas. Salir del bucle es un Break

var
  Cursor: TXLSRowCursor;
  Hits: Integer;
begin
  Cursor := TXLSRowCursor.Create;
  try
    Cursor.FirstRow := 2;        // salta la banda de encabezado
    Cursor.IncludeColumn(1);     // decodifica solo estas dos columnas
    Cursor.IncludeColumn(7);
    if not Cursor.Open('postings-200mb.xlsx') then
      Exit;
    if not Cursor.SelectSheetByName('Ledger') then
      Exit;

    Hits := 0;
    if Cursor.FindFirst then
      repeat
        if VarToStr(Cursor.ValueByCol[7]) = 'REVERSED' then
        begin
          Inc(Hits);
          if Hits = 100 then
            Break;               // Break normal; sin flag de aborto, sin centinela
        end;
      until not Cursor.FindNext;
  finally
    Cursor.Free;                 // el destructor termina la pasada
  end;
end;

La proyección y el rango se fijan antes de la pasada, no se filtran después. FirstRow, LastRow, IncludeColumn, ClearColumnProjection, IncludeFormulaText, DetectDates y DetectTextTypes se respetan dentro de los backends, de modo que una columna no seleccionada nunca asigna su valor, su cadena de fórmula ni su payload de texto enriquecido en primer lugar — la suite de regresión lo demuestra con fórmulas de 16 KiB y cadenas en caché que nunca se materializan cuando su columna no está proyectada. Esas opciones quedan deliberadamente congeladas mientras una pasada está activa y vuelven a ser escribibles al llegar a EOF, con SelectSheet o tras Close, de modo que un mismo escaneo jamás mezcle dos contratos de decodificación. Si solo necesitan el inventario de hojas y no las filas, la carga de solo metadatos y hojas selectivas es el punto de entrada más barato

Un backend por formato, un bucle de escaneo cada uno

Cada formato tiene exactamente un escáner forward dentro de HotXLS, y tanto el cursor pull como el lector de callbacks manejan ese mismo escáner. TXLSXForwardRowBackend es la única máquina de estados SAX de hoja de cálculo para las partes de hoja de ECMA-376 Part 1 §18.3, sostiene el lector XML, la tabla de fórmulas compartidas y el parser de texto enriquecido, y avanza exactamente a un límite físico de <row> por llamada. TXLSBiffForwardParser es dueño de los globales, la selección de hoja y el avance de fila del flujo de registros [MS-XLS]; hacerlo pausable produjo la restricción más aguda de todo el diseño, porque una fórmula de cadena en caché es un registro Formula seguido inmediatamente de un registro String, así que un punto de suspensión por fila nunca debe caer entre ambos. TXLSForwardTextBackend mantiene un lector consciente del BOM, el delimitador activo y un registro lógico — CSV husmea coma, punto y coma, tabulación o barra vertical desde el primer registro ignorando los caracteres entre comillas, y los campos multilínea entre comillas se unen con #10 de modo que el número de fila siga registros lógicos y no saltos de línea físicos. TXLSForwardOdsBackend mantiene una única plantilla de fila física para las tablas de OpenDocument §9, trata table:number-rows-repeated como un conteo restante y no como una expansión, y avanza más allá de las celdas cubiertas sin emitir valores. El lector directo en streaming comparte el mismo cargador de cadenas compartidas y de estilo de fecha

El cursor de filas pull de HotXLS despachando a un escáner forward por formato, un backend SAX para XLSX, un parser de registros para BIFF, un backend de texto que husmea delimitadores y una plantilla de filas ODS, con el lector de callbacks configurado encima como adaptador
Cada formato tiene exactamente un escáner forward, y tanto el cursor pull como el lector de callbacks manejan ese mismo escáner, de modo que la semántica de filtrado y de errores no puede divergir

¿Por qué seis estados en vez de un flag Eof?

Porque un solo booleano vuelve indistinguibles cuatro situaciones distintas, y los llamadores se equivocan con todas. TXLSRowCursorState las nombra explícitamente

  • xrcsClosed — ninguna fuente está abierta
  • xrcsBeforeFirst — abierto o re-apuntado, aún no se leyó ninguna fila
  • xrcsActive — parado sobre una fila válida
  • xrcsEof — la hoja se consumió hasta el final
  • xrcsCancelled — el llamador detuvo la pasada deliberadamente
  • xrcsFaulted — la pasada falló y se lanzó la excepción original

Esa última distinción es la que importa en producción. Una parte de hoja faltante o un inicio de pasada fallido conserva su EReadError y mueve el cursor a xrcsFaulted; nunca se degrada a un False plano que el llamador leería como "esta hoja estaba vacía". Cancel es deliberadamente más estrecho que Close: cierra el backend de la hoja de cálculo actual y su subflujo inflate e invalida la fila actual, pero no libera el archivo ZIP ni el flujo de origen, y llamarlo dos veces es un no-op. Tras una cancelación se reanuda llamando SelectSheet explícitamente — el cursor no reiniciará una pasada por su cuenta en silencio. La propiedad del flujo sigue la misma regla defensiva: xsoBorrowed es el valor predeterminado y restaura la posición del flujo al cerrar, xsoOwned transfiere la propiedad solo después de que Open ya haya tenido éxito, así que una apertura fallida nunca libera un flujo que el llamador aún sostiene

Los seis estados del cursor de filas de HotXLS con las transiciones entre ellos, mostrando a Cancel moviendo una pasada activa a cancelada, un inicio de pasada fallido moviéndola a faulted, y cómo ambos se mantienen distintos del fin de hoja
Seis estados con nombre mantienen distinguibles una hoja vacía, una detención deliberada y una pasada fallida, lo que un booleano Eof único no puede hacer
var
  Cursor: TXLSRowCursor;
  Src: TFileStream;
begin
  Src := TFileStream.Create('quarter.ods', fmOpenRead or fmShareDenyWrite);
  try
    Cursor := TXLSRowCursor.Create;
    try
      // xsoBorrowed: el cursor nunca libera Src, y Close restaura
      // la posición que el flujo tenía cuando se llamó a Open
      if not Cursor.Open(Src, xffAuto, xsoBorrowed) then
        Exit;

      if Cursor.FindFirst then
        repeat
          if UserPressedStop then
          begin
            Cursor.Cancel;   // cierra el backend de la hoja de cálculo
            Break;           // y su subflujo inflate; idempotente
          end;
        until not Cursor.FindNext;

      case Cursor.State of
        xrcsEof:       Log('sheet consumed to the end');
        xrcsCancelled: Log('stopped by the operator');
        xrcsFaulted:   Log('pass failed; the EReadError was already raised');
      end;
    finally
      Cursor.Free;
    end;
  finally
    Src.Free;                // sigue siendo nuestra, sigue válida, posición restaurada
  end;
end;

Prestar la fila actual sin copiarla

IXLSRowCursorView entrega una fila a otra rutina sin duplicar el arreglo de celdas. La vista guarda un guard compartido que contiene el puntero del cursor más un contador de generación UInt64; avanzar, seleccionar una hoja, cancelar, cerrar y destruir el cursor incrementan esa generación, y la destrucción además limpia el dueño del guard. Así una vista obsoleta no puede leer memoria liberada: Valid es una sonda sin excepciones que pueden llamar en cualquier momento, mientras que todos los demás miembros validan primero y lanzan EXLSRowCursorViewInvalidated. Sean honestos con lo que es este contrato — es fail-fast de tiempo de vida, no una garantía de seguridad de hilos, y no autoriza a leer una fila desde un segundo hilo mientras el primero avanza el cursor

var
  View: IXLSRowCursorView;
  Cell: TXLSRowCursorCell;
  I: Integer;
begin
  if Cursor.FindFirst then
    repeat
      View := Cursor.CurrentRowView;      // prestada; no se copia el arreglo de celdas
      for I := 0 to View.CellCount - 1 do
      begin
        Cell := View.Cells[I];
        if Cell.HasFormula and not Cell.FormulaTextAvailable then
          UseCachedResult(Cell.Value)     // las lecturas forward BIFF conservan
        else if Cell.Kind = xdkEmpty then //   el resultado en caché, no los tokens
          UseStyleOnly(Cell.StyleIndex)   // Blank / MulBlank son celdas reales
        else
          UseValue(Cell.Col, Cell.Value);
      end;
    until not Cursor.FindNext;

  // La interfaz sobrevive al bucle, pero la fila detrás de ella no
  if not View.Valid then    // Valid nunca lanza; Cells[] ahora sí lanzaría
    View := nil;            // EXLSRowCursorViewInvalidated
end;

PeakRowBufferedBytes y lo que puede demostrar

PeakRowBufferedBytes existe para demostrar que la memoria sigue el ancho de la fila y no la cantidad de filas. Acumula los registros de celdas, los Variant, las cadenas de fórmula y los payloads de texto enriquecido de la fila de salida actual, y suma el conjunto de trabajo específico del formato — el registro lógico CSV, la plantilla de fila física ODS, el pico de registros BIFF o la celda XLSX cruda que se está decodificando. Léanlo junto con SheetPassesStarted, que cuenta cuántas pasadas de hoja de cálculo comenzaron realmente. Dos advertencias mantienen honesta esta métrica: la cifra es una estimación, no una contabilidad exacta del heap, y es monótona desde el Open más reciente, así que es un instrumento de depuración y regresión más que un medidor en vivo. Para una visión más amplia de adónde van el tiempo y los bytes en libros muy grandes, vean el rendimiento con libros grandes en Delphi

Una comparación de HotXLS que muestra una carga de hoja completa manteniendo todas las filas residentes frente al cursor pull que solo retiene la fila actual más un conjunto de trabajo del formato, que es lo que PeakRowBufferedBytes acumula y reporta
PeakRowBufferedBytes acumula la fila de salida actual más el conjunto de trabajo específico del formato, de modo que la memoria sigue el ancho de una fila y no cuántas filas tiene la hoja

El lector push se volvió un adaptador, y lo que el cursor no hará

TXLSForwardReader ya no lleva puntos de entrada de escaneo separados para XLSX, BIFF y texto. Configura un cursor, lo recorre y traduce la fila actual a los eventos OnSheet y OnCell, y por eso las dos fachadas ya no pueden divergir en filtrado, estado de fórmulas ni manejo de errores. Dos consecuencias conviene conocer antes de actualizar: el SheetIndex de los callbacks es ahora uniformemente 1-based en TXLSForwardReader (TXLSDirectReader conserva su contrato de eventos 0-based existente), y OnSheet se dispara antes de SelectSheet, así que fijar SkipSheet significa que la parte de la hoja de cálculo nunca se abre ni se descomprime. Los límites son igual de explícitos: el libro no debe modificarse mientras una pasada está activa, cancelar exige un reinicio explícito, y la vía forward BIFF nunca decompila los tokens de fórmula, de modo que las celdas de fórmula clásicas reportan HasFormula verdadero con FormulaTextAvailable falso y entregan el resultado en caché en lugar de inventar una cadena de fórmula vacía. El cursor de filas y su adaptador pasaron 1,298 verificaciones en Delphi Win32 y Win64 más el paquete estático Win64 de C++Builder 37.0

Si están ponderando un cursor pull frente al cargador que tienen ahora, la pregunta no es cuál parsea más rápido sino cuál les permite escribir la condición de salida que realmente necesitan. Los detalles completos del componente, las versiones de IDE soportadas y las licencias están en la página del componente de hoja de cálculo HotXLS para Delphi