unit rwMain;

{$I ..\..\..\fpExif.inc}

{$IFDEF FPC}
  {$MODE DELPHI}
{$ENDIF}

interface

uses
 {$IFDEF FPC}
  LazUtf8,
 {$ELSE}
  Windows, Messages, ImgList, jpeg,
  {$IFDEF UNICODE}
  System.ImageList,
  {$ENDIF}
 {$ENDIF}
  Classes, SysUtils, Forms, Controls, Graphics, Dialogs,
  StdCtrls, Buttons, ComCtrls, ExtCtrls, Variants,
  fpeGlobal, fpeTags, fpeMetadata;

type

  { TMainForm }

  TMainForm = class(TForm)
    BtnTest1: TSpeedButton;
    BtnTest2: TSpeedButton;
    CbTestfile: TComboBox;
    ImageList1: TImageList;
    Label1: TLabel;
    ListView: TListView;
    OpenDialog: TOpenDialog;
    Panel1: TPanel;
    Panel2: TPanel;
    ExifListView: TListView;
    ExifTabControl: TTabControl;
    BtnBrowse: TSpeedButton;
    Splitter1: TSplitter;
    procedure CbTestfileEditingDone(Sender: TObject);
    procedure FormCreate(Sender: TObject);
    procedure FormDestroy(Sender: TObject);
    procedure BtnTest1Click(Sender: TObject);
    procedure ExifTabControlChange(Sender: TObject);
    procedure BtnBrowseClick(Sender: TObject);
  private
    ImgInfo: TImgInfo;
    OutFile: String;
    procedure ExecTest(const AParamsFile: String);
    procedure ExifToListview(AImgInfo: TImgInfo; AListView: TListView);
    function ReadTagValue(ATagName: String; out ATag: TTag): String; overload;
    function ReadTagValue(ATagName: String): String; overload;
    function Success(ATag: TTag; ACurrValue, AExpectedValue: String): Boolean;
    procedure WriteTagValue(ATagName, ATagValue: String);

    procedure AddToHistory(AFilename: String);
    procedure ReadFromIni;
    procedure WriteToIni;

  public
    procedure BeforeRun;

  end;

var
  MainForm: TMainForm;

implementation

{$IFDEF FPC}
 {$R *.lfm}
{$ELSE}
 {$R *.dfm}
{$ENDIF}

uses
  StrUtils, Math, IniFiles,
  fpeUtils, fpeExifData;

const
  IMGINDEX_SUCCESS = 0;
  IMGINDEX_FAIL = 1;

  TESTCASES_DIR = 'common\';

type
  TStringArray = array of string;

