|
Introduction
Recently, I needed to display some JPG and PNG files. I had an old copy of
LeadTools and the open source libraries for both formats, but wanted my
executable to be as small as possible. So I decided to give GDI+ a try. I
quickly found GDI+ to be poorly designed and very quirky, but it worked well
for my purposes until I discovered, to my horror, that GDI+ cannot load JPG or
PNG images stored as resources!
Like, I'm sure, other developers facing this issue, I disbelieved the
documentation and tried Bitmap::FromResource to no avail. While
perusing the Bitmap methods available, I ran across Bitmap::FromStream.
After a bit of testing and several errors, due mostly to the horrible GDI+
documentation, I came up with working code. After a night of rest, I decided to
encapsulate the code in a simple class to ensure memory got freed. The result
were two classes: CGdiPlusBitmap and CGdiPlusBitmapResource.
The Gotcha
Before discussing the code itself, there is a caveat with GDI+ that must be
addressed. With JPG, some TIFF and other formats, the original image
information must be available at all times. In other words, if you open a
bitmap using Bitmap::FromFile, you cannot delete or otherwise
change that file while the image is open. This same restriction applies to CGdiPlusBitmapResource.
(My testing found that PNG and BMP files don't seem to follow this
generalization, though I don't know if this is standard behavior or just a
fluke with my file set.)
GDI+ Initialization
GDI+ needs to be initialized before any GDI+ calls are made. I suggest adding
the following data member to the class derived from CWinApp:
ULONG_PTR m_gdiplusToken;
In InitInstance(), add the following calls:
Gdiplus::GdiplusStartupInput gdiplusStartupInput;
Gdiplus::GdiplusStartup(&m_gdiplusToken, &gdiplusStartupInput, NULL);
In ExitInstance(), add the following:
Gdiplus::GdiplusShutdown(m_gdiplusToken);
The Class
I created two classes, with the base class being a very simple encapsulation of
Bitmap and the derived class encapsulating the global memory. I
suppose that if I ever had the patience and desire, I could extend that class,
but I have no need to do so. (If you're curious why I didn't simply derive from
the ATL class CImage, it's because the code was used in a
program that didn't use MFC or ATL. However, the code is so simple, it could
easily be modified to use CImage as the base class.)
I'm not going to bother going over the CGdiPlusBitmap class except
to say that it has a single, public, data member Bitmap* m_pBitmap.
(In the class I prefaced the GDI+ objects with the Gdiplus namespace
in case the developer doesn't want to declare using namespace Gdiplus;.)
The CGdiPlusBitmapResource class has several constructors and
several overloaded Load functions. The overloaded functions simply
allow lazy programmers, like myself, to not have to type MAKEINTRESOURCE.
The main Load function takes the resource name and type as strings
and is the key to the class. This code follows in its entirety:
inline
bool CGdiPlusBitmapResource::Load(LPCTSTR pName, LPCTSTR pType,
HMODULE hInst)
{
Empty();
HRSRC hResource = ::FindResource(hInst, pName, pType);
if (!hResource)
return false;
DWORD imageSize = ::SizeofResource(hInst, hResource);
if (!imageSize)
return false;
const void* pResourceData = ::LockResource(::LoadResource(hInst,
hResource));
if (!pResourceData)
return false;
m_hBuffer = ::GlobalAlloc(GMEM_MOVEABLE, imageSize);
if (m_hBuffer)
{
void* pBuffer = ::GlobalLock(m_hBuffer);
if (pBuffer)
{
CopyMemory(pBuffer, pResourceData, imageSize);
IStream* pStream = NULL;
if (::CreateStreamOnHGlobal(m_hBuffer, FALSE, &pStream) == S_OK)
{
m_pBitmap = Gdiplus::Bitmap::FromStream(pStream);
pStream->Release();
if (m_pBitmap)
{
if (m_pBitmap->GetLastStatus() == Gdiplus::Ok)
return true;
delete m_pBitmap;
m_pBitmap = NULL;
}
}
m_pBitmap = NULL;
::GlobalUnlock(m_hBuffer);
}
::GlobalFree(m_hBuffer);
m_hBuffer = NULL;
}
return false;
}
I find the code very self explanatory, though those that know the return value
of ::LoadResource is an HGLOBAL may find the apparent
double copy using CopyMemory confusing. In brief CreateStreamOnHGlobal
requires a HGLOBAL handle allocated by GlobalAlloc using
the GMEM_MOVEABLE flag.
The Demo
The demo is a Visual Studio .NET 2003 project with ANSI and UNICODE builds. It
allows you to load resampled JPG or PNG resources (For the curious, I took both
photographs in Oahu, Hawaii for, and while filming content of, a multimedia
product. One is of Laie Bay, the other is a sunset viewed from Waikiki.)
GDI+ Disclaimer
I am not a GDI+ expert, nor am I a big fan, even if I do find GDI+ occasionally
very useful. Please don't ask me questions about it.
Why not IPicture?
I've been asked why I didn't use IPicture. The answer is threefold;
first, IPicture does not support PNG images. Second, IPicture
is pretty much only an image loader, with little more capability than the
standard GDI bitmap calls. Third, IPicture decodes the image data
immediately. JPG and GIF images will use more memory than this class.
Updates
22 April 2004
CreateStreamOnHGlobal now uses FALSE for the second
argument since Bitmap requires that the memory be retained for at least JPG
images and I decided to err on the side of caution. Interestingly, my testing
showed this flag is often ignored, but I received reports that this wasn't
always the case and was technically incorrect.
In addition, while fixing this bug I realized I wasn't clearing up the global
memory on failure. That resulted in the code being rearranged as it is now.
15 June 2004
If Gdiplus::Bitmap::FromStream fails, I added what I believe is
redundant, but more correct code in handling m_pBitmap.
(Unfortunately, the documentation is silent on the subject as to whether NULL
will always be returned on failure.)
3 September 2004
Added a section on GDI+ initialization.
Fixed a potential memory leak on image load failure in the sample application.
| You must Sign In to use this message board. |
|
| | Msgs 1 to 25 of 89 (Total in Forum: 89) (Refresh) | FirstPrevNext |
|
 |
|
|
First of all thank you very much for this code... I am trying to change the functionality in order to do something when the button is double clicked and to move the button or the dialog that contains the button when the left button is released, do you think is possible? could you give me some clues? Thanks in advance
|
| Sign In·View Thread·PermaLink | |
|
|
|
 |
|
|
Hello, another more question, when the mouse is over the image the image is resized, it’s mean I see two images with different size, do you know which the problem could be? Thank you very much
|
| Sign In·View Thread·PermaLink | |
|
|
|
 |
|
|
 |
|
|
Instead of having to copy the resource data to global memory in order to use CreateStreamOnHGlobal, I wrote a simple COM class to allow the image to be loaded directly from the resource data.
//////////////////////////////////////////////////////////////////////////////// // class MEMORY_STREAM // //////////////////////////////////////////////////////////////////////////////// : IStream { ULONG m_ReferenceCount; const PUCHAR m_Memory; ULARGE_INTEGER m_Offset; ULARGE_INTEGER m_Size;
public: MEMORY_STREAM ( const void* Memory, DWORD Size ); // IUnknown STDMETHODIMP QueryInterface ( REFIID InterfaceId, void** Object ); STDMETHODIMP_(ULONG) AddRef(); STDMETHODIMP_(ULONG) Release();
// ISequentialStream STDMETHODIMP Read ( void* Buffer, ULONG ReadBytes, ULONG* BytesRead ); STDMETHODIMP Write ( const void* Buffer, ULONG WriteBytes, ULONG* BytesWritten );
// IStream
STDMETHODIMP Seek ( LARGE_INTEGER Move, DWORD Origin, ULARGE_INTEGER* NewPosition ); STDMETHODIMP SetSize ( ULARGE_INTEGER NewSize ); STDMETHODIMP CopyTo ( IStream* Stream, ULARGE_INTEGER CopyBytes, ULARGE_INTEGER* BytesRead, ULARGE_INTEGER* BytesWritten ); STDMETHODIMP Commit ( DWORD Flags ); STDMETHODIMP Revert(); STDMETHODIMP LockRegion ( ULARGE_INTEGER Offset, ULARGE_INTEGER Size, DWORD Type ); STDMETHODIMP UnlockRegion ( ULARGE_INTEGER Offset, ULARGE_INTEGER Size, DWORD Type ); STDMETHODIMP Stat ( STATSTG* Stats, DWORD Flag ); STDMETHODIMP Clone ( IStream** Stream ); };
//////////////////////////////////////////////////////////////////////////////// // // MEMORY_STREAM Implementation // ////////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////// // STDMETHODCALLTYPE MEMORY_STREAM::MEMORY_STREAM ( const void* Image, DWORD Size ) // //////////////////////////////////////////////////////////////////////////////// : m_ReferenceCount(0), m_Memory((PUCHAR)Image) { m_Offset.QuadPart = 0; m_Size.QuadPart = Size; }
//////////////////////////////////////////////////////////////////////////////// // STDMETHODIMP_(ULONG) MEMORY_STREAM::AddRef() // //////////////////////////////////////////////////////////////////////////////// { return ++m_ReferenceCount; }
//////////////////////////////////////////////////////////////////////////////// // STDMETHODIMP_(ULONG) MEMORY_STREAM::Release() // //////////////////////////////////////////////////////////////////////////////// { ULONG ReferenceCount(--m_ReferenceCount); if (m_ReferenceCount == 0) { delete this; }
return ReferenceCount; }
//////////////////////////////////////////////////////////////////////////////// // STDMETHODIMP MEMORY_STREAM::QueryInterface ( REFIID InterfaceId, void** Object ) // //////////////////////////////////////////////////////////////////////////////// { while (true) { if (IsEqualGUID(InterfaceId, __uuidof(IUnknown))) { *Object = static_cast<IUnknown*>(this); break; }
if (IsEqualGUID(InterfaceId, __uuidof(IStream))) { *Object = static_cast<IStream*>(this); break; }
*Object = NULL; return E_NOINTERFACE; }
AddRef(); return S_OK; }
//////////////////////////////////////////////////////////////////////////////// // STDMETHODIMP MEMORY_STREAM::Read ( void* Buffer, ULONG ReadBytes, ULONG* BytesRead ) // //////////////////////////////////////////////////////////////////////////////// { ULONG Length; HRESULT Result;
if (m_Offset.QuadPart + ReadBytes > m_Size.QuadPart) { Length = (ULONG)(m_Size.QuadPart - m_Offset.QuadPart); Result = S_FALSE; } else { Length = ReadBytes; Result = S_OK; } CopyMemory(Buffer, m_Memory + (ULONG_PTR)m_Offset.QuadPart, Length); m_Offset.QuadPart += Length;
if (BytesRead) { *BytesRead = Length; }
return Result; }
//////////////////////////////////////////////////////////////////////////////// // STDMETHODIMP MEMORY_STREAM::Write ( const void* Buffer, ULONG WriteBytes, ULONG* BytesWritten ) // //////////////////////////////////////////////////////////////////////////////// { return E_NOTIMPL; }
//////////////////////////////////////////////////////////////////////////////// // STDMETHODIMP MEMORY_STREAM::Seek ( LARGE_INTEGER Move, DWORD Origin, ULARGE_INTEGER* NewPosition ) // //////////////////////////////////////////////////////////////////////////////// { switch (Origin) { case STREAM_SEEK_SET: m_Offset.QuadPart = Move.QuadPart; break; case STREAM_SEEK_CUR: if (m_Offset.QuadPart + Move.QuadPart > m_Size.QuadPart) { return STG_E_INVALIDFUNCTION; } m_Offset.QuadPart += Move.QuadPart; break; case STREAM_SEEK_END: if ((ULONGLONG)Move.QuadPart > m_Offset.QuadPart) { return STG_E_INVALIDFUNCTION; } m_Offset.QuadPart = m_Size.QuadPart - Move.QuadPart; break; default: return STG_E_INVALIDFUNCTION; } if (NewPosition) { *NewPosition = m_Offset; }
return S_OK; }
//////////////////////////////////////////////////////////////////////////////// // STDMETHODIMP MEMORY_STREAM::SetSize ( ULARGE_INTEGER NewSize ) { return E_NOTIMPL; }
//////////////////////////////////////////////////////////////////////////////// // STDMETHODIMP MEMORY_STREAM::CopyTo ( IStream* Stream, ULARGE_INTEGER CopyBytes, ULARGE_INTEGER* BytesRead, ULARGE_INTEGER* BytesWritten ) // //////////////////////////////////////////////////////////////////////////////// { return E_NOTIMPL; }
//////////////////////////////////////////////////////////////////////////////// // STDMETHODIMP MEMORY_STREAM::Commit ( DWORD Flags ) { return E_NOTIMPL; }
//////////////////////////////////////////////////////////////////////////////// // STDMETHODIMP MEMORY_STREAM::Revert() { return E_NOTIMPL; }
//////////////////////////////////////////////////////////////////////////////// // STDMETHODIMP MEMORY_STREAM::LockRegion ( ULARGE_INTEGER Offset, ULARGE_INTEGER Size, DWORD Type ) // //////////////////////////////////////////////////////////////////////////////// { return E_NOTIMPL; }
//////////////////////////////////////////////////////////////////////////////// // STDMETHODIMP MEMORY_STREAM::UnlockRegion ( ULARGE_INTEGER Offset, ULARGE_INTEGER Size, DWORD Type ) // //////////////////////////////////////////////////////////////////////////////// { return E_NOTIMPL; }
//////////////////////////////////////////////////////////////////////////////// // STDMETHODIMP MEMORY_STREAM::Stat ( STATSTG* Stats, DWORD Flags ) // //////////////////////////////////////////////////////////////////////////////// { ZeroMemory(Stats, sizeof(*Stats));
if ((Flags & STATFLAG_NONAME) != STATFLAG_NONAME) { Stats->pwcsName = (LPOLESTR)CoTaskMemAlloc(sizeof(OLECHAR)); if (Stats->pwcsName) { *Stats->pwcsName = 0; } } Stats->type = STGTY_STREAM; Stats->cbSize = m_Size; return S_OK; }
//////////////////////////////////////////////////////////////////////////////// // STDMETHODIMP MEMORY_STREAM::Clone ( IStream** Stream ) // //////////////////////////////////////////////////////////////////////////////// { return E_NOTIMPL; }
//////////////////////////////////////////////////////////////////////////////// // HRESULT CreateStreamOnResource ( HMODULE Instance, PCTSTR Type, PCTSTR Name, IStream** Stream ) // //////////////////////////////////////////////////////////////////////////////// { HRSRC ResourceHandle = FindResource(Instance, Name, Type);
if (ResourceHandle) { DWORD Size = ::SizeofResource(Instance, ResourceHandle);
if (Size) { HGLOBAL Resource = LoadResource(Instance, ResourceHandle);
if (Resource) { const void* Data = LockResource(Resource); MEMORY_STREAM* MemoryStream = new MEMORY_STREAM(Data, Size); if (!MemoryStream) { return E_OUTOFMEMORY; } return MemoryStream->QueryInterface ( __uuidof(IStream), (void**)Stream ); } } }
return HRESULT_FROM_WIN32(GetLastError()); }
You can then use the class like this to create a GDI+ bitmap:
IStream* Stream; Bitmap* bitmap(NULL);
if ( CreateStreamOnResource ( Instance, _T("PNG"), MAKEINTRESOURCE(IDI_IMAGE), &Stream ) == S_OK ) { bitmap = Gdiplus::Bitmap::FromStream(Stream); Stream->Release(); }
Nick Acquaviva
|
| Sign In·View Thread·PermaLink | |
|
|
|
 |
