Technical Article

Generating Excel Files in Delphi Without Office Automation

The pager goes off at 02:10. The nightly report job has stalled, and Task Manager on the server shows eleven EXCEL.EXE processes owned by the service account, each waiting on a dialog box that no one will ever click. Anyone who has driven Excel through COM automation from a server has lived some version of that night. Microsoft's own guidance has been blunt for two decades: Office is neither designed nor licensed for unattended, server-side automation. HotXLS exists for exactly this gap - it is a native Object Pascal library that reads and writes the spreadsheet file formats directly, so there is no Excel process to hang, leak, or license.

Why driving EXCEL.EXE from a service fails

COM automation remote-controls a desktop application, and a desktop application assumes things a Windows service cannot provide: a loaded user profile, an interactive window station, and a human watching the screen. Under a service account those assumptions break in ways that never show up on a developer machine. A file-recovery prompt, an add-in error, or a license-activation dialog appears on a desktop nobody can see, and the automation call simply never returns. When the calling process gives up and dies, the Excel instance often survives as an orphan, holding file locks that break the next run too.

Even when nothing crashes, the model scales badly. Each Excel instance is effectively a single-workbook pipeline, cross-process COM marshalling adds latency to every property access, and the machine running the code needs an Office license that its EULA says does not cover this use. Teams typically discover each of these constraints one outage at a time, which is why "replace the COM layer" eventually lands on someone's quarter plan.

The replacement has a hidden scope question worth settling early: COM code rarely just writes cells. It calls Workbook.SaveAs with format constants, triggers recalculation, applies print setup, and occasionally drives the clipboard. Inventory those behaviors before the rewrite, because each maps to a different part of a native library's API - and a few, like clipboard interop, have no server-side equivalent and should not be reproduced at all.

Two native engines, two ownership models

HotXLS replaces the Excel process with direct format implementations: a BIFF8 record-stream engine (TXLSWorkbook, unit lxHandle) for .xls, and an OOXML package writer (TXLSXWorkbook, unit lxHandleX) that produces .xlsx conforming to ECMA-376 / ISO/IEC 29500. Nothing to register, nothing to install on the server, and as many workbooks in flight as memory allows.

The first thing to internalize is that the two facades follow different lifetime rules, and confusing them is the classic first-week bug:

var
  Book: IXLSWorkbook;          // interface reference: released automatically
  Sheet: IXLSWorksheet;
  BookX: TXLSXWorkbook;        // plain object: you free it
  SheetX: TXLSXWorksheet;
begin
  // BIFF8 .xls output - no Free; the interface refcount owns it
  Book := TXLSWorkbook.Create;
  Sheet := Book.Sheets.Add;
  Sheet.Name := 'Report';
  Sheet.Cells.Item[1, 1].Value := 'Generated without Excel';
  Book.SaveAs('report.xls');

  // OOXML .xlsx output - explicit lifetime
  BookX := TXLSXWorkbook.Create;
  try
    SheetX := BookX.Sheets.Add('Report');
    SheetX.Cells[1, 1].Value := 'Generated without Excel';
    BookX.SaveAs('report.xlsx');
  finally
    BookX.Free;
  end;
end;

The XLS facade is reference-counted through the IXLSWorkbook interface: declare the variable as the interface type and never call Free, because holding it in an object variable and freeing manually sets up a double release. The XLSX facade is an ordinary object with an ordinary try..finally. Cell addressing is 1-based on both sides, but the sheet collections differ - Entries on the XLS side is 1-based while the XLSX Items indexer is 0-based, an off-by-one that compiles cleanly in either direction and only fails at runtime.

Writing a workbook straight into an HTTP response

Server-side exports usually should not touch disk at all. Temporary files need a cleanup policy, collide between concurrent requests, and leave customer data on volumes nobody audits. Both facades expose SaveAs overloads that take a TStream:

Mem := TMemoryStream.Create;
Book := TXLSXWorkbook.Create;
try
  Sheet := Book.Sheets.Add('Data');
  Sheet.Cells[1, 1].Value := 'Generated ' + DateTimeToStr(Now);
  Book.SaveAs(Mem);          // writes from the CURRENT stream position
  Mem.Position := 0;         // rewind before handing the stream over
  Response.ContentType :=
    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
  Response.ContentStream := Mem;   // the framework now owns Mem
finally
  Book.Free;
end;

The line that earns its comment is the rewind. SaveAs(Stream) writes from the stream's current position and does not seek back to zero when it finishes. Skip Mem.Position := 0 and the client receives a zero-byte download or Excel reports a corrupt file - by a wide margin the most common mistake in web-facing workbook code, and one that passes every unit test that only checks the stream size.

The same generation code fans out to other delivery formats without restructuring. SaveAsCSV and SaveAsHTML cover the "just give me the data" and "embed it in a portal page" requests that always follow a successful Excel export, SaveAsRTF feeds document pipelines, and SaveAsODS satisfies OpenDocument mandates - each with file and stream overloads. A service that exposes one workbook-building routine and a format parameter replaces what used to be four different COM macros, and the HTML exporter's TXLSXHtmlExportOptions (title, CSS class, fragment-versus-document output) keeps the portal case from devolving into string surgery on exported markup.

Formula values without an Excel process to compute them

With COM automation, Excel recalculated everything for free. A native writer changes the contract: SaveAs stores formulas without evaluating them, and the values appear when Excel opens the file and recalculates (the XLS facade exposes RecalcOnSave and CalculationMode to tune that behavior). That is fine for files headed to a human, but a service that must validate totals before delivery - or export to CSV, which emits formula text rather than results - has to evaluate server-side with the built-in engine:

SheetX.Cells[1, 1].Value := 1200;
SheetX.Cells[2, 1].Value := 950;
SheetX.Cells[3, 1].Formula := 'SUM(A1:A2)';   // XLSX facade: no '=' prefix
Total := BookX.Calculate('SUM(A1:A2)');       // evaluate on the server, now
if Total <> 2150 then
  raise Exception.Create('reconciliation failed before delivery');

Watch the facade convention here as well: the XLSX side assigns expressions through Cell.Formula without an equals sign, while the XLS side writes formulas through Cell.Value with a leading '='. Port code between the two and the wrong convention silently stores a text string that merely looks like a formula. When workbook formulas need to call into your own business logic, the OnUserFunction callback lets the calculation engine dispatch unknown function names to Delphi code at evaluation time - the closest native equivalent to the UDF add-ins that often lurk inside the spreadsheets a COM-automation system was built around.

Deployment edges that only surface on the server

Three details routinely separate a clean rollout from a confusing one. First, the unit graph: the drag-and-drop dataset exporter TDataToXLS uses VCL Forms, Controls, and Dialogs, which is harmless in a desktop tool but drags the VCL into a console service. The core units lxHandle and lxHandleX depend only on Windows, Classes, SysUtils, and Variants, so pure services should write their own dataset loop against the core API instead of pulling in the component.

Second, threading: workbook instances are not thread-safe, but there is no shared global state between instances either, so the scalable pattern is simply one workbook object per job or per worker thread - parallel report generation that COM automation could never offer. A request handler that creates, fills, saves, and frees its own workbook needs no locks at all, and the failure domain shrinks from "the shared Excel instance is wedged" to "this one request threw an exception," which is a failure your existing error handling already knows how to report.

Third, format targeting: TXLSWorkbook.SaveAs writes BIFF (xlExcel97) by default, and turning XLS content into .xlsx goes through the SaveXLSWorkbookAsXLSX bridge with reduced fidelity. Choose the facade by target format at design time rather than converting at the end of the pipeline.

For the data-loading half of a typical replacement project, the database-to-workbook export patterns cover both the component and the hand-written loop, and once row counts reach six figures the large-workbook performance techniques become the difference between minutes and seconds. Reports built from designer-maintained layouts are covered in the template report generation walkthrough.

HotXLS ships as Object Pascal source for Delphi and C++Builder; editions, licensing, and the full API reference are on the HotXLS Component product page.