{ fpspreadsheet.pas Writes an spreadsheet document AUTHORS: Felipe Monteiro de Carvalho } unit fpspreadsheet; {$ifdef fpc} {$mode delphi} {$endif} interface uses Classes, SysUtils; type TsSpreadsheetFormat = (sfExcel2, sfExcel3, sfExcel4, sfExcel5, sfExcel8, sfOOXML, sfOpenDocument, sfCSV); const { Default extensions } STR_EXCEL_EXTENSION = '.xls'; STR_OOXML_EXCEL_EXTENSION = '.xlsx'; STR_OPENDOCUMENT_CALC_EXTENSION = '.ods'; type {@@ Describes a formula Supported syntax: =A1+B1+C1/D2... - Array with simple mathematical operations =SUM(A1:D1) - SUM operation in a interval } TsFormula = record FormulaStr: string; DoubleValue: double; end; {@@ Expanded formula. Used by backend modules. Provides more information then the text only } TFEKind = (fekCell, fekAdd, fekSub, fekDiv, fekMul, fekOpSUM); TsFormulaElement = record ElementKind: TFEKind; Row1, Row2: Word; Col1, Col2: Byte; DoubleValue: double; end; TsExpandedFormula = array of TsFormulaElement; {@@ Describes the type of content of a cell on a TsWorksheet } TCellContentType = (cctEmpty, cctFormula, cctNumber, cctUTF8String); {@@ Cell structure for TsWorksheet } TCell = record Col: Byte; Row: Word; ContentType: TCellContentType; FormulaValue: TsFormula; NumberValue: double; UTF8StringValue: ansistring; end; PCell = ^TCell; type TsCustomSpreadReader = class; TsCustomSpreadWriter = class; { TsWorksheet } TsWorksheet = class private procedure RemoveCallback(data, arg: pointer); public FCells: TFPList; Name: string; { Base methods } constructor Create; destructor Destroy; override; { Data manipulation methods } function FindCell(ARow, ACol: Cardinal): PCell; function GetCell(ARow, ACol: Cardinal): PCell; function GetCellCount: Cardinal; function GetCellByIndex(AIndex: Cardinal): PCell; function GetLastColNumber: Cardinal; function GetLastRowNumber: Cardinal; function ReadAsUTF8Text(ARow, ACol: Cardinal): ansistring; procedure RemoveAllCells; procedure WriteUTF8Text(ARow, ACol: Cardinal; AText: ansistring); procedure WriteNumber(ARow, ACol: Cardinal; ANumber: double); procedure WriteFormula(ARow, ACol: Cardinal; AFormula: TsFormula); end; { TsWorkbook } TsWorkbook = class private { Internal data } FWorksheets: TFPList; { Internal methods } procedure RemoveCallback(data, arg: pointer); public { Base methods } constructor Create; destructor Destroy; override; function CreateSpreadReader(AFormat: TsSpreadsheetFormat): TsCustomSpreadReader; function CreateSpreadWriter(AFormat: TsSpreadsheetFormat): TsCustomSpreadWriter; procedure ReadFromFile(AFileName: string; AFormat: TsSpreadsheetFormat); procedure ReadFromStream(AStream: TStream; AFormat: TsSpreadsheetFormat); procedure WriteToFile(AFileName: string; AFormat: TsSpreadsheetFormat); procedure WriteToStream(AStream: TStream; AFormat: TsSpreadsheetFormat); { Worksheet list handling methods } function AddWorksheet(AName: string): TsWorksheet; function GetFirstWorksheet: TsWorksheet; function GetWorksheetByIndex(AIndex: Cardinal): TsWorksheet; function GetWorksheetCount: Cardinal; procedure RemoveAllWorksheets; end; {@@ TsSpreadReader class reference type } TsSpreadReaderClass = class of TsCustomSpreadReader; { TsCustomSpreadReader } TsCustomSpreadReader = class protected FWorkbook: TsWorkbook; FCurrentWorksheet: TsWorksheet; public { General writing methods } procedure ReadFromFile(AFileName: string; AData: TsWorkbook); virtual; procedure ReadFromStream(AStream: TStream; AData: TsWorkbook); virtual; { Record reading methods } procedure ReadFormula(AStream: TStream); virtual; abstract; procedure ReadLabel(AStream: TStream); virtual; abstract; procedure ReadNumber(AStream: TStream); virtual; abstract; end; {@@ TsSpreadWriter class reference type } TsSpreadWriterClass = class of TsCustomSpreadWriter; { TsCustomSpreadWriter } TsCustomSpreadWriter = class public { Helper routines } function ExpandFormula(AFormula: TsFormula): TsExpandedFormula; { General writing methods } procedure WriteCellCallback(data, arg: pointer); procedure WriteCellsToStream(AStream: TStream; ACells: TFPList); procedure WriteToFile(AFileName: string; AData: TsWorkbook); virtual; procedure WriteToStream(AStream: TStream; AData: TsWorkbook); virtual; { Record writing methods } procedure WriteFormula(AStream: TStream; const ARow, ACol: Word; const AFormula: TsFormula); virtual; abstract; procedure WriteLabel(AStream: TStream; const ARow, ACol: Word; const AValue: string); virtual; abstract; procedure WriteNumber(AStream: TStream; const ARow, ACol: Cardinal; const AValue: double); virtual; abstract; end; {@@ List of registered formats } TsSpreadFormatData = record ReaderClass: TsSpreadReaderClass; WriterClass: TsSpreadWriterClass; Format: TsSpreadsheetFormat; end; var GsSpreadFormats: array of TsSpreadFormatData; procedure RegisterSpreadFormat( AReaderClass: TsSpreadReaderClass; AWriterClass: TsSpreadWriterClass; AFormat: TsSpreadsheetFormat); implementation var { Translatable strings } lpUnsupportedReadFormat, lpUnsupportedWriteFormat: string; {@@ Registers a new reader/writer pair for a format } procedure RegisterSpreadFormat( AReaderClass: TsSpreadReaderClass; AWriterClass: TsSpreadWriterClass; AFormat: TsSpreadsheetFormat); var len: Integer; begin len := Length(GsSpreadFormats); SetLength(GsSpreadFormats, len + 1); GsSpreadFormats[len].ReaderClass := AReaderClass; GsSpreadFormats[len].WriterClass := AWriterClass; GsSpreadFormats[len].Format := AFormat; end; { TsWorksheet } {@@ Helper method for clearing the records in a spreadsheet. } procedure TsWorksheet.RemoveCallback(data, arg: pointer); begin FreeMem(data); end; {@@ Constructor. } constructor TsWorksheet.Create; begin inherited Create; FCells := TFPList.Create; end; {@@ Destructor. } destructor TsWorksheet.Destroy; begin RemoveAllCells; FCells.Free; inherited Destroy; end; {@@ Tryes to locate a Cell in the list of already written Cells @param ARow The row of the cell @param ACol The column of the cell @return Nil if no existing cell was found, otherwise a pointer to the desired Cell @see TCell } function TsWorksheet.FindCell(ARow, ACol: Cardinal): PCell; var i: Integer; ACell: PCell; begin i := 0; Result := nil; while (i < FCells.Count) do begin ACell := PCell(FCells.Items[i]); if (ACell^.Row = ARow) and (ACell^.Col = ACol) then begin Result := ACell; Exit; end; Inc(i); end; end; {@@ Obtains an allocated cell at the desired location. If the Cell already exists, a pointer to it will be returned. If not, then new memory for the cell will be allocated, a pointer to it will be returned and it will be added to the list of Cells. @param ARow The row of the cell @param ACol The column of the cell @return A pointer to the Cell on the desired location. @see TCell } function TsWorksheet.GetCell(ARow, ACol: Cardinal): PCell; begin Result := FindCell(ARow, ACol); if (Result = nil) then begin Result := GetMem(SizeOf(TCell)); FillChar(Result^, SizeOf(TCell), #0); Result^.Row := ARow; Result^.Col := ACol; FCells.Add(Result); end; end; {@@ Returns the number of cells in the worksheet with contents. This routine is used together with GetCellByIndex to iterate througth all cells in a worksheet efficiently. @return The number of cells with contents in the worksheet @see TCell @see GetCellByIndex } function TsWorksheet.GetCellCount: Cardinal; begin Result := FCells.Count; end; {@@ Obtains the cell with a specific index in the internal list of cells. The index goes from 0 to GetCellCount - 1. This routine is used together with GetCellCount to iterate througth all cells in a worksheet efficiently. @param AIndex The index of the cell to be obtained @return A pointer to the cell, or nil if it doesn't exist @see TCell @see GetCellCount } function TsWorksheet.GetCellByIndex(AIndex: Cardinal): PCell; begin if FCells.Count > AIndex then Result := PCell(FCells.Items[AIndex]) else Result := nil; end; {@@ Returns the number of the last column with a cell with contents. If no cells have contents, zero will be returned, which is also a valid value. Use GetCellCount to verify if there is at least one cell with contents in the worksheet. @see GetCellCount } function TsWorksheet.GetLastColNumber: Cardinal; var i: Integer; ACell: PCell; begin i := 0; Result := 0; while (i < FCells.Count) do begin ACell := PCell(FCells.Items[i]); if ACell^.Col > Result then Result := ACell^.Col; Inc(i); end; end; {@@ Returns the number of the last row with a cell with contents. If no cells have contents, zero will be returned, which is also a valid value. Use GetCellCount to verify if there is at least one cell with contents in the worksheet. @see GetCellCount } function TsWorksheet.GetLastRowNumber: Cardinal; var i: Integer; ACell: PCell; begin i := 0; Result := 0; while (i < FCells.Count) do begin ACell := PCell(FCells.Items[i]); if ACell^.Row > Result then Result := ACell^.Row; Inc(i); end; end; {@@ Reads the contents of a cell and returns an user readable text representing the contents of the cell. The resulting ansistring is UTF-8 encoded. @param ARow The row of the cell @param ACol The column of the cell @return The text representation of the cell } function TsWorksheet.ReadAsUTF8Text(ARow, ACol: Cardinal): ansistring; var ACell: PCell; begin ACell := FindCell(ARow, ACol); if ACell = nil then begin Result := ''; Exit; end; case ACell^.ContentType of //cctFormula cctNumber: Result := FloatToStr(ACell^.NumberValue); cctUTF8String: Result := UTF8ToAnsi(ACell^.UTF8StringValue); else Result := ''; end; end; {@@ Clears the list of Cells and releases their memory. } procedure TsWorksheet.RemoveAllCells; begin FCells.ForEachCall(RemoveCallback, nil); end; {@@ Writes UTF-8 encoded text to a determined cell. On formats that only support unicode the text will be converted to the unicode encoding that the format supports. @param ARow The row of the cell @param ACol The column of the cell @param AText The text to be written encoded with the system encoding } procedure TsWorksheet.WriteUTF8Text(ARow, ACol: Cardinal; AText: ansistring); var ACell: PCell; begin ACell := GetCell(ARow, ACol); ACell^.ContentType := cctUTF8String; ACell^.UTF8StringValue := AText; end; {@@ Writes a floating-point number to a determined cell @param ARow The row of the cell @param ACol The column of the cell @param ANumber The number to be written } procedure TsWorksheet.WriteNumber(ARow, ACol: Cardinal; ANumber: double); var ACell: PCell; begin ACell := GetCell(ARow, ACol); ACell^.ContentType := cctNumber; ACell^.NumberValue := ANumber; end; {@@ Writes a formula to a determined cell @param ARow The row of the cell @param ACol The column of the cell @param AFormula The formula to be written } procedure TsWorksheet.WriteFormula(ARow, ACol: Cardinal; AFormula: TsFormula); var ACell: PCell; begin ACell := GetCell(ARow, ACol); ACell^.ContentType := cctFormula; ACell^.FormulaValue := AFormula; end; { TsWorkbook } {@@ Helper method for clearing the spreadsheet list. } procedure TsWorkbook.RemoveCallback(data, arg: pointer); begin TsWorksheet(data).Free; end; {@@ Constructor. } constructor TsWorkbook.Create; begin inherited Create; FWorksheets := TFPList.Create; // In the future: add support for translations lpUnsupportedReadFormat := 'Tryed to read a spreadsheet using an unsupported format'; lpUnsupportedWriteFormat := 'Tryed to write a spreadsheet using an unsupported format'; end; {@@ Destructor. } destructor TsWorkbook.Destroy; begin RemoveAllWorksheets; FWorksheets.Free; inherited Destroy; end; {@@ Convenience method which creates the correct reader object for a given spreadsheet format. } function TsWorkbook.CreateSpreadReader(AFormat: TsSpreadsheetFormat): TsCustomSpreadReader; var i: Integer; begin Result := nil; for i := 0 to Length(GsSpreadFormats) - 1 do if GsSpreadFormats[i].Format = AFormat then begin Result := GsSpreadFormats[i].ReaderClass.Create; Break; end; if Result = nil then raise Exception.Create(lpUnsupportedReadFormat); end; {@@ Convenience method which creates the correct writer object for a given spreadsheet format. } function TsWorkbook.CreateSpreadWriter(AFormat: TsSpreadsheetFormat): TsCustomSpreadWriter; var i: Integer; begin Result := nil; for i := 0 to Length(GsSpreadFormats) - 1 do if GsSpreadFormats[i].Format = AFormat then begin Result := GsSpreadFormats[i].WriterClass.Create; Break; end; if Result = nil then raise Exception.Create(lpUnsupportedWriteFormat); end; {@@ Reads the document from a file. } procedure TsWorkbook.ReadFromFile(AFileName: string; AFormat: TsSpreadsheetFormat); var AReader: TsCustomSpreadReader; begin AReader := CreateSpreadReader(AFormat); try AReader.ReadFromFile(AFileName, Self); finally AReader.Free; end; end; {@@ Reads the document from a seekable stream. } procedure TsWorkbook.ReadFromStream(AStream: TStream; AFormat: TsSpreadsheetFormat); var AReader: TsCustomSpreadReader; begin AReader := CreateSpreadReader(AFormat); try AReader.ReadFromStream(AStream, Self); finally AReader.Free; end; end; {@@ Writes the document to a file. If the file doesn't exist, it will be created. } procedure TsWorkbook.WriteToFile(AFileName: string; AFormat: TsSpreadsheetFormat); var AWriter: TsCustomSpreadWriter; begin AWriter := CreateSpreadWriter(AFormat); try AWriter.WriteToFile(AFileName, Self); finally AWriter.Free; end; end; {@@ Writes the document to a stream } procedure TsWorkbook.WriteToStream(AStream: TStream; AFormat: TsSpreadsheetFormat); var AWriter: TsCustomSpreadWriter; begin AWriter := CreateSpreadWriter(AFormat); try AWriter.WriteToStream(AStream, Self); finally AWriter.Free; end; end; {@@ Adds a new worksheet to the workbook It is added to the end of the list of worksheets @param AName The name of the new worksheet @return The instace of the newly created worksheet @see TsWorkbook } function TsWorkbook.AddWorksheet(AName: string): TsWorksheet; begin Result := TsWorksheet.Create; Result.Name := AName; FWorksheets.Add(Pointer(Result)); end; {@@ Quick helper routine which returns the first worksheet @return A TsWorksheet instance if at least one is present. nil otherwise. @see TsWorkbook.GetWorksheetByIndex @see TsWorksheet } function TsWorkbook.GetFirstWorksheet: TsWorksheet; begin Result := TsWorksheet(FWorksheets.First); end; {@@ Gets the worksheet with a given index The index is zero-based, so the first worksheet added has index 0, the second 1, etc. @param AIndex The index of the worksheet (0-based) @return A TsWorksheet instance if one is present at that index. nil otherwise. @see TsWorkbook.GetFirstWorksheet @see TsWorksheet } function TsWorkbook.GetWorksheetByIndex(AIndex: Cardinal): TsWorksheet; begin if AIndex < FWorksheets.Count then Result := TsWorksheet(FWorksheets.Items[AIndex]) else Result := nil; end; {@@ The number of worksheets on the workbook @see TsWorksheet } function TsWorkbook.GetWorksheetCount: Cardinal; begin Result := FWorksheets.Count; end; {@@ Clears the list of Worksheets and releases their memory. } procedure TsWorkbook.RemoveAllWorksheets; begin FWorksheets.ForEachCall(RemoveCallback, nil); end; { TsCustomSpreadReader } {@@ Default file reading method. Opens the file and calls ReadFromStream @param AFileName The input file name. @param AData The Workbook to be filled with information from the file. @see TsWorkbook } procedure TsCustomSpreadReader.ReadFromFile(AFileName: string; AData: TsWorkbook); var InputFile: TFileStream; begin InputFile := TFileStream.Create(AFileName, fmOpenRead); try ReadFromStream(InputFile, AData); finally InputFile.Free; end; end; {@@ This routine should be overriden in descendent classes. } procedure TsCustomSpreadReader.ReadFromStream(AStream: TStream; AData: TsWorkbook); begin raise Exception.Create(lpUnsupportedReadFormat); end; { TsCustomSpreadWriter } {@@ Expands a formula, separating it in it's constituent parts, so that it is already partially parsed and it is easier to convert it into the format supported by the writer module } function TsCustomSpreadWriter.ExpandFormula(AFormula: TsFormula): TsExpandedFormula; var StrPos: Integer; ResPos: Integer; begin ResPos := -1; SetLength(Result, 0); // The formula needs to start with a = if AFormula.FormulaStr[1] <> '=' then raise Exception.Create('Formula doesn''t start with ='); StrPos := 2; while Length(AFormula.FormulaStr) <= StrPos do begin // Checks for cell with the format [Letter][Number] { if (AFormula.FormulaStr[StrPos] in [a..zA..Z]) and (AFormula.FormulaStr[StrPos + 1] in [0..9]) then begin Inc(ResPos); SetLength(Result, ResPos + 1); Result[ResPos].ElementKind := fekCell; // Result[ResPos].Col1 := fekCell; Result[ResPos].Row1 := AFormula.FormulaStr[StrPos + 1]; Inc(StrPos); end // Checks for arithmetical operations else} if AFormula.FormulaStr[StrPos] = '+' then begin Inc(ResPos); SetLength(Result, ResPos + 1); Result[ResPos].ElementKind := fekAdd; end; Inc(StrPos); end; end; {@@ Helper function for the spreadsheet writers. @see TsCustomSpreadWriter.WriteCellsToStream } procedure TsCustomSpreadWriter.WriteCellCallback(data, arg: pointer); var ACell: PCell; AStream: TStream; begin ACell := PCell(data); AStream := TStream(arg); case ACell.ContentType of cctFormula: WriteFormula(AStream, ACell^.Row, ACell^.Col, ACell^.FormulaValue); cctNumber: WriteNumber(AStream, ACell^.Row, ACell^.Col, ACell^.NumberValue); cctUTF8String: WriteLabel(AStream, ACell^.Row, ACell^.Col, ACell^.UTF8StringValue); end; end; {@@ Helper function for the spreadsheet writers. Iterates all cells on a list, calling the appropriate write method for them. @param AStream The output stream. @param ACells List of cells to be writeen } procedure TsCustomSpreadWriter.WriteCellsToStream(AStream: TStream; ACells: TFPList); begin ACells.ForEachCall(WriteCellCallback, Pointer(AStream)); end; {@@ Default file writting method. Opens the file and calls WriteToStream @param AFileName The output file name. If the file already exists it will be replaced. @param AData The Workbook to be saved. @see TsWorkbook } procedure TsCustomSpreadWriter.WriteToFile(AFileName: string; AData: TsWorkbook); var OutputFile: TFileStream; begin OutputFile := TFileStream.Create(AFileName, fmCreate or fmOpenWrite); try WriteToStream(OutputFile, AData); finally OutputFile.Free; end; end; {@@ This routine should be overriden in descendent classes. } procedure TsCustomSpreadWriter.WriteToStream(AStream: TStream; AData: TsWorkbook); begin raise Exception.Create(lpUnsupportedWriteFormat); end; finalization SetLength(GsSpreadFormats, 0); end.