|
|
I like it.
This could be an article.
Anyone who thinks he has a better idea of what's good for people than people do is a swine. - P.J. O'Rourke
|
| Sign In·View Thread·PermaLink | |
|
|
|
 |
|
|
 |
|
|
I appreciate feedback, but I find your comments useless without any explanation as to why you are making your assertions. Please explain how Release() is a bug. According to all the documentation I have seen, the Release() is proper.
You statement about try and catch make no sense. You do understand that IStream is part of windows structured storage, not STL.
If you want to have exceptions thrown, you may modify the code for your benefit.
Anyone who thinks he has a better idea of what's good for people than people do is a swine. - P.J. O'Rourke
|
| Sign In·View Thread·PermaLink | |
|
|
|
 |
|
|
Thanks for your reply.
The testing like this Begin another thread and load resource image in thread function (could sleep(100) once for a while), then pop up a simple modal dialog with an edit box, you could see when you press the edit box it will display rubbish data, then press editbox another time, crash!
The problem seems about CreateStreamOnHGlobal, I made second parameter FALSE, then it's ok HRESULT hResult = ::CreateStreamOnHGlobal(hBuffer, FALSE, &pStream);
HGLOBAL hBuffer = NULL;
try { // get resource from PNG HRSRC hResource = ::FindResource(hInst, MAKEINTRESOURCE(nResourcId), pType); if (!hResource) throw int();
// get size of read resource DWORD dwSize = ::SizeofResource(hInst, hResource); if (!dwSize) throw int(); // allocate memory to store data hBuffer = ::GlobalAlloc(GMEM_FIXED, dwSize);
// put data into HGLOBAL HGLOBAL hGresource = ::LoadResource(hInst, hResource); if (!hGresource) throw int();
// copy data into buffer void *pData = ::LockResource(hGresource); memcpy((void*)hBuffer, pData, dwSize);
// convert data into stream LPSTREAM pStream; HRESULT hResult = ::CreateStreamOnHGlobal(hBuffer, FALSE, &pStream); if (hResult != S_OK) throw int();
// convert data into Bitmap m_pbmPicture = Gdiplus::Bitmap::FromStream(pStream); bResult = TRUE; pStream->Release();
// release buffer ::GlobalFree(hBuffer); } catch (...) // on error: release memory { if (hBuffer) { ::GlobalFree(hBuffer); }
if (m_pbmPicture != NULL) { delete m_pbmPicture; m_pbmPicture = NULL; } }
|
| Sign In·View Thread·PermaLink | |
|
|
|
 |