function Split(s: String; AMinCount: Integer; Separator: Char = #9): TStringArray;
const
  BLOCK_SIZE = 20;
var
  i, j, n, L: Integer;
begin
  if s = '' then begin
    SetLength(Result, 0);
    exit;
  end;

  s := s + Separator;
  L := Length(s);
  SetLength(Result, BLOCK_SIZE);
  i := 1;
  j := 1;
  n := 0;
  while (i <= L) do begin
    if (s[i] = Separator) or (i = L)  then begin
      Result[n] := Copy(s, j, i-j);
      inc(n);
      if n mod BLOCK_SIZE = 0 then
        SetLength(Result, Length(Result) + BLOCK_SIZE);
      j := i+1;
    end;
    inc(i);
  end;
  while n < AMinCount do begin
    Result[n] := '';
    inc(n);
    if n mod BLOCK_SIZE = 0 then
      SetLength(Result, Length(Result) + BLOCK_SIZE);
  end;
  SetLength(Result, n);
end;

{ The date/time string is expected in the ISO format "yyyy-mm-dd hh:nn:ss" }
function ExtractDateTime(AValue: String): TDateTime;
var
  p: Integer;
  yr, mn, dy, h, m, s: Integer;
begin
  Result := 0;
  p := pos('-', AValue);
  if p = 0 then
    raise Exception.Create('ISO date/time format expected: "yyyy-mm-dd hh:nn:ss"');
  yr := StrToInt(copy(AValue, 1, p-1));
  Delete(AValue, 1, p);
  p := pos('-', AValue);
  if p = 0 then
    raise Exception.Create('ISO date/time format expected: "yyyy-mm-dd hh:nn:ss"');
  mn := StrToInt(copy(AValue, 1, p-1));
  Delete(AValue, 1, p);
  p := pos(' ', AValue);
  if p = 0 then begin
    dy := StrToInt(AValue);
    Result := EncodeDate(yr, mn, dy);
    exit;
  end;
  dy := StrToInt(copy(AValue, 1, p-1));
  Delete(AValue, 1, p);
  p := pos(':', AValue);
  if p = 0 then
    raise Exception.Create('ISO date/time format expected: "yyyy-mm-dd hh:nn:ss"');
  h := StrToInt(copy(AValue, 1, p-1));
  Delete(AValue, 1, p);
  p := pos(':', AValue);
  if p = 0 then begin
    m := StrToInt(AValue);
    s := 0;
  end else begin
    m := StrToInt(copy(AValue, 1, p-1));
    s := StrToInt(copy(AValue, p+1, MaxInt));
  end;
  Result := EncodeDate(yr, mn, dy) + EncodeTime(h, m, s, 0);
end;

function DecimalSep: Char;
begin
 {$IFDEF FPC}
   Result := FormatSettings.DecimalSeparator;
 {$ELSE}
  {$IFDEF VER150}  // Delphi 7
   Result := DecimalSeparator;
  {$ELSE}
   Result := FormatSettings.DecimalSeparator;
  {$ENDIF}
 {$ENDIF}
end;

function CleanFloatStr(AText: String): String;
var
  i: Integer;
begin
  Result := '';
  i := 1;
  while i <= Length(AText) do begin
    // case aperture value, e.g. "F/2.8"
    if (i < Length(AText)) and (AText[i] in ['f', 'F']) and (AText[i+1] = '/') then
      inc(i)
    else
    if AText[i] in ['0'..'9', '.', '/'] then
      Result := Result + AText[i]
    else if AText[i] = ',' then
      Result := Result + '.';
    inc(i);
  end;
end;


{ TMainForm }

procedure TMainForm.AddToHistory(AFileName: String);
var
  i: Integer;
begin
  if (AFileName = '') or (not FileExists(AFileName)) then
    exit;

  i := CbTestFile.Items.Indexof(AFileName);
  if i > -1 then
    CbTestfile.Items.Delete(i);
  CbTestFile.Items.Insert(0, AFileName);
  CbTestFile.ItemIndex := 0;
end;

procedure TMainForm.BeforeRun;
begin
  ReadFromIni;
end;

procedure TMainForm.FormCreate(Sender: TObject);
begin
  ImgInfo := TImgInfo.Create;
end;

procedure TMainForm.FormDestroy(Sender: TObject);
begin
  WriteToIni;
  ImgInfo.Free;
end;

procedure TMainForm.BtnTest1Click(Sender: TObject);
begin
  AddToHistory(CbTestFile.Text);
  if Sender = BtnTest1 then
    ExecTest(TESTCASES_DIR + 'testcases1.txt')
  else if Sender = BtnTest2 then
    ExecTest(TESTCASES_DIR + 'testcases2.txt')
  else
    raise Exception.Create('BtnTextClick: Unexpected Sender');
end;

procedure TMainForm.CbTestfileEditingDone(Sender: TObject);
begin
  AddToHistory(CbTestFile.Text);
end;

procedure TMainForm.ExecTest(const AParamsFile: String);
var
  testCases: TStringList;
  i, j, n, p: Integer;
  s: String;
  testdata: TStringArray;
  listitem: TListItem;
  lTag: TTag;
  tagName: String;
  currTagValue: String;
  newTagValue: String;
  newTagValues: TStringArray;
  jpeg: TJpegImage;
  {$IFDEF FPC}
  stream: TMemorystream;
  {$ELSE}
  stream: TMemoryStream;
  a: ansistring;
  {$ENDIF}
begin
  Listview.Items.Clear;

  if not FileExists(AParamsFile) then begin
    showMessage('Parameter file "' + AParamsFile + '" not found.');
    exit;
  end;
  if not FileExists(CbTestfile.Text) then begin
    ShowMessage('Test picture file "' + CbTestfile.Text + '" not found.');
    exit;
  end;

  // Read test parameters
  testCases := TStringList.Create;
  try

  {$IFDEF FPC}
    // The testcases text files are encoded in ANSI for Delphi7 compatibility
    // In Lazarus we must convert to UTF8 }
    testCases.LoadFromFile(AParamsFile);
    s := testCases.Text;
   {$IFDEF FPC3+}
    testCases.Text := WinCPToUTF8(s);
   {$ELSE}
    testCases.Text := AnsiToUTF8(s);
   {$ENDIF}
  {$ELSE}
    stream := TMemoryStream.Create;
    try
      stream.LoadFromFile(AParamsFile);
      SetLength(a, stream.Size);
      stream.Read(a[1], Length(a));
      testcases.Text := a;
    finally
      stream.Free;
    end;
  {$ENDIF}

    // Read EXIF tags from image file
    ImgInfo.LoadFromFile(CbTestfile.Text);
    if not ImgInfo.HasExif then
      ImgInfo.CreateExifData(false);

    OutFile := 'test-image.jpg';   // File name of the modified test image

    ListView.Items.BeginUpdate;
    try
      j := 0;
      n := testCases.Count;
      for i:=0 to n-1 do begin
        if (testCases[i] = ':quit') then
          break;

        if (testCases[i] = '') or (testCases[i][1] = ';') then
          Continue;

        // Extract test parameters
        testdata := Split(testCases[i], 2);
        tagName := testdata[0];
        newTagValue := testdata[1];
        newTagValues := Split(newTagValue, 2, '|');
        if Length(newTagValues) =0 then begin
          SetLength(newTagValues, 1);
          newTagValues[0] := '';
        end;

        // Add test to listview
        listitem := ListView.Items.Add;
        listItem.Caption := tagname;

        // Read current tag value
        currTagValue := ReadTagValue(tagName, lTag);
        listItem.SubItems.Add(currTagValue);
        listItem.Data := lTag;

        // Write new tag value into ExifObj
        WriteTagValue(tagName, newTagValues[0]);
        listItem.SubItems.Add(newTagValue);
      end;
    finally
      ListView.Items.EndUpdate;
    end;

    // Write new tags to file
    ImgInfo.SaveToFile(OutFile);

    // read back
    ImgInfo.LoadFromFile(OutFile);
    if not ImgInfo.HasExif then
      raise Exception.Create('No EXIF structure detected in "' + Outfile + '"');

    j := 0;
    for i:=0 to testCases.Count-1 do begin
      if (testcases[i] = ':quit') then
        break;
      if (testcases[i] = '') or (testcases[i][1] = ';') then
        Continue;
      testdata := Split(testCases[i], 2);
      tagname := testdata[0];
      newTagValue := testdata[1];
      currTagValue := ReadTagValue(tagname, lTag);
      listItem := ListView.Items[j];
      listItem.SubItems.Add(currTagValue);
      if Success(lTag, currTagValue, newTagValue) then
        listItem.ImageIndex := IMGINDEX_SUCCESS else
        listItem.ImageIndex := IMGINDEX_FAIL;
      inc(j);
    end;

    jpeg := TJpegImage.Create;
    try
      try
        jpeg.LoadFromFile(OutFile);
        listitem := ListView.Items.Add;
        listItem.Caption := 'Successfully loaded';
        listItem.ImageIndex := IMGINDEX_SUCCESS;
      except
        listitem := ListView.Items.Add;
        listItem.Caption := 'Loading failed.';
        listItem.ImageIndex := IMGINDEX_FAIL;
      end;
    finally
      jpeg.Free;
    end;

  finally
    testCases.Free;
  end;

  ExifTabControlChange(nil);
end;

procedure TMainForm.BtnBrowseClick(Sender: TObject);
var
  olddir: String;
begin
  olddir := GetCurrentDir;
  OpenDialog.FileName := '';
  if OpenDialog.Execute then
    AddToHistory(OpenDialog.Filename);
  SetCurrentDir(oldDir);
end;

function TMainForm.Success(ATag: TTag; ACurrValue, AExpectedValue: String): Boolean;
const
  relEPS = 1E-3;
var
  p: Integer;
  snum, sdenom: String;
  valexp, valcurr: Double;
  decode: Boolean;
  currVal, expVal: String;
begin
  Result := ACurrValue = AExpectedValue;
  if Result then
    exit;

  if (ACurrValue = '') or (AExpectedValue = '') then begin
    Result := false;
    exit;
  end;
                                       (*
  { Check for alternative expected value }
  p := pos('|', AExpectedValue);
  if p > 0 then begin
    expected2 := Copy(AExpectedValue, p+1, MaxInt);;
    expected1 := Copy(AExpectedValue, 1, p-1);
    Result := (ACurrValue = expected1);
    if Result then
      exit;
    Result := (ACurrValue = expected2);
    if Result then
      exit;
  end;    *)

  { Check for float values, e.g. 12.0 vs 12 }
  if (ATag is TFloatTag) then begin
    currVal := CleanFloatStr(ACurrValue);
    expVal := CleanFloatStr(AExpectedValue);

    Result := currVal = expval;
    if Result then
      exit;

    { Check for fractional result, e.g. exposure time }
    p := pos('/', currVal);
    if p > 0 then begin
      snum := Copy(currVal, 1, p-1);
      sdenom := Copy(currVal, p+1, MaxInt);
      valcurr := StrToInt(snum) / StrToInt(sdenom);
    end else
      valcurr := StrToFloat(currVal, fpExifFmtSettings);

    p := pos('/', expVal);
    if p > 0 then begin
      snum := Copy(expval, 1, p-1);
      sdenom := Copy(currval, p+1, MaxInt);
      valexp := StrToInt(snum) / StrToInt(sdenom);
    end else
      valexp := StrToFloat(expval, fpExifFmtSettings);

    Result := SameValue(valcurr, valexp, relEPS * valexp);
    if Result then
      exit;
  end;

  if (ATag is TIntegerTag) then begin
    decode := ATag.DecodeValue;
    ATag.DecodeValue := not decode;
    currVal := ATag.AsString;
    ATag.DecodeValue := decode;
    Result := (currVal = AExpectedValue);
    if Result then
      exit;
  end;
end;

procedure TMainForm.ExifToListview(AImgInfo: TImgInfo; AListView: TListView);
var
  i: Integer;
  lTag: TTag;
  item: TListItem;
begin
  AListview.Items.BeginUpdate;
  try
    AListview.Items.Clear;
    if not AImgInfo.HasExif then
      exit;
    for i:=0 to AImgInfo.ExifData.TagCount-1 do begin
      lTag := AImgInfo.ExifData.TagByIndex[i];
      if lTag = nil then
        Continue;
      item := AListView.Items.Add;
      with item do begin
        Caption := lTag.Description;
        SubItems.Add(lTag.AsString);
      end;
    end;
    AListView.AlphaSort;
  finally
    AListview.Items.EndUpdate;
  end;
end;

function TMainForm.ReadTagValue(ATagName: String): String;
var
  lTag: TTag;
begin
  Result := ReadTagValue(ATagName, lTag);
end;

function TMainForm.ReadTagValue(ATagName: String; out ATag: TTag): String;
begin
  if ATagName = 'Comment' then begin
    Result := ImgInfo.Comment;
    ATag := nil;
  end else
  begin
    ATag := ImgInfo.ExifData.FindTagByName(ATagName);
    if ATag = nil then
      Result := ''
    else
      Result := ATag.AsString;
  end;
end;

procedure TMainForm.ExifTabControlChange(Sender: TObject);
var
  data: TImgInfo;
begin
  data := TImgInfo.Create;
  try
    case ExifTabControl.TabIndex of
      0: data.LoadFromFile(CbTestfile.Text);
      1: data.LoadFromFile(OutFile);
    end;
    ExifToListView(data, ExifListView);
  finally
    data.Free;
  end;
end;

procedure TMainForm.WriteTagValue(ATagName, ATagValue: String);
var
  lTag: TTag;
begin
  if ATagName = 'Comment' then
    ImgInfo.Comment := ATagValue
  else begin
    lTag := ImgInfo.ExifData.TagByName[ATagName];
    if lTag = nil then
      lTag := ImgInfo.ExifData.AddTagByName(ATagName);
    lTag.AsString := ATagvalue;
  end;
end;

function CreateIni: TCustomIniFile;
begin
  Result := TMemIniFile.Create(ChangeFileExt(Application.ExeName, '.ini'));
end;

procedure TMainForm.ReadFromIni;
var
  ini: TCustomIniFile;
  list: TStrings;
  i: Integer;
  W, H, L, T: Integer;
  R: TRect;
begin
  ini := CreateIni;
  try
    list := TStringList.Create;
    try
      if WindowState = wsNormal then begin
        W := ini.ReadInteger('MainForm', 'Width', Width);
        H := ini.ReadInteger('MainForm', 'Height', Height);
        L := ini.ReadInteger('MainForm', 'Left', Left);
        T := ini.ReadInteger('MainForm', 'Top', Top);
        R := Screen.DesktopRect;
        if W > R.Right - R.Left then W := R.Right - R.Left;
        if L+W > R.Right then L := R.Right - W;
        if L < R.Left then L := R.Left;
        if H > R.Bottom - R.Top then H := R.Bottom - R.Top;
        if T+H > R.Bottom then T := R.Bottom - H;
        if T < R.Top then T := R.Top;
        SetBounds(L, T, W, H);
      end;

      ini.ReadSection('History', list);
      for i:=list.Count-1 downto 0 do  // count downward because AddToHistory adds to the beginning of the list
        AddToHistory(ini.ReadString('History', list[i], ''));
      CbTestFile.ItemIndex := 0;
    finally
      list.Free;
    end;
  finally
    ini.Free;
  end;
end;

procedure TMainForm.WriteToIni;
var
  ini: TCustomIniFile;
  i: Integer;
begin
  ini := CreateIni;
  try
    ini.WriteInteger('MainForm', 'Left', Left);
    ini.WriteInteger('MainForm', 'Top', Top);
    ini.WriteInteger('MainForm', 'Width', Width);
    ini.WriteInteger('MainForm', 'Height', Height);

    for i:=0 to CbTestFile.Items.Count-1 do
      if (CbTestFile.Items[i] <> '') and FileExists(CbTestFile.Items[i]) then
        ini.WriteString('History', 'Item'+IntToStr(i+1), CbTestFile.Items[i]);
    ini.UpdateFile;
  finally
    ini.Free;
  end;
end;

end.