2

In Delphi 10.4, I have sucessfully saved a valid TPicture base64-encoded to an INI file, using this code:

procedure TForm1.SavePictureToIniFile(const APicture: TPicture);
// https://stackoverflow.com/questions/63216011/tinifile-writebinarystream-creates-exception
var
  LInput: TMemoryStream;
  MyIni: TMemIniFile;
  Base64Enc: TBase64Encoding;
  ThisFile: string;
begin
  if FileSaveDialog1.Execute then
    ThisFile := FileSaveDialog1.FileName
  else EXIT;

  //CodeSite.Send('TForm1.btnSaveToIniClick: VOR Speichern');
  LInput := TMemoryStream.Create;
  try
    APicture.SaveToStream(LInput);
    LInput.Position := 0;
    MyIni := TMemIniFile.Create(ThisFile);
    try
      Base64Enc := TBase64Encoding.Create(Integer.MaxValue, '');
      try
        MyIni.WriteString('Custom', 'IMG', Base64Enc.EncodeBytesToString(LInput.Memory, LInput.Size));
      finally
        Base64Enc.Free;
      end;
      MyIni.UpdateFile;
    finally
      MyIni.Free;
    end;
  finally
    LInput.Free;
  end;
  //CodeSite.Send('TForm1.btnSaveToIniClick: NACH Speichern'); // 0,024 Sek.
end;

Now I want to REVERSE this process, i.e. load the data back from the INI file to a TPicture:

procedure TForm1.btnLoadFromIniClick(Sender: TObject);
var
  LInput: TMemoryStream;
  LOutput: TMemoryStream;
  ThisFile: string;
  MyIni: TMemIniFile;
  Base64Enc: TBase64Encoding;
  ThisEncodedString: string;
  ThisPicture: TPicture;