|
|
Hi,I'm not an expert, just want to find the problem,
The reason maybe complicated, i just testing your demo(2008) and find no crash, since my project using 2002 I'm not sure if project environment is different.
The testing result is either I change HRESULT hResult = ::CreateStreamOnHGlobal(hBuffer, FALSE, &pStream); then lpStream->Release(); it's ok or I remove lpStream->Release(); , keep HRESULT hResult = CreateStreamOnHGlobal(hBuffer, TRUE, &lpStream);, also ok.
BTW, english is not my first language, sorry for any impolite
|
| Sign In·View Thread·PermaLink | |
|
|
|
 |
|
|
I really find this to be cool if wrapped in a user control, could you help me port it into C# ?
"Imagination is more important than knowledge.." {Albert Einstein}
|
| Sign In·View Thread·PermaLink | |
|
|
|
 |
|
|
No, since this isn't an issue in .NET.
Anyone who thinks he has a better idea of what's good for people than people do is a swine. - P.J. O'Rourke
|
| Sign In·View Thread·PermaLink | |
|
|
|
 |
|
|
 |
|
|
All this inherited object stuff was looking a bit complicated to me so I've re-written this code (thanks very much for it BTW!) in a single function. It seems to work but please let me know if you spot anything wrong with what I've done as I'm new to GDIplus.
Bitmap* pToolbar; pToolbar = LoadPNG("TOOLBAR", RT_RCDATA, hInstance);
// use pToolbar
delete pToolbar
Bitmap* LoadPNG(LPCTSTR pName, LPCTSTR pType, HMODULE hInstance) { HGLOBAL hBuffer; Bitmap* pBitmap;
// find this resource HRSRC hResource = ::FindResource(hInstance, pName, pType); if (!hResource) { return NULL; }
// get the size DWORD dwImageSize = ::SizeofResource(hInstance, hResource); if (!dwImageSize) { return NULL; }
// get data const void* pResourceData = ::LockResource(::LoadResource(hInstance, hResource)); if (!pResourceData) { return NULL; }
// allocate memory hBuffer = ::GlobalAlloc(GMEM_MOVEABLE, dwImageSize); if (hBuffer) { // lock this memory void* pBuffer = ::GlobalLock(hBuffer);
if (pBuffer) { // copy memory CopyMemory(pBuffer, pResourceData, dwImageSize);
// stream in data IStream* pStream = NULL; if (::CreateStreamOnHGlobal(hBuffer, FALSE, &pStream) == S_OK) { pBitmap = Gdiplus::Bitmap::FromStream(pStream); pStream->Release();
if (pBitmap) { if (pBitmap->GetLastStatus() == Gdiplus::Ok) { // success ::GlobalUnlock(hBuffer); ::GlobalFree(hBuffer); return pBitmap; }
delete pBitmap; } }
::GlobalUnlock(hBuffer); }
::GlobalFree(hBuffer); }
return NULL; }
|
| Sign In·View Thread·PermaLink | |
|
|
|
 |
