Technical Article

Generating Excel Files in Delphi Without Office Automation

If a server's only job is to emit Excel files, it has no business running Excel. Installing Office on a build agent or a report service to drive it through COM automation is the wrong design, and it has been the wrong design for as long as the practice has existed. Microsoft says so itself, in guidance that has not softened in twenty years: Office is neither built nor licensed to be automated from an unattended, server-side process. The right answer is to write the BIFF and OOXML bytes directly, with no Excel in the picture at all. That is the entire premise of HotXLS, a native Object Pascal library that reads and writes the spreadsheet formats itself, so there is no desktop application to hang, leak, or pay per seat for

Why driving EXCEL.EXE from a service fails

COM automation remote-controls a desktop program, and a desktop program quietly assumes three things a Windows service cannot hand it: a loaded user profile, an interactive window station, and a human watching the screen. Strip those away and the failures arrive in a form no developer machine ever reproduces. A file-recovery prompt, an add-in error, or a license-activation dialog opens on a desktop nobody can see, and the automation call that triggered it never returns. The caller eventually times out and dies; the Excel instance frequently does not, surviving as an orphan that holds file locks and poisons the next run. Anyone who has watched eleven stray EXCEL.EXE processes pile up under a service account knows the rest of that story

The scaling story is no better even when nothing crashes. An Excel instance is a single-workbook pipeline, every property access pays the cost of cross-process COM marshaling, and the box running the code carries an Office license whose terms exclude this exact use. Most teams meet these limits one outage at a time, which is roughly how "retire the COM layer" ends up on a roadmap

Before that rewrite starts, settle one scope question, because it decides how much of the work is real. COM code almost never just sets cell values. It calls Workbook.SaveAs with format constants, forces recalculation, pushes print setup, sometimes reaches for the clipboard. Walk the old code and write down which of those behaviors actually ship in the output, since each lands in a different corner of a native library, and a couple of them (clipboard interop being the obvious one) have no server-side meaning and should be dropped rather than ported

Two native engines, two ownership models

HotXLS swaps the Excel process for two direct format implementations. A BIFF8 record-stream engine (TXLSWorkbook, unit lxHandle) handles .xls. An OOXML package writer (TXLSXWorkbook, unit lxHandleX) produces .xlsx that conforms to ECMA-376 / ISO/IEC 29500. There is nothing to register and nothing to install on the server, and you can keep as many workbooks open at once as memory allows

What trips people up early is that the two facades own their memory differently, and the difference is silent until it crashes:

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 on it; hold the same object in a plain object variable and free it yourself, and the refcount frees it a second time. The XLSX facade is an ordinary object that wants an ordinary try..finally. Cell addressing is 1-based on both sides, which is the one place the two agree. The sheet collections do not: Entries on the XLS side is 1-based, the XLSX Items indexer is 0-based, and that off-by-one compiles cleanly whichever way you get it wrong and only shows itself at runtime

Writing a workbook straight into an HTTP response

A server-side export usually has no reason to touch disk. Temp files demand a cleanup policy, collide under concurrent requests, and leave customer data sitting on volumes nobody thought to audit. Both facades take a TStream through their SaveAs overloads, so the workbook can go straight into the response:

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 rewind is the line that earns its comment. SaveAs(Stream) writes from the stream's current position and never seeks back to zero afterward. Forget Mem.Position := 0 and the client gets a zero-byte download, or Excel calls the file corrupt. This is the most common bug in web-facing workbook code, and the cruelest, because it sails past any unit test that only asserts the stream has a non-zero length

One workbook-building routine reaches every other delivery format without restructuring. SaveAsCSV answers the "just give me the raw data" request, SaveAsHTML handles "drop it into a portal page," SaveAsRTF feeds document pipelines, and SaveAsODS covers an OpenDocument mandate, all with both file and stream overloads. A single export routine plus a format parameter replaces what tended to be four separate COM macros. The HTML exporter's TXLSXHtmlExportOptions carries title, CSS class, and a fragment-or-full-document switch, which keeps the portal case out of the business of regex-editing exported markup

Formula values without an Excel process to compute them

Under COM automation Excel recalculated everything for free, and dropping COM quietly revokes that. SaveAs stores formulas as text without evaluating them; the numbers only appear once Excel opens the file and recalculates, behavior the XLS facade lets you tune through RecalcOnSave and CalculationMode. For a file headed to a person that is exactly right. It is wrong for a service that has to confirm a total before it ships, and wrong for CSV export, which writes the formula text rather than its result. Either case has to evaluate on the server 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');

The facade convention bites again here. The XLSX side assigns expressions through Cell.Formula with no equals sign; the XLS side writes them through Cell.Value with a leading '='. Carry code from one to the other unchanged and the wrong convention stores a text string that merely resembles a formula, with no error to flag it. When a workbook's formulas need to reach into your own business logic, the OnUserFunction callback lets the engine hand unknown function names off to Delphi code at evaluation time. That is the native stand-in for the UDF add-ins that tend to hide inside the very spreadsheets a COM-automation system grew up around

Deployment edges that only surface on the server

A few details decide whether the rollout is clean or puzzling, and the first is the unit graph. The drag-and-drop dataset exporter TDataToXLS pulls in VCL Forms, Controls, and Dialogs. Harmless in a desktop tool; in a console service it hauls the entire VCL along behind it. The core units lxHandle and lxHandleX reach only for Windows, Classes, SysUtils, and Variants, so a pure service is better off writing its own dataset loop against the core API than importing the component for convenience

Then there is threading. Workbook instances are not thread-safe, but they share no global state either, so the pattern that scales is the simplest one: a workbook object per job, or per worker thread. That buys parallel report generation, which a single shared Excel instance can never do. A request handler that creates, fills, saves, and frees its own workbook needs no locks at all, and the blast radius of a failure collapses from "the shared Excel instance is wedged for everyone" down to "this one request raised an exception," which your existing error handling already knows what to do with

Format targeting is the last of them. TXLSWorkbook.SaveAs writes BIFF (xlExcel97) by default, and pushing XLS content into .xlsx runs through the SaveXLSWorkbookAsXLSX bridge at reduced fidelity. Pick the facade by the format you mean to ship, at design time, rather than building in one and 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