begin
  if FileOpenDialog1.Execute then
    ThisFile := FileOpenDialog1.FileName
  else EXIT;

  MyIni := TMemIniFile.Create(ThisFile);
  try
    Base64Enc := TBase64Encoding.Create(Integer.MaxValue, '');
    try
      (*ThisEncodedString := MyIni.ReadString('Custom', 'IMG', '');
      Base64Enc.Decode(ThisEncodedString); // And now???*)
      LInput := TMemoryStream.Create;
      LOutput := TMemoryStream.Create;
      try
        MyIni.ReadBinaryStream('Custom', 'IMG', LInput);
        MyIni.UpdateFile;
        LInput.Position := 0;
        Base64Enc.Decode(LInput, LOutput);
        LOutput.Position := 0;

        ThisPicture := TPicture.Create;
        try
          ThisPicture.LoadFromStream(LOutput);
          CodeSite.Send('TForm1.btnLoadFromIniClick: ThisPicture', ThisPicture); // AV!
        finally
          ThisPicture.Free;
        end;
      finally
        LOutput.Free;
        LInput.Free;
      end;
    finally
      Base64Enc.Free;
    end;        
  finally
    MyIni.Free;
  end;
end;

But when sending the Picture with CodeSite.Send creates an AV! (Sending a TPicture with CodeSite.Send usually DOES work, in this case, the AV obviously means the Picture is corrupted).

So how can I load the data back from the INI file to a TPicture?

user1580348
  • 5,721
  • 4
  • 43
  • 105

1 Answers1

2

This is essentially the same problem as in the original question.

The INI file's data is a Base64 representation of the binary image, that is, a string. So you need to read this Base64 string and convert it to a binary blob using Base64Enc.

But your code uses the ReadBinaryStream method, which treats the text not as a Base64 string but as a hexadecimal byte sequence and returns it as a binary blob, and then you give it to Base64Enc.

Do this instead:

var
  ImgData: TBytes;
begin
  MyIni := TMemIniFile.Create('D:\img.ini');
  try
    Base64Enc := TBase64Encoding.Create(Integer.MaxValue, '');
    try
      LInput := TMemoryStream.Create;
      try
        ImgData := Base64Enc.DecodeStringToBytes(MyIni.ReadString('Custom', 'IMG', ''));
        LInput.WriteData(ImgData, Length(ImgData));
        LInput.Position := 0;
        ThisPicture := TPicture.Create;
        try
          ThisPicture.LoadFromStream(LInput);
          // Use ThisPicture
        finally
          ThisPicture.Free;
        end;
      finally
        LInput.Free;
      end;
    finally
      Base64Enc.Free;
    end;
  finally
    MyIni.Free;
  end;

One way you could have realised this is by thinking like this:

How do I encode? Well, I do

  1. Base64Enc.EncodeBytesToString
  2. MyIni.WriteString

So, to decode, I do the opposite procedures in the opposite order:

  1. MyIni.ReadString
  2. Base64Enc.DecodeStringToBytes

Getting rid of the unnecessary copy

In the comments, Remy Lebeau correctly points out that the code above performs an unnecessary in-memory copy of the binary image data. Although this is unlikely to be a problem (or even measurable!) in practice, given that we are reading the image from a Base64-encoded field in an INI file, it is nevertheless wasteful and ugly.

By replacing the TMemoryStream with a TBytesStream (a descendant of TMemoryStream), we can decode the Base64 data directly into the stream:

var
  ImgStream: TBytesStream;
begin
  MyIni := TMemIniFile.Create('D:\img.ini');
  try
    Base64Enc := TBase64Encoding.Create(Integer.MaxValue, '');
    try
      ImgStream := TBytesStream.Create(Base64Enc.DecodeStringToBytes(MyIni.ReadString('Custom', 'IMG', '')));
      try
        ThisPicture := TPicture.Create;
        try
          ThisPicture.LoadFromStream(ImgStream);
          // Use ThisPicture
        finally
          ThisPicture.Free;
        end;
      finally
        ImgStream.Free;
      end;
    finally
      Base64Enc.Free;
    end;
  finally
    MyIni.Free;
  end;
Andreas Rejbrand
  • 105,602
  • 8
  • 282
  • 384
  • Thank you! It works! Thanks also for delineating the correct thinking directions! But why in this case `MyIni.UpdateFile;` is not needed? Could it be that in some cases it fails because of the missing `MyIni.UpdateFile;`? – user1580348 Aug 03 '20 at 10:15
  • 1
    @user1580348: [`UpdateFile`](http://docwiki.embarcadero.com/Libraries/Sydney/en/System.IniFiles.TMemIniFile.UpdateFile) is used to save the in-memory INI file to disk when you are *writing* to an INI file. In this case, we are only *reading* from it. – Andreas Rejbrand Aug 03 '20 at 10:18
  • How to correctly detect whether `ImgData` is empty? (Which would occur in this case if the INI file contains no `Custom` nor `IMG` field). There is no `TBytes.IsEmpty` function. – user1580348 Aug 03 '20 at 10:39
  • 1
    `TBytes` is defined as `TArray`, which is the same thing as `array of Byte`. Hence, a `TBytes` variable is actually nothing but a [dynamic array](http://docwiki.embarcadero.com/RADStudio/en/Structured_Types_(Delphi)#Dynamic_Arrays) (of bytes)! Consequently, `Length(ImgData) = 0` [or](http://docwiki.embarcadero.com/RADStudio/en/Internal_Data_Formats_(Delphi)#Dynamic_Array_Types) `ImgData = nil` tests for "emptiness". – Andreas Rejbrand Aug 03 '20 at 10:44
  • 1
    If you decode to `TBytes`, you can use `TBytesStream` instead of `TMemoryStream` to avoid making a duplicate copy in memory. Otherwise, I would use `Decode()` instead of `DecodeStringToBytes()` to decode straight to the `TMemoryStream` – Remy Lebeau Aug 03 '20 at 17:08
  • 1
    @RemyLebeau is absolutely right. The above code performs an unnecessary copy of the entire image (which is very unlikely to be an issue in practice, though, considering we are reading the image from a Base64-encoded field in an INI file). Using a `TByteStream` and its `TBytes`-accepting constructor solves this. (The other approach forces us to create an additional stream object, a `TStringStream`, doesn't it?) – Andreas Rejbrand Aug 03 '20 at 17:38
  • 1
    @AndreasRejbrand yes, using `Decode()` would require an input `TStream` for the `string` data. In D2009+, `TStringStream` converts the input `string` to `TBytes` before saving it. So, you could avoid that by assigning the INI `string` to an `AnsiString` and then use `TCustomMemoryStream`. Or, you could convert the `string` to `TBytes` yourself with `TEncoding.GetBytes()` and then use `TBytesStream` (which is what `TStringStream` does). The point is to try to avoid having 2 copies of the base64 data in memory at the same time. – Remy Lebeau Aug 03 '20 at 17:48
  • Your comments on how to optimize the code are very valuable. Thank you for that! What I need to do now is some validation of the load-process. I've already checked the existence of the `Custom` section and the existence of the `IMG` key. Should I do the last validation at the `ImgStream := TBytesStream.Create` step or at the `ThisPicture.LoadFromStream` step? – user1580348 Aug 05 '20 at 15:49
  • To explain further what I mean: Is there a way to test whether `ImgStream` is a valid `TBytesStream` or whether a Picture object is a valid `TPicture`? These tests should fail if the `IMG` Key of the INI file would be e.g. `IMG=stackoverflow`. – user1580348 Aug 05 '20 at 16:09
  • 1
    @user1580348: The string in the INI file is a [Base64](https://en.wikipedia.org/wiki/Base64) string. Hence, to see if it is valid, it (basically) is sufficient to check that it only contains the 64 Base64 characters. For instance, `hNbrAbbN` is valid Base64, but `hNbrAbb!` isn't. I am a bit disappointed to see that `TBase64Encoding` doesn't raise an exception on invalid input. Turning to `ImgStream`, this is only a sequence of bytes. Every possible sequence of bytes is a valid sequence of bytes, so `ImgStream` is always valid. But is it a valid image file? That's a more involved question. – Andreas Rejbrand Aug 05 '20 at 16:21
  • First of all, there are different image file formats: BMP, PNG, GIF, JPG, ICO, etc. If you know that you will always use one of these, you could see if the binary data begins with a known [signature](https://en.wikipedia.org/wiki/List_of_file_signatures). I'd hope that `TPicture` raises an exception if it is asked to load an invalid image, but maybe it doesn't (haven't tried). – Andreas Rejbrand Aug 05 '20 at 16:24
  • (Well, just to clarify: the fact that the byte stream starts with a PNG signature, for instance, is a *necessary* condition for it to be a valid PNG image, but it is very far from sufficient, of course. But it is a very strong indication that it is supposed to be a PNG file.) – Andreas Rejbrand Aug 05 '20 at 16:36
  • When `IMG=abcdefghijklmnopqrstuvwxyz` then in `ThisPicture.LoadFromStream(ImgStream);` I get an exception `EInvalidGraphic` with message `Not supported stream format`. So could I use this as validation? – user1580348 Aug 05 '20 at 17:26
  • But when I have `IMG=?` then there is no exception and even `Assigned(ThisPicture.Graphic)` is TRUE! – user1580348 Aug 05 '20 at 17:36
  • 1
    Then you need special code to detect that. You can (1) verify if the INI string is empty, `MyIni.ReadString('Custom', 'IMG', '').IsEmpty` (in this case, you want to save the string to a local variable so you don't need to invoke `ReadString` twice which would not be [DRY](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself)). Or you could test (2) `Length(ImgBytes) = 0` or `ImgBytes = nil` or (3) see if `ImgStream.Size = 0` after decoding. But better detect the condition early. – Andreas Rejbrand Aug 05 '20 at 17:41
  • 1
    Ah, sorry, the question mark is part of the INI file (I thought you meant that the field was the empty string). Yes, as I pointed out, I am disappointed that the Base64 decoder doesn't raise an exception if the string contains invalid Base64 characters. In this case, I assume it creates an empty byte array, though, so you can still test the byte array for length 0 (`Length(ImgBytes) = 0` or `ImgBytes = nil`) or see if the stream is of size 0. Better to do the former (detect error as early as possible). – Andreas Rejbrand Aug 05 '20 at 17:45
  • I've also come up with this: When `IMG=?` then (and if the other validations don't fail) then `ThisPicture.Width=0` is the ultimate validation? (Tested). – user1580348 Aug 05 '20 at 17:53
  • I now use *3* validations: `MyIni.ReadString('Custom', 'IMG', '').IsEmpty` (with local variable as suggested by Andreas), `try ThisPicture.LoadFromStream(ImgStream); except ... end`, `ThisPicture.Width = 0`. It seems to work well. – user1580348 Aug 06 '20 at 09:29