|
|
Your version will fail on success with those files (particularly JPG) which require that the global buffer remain valid while the bitmap object is being used.
Anyone who thinks he has a better idea of what's good for people than people do is a swine. - P.J. O'Rourke
|
| Sign In·View Thread·PermaLink | |
|
|
|
 |
|
|
Ahhh, I hadn't realised that! Personally I only need this code for loading PNG files though so I guess I'm OK?
|
| Sign In·View Thread·PermaLink | |
|
|
|
 |
|
|
Sean O'Connor wrote: so I guess I'm OK?
I think so; it appears that PNG's decompress right away. Of course, the test is whether you have any problems.
Anyone who thinks he has a better idea of what's good for people than people do is a swine. - P.J. O'Rourke
|
| Sign In·View Thread·PermaLink | |
|
|
|
 |
|
|
This article was just what I needed .
I've got a VS2003 project that has a lot of bitmap resources and uses normal GDI for drawing. The Powers That Be have decided all of these bitmaps (stolen from all the best web sources) look crappy, so they've got some yabbo's drawing new ones. Said yabbo's only produce PNG's (bunch of Mac sissies), and of course the images all include an alpha channel. My GDI drawing code could handle that, but I didn't have any way to load the PNG's as resources.
Your code does the trick, and I've added a slight refinement. Since VS2003 doesn't let you define PNG's as a normal BITMAP resource, I put the following in my .RC file:IDB_BITMAP BITMAP "res\bitmap.bmp" IDB_BITMAP PNG "res\bitmap.png" If then use your approach to try and load a resource of type "PNG" with the ID IDB_BITMAP. If it works, all well and good. If it doesn't, I issue the normal ::LoadImage call. This way, all I need to do is update the .RC file with PNG resources as the artsy types get them done.
Since I do all of my image loading through a common routine, all I've had to do to make this work is modify the routine and begin updating .RC's, rather than all of the load calls. I can also programatically decide whether or not to use the PNG resources in one place. Very, very, .
Thanks again .
Software Zen: delete this;
|
| Sign In·View Thread·PermaLink | |
|
|
|
 |
|
|
In your code: If the snippet...
if (m_pBitmap->GetLastStatus() == Gdiplus::Ok) return true;
executes then...
::GlobalUnlock(m_hBuffer); } ::GlobalFree(m_hBuffer);
does not.
This leaves m_hBuffer locked and unfreed whch would reinforce the commonly stated warning about returning from within a function.
Peter Wasser
|
| Sign In·View Thread·PermaLink | |
|
|
|
 |
|
|
I never got notified about this message. However, the answer is that the code is correct since the buffer needs to remain locked while the bitmap is being used.
Anyone who thinks he has a better idea of what's good for people than people do is a swine. - P.J. O'Rourke
|
| Sign In·View Thread·PermaLink | |
|
|
|
 |
|
|
The class is nice and simple. Its easy to use. Does exactly what it says on the tin. You get my 5.
|
| Sign In·View Thread·PermaLink | 5.00/5 (1 vote) |
|
|
|
 |
|
|
Thank you.
Anyone who thinks he has a better idea of what's good for people than people do is a swine. - P.J. O'Rourke
|
| Sign In·View Thread·PermaLink | |
|
|
|
 |
|
|
Just to let you guys know that ATL8 introduced the CImage class for this purpose.
It basically GDI+ wrapped in a class so you don't need to bother setting up the environnement.
Hope it helps.
|
| Sign In·View Thread·PermaLink | 2.00/5 (1 vote) |
|
|
|
 |
|
|
This doesn't help since CImage does not load PNG and JPG format images from resources.
(Also, CImage was introduced in ATL 7.)
Anyone who thinks he has a better idea of what's good for people than people do is a swine. - P.J. O'Rourke
|
| Sign In·View Thread·PermaLink | |
|
|
|
 |
|
|
Oops, my bad. I am still wondering why they didn't expose the GDI+ image loading API so the whole burden could have made sense.
|
| Sign In·View Thread·PermaLink | |
|
|
|
 |
|
|
One reason, though not justification, is that GDI+ was originally an internal proof-of-concept project that got released. The core API should have actually been released years ago (the lack of JPG, GIF and PNG decoders in Windows 95, as well as the lack of basic ZIP functionality, was inexcusable.)
Anyone who thinks he has a better idea of what's good for people than people do is a swine. - P.J. O'Rourke
|
| Sign In·View Thread·PermaLink | 2.00/5 (1 vote) |
|
|
|
 |
|
|
General News Question Answer Joke Rant Admin
|