mirror of
https://github.com/taglib/taglib.git
synced 2026-11-06 06:28:50 -05:00
RIFF/WAV: Default to latin1, change charset when CSET chunk is present (#1451)
- WAV files being read evaluate the charset properly (overridable via option by caller) - WAV files being saved always use UTF-8 (they add a CSET chunk) following the specification at https://www.robotplanet.dk/audio/wav_meta_data/riff_mci.pdf#page=25 The code page charset can also be used for ID3v1 tags, both for parsing and rendering. For users in space restricted environments, there is a build flag WITH_CHARSETTABLES to exclude the character tables. --------- Co-authored-by: Urs Fleisch <[email protected]>
This commit is contained in:
co-authored by
Urs Fleisch
parent
bdfeb82cbe
commit
ff3b53670a
@@ -26,22 +26,54 @@
|
||||
#include <string>
|
||||
#include <cstdio>
|
||||
|
||||
#include "taglib_config.h"
|
||||
#include "tstring.h"
|
||||
#include "mpegfile.h"
|
||||
#include "id3v1tag.h"
|
||||
#include "id3v1genres.h"
|
||||
#include <cppunit/extensions/HelperMacros.h>
|
||||
#include "plainfile.h"
|
||||
#include "utils.h"
|
||||
|
||||
using namespace std;
|
||||
using namespace TagLib;
|
||||
|
||||
namespace
|
||||
{
|
||||
// The bytes of a Windows code page an ID3v1 field cannot tell from ISO 8859-1
|
||||
// on disk, and the text they stand for. 0x80 is a Euro sign in every ANSI
|
||||
// code page in use and a control character in ISO 8859-1.
|
||||
const ByteVector kAnsiArtist("A \x80 B", 5);
|
||||
const String kAnsiArtistText(L"A \x20AC B");
|
||||
|
||||
// A Cyrillic byte, which ISO 8859-1 cannot represent at all and which is once
|
||||
// again a control character there.
|
||||
const ByteVector kCyrillicArtist("A \xC0 B", 5);
|
||||
const String kCyrillicArtistText(L"A \x0410 B");
|
||||
|
||||
// The same text in UTF-8, as software that writes UTF-8 into ID3v1 produces
|
||||
// it, plus the ISO 8859-1 reading of those same bytes.
|
||||
const ByteVector kUtf8Artist("Caf\xC3\xA9", 5);
|
||||
const String kUtf8ArtistText(L"Caf\xE9");
|
||||
const String kUtf8ArtistLatin1(L"Caf\xC3\xA9");
|
||||
}
|
||||
|
||||
class TestID3v1 : public CppUnit::TestFixture
|
||||
{
|
||||
CPPUNIT_TEST_SUITE(TestID3v1);
|
||||
CPPUNIT_TEST(testStripWhiteSpace);
|
||||
CPPUNIT_TEST(testGenres);
|
||||
CPPUNIT_TEST(testRenamedGenres);
|
||||
#ifdef TAGLIB_WITH_CHARSETTABLES
|
||||
CPPUNIT_TEST(testAssumedCodePageDefaultIsLatin1);
|
||||
CPPUNIT_TEST(testAssumedCodePage);
|
||||
CPPUNIT_TEST(testConstructorCodePage);
|
||||
CPPUNIT_TEST(testUnsupportedCodePage);
|
||||
CPPUNIT_TEST(testCommentUsesCodePage);
|
||||
CPPUNIT_TEST(testUserHandlerBeatsCodePage);
|
||||
CPPUNIT_TEST(testMp3ReadsWithIso88591);
|
||||
CPPUNIT_TEST(testMp3ReadsWithWindows1252);
|
||||
#endif
|
||||
CPPUNIT_TEST_SUITE_END();
|
||||
|
||||
public:
|
||||
@@ -84,6 +116,360 @@ public:
|
||||
CPPUNIT_ASSERT_EQUAL(129U, tag.genreNumber());
|
||||
}
|
||||
|
||||
#ifdef TAGLIB_WITH_CHARSETTABLES
|
||||
//! A temporary MP3 file which is automatically deleted when it goes out of scope.
|
||||
class ScopedMp3FileCopyWithId3v1Tag : public ScopedFileCopy
|
||||
{
|
||||
public:
|
||||
static constexpr unsigned int ARTIST_OFFSET = 3 + 30;
|
||||
static constexpr unsigned int COMMENT_OFFSET = 3 + 30 + 30 + 30 + 4;
|
||||
|
||||
//! Construct MP3 file with an ID3v1 tag which has one raw \a value at
|
||||
//! \a offset, which points by default to the artist field.
|
||||
explicit ScopedMp3FileCopyWithId3v1Tag(const ByteVector &value,
|
||||
unsigned int offset = ARTIST_OFFSET)
|
||||
: ScopedFileCopy("xing", ".mp3")
|
||||
{
|
||||
ByteVector tag = ID3v1::Tag::fileIdentifier();
|
||||
// Leave empty fields up to the given offset.
|
||||
tag.resize(offset, 0);
|
||||
tag.append(value);
|
||||
tag.resize(128, 0);
|
||||
// 0xff in the genre byte is the ID3v1 value for "no genre".
|
||||
tag[127] = static_cast<char>(0xff);
|
||||
|
||||
std::ofstream file(fileName().c_str(), std::ios::in | std::ios::out | std::ios::binary);
|
||||
// The tag goes where a file already ending in one would carry it: the last
|
||||
// 128 bytes. The source file is longer than that, so a shorter tag would
|
||||
// not be found at all.
|
||||
file.seekp(-128, std::ios::end);
|
||||
file.write(tag.data(), tag.size());
|
||||
}
|
||||
|
||||
//! The file offset of the ID3v1 tag.
|
||||
offset_t tagOffset() const
|
||||
{
|
||||
std::ifstream file(fileName().c_str(), std::ios::binary | std::ios::ate);
|
||||
return static_cast<offset_t>(file.tellg()) - 128;
|
||||
}
|
||||
|
||||
//! Get the raw field value from the ID3v1 tag starting at \a offset to
|
||||
//! the next zero byte.
|
||||
ByteVector getRawValue(unsigned int offset = ARTIST_OFFSET)
|
||||
{
|
||||
std::ifstream file(fileName().c_str(), std::ios::binary | std::ios::ate);
|
||||
file.seekg(-128, std::ios::end);
|
||||
ByteVector tag(128U, 0);
|
||||
file.read(tag.data(), tag.size());
|
||||
return extractRawValue(tag, offset);
|
||||
}
|
||||
|
||||
static ByteVector extractRawValue(const ByteVector &tag, unsigned int offset = ARTIST_OFFSET)
|
||||
{
|
||||
if(tag.size() != 128)
|
||||
return {};
|
||||
const char *str = tag.data();
|
||||
const char *end = std::find(str + offset, str + 128, '\0');
|
||||
return tag.mid(offset, static_cast<unsigned int>(end - str - offset));
|
||||
}
|
||||
};
|
||||
|
||||
// Puts back the code page a tag is assumed to have when none is stated, so
|
||||
// that a test stating another one does not carry it into the next test.
|
||||
class RestoreAssumedCodePage
|
||||
{
|
||||
public:
|
||||
RestoreAssumedCodePage() :
|
||||
m_previous(ID3v1::Tag::assumedCodePage())
|
||||
{
|
||||
}
|
||||
|
||||
~RestoreAssumedCodePage()
|
||||
{
|
||||
ID3v1::Tag::setAssumedCodePage(m_previous);
|
||||
}
|
||||
|
||||
private:
|
||||
const unsigned int m_previous;
|
||||
};
|
||||
|
||||
// A handler the user installed is an explicit override, so it outranks any
|
||||
// code page, once again because installing one is a deliberate act.
|
||||
class FixedStringHandler : public ID3v1::StringHandler
|
||||
{
|
||||
public:
|
||||
String parse(const ByteVector &) const override { return String(L"from the handler"); }
|
||||
};
|
||||
|
||||
// The ID3v1 specification says ISO 8859-1, and that is what an untouched
|
||||
// library assumes: the bytes of a file that holds another encoding are read
|
||||
// as the Latin-1 characters they also happen to be.
|
||||
void testAssumedCodePageDefaultIsLatin1()
|
||||
{
|
||||
CPPUNIT_ASSERT_EQUAL(0u, ID3v1::Tag::assumedCodePage());
|
||||
|
||||
ScopedMp3FileCopyWithId3v1Tag copy(kUtf8Artist);
|
||||
const string name = copy.fileName();
|
||||
|
||||
MPEG::File f(name.c_str());
|
||||
CPPUNIT_ASSERT(f.ID3v1Tag(false));
|
||||
CPPUNIT_ASSERT_EQUAL(kUtf8ArtistLatin1, f.ID3v1Tag(false)->artist());
|
||||
}
|
||||
|
||||
// Software that writes UTF-8 or an ANSI code page without any way of saying
|
||||
// so is what the option is for. Stated once, it is what every tag read
|
||||
// afterwards is decoded with.
|
||||
void testAssumedCodePage()
|
||||
{
|
||||
const RestoreAssumedCodePage restore;
|
||||
|
||||
ID3v1::Tag::setAssumedCodePage(65001);
|
||||
CPPUNIT_ASSERT_EQUAL(65001u, ID3v1::Tag::assumedCodePage());
|
||||
|
||||
{
|
||||
ScopedMp3FileCopyWithId3v1Tag copy(kUtf8Artist);
|
||||
const string name = copy.fileName();
|
||||
MPEG::File f(name.c_str());
|
||||
CPPUNIT_ASSERT_EQUAL(kUtf8ArtistText, f.ID3v1Tag(false)->artist());
|
||||
f.save();
|
||||
CPPUNIT_ASSERT_EQUAL(kUtf8Artist, copy.getRawValue());
|
||||
}
|
||||
|
||||
ID3v1::Tag::setAssumedCodePage(1252);
|
||||
CPPUNIT_ASSERT_EQUAL(1252u, ID3v1::Tag::assumedCodePage());
|
||||
|
||||
{
|
||||
ScopedMp3FileCopyWithId3v1Tag copy(kAnsiArtist);
|
||||
const string name = copy.fileName();
|
||||
MPEG::File f(name.c_str());
|
||||
CPPUNIT_ASSERT_EQUAL(kAnsiArtistText, f.ID3v1Tag(false)->artist());
|
||||
f.save();
|
||||
CPPUNIT_ASSERT_EQUAL(kAnsiArtist, copy.getRawValue());
|
||||
}
|
||||
|
||||
ID3v1::Tag::setAssumedCodePage(1251);
|
||||
CPPUNIT_ASSERT_EQUAL(1251u, ID3v1::Tag::assumedCodePage());
|
||||
|
||||
{
|
||||
ScopedMp3FileCopyWithId3v1Tag copy(kCyrillicArtist);
|
||||
const string name = copy.fileName();
|
||||
{
|
||||
MPEG::File f(name.c_str());
|
||||
CPPUNIT_ASSERT_EQUAL(kCyrillicArtistText, f.ID3v1Tag(false)->artist());
|
||||
f.save();
|
||||
CPPUNIT_ASSERT_EQUAL(kCyrillicArtist, copy.getRawValue());
|
||||
}
|
||||
{
|
||||
MPEG::File f(name.c_str());
|
||||
ID3v1::Tag *tag = f.ID3v1Tag(false);
|
||||
CPPUNIT_ASSERT_EQUAL(kCyrillicArtistText, tag->artist());
|
||||
// The character 03B1 is a greek alpha, which is not represented in 1251
|
||||
tag->setArtist((L"A \x0410\x03B1 B"));
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
{
|
||||
MPEG::File f(name.c_str());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"A \x0410? B"), f.ID3v1Tag(false)->artist());
|
||||
}
|
||||
}
|
||||
|
||||
// Stating 0 again is going back to what the specification says.
|
||||
ID3v1::Tag::setAssumedCodePage(0);
|
||||
CPPUNIT_ASSERT_EQUAL(0u, ID3v1::Tag::assumedCodePage());
|
||||
|
||||
{
|
||||
ScopedMp3FileCopyWithId3v1Tag copy(kUtf8Artist);
|
||||
const string name = copy.fileName();
|
||||
MPEG::File f(name.c_str());
|
||||
CPPUNIT_ASSERT_EQUAL(kUtf8ArtistLatin1, f.ID3v1Tag(false)->artist());
|
||||
f.save();
|
||||
CPPUNIT_ASSERT_EQUAL(kUtf8Artist, copy.getRawValue());
|
||||
}
|
||||
}
|
||||
|
||||
// A tag can be given its own code page as well, for callers that read files
|
||||
// from more than one source and cannot state a single process wide default.
|
||||
void testConstructorCodePage()
|
||||
{
|
||||
const RestoreAssumedCodePage restore;
|
||||
|
||||
// Even with another assumption in place, the code page handed to the
|
||||
// constructor is the one that decides.
|
||||
ID3v1::Tag::setAssumedCodePage(65001);
|
||||
|
||||
{
|
||||
ScopedMp3FileCopyWithId3v1Tag copy(kAnsiArtist);
|
||||
const string name = copy.fileName();
|
||||
PlainFile file(name.c_str());
|
||||
CPPUNIT_ASSERT(file.isValid());
|
||||
ID3v1::Tag tag(&file, copy.tagOffset(), 1252);
|
||||
CPPUNIT_ASSERT_EQUAL(kAnsiArtistText, tag.artist());
|
||||
CPPUNIT_ASSERT_EQUAL(kAnsiArtist,
|
||||
ScopedMp3FileCopyWithId3v1Tag::extractRawValue(tag.render()));
|
||||
}
|
||||
|
||||
{
|
||||
ScopedMp3FileCopyWithId3v1Tag copy(kCyrillicArtist);
|
||||
const string name = copy.fileName();
|
||||
PlainFile file(name.c_str());
|
||||
ID3v1::Tag tag(&file, copy.tagOffset(), 1251);
|
||||
CPPUNIT_ASSERT_EQUAL(kCyrillicArtistText, tag.artist());
|
||||
CPPUNIT_ASSERT_EQUAL(kCyrillicArtist,
|
||||
ScopedMp3FileCopyWithId3v1Tag::extractRawValue(tag.render()));
|
||||
}
|
||||
|
||||
// Code page 0 is the "not stated" value, which means the assumption.
|
||||
{
|
||||
ScopedMp3FileCopyWithId3v1Tag copy(kUtf8Artist);
|
||||
const string name = copy.fileName();
|
||||
PlainFile file(name.c_str());
|
||||
ID3v1::Tag tag(&file, copy.tagOffset(), 0);
|
||||
CPPUNIT_ASSERT_EQUAL(kUtf8ArtistText, tag.artist());
|
||||
CPPUNIT_ASSERT_EQUAL(kUtf8Artist,
|
||||
ScopedMp3FileCopyWithId3v1Tag::extractRawValue(tag.render()));
|
||||
}
|
||||
}
|
||||
|
||||
// A code page this library cannot decode falls back to what is assumed: the
|
||||
// number is a hint about how the text was written, and dropping the whole tag
|
||||
// over a hint that could not be followed would lose the fields as well.
|
||||
void testUnsupportedCodePage()
|
||||
{
|
||||
const RestoreAssumedCodePage restore;
|
||||
|
||||
// A code page this library has no table for, here a Japanese one, is
|
||||
// refused as the assumption and the previous one stays.
|
||||
ID3v1::Tag::setAssumedCodePage(1252);
|
||||
ID3v1::Tag::setAssumedCodePage(932);
|
||||
CPPUNIT_ASSERT_EQUAL(1252u, ID3v1::Tag::assumedCodePage());
|
||||
|
||||
{
|
||||
ScopedMp3FileCopyWithId3v1Tag copy(kAnsiArtist);
|
||||
const string name = copy.fileName();
|
||||
PlainFile file(name.c_str());
|
||||
// Handing the same unsupported number to the constructor falls back to
|
||||
// the assumption rather than refusing to read the tag.
|
||||
ID3v1::Tag tag(&file, copy.tagOffset(), 932);
|
||||
CPPUNIT_ASSERT_EQUAL(kAnsiArtistText, tag.artist());
|
||||
CPPUNIT_ASSERT_EQUAL(kAnsiArtist,
|
||||
ScopedMp3FileCopyWithId3v1Tag::extractRawValue(tag.render()));
|
||||
}
|
||||
}
|
||||
|
||||
// The charset covers the whole tag, the ID3v1.0 comment included. Its 30
|
||||
// bytes are a single field there, so it goes through the same decoding even
|
||||
// though ID3v1.1 would have cut the track number out of it.
|
||||
void testCommentUsesCodePage()
|
||||
{
|
||||
const RestoreAssumedCodePage restore;
|
||||
ID3v1::Tag::setAssumedCodePage(1252);
|
||||
|
||||
ScopedMp3FileCopyWithId3v1Tag copy(kAnsiArtist,
|
||||
ScopedMp3FileCopyWithId3v1Tag::COMMENT_OFFSET);
|
||||
const string name = copy.fileName();
|
||||
MPEG::File f(name.c_str());
|
||||
CPPUNIT_ASSERT(f.ID3v1Tag(false));
|
||||
CPPUNIT_ASSERT_EQUAL(kAnsiArtistText, f.ID3v1Tag(false)->comment());
|
||||
CPPUNIT_ASSERT_EQUAL(kAnsiArtist, copy.getRawValue(
|
||||
ScopedMp3FileCopyWithId3v1Tag::COMMENT_OFFSET));
|
||||
}
|
||||
|
||||
// A real MP3 carrying an ID3v1 tag whose bytes are only meaningful as ISO
|
||||
// 8859-1, which is what the ID3v1 specification prescribes and what a caller
|
||||
// states explicitly. The bytes are one of Latin-1's own characters, so the
|
||||
// reading is the text it spells; a code page that reassigns this range would
|
||||
// not produce it.
|
||||
void testMp3ReadsWithIso88591()
|
||||
{
|
||||
const RestoreAssumedCodePage restore;
|
||||
|
||||
// Keep any assumption out of it: this tag is read as ISO 8859-1 because
|
||||
// the code page says so, not because the default happens to agree.
|
||||
ID3v1::Tag::setAssumedCodePage(65001);
|
||||
|
||||
const ByteVector latin1Artist("Caf\xE9", 4);
|
||||
const String latin1ArtistText(L"Caf\xE9");
|
||||
|
||||
ScopedMp3FileCopyWithId3v1Tag copy(latin1Artist);
|
||||
const string name = copy.fileName();
|
||||
PlainFile file(name.c_str());
|
||||
CPPUNIT_ASSERT(file.isValid());
|
||||
ID3v1::Tag tag(&file, copy.tagOffset(), 28591);
|
||||
CPPUNIT_ASSERT_EQUAL(latin1ArtistText, tag.artist());
|
||||
}
|
||||
|
||||
// The same MP3 shape read as Windows-1252 instead. The bytes below are a
|
||||
// Euro sign and a pair of curly quotes in 1252, and C1 controls in ISO
|
||||
// 8859-1, so the two readings differ and only one of them is right.
|
||||
void testMp3ReadsWithWindows1252()
|
||||
{
|
||||
const RestoreAssumedCodePage restore;
|
||||
|
||||
// An assumption the tag contradicts, so the reading has to come from the
|
||||
// code page handed to the constructor.
|
||||
ID3v1::Tag::setAssumedCodePage(28591);
|
||||
|
||||
const ByteVector windows1252Artist("A \x80 \x93q" "\x94", 7);
|
||||
const String windows1252ArtistText(L"A \x20AC \x201Cq\x201D");
|
||||
|
||||
ScopedMp3FileCopyWithId3v1Tag copy(windows1252Artist);
|
||||
const string name = copy.fileName();
|
||||
PlainFile file(name.c_str());
|
||||
CPPUNIT_ASSERT(file.isValid());
|
||||
ID3v1::Tag tag(&file, copy.tagOffset(), 1252);
|
||||
CPPUNIT_ASSERT_EQUAL(windows1252ArtistText, tag.artist());
|
||||
}
|
||||
|
||||
// An installed handler is used for every tag, whatever code page is stated,
|
||||
// and removing it hands the tags back to that code page.
|
||||
void testUserHandlerBeatsCodePage()
|
||||
{
|
||||
const RestoreAssumedCodePage restore;
|
||||
|
||||
class RestoreHandler
|
||||
{
|
||||
public:
|
||||
~RestoreHandler() { ID3v1::Tag::setStringHandler(nullptr); }
|
||||
} restoreHandler;
|
||||
|
||||
ID3v1::Tag::setAssumedCodePage(1252);
|
||||
|
||||
{
|
||||
ScopedMp3FileCopyWithId3v1Tag copy(kAnsiArtist);
|
||||
const string name = copy.fileName();
|
||||
MPEG::File f(name.c_str());
|
||||
CPPUNIT_ASSERT_EQUAL(kAnsiArtistText, f.ID3v1Tag(false)->artist());
|
||||
}
|
||||
|
||||
const FixedStringHandler handler;
|
||||
ID3v1::Tag::setStringHandler(&handler);
|
||||
|
||||
{
|
||||
ScopedMp3FileCopyWithId3v1Tag copy(kAnsiArtist);
|
||||
const string name = copy.fileName();
|
||||
MPEG::File f(name.c_str());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"from the handler"), f.ID3v1Tag(false)->artist());
|
||||
}
|
||||
|
||||
// Also for a tag whose code page was stated explicitly.
|
||||
{
|
||||
ScopedMp3FileCopyWithId3v1Tag copy(kAnsiArtist);
|
||||
const string name = copy.fileName();
|
||||
PlainFile file(name.c_str());
|
||||
ID3v1::Tag tag(&file, copy.tagOffset(), 1252);
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"from the handler"), tag.artist());
|
||||
}
|
||||
|
||||
ID3v1::Tag::setStringHandler(nullptr);
|
||||
|
||||
{
|
||||
ScopedMp3FileCopyWithId3v1Tag copy(kAnsiArtist);
|
||||
const string name = copy.fileName();
|
||||
MPEG::File f(name.c_str());
|
||||
CPPUNIT_ASSERT_EQUAL(kAnsiArtistText, f.ID3v1Tag(false)->artist());
|
||||
}
|
||||
}
|
||||
#endif // TAGLIB_WITH_CHARSETTABLES
|
||||
};
|
||||
|
||||
CPPUNIT_TEST_SUITE_REGISTRATION(TestID3v1);
|
||||
|
||||
+836
-1
@@ -23,9 +23,12 @@
|
||||
* http://www.mozilla.org/MPL/ *
|
||||
***************************************************************************/
|
||||
|
||||
#include <initializer_list>
|
||||
#include <string>
|
||||
#include <cstdio>
|
||||
|
||||
#include "taglib_config.h"
|
||||
#include "tcharset.h"
|
||||
#include "id3v2tag.h"
|
||||
#include "infotag.h"
|
||||
#include "tbytevectorlist.h"
|
||||
@@ -40,6 +43,253 @@
|
||||
using namespace std;
|
||||
using namespace TagLib;
|
||||
|
||||
#ifdef TAGLIB_WITH_CHARSETTABLES
|
||||
namespace
|
||||
{
|
||||
// The bytes of a chunk: its id, its size, its payload, and the pad byte that
|
||||
// brings an odd sized payload to an even length.
|
||||
ByteVector riffChunk(const char *id, const ByteVector &payload)
|
||||
{
|
||||
ByteVector chunk = ByteVector(id, 4) + ByteVector::fromUInt(payload.size(), false) + payload;
|
||||
if(payload.size() % 2)
|
||||
chunk.append(static_cast<char>(0));
|
||||
return chunk;
|
||||
}
|
||||
|
||||
// The payload of a conforming CSET chunk, which the RIFF specification lays
|
||||
// out as wCodePage, wCountryCode, wLanguageCode and wDialect, one WORD each.
|
||||
// Only the code page matters here, so the other three are the zero that means
|
||||
// "no opinion".
|
||||
ByteVector riffCsetPayload(unsigned int codePage)
|
||||
{
|
||||
return ByteVector::fromShort(static_cast<short>(codePage), false) + ByteVector(6, '\0');
|
||||
}
|
||||
|
||||
// The same layout, with the country, language and dialect the file was written
|
||||
// for filled in as well. These are what a reader that took the code page for a
|
||||
// 32 bit number mixed into its result.
|
||||
ByteVector riffCsetPayloadWithLanguage(unsigned int codePage)
|
||||
{
|
||||
return ByteVector::fromShort(static_cast<short>(codePage), false) +
|
||||
ByteVector::fromShort(1, false) + ByteVector::fromShort(9, false) +
|
||||
ByteVector::fromShort(1, false);
|
||||
}
|
||||
|
||||
// One field of an INFO list, as the reader will find it: an id and the text
|
||||
// in whatever encoding the file declares, without the NUL that ends it.
|
||||
struct InfoField
|
||||
{
|
||||
const char *id;
|
||||
ByteVector text;
|
||||
};
|
||||
|
||||
// A WAV file with an INFO tag holding \a fields, and a CSET chunk whose raw
|
||||
// payload is \a cset. An empty \a cset means no CSET chunk at all, which is
|
||||
// what a file that declares no charset looks like. The CSET is written in
|
||||
// front of the LIST it describes, unless \a csetBehindList is set, which is
|
||||
// where a file TagLib did not write tends to put it.
|
||||
ByteVector riffWithInfoCset(std::initializer_list<InfoField> fields,
|
||||
const ByteVector &cset, bool csetBehindList = false)
|
||||
{
|
||||
ByteVector list("INFO", 4);
|
||||
for(const auto &field : fields)
|
||||
list.append(riffChunk(field.id, field.text + ByteVector(1, '\0')));
|
||||
|
||||
// fmt: PCM, one channel, 22050 Hz, 8 bits, 22050 bytes per second, and the
|
||||
// 8 bytes of audio the data chunk below has to hold for the file to parse.
|
||||
ByteVector body =
|
||||
riffChunk("fmt ", ByteVector("\x01\x00\x01\x00\x3E\x56\x00\x00"
|
||||
"\x3E\x56\x00\x00\x01\x00\x08\x00", 16));
|
||||
body.append(riffChunk("data", ByteVector(8, static_cast<char>(0x80))));
|
||||
|
||||
if(!cset.isEmpty() && !csetBehindList)
|
||||
body.append(riffChunk("CSET", cset));
|
||||
body.append(riffChunk("LIST", list));
|
||||
if(!cset.isEmpty() && csetBehindList)
|
||||
body.append(riffChunk("CSET", cset));
|
||||
|
||||
return ByteVector("RIFF", 4) + ByteVector::fromUInt(body.size() + 4, false) +
|
||||
ByteVector("WAVE", 4) + body;
|
||||
}
|
||||
|
||||
// The same file, with the charset declared by \a codePage in the conforming
|
||||
// 8 byte CSET layout, or with no CSET chunk at all when \a codePage is 0.
|
||||
ByteVector riffWithInfo(std::initializer_list<InfoField> fields,
|
||||
unsigned int codePage = 0, bool csetBehindList = false)
|
||||
{
|
||||
return riffWithInfoCset(fields, codePage ? riffCsetPayload(codePage) : ByteVector(),
|
||||
csetBehindList);
|
||||
}
|
||||
|
||||
// An INFO tag with no CSET chunk, which is what a reader has to fall back to
|
||||
// Latin-1 on.
|
||||
ByteVector latin1Riff()
|
||||
{
|
||||
return riffWithInfo({{"IART", ByteVector("A \xAE B", 5)},
|
||||
{"IPRD", ByteVector("A \xAE B", 5)},
|
||||
{"ICMT", ByteVector("Caf\xE9", 4)},
|
||||
{"ICRD", ByteVector("2024-01-01", 10)}});
|
||||
}
|
||||
|
||||
// An INFO tag that declares UTF-8, with the CSET behind the LIST it describes.
|
||||
ByteVector utf8Riff()
|
||||
{
|
||||
return riffWithInfo({{"IPRD", ByteVector("Caf\xC3\xA9", 5)},
|
||||
{"ICRD", ByteVector("2024-03-03", 10)}},
|
||||
65001, true);
|
||||
}
|
||||
|
||||
// A WAV file whose INFO text is in \a codePage, holding samples that a
|
||||
// different table would decode differently: 0x80 is the Euro sign in 1252
|
||||
// and U+0080 in Latin-1, 0x93/0x94 are curly quotes in 1252 and C1 controls
|
||||
// in Latin-1, 0xc0..0xc2 are Cyrillic capitals in 1251 and accented Latin
|
||||
// letters in Latin-1. So the expected strings below only come out right
|
||||
// when the page the CSET declares is the table actually used.
|
||||
ByteVector windows1252Riff()
|
||||
{
|
||||
return riffWithInfo({{"IART", ByteVector("A \x80 B", 5)},
|
||||
{"IPRD", ByteVector("Caf\xE9 \x80", 6)},
|
||||
{"ICMT", ByteVector("\x93quoted\x94", 10)}},
|
||||
1252);
|
||||
}
|
||||
|
||||
// The same file, with the country, language and dialect of the CSET chunk
|
||||
// filled in instead of left at their zero defaults.
|
||||
ByteVector windows1252WithLanguageRiff()
|
||||
{
|
||||
return riffWithInfoCset({{"IART", ByteVector("A \x80 B", 5)},
|
||||
{"IPRD", ByteVector("Caf\xE9 \x80", 6)},
|
||||
{"ICMT", ByteVector("\x93quoted\x94", 10)}},
|
||||
riffCsetPayloadWithLanguage(1252));
|
||||
}
|
||||
|
||||
ByteVector windows1251Riff()
|
||||
{
|
||||
return riffWithInfo({{"IART", ByteVector("A \xC0 B", 5)},
|
||||
{"IPRD", ByteVector("Caf\xE9", 4)},
|
||||
{"ICMT", ByteVector("\xC0\xC1\xC2", 3)}},
|
||||
1251);
|
||||
}
|
||||
|
||||
// The same text in a file whose CSET chunk is truncated: its payload holds a
|
||||
// single byte, where wCodePage alone needs 2, so it cannot hold the number the
|
||||
// chunk exists to declare and the file is malformed.
|
||||
ByteVector truncatedCsetRiff()
|
||||
{
|
||||
return riffWithInfoCset({{"IART", ByteVector("A \x80 B", 5)}},
|
||||
ByteVector("\xA4", 1));
|
||||
}
|
||||
|
||||
// The same text in a file whose CSET holds the code page and nothing else: the
|
||||
// three WORD fields behind wCodePage are what a conforming writer fills in, and
|
||||
// a file that leaves them out entirely still says all there is to say.
|
||||
ByteVector codePageOnlyCsetRiff()
|
||||
{
|
||||
return riffWithInfoCset({{"IART", ByteVector("A \x80 B", 5)}},
|
||||
ByteVector::fromShort(1252, false));
|
||||
}
|
||||
|
||||
// A file whose CSET declares a code page this library has no table for, here a
|
||||
// Japanese one. Its text is written in the RIFF default ISO 8859-1, so it reads
|
||||
// back as itself: the number is a hint, and a hint that cannot be followed must
|
||||
// not cost the tag its fields.
|
||||
ByteVector unsupportedCsetRiff()
|
||||
{
|
||||
return riffWithInfo({{"IART", ByteVector("A \xAE B", 5)},
|
||||
{"ICMT", ByteVector("Caf\xE9", 4)}},
|
||||
932);
|
||||
}
|
||||
|
||||
// A file whose INFO text is UTF-8 and which declares no charset at all. The
|
||||
// specification cannot recognize this combination, because a file holding
|
||||
// ISO 8859-1 and one holding UTF-8 look the same without a CSET chunk; only
|
||||
// the reader's application knows which of them it wrote.
|
||||
ByteVector utf8WithoutCsetRiff()
|
||||
{
|
||||
return riffWithInfo({{"IART", ByteVector("Caf\xC3\xA9", 5)}}, 0);
|
||||
}
|
||||
|
||||
// Puts back the code page an INFO tag assumes when a file declares none, so
|
||||
// that a test stating another one does not carry it into the next test.
|
||||
class RestoreAssumedCodePage
|
||||
{
|
||||
public:
|
||||
RestoreAssumedCodePage() :
|
||||
m_previous(RIFF::Info::Tag::assumedCodePage())
|
||||
{
|
||||
}
|
||||
|
||||
~RestoreAssumedCodePage()
|
||||
{
|
||||
RIFF::Info::Tag::setAssumedCodePage(m_previous);
|
||||
}
|
||||
|
||||
private:
|
||||
const unsigned int m_previous;
|
||||
};
|
||||
|
||||
// Validates the raw chunk layout of the RIFF file in \a data against the
|
||||
// format itself instead of against TagLib, so that a file TagLib has just
|
||||
// written is checked independently of how TagLib would read it back. Returns
|
||||
// the data of the first top level chunk called \a name.
|
||||
ByteVector checkedRawChunk(const ByteVector &data, const char *name)
|
||||
{
|
||||
const ByteVector wanted(name);
|
||||
|
||||
CPPUNIT_ASSERT(data.size() >= 12u);
|
||||
CPPUNIT_ASSERT(data.startsWith("RIFF"));
|
||||
|
||||
// The size in the header covers everything after the size field itself,
|
||||
// so the file must be exactly that long: no gap and no trailing junk.
|
||||
const offset_t declaredEnd = static_cast<offset_t>(data.toUInt(4, false)) + 8;
|
||||
CPPUNIT_ASSERT_EQUAL(declaredEnd, static_cast<offset_t>(data.size()));
|
||||
|
||||
ByteVector found;
|
||||
offset_t offset = 12;
|
||||
while(offset + 8 <= declaredEnd) {
|
||||
const ByteVector chunkHeader = data.mid(static_cast<unsigned int>(offset), 8);
|
||||
CPPUNIT_ASSERT_EQUAL(8u, chunkHeader.size());
|
||||
|
||||
const unsigned int size = chunkHeader.toUInt(4, false);
|
||||
const ByteVector payload = data.mid(static_cast<unsigned int>(offset + 8), size);
|
||||
CPPUNIT_ASSERT_EQUAL(size, payload.size());
|
||||
|
||||
if(found.isEmpty() && chunkHeader.startsWith(wanted))
|
||||
found = payload;
|
||||
|
||||
// Chunks are padded to an even length, so an odd size is followed by a
|
||||
// pad byte which is not counted in the size.
|
||||
offset += 8 + size + (size % 2);
|
||||
}
|
||||
|
||||
// The chunks must tile the file exactly, up to and including its end.
|
||||
CPPUNIT_ASSERT_EQUAL(declaredEnd, offset);
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
// The CSET chunk of a saved file must declare UTF-8 and nothing else, in the
|
||||
// full 8 byte layout of four WORD fields: wCodePage is UTF-8's 65001, and the
|
||||
// country, language and dialect behind it are the zero that means the default.
|
||||
void assertRawCsetDeclaresUtf8(const ByteVector &data)
|
||||
{
|
||||
const ByteVector cset = checkedRawChunk(data, "CSET");
|
||||
CPPUNIT_ASSERT_EQUAL(8u, cset.size());
|
||||
CPPUNIT_ASSERT_EQUAL(65001u,
|
||||
static_cast<unsigned int>(
|
||||
static_cast<unsigned short>(cset.toShort(0, false))));
|
||||
CPPUNIT_ASSERT_EQUAL(ByteVector(6, '\0'), cset.mid(2, 6));
|
||||
}
|
||||
|
||||
// Tags no single byte charset could have represented, as the strings and as
|
||||
// the UTF-8 bytes they have to end up as on disk.
|
||||
const String kNewTitle(L"Title \x4F60\x597D \x20AC");
|
||||
const ByteVector kNewTitleUtf8("Title \xE4\xBD\xA0\xE5\xA5\xBD \xE2\x82\xAC", 16);
|
||||
const String kNewComment(L"\x4F60\x597D");
|
||||
const ByteVector kNewCommentUtf8("\xE4\xBD\xA0\xE5\xA5\xBD", 6);
|
||||
}
|
||||
#endif // TAGLIB_WITH_CHARSETTABLES
|
||||
|
||||
class TestWAV : public CppUnit::TestFixture
|
||||
{
|
||||
CPPUNIT_TEST_SUITE(TestWAV);
|
||||
@@ -69,6 +319,30 @@ class TestWAV : public CppUnit::TestFixture
|
||||
CPPUNIT_TEST(testBEXTTagWithOtherTags);
|
||||
CPPUNIT_TEST(testiXMLTag);
|
||||
CPPUNIT_TEST(testiXMLTagWithOtherTags);
|
||||
#ifdef TAGLIB_WITH_CHARSETTABLES
|
||||
CPPUNIT_TEST(testInfoLatin1Default);
|
||||
CPPUNIT_TEST(testInfoWindows1252);
|
||||
CPPUNIT_TEST(testInfoUtf8);
|
||||
CPPUNIT_TEST(testInfoLatin1SaveAsUtf8);
|
||||
CPPUNIT_TEST(testInfoWindows1252SaveAsUtf8);
|
||||
CPPUNIT_TEST(testInfoUtf8SaveAsUtf8);
|
||||
CPPUNIT_TEST(testInfoSaveWithoutFieldsWritesNoCset);
|
||||
CPPUNIT_TEST(testInfoSaveWithoutModificationPreservesTag);
|
||||
CPPUNIT_TEST(testInfoStripRemovesCset);
|
||||
CPPUNIT_TEST(testInfoTruncatedCsetIsFormatError);
|
||||
CPPUNIT_TEST(testInfoCsetWithLanguageFields);
|
||||
CPPUNIT_TEST(testInfoCodePageOnlyCset);
|
||||
CPPUNIT_TEST(testInfoUnsupportedCsetKeepsFields);
|
||||
CPPUNIT_TEST(testCharsetDecodeStopsAtNul);
|
||||
CPPUNIT_TEST(testCharsetEncodeIsUtf8Only);
|
||||
CPPUNIT_TEST(testInfoUserHandlerBeatsCodePage);
|
||||
CPPUNIT_TEST(testInfoCodePageReturnsWithoutUserHandler);
|
||||
CPPUNIT_TEST(testInfoAssumedCodePageDefaultIsLatin1);
|
||||
CPPUNIT_TEST(testInfoAssumedCodePageAcceptsUtf8);
|
||||
CPPUNIT_TEST(testInfoAssumedCodePageWindows1252);
|
||||
CPPUNIT_TEST(testInfoAssumedCodePageOnlyWhenUndeclared);
|
||||
CPPUNIT_TEST(testInfoAssumedCodePageRejectsUnsupported);
|
||||
#endif
|
||||
CPPUNIT_TEST_SUITE_END();
|
||||
|
||||
public:
|
||||
@@ -236,6 +510,565 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef TAGLIB_WITH_CHARSETTABLES
|
||||
void testInfoLatin1Default()
|
||||
{
|
||||
ByteVectorStream stream(latin1Riff());
|
||||
RIFF::WAV::File f(&stream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(f.hasInfoTag());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"A \xAE B"), f.InfoTag()->album());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"A \xAE B"), f.InfoTag()->artist());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"Caf\xE9"), f.InfoTag()->comment());
|
||||
CPPUNIT_ASSERT_EQUAL(static_cast<unsigned int>(2024), f.InfoTag()->year());
|
||||
}
|
||||
|
||||
void testInfoWindows1252()
|
||||
{
|
||||
ByteVectorStream stream(windows1252Riff());
|
||||
RIFF::WAV::File f(&stream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(f.hasInfoTag());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"Caf\xE9 \x20AC"), f.InfoTag()->album());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"A \x20AC B"), f.InfoTag()->artist());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"\x201Cquoted\x201D"), f.InfoTag()->comment());
|
||||
}
|
||||
|
||||
void testInfoUtf8()
|
||||
{
|
||||
ByteVectorStream stream(utf8Riff());
|
||||
RIFF::WAV::File f(&stream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(f.hasInfoTag());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"Caf\xE9"), f.InfoTag()->album());
|
||||
CPPUNIT_ASSERT_EQUAL(static_cast<unsigned int>(2024), f.InfoTag()->year());
|
||||
}
|
||||
|
||||
// Each of the three files is put through the same two steps. First it must
|
||||
// parse, with the charset its own CSET chunk declares. Then its tags are
|
||||
// changed and written back, which rewrites the INFO chunk as UTF-8 and
|
||||
// declares that in a CSET chunk, and the result must be read back correctly.
|
||||
|
||||
void testInfoLatin1SaveAsUtf8()
|
||||
{
|
||||
// Step 1: the file has no CSET chunk, so it is Latin-1 by default.
|
||||
{
|
||||
ByteVectorStream stream(latin1Riff());
|
||||
RIFF::WAV::File f(&stream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(f.hasInfoTag());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"A \xAE B"), f.InfoTag()->album());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"A \xAE B"), f.InfoTag()->artist());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"Caf\xE9"), f.InfoTag()->comment());
|
||||
CPPUNIT_ASSERT_EQUAL(2024u, f.InfoTag()->year());
|
||||
}
|
||||
|
||||
// Step 2: change the tags and write it out.
|
||||
ByteVectorStream stream(latin1Riff());
|
||||
{
|
||||
RIFF::WAV::File f(&stream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
|
||||
f.InfoTag()->setTitle(kNewTitle);
|
||||
f.InfoTag()->setComment(kNewComment);
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
CPPUNIT_ASSERT(f.hasInfoTag());
|
||||
}
|
||||
const ByteVector saved = *stream.data();
|
||||
|
||||
// What was written must be a well formed RIFF file that declares UTF-8.
|
||||
assertRawCsetDeclaresUtf8(saved);
|
||||
const ByteVector list = checkedRawChunk(saved, "LIST");
|
||||
CPPUNIT_ASSERT(list.startsWith("INFO"));
|
||||
CPPUNIT_ASSERT(list.find(kNewTitleUtf8) >= 0);
|
||||
CPPUNIT_ASSERT(list.find(kNewCommentUtf8) >= 0);
|
||||
|
||||
// Reading it back must give the changed tags, plus the fields that were left
|
||||
// alone unchanged by the Latin-1 to UTF-8 rewrite.
|
||||
{
|
||||
ByteVectorStream savedStream(saved);
|
||||
RIFF::WAV::File f(&savedStream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(f.hasInfoTag());
|
||||
CPPUNIT_ASSERT_EQUAL(kNewTitle, f.InfoTag()->title());
|
||||
CPPUNIT_ASSERT_EQUAL(kNewComment, f.InfoTag()->comment());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"A \xAE B"), f.InfoTag()->album());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"A \xAE B"), f.InfoTag()->artist());
|
||||
CPPUNIT_ASSERT_EQUAL(2024u, f.InfoTag()->year());
|
||||
}
|
||||
}
|
||||
|
||||
void testInfoWindows1252SaveAsUtf8()
|
||||
{
|
||||
// Step 1: the file declares code page 1252 in its CSET chunk.
|
||||
{
|
||||
ByteVectorStream stream(windows1252Riff());
|
||||
RIFF::WAV::File f(&stream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(f.hasInfoTag());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"Caf\xE9 \x20AC"), f.InfoTag()->album());
|
||||
}
|
||||
|
||||
// Step 2: change the tags and write it out.
|
||||
ByteVectorStream stream(windows1252Riff());
|
||||
{
|
||||
RIFF::WAV::File f(&stream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
|
||||
f.InfoTag()->setTitle(kNewTitle);
|
||||
f.InfoTag()->setComment(kNewComment);
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
const ByteVector saved = *stream.data();
|
||||
|
||||
// It used to declare code page 1252 and now declares UTF-8.
|
||||
assertRawCsetDeclaresUtf8(saved);
|
||||
const ByteVector list = checkedRawChunk(saved, "LIST");
|
||||
CPPUNIT_ASSERT(list.find(kNewTitleUtf8) >= 0);
|
||||
CPPUNIT_ASSERT(list.find(kNewCommentUtf8) >= 0);
|
||||
|
||||
{
|
||||
ByteVectorStream savedStream(saved);
|
||||
RIFF::WAV::File f(&savedStream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(f.hasInfoTag());
|
||||
CPPUNIT_ASSERT_EQUAL(kNewTitle, f.InfoTag()->title());
|
||||
CPPUNIT_ASSERT_EQUAL(kNewComment, f.InfoTag()->comment());
|
||||
// The album was never touched and survived the rewrite unchanged.
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"Caf\xE9 \x20AC"), f.InfoTag()->album());
|
||||
}
|
||||
}
|
||||
|
||||
void testInfoUtf8SaveAsUtf8()
|
||||
{
|
||||
// Step 1: the file already declares UTF-8, and its CSET chunk comes
|
||||
// after the LIST chunk it applies to.
|
||||
{
|
||||
ByteVectorStream stream(utf8Riff());
|
||||
RIFF::WAV::File f(&stream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(f.hasInfoTag());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"Caf\xE9"), f.InfoTag()->album());
|
||||
CPPUNIT_ASSERT_EQUAL(2024u, f.InfoTag()->year());
|
||||
}
|
||||
|
||||
// Step 2: change the tags and write it out.
|
||||
ByteVectorStream stream(utf8Riff());
|
||||
{
|
||||
RIFF::WAV::File f(&stream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
|
||||
f.InfoTag()->setTitle(kNewTitle);
|
||||
f.InfoTag()->setComment(kNewComment);
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
const ByteVector saved = *stream.data();
|
||||
|
||||
assertRawCsetDeclaresUtf8(saved);
|
||||
const ByteVector list = checkedRawChunk(saved, "LIST");
|
||||
CPPUNIT_ASSERT(list.find(kNewTitleUtf8) >= 0);
|
||||
CPPUNIT_ASSERT(list.find(kNewCommentUtf8) >= 0);
|
||||
|
||||
{
|
||||
ByteVectorStream savedStream(saved);
|
||||
RIFF::WAV::File f(&savedStream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(f.hasInfoTag());
|
||||
CPPUNIT_ASSERT_EQUAL(kNewTitle, f.InfoTag()->title());
|
||||
CPPUNIT_ASSERT_EQUAL(kNewComment, f.InfoTag()->comment());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"Caf\xE9"), f.InfoTag()->album());
|
||||
CPPUNIT_ASSERT_EQUAL(2024u, f.InfoTag()->year());
|
||||
}
|
||||
}
|
||||
|
||||
void testInfoSaveWithoutFieldsWritesNoCset()
|
||||
{
|
||||
ByteVectorStream stream(latin1Riff());
|
||||
{
|
||||
RIFF::WAV::File f(&stream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(f.hasInfoTag());
|
||||
|
||||
for(const auto &[id, value] : f.InfoTag()->fieldListMap())
|
||||
f.InfoTag()->removeField(id);
|
||||
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
// Nothing is left to describe, so no CSET chunk is written either.
|
||||
CPPUNIT_ASSERT(!f.hasInfoTag());
|
||||
}
|
||||
|
||||
const ByteVector saved = *stream.data();
|
||||
CPPUNIT_ASSERT(checkedRawChunk(saved, "CSET").isEmpty());
|
||||
CPPUNIT_ASSERT(checkedRawChunk(saved, "LIST").isEmpty());
|
||||
}
|
||||
|
||||
// Saving a file that already has an INFO tag must keep that tag. It comes
|
||||
// back in UTF-8 with a CSET chunk saying so, whatever it was read as.
|
||||
void testInfoSaveWithoutModificationPreservesTag()
|
||||
{
|
||||
struct {
|
||||
ByteVector file;
|
||||
const wchar_t *album;
|
||||
} cases[] = {
|
||||
{latin1Riff(), L"A \xAE B"},
|
||||
{windows1251Riff(), L"Caf\x0439"},
|
||||
{windows1252Riff(), L"Caf\xE9 \x20AC"},
|
||||
{utf8Riff(), L"Caf\xE9"}
|
||||
};
|
||||
|
||||
for(const auto &c : cases) {
|
||||
// Save without touching a single field.
|
||||
ByteVectorStream stream(c.file);
|
||||
{
|
||||
RIFF::WAV::File f(&stream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(f.hasInfoTag());
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
|
||||
// The tag survived, and the file now declares UTF-8 for it.
|
||||
const ByteVector saved = *stream.data();
|
||||
assertRawCsetDeclaresUtf8(saved);
|
||||
CPPUNIT_ASSERT(checkedRawChunk(saved, "LIST").startsWith("INFO"));
|
||||
|
||||
ByteVectorStream savedStream(saved);
|
||||
RIFF::WAV::File f(&savedStream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(f.hasInfoTag());
|
||||
CPPUNIT_ASSERT_EQUAL(String(c.album), f.InfoTag()->album());
|
||||
}
|
||||
}
|
||||
|
||||
// Stripping the INFO tag takes the CSET chunk with it. A CSET that outlived
|
||||
// its LIST would describe a charset for text the file no longer has.
|
||||
void testInfoStripRemovesCset()
|
||||
{
|
||||
const ByteVector files[] = {latin1Riff(), utf8Riff(), windows1251Riff(), windows1252Riff()};
|
||||
|
||||
for(const ByteVector &file : files) {
|
||||
ByteVectorStream stream(file);
|
||||
{
|
||||
RIFF::WAV::File f(&stream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(f.hasInfoTag());
|
||||
|
||||
f.strip(RIFF::WAV::File::Info);
|
||||
CPPUNIT_ASSERT(f.InfoTag()->isEmpty());
|
||||
|
||||
CPPUNIT_ASSERT(f.save(RIFF::WAV::File::Info));
|
||||
CPPUNIT_ASSERT(!f.hasInfoTag());
|
||||
}
|
||||
|
||||
const ByteVector saved = *stream.data();
|
||||
CPPUNIT_ASSERT(checkedRawChunk(saved, "LIST").isEmpty());
|
||||
CPPUNIT_ASSERT(checkedRawChunk(saved, "CSET").isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
// A CSET chunk too short to hold its own wCodePage is a file format error.
|
||||
// Two bytes are the whole of wCodePage, so there is nothing shorter to read,
|
||||
// and the chunk is ignored instead of guessed at: the INFO text keeps the RIFF
|
||||
// default of Latin-1, under which 0x80 is U+0080 and not the Euro sign that the
|
||||
// page the chunk tried to declare would have made of it.
|
||||
void testInfoTruncatedCsetIsFormatError()
|
||||
{
|
||||
// Step 1: the chunk is ignored, and the text it failed to declare the
|
||||
// charset of decodes as the Latin-1 default.
|
||||
{
|
||||
ByteVectorStream stream(truncatedCsetRiff());
|
||||
RIFF::WAV::File f(&stream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(f.hasInfoTag());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"A \x80 B"), f.InfoTag()->artist());
|
||||
}
|
||||
|
||||
// Step 2: the malformed chunk never declared a charset, so saving replaces
|
||||
// it with the one valid CSET chunk TagLib writes, instead of keeping it.
|
||||
ByteVectorStream stream(truncatedCsetRiff());
|
||||
{
|
||||
RIFF::WAV::File f(&stream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
assertRawCsetDeclaresUtf8(*stream.data());
|
||||
}
|
||||
|
||||
// The code page of a CSET chunk is wCodePage, one WORD of the four the layout
|
||||
// has. The country, language and dialect behind it are not part of that number:
|
||||
// a file that fills them in declares the page its first WORD holds, and has to
|
||||
// be read with that page.
|
||||
void testInfoCsetWithLanguageFields()
|
||||
{
|
||||
{
|
||||
ByteVectorStream stream(windows1252WithLanguageRiff());
|
||||
RIFF::WAV::File f(&stream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(f.hasInfoTag());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"A \x20AC B"), f.InfoTag()->artist());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"Caf\xE9 \x20AC"), f.InfoTag()->album());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"\x201Cquoted\x201D"), f.InfoTag()->comment());
|
||||
}
|
||||
|
||||
// Saving rewrites the chunk the way TagLib writes it: the code page, and the
|
||||
// three fields behind it back at their zero defaults.
|
||||
ByteVectorStream stream(windows1252WithLanguageRiff());
|
||||
{
|
||||
RIFF::WAV::File f(&stream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
assertRawCsetDeclaresUtf8(*stream.data());
|
||||
}
|
||||
|
||||
// wCodePage on its own is all the chunk has to say. A file that leaves the
|
||||
// three fields behind it out entirely still declares a charset.
|
||||
void testInfoCodePageOnlyCset()
|
||||
{
|
||||
ByteVectorStream stream(codePageOnlyCsetRiff());
|
||||
RIFF::WAV::File f(&stream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(f.hasInfoTag());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"A \x20AC B"), f.InfoTag()->artist());
|
||||
}
|
||||
|
||||
// A code page this library has no table for is a hint it cannot follow, not a
|
||||
// reason to throw the tag away. The text keeps the RIFF default of ISO 8859-1,
|
||||
// so every field is still read, and a save still writes it back.
|
||||
void testInfoUnsupportedCsetKeepsFields()
|
||||
{
|
||||
{
|
||||
ByteVectorStream stream(unsupportedCsetRiff());
|
||||
RIFF::WAV::File f(&stream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(f.hasInfoTag());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"A \xAE B"), f.InfoTag()->artist());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"Caf\xE9"), f.InfoTag()->comment());
|
||||
}
|
||||
|
||||
ByteVectorStream stream(unsupportedCsetRiff());
|
||||
{
|
||||
RIFF::WAV::File f(&stream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
f.InfoTag()->setTitle(kNewTitle);
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
|
||||
// The field was read as ISO 8859-1 and is written back as UTF-8, so the
|
||||
// character it held is in the saved LIST as its two UTF-8 bytes.
|
||||
const ByteVector saved = *stream.data();
|
||||
assertRawCsetDeclaresUtf8(saved);
|
||||
CPPUNIT_ASSERT(checkedRawChunk(saved, "LIST").find(ByteVector("A \xC2\xAE B", 6)) >= 0);
|
||||
|
||||
ByteVectorStream savedStream(saved);
|
||||
RIFF::WAV::File f(&savedStream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT_EQUAL(kNewTitle, f.InfoTag()->title());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"A \xAE B"), f.InfoTag()->artist());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"Caf\xE9"), f.InfoTag()->comment());
|
||||
}
|
||||
|
||||
// Byte 0x00 is the one byte of a code page's repertoire that cannot be part
|
||||
// of a field's text, because decoding stops at the first NUL: that is how a
|
||||
// string ends. It has to end the string and not turn into a character.
|
||||
void testCharsetDecodeStopsAtNul()
|
||||
{
|
||||
const ByteVector data("\x41\x00\x42", 3);
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"A"), Charset::decode(data, Charset::Type::Windows1251));
|
||||
CPPUNIT_ASSERT_EQUAL(String(), Charset::decode(ByteVector("\x00\x41", 2), Charset::Type::Windows1251));
|
||||
}
|
||||
|
||||
// A string is decoded into TagLib's internal UTF-16 representation, which is
|
||||
// not a byte encoding at all, so there is no way to encode back into a legacy
|
||||
// code page. That is precisely what keeps text a code page cannot hold from
|
||||
// being quietly replaced.
|
||||
void testCharsetEncodeIsUtf8Only()
|
||||
{
|
||||
CPPUNIT_ASSERT_EQUAL(Charset::Type::UTF8, Charset::typeForCodePage(65001).value());
|
||||
|
||||
// Encoding is UTF-8 and nothing else, so text no single byte code page could
|
||||
// ever represent survives instead of turning into '?'.
|
||||
CPPUNIT_ASSERT_EQUAL(ByteVector("\xE4\xBD\xA0", 3), Charset::encode(String(L"\x4F60"), Charset::Type::UTF8));
|
||||
CPPUNIT_ASSERT_EQUAL(kNewTitleUtf8, Charset::encode(kNewTitle, Charset::Type::UTF8));
|
||||
|
||||
// And it is a plain UTF-8 round trip, so nothing is lost on the way through.
|
||||
const String sample(L"\x0416\x0490\x05D0\x0625\x0E01\x20A7");
|
||||
CPPUNIT_ASSERT_EQUAL(sample, Charset::decode(Charset::encode(sample, Charset::Type::UTF8), Charset::Type::UTF8));
|
||||
}
|
||||
|
||||
// A handler the user installed is an explicit override, so it outranks the
|
||||
// code page the file declares. Otherwise setStringHandler() would do nothing
|
||||
// at all for every file TagLib itself writes, since those all carry a CSET.
|
||||
class FixedStringHandler : public RIFF::Info::StringHandler
|
||||
{
|
||||
public:
|
||||
String parse(const ByteVector &) const override { return String(L"from the handler"); }
|
||||
ByteVector render(const String &) const override { return ByteVector("from the handler"); }
|
||||
};
|
||||
|
||||
void testInfoUserHandlerBeatsCodePage()
|
||||
{
|
||||
class Restore
|
||||
{
|
||||
public:
|
||||
~Restore() { RIFF::Info::Tag::setStringHandler(nullptr); }
|
||||
} restore;
|
||||
|
||||
// The file declares 1252, and the default path reads it with that table.
|
||||
{
|
||||
ByteVectorStream stream(windows1252Riff());
|
||||
RIFF::WAV::File f(&stream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(f.hasInfoTag());
|
||||
CPPUNIT_ASSERT(f.InfoTag()->artist() != String(L"from the handler"));
|
||||
}
|
||||
|
||||
const FixedStringHandler handler;
|
||||
RIFF::Info::Tag::setStringHandler(&handler);
|
||||
|
||||
// With a handler installed it is used even though the file declares 1252,
|
||||
// on the way in ...
|
||||
{
|
||||
ByteVectorStream stream(windows1252Riff());
|
||||
RIFF::WAV::File f(&stream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(f.hasInfoTag());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"from the handler"), f.InfoTag()->artist());
|
||||
}
|
||||
|
||||
// ... and on the way out, so the handler decides the bytes as well.
|
||||
ByteVectorStream stream(windows1252Riff());
|
||||
{
|
||||
RIFF::WAV::File f(&stream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(f.hasInfoTag());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"from the handler"), f.InfoTag()->artist());
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
CPPUNIT_ASSERT(checkedRawChunk(*stream.data(), "LIST")
|
||||
.find(ByteVector("from the handler")) >= 0);
|
||||
}
|
||||
|
||||
// Removing the handler again must put the file's own code page back in charge.
|
||||
void testInfoCodePageReturnsWithoutUserHandler()
|
||||
{
|
||||
ByteVectorStream stream(windows1251Riff());
|
||||
RIFF::WAV::File f(&stream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(f.hasInfoTag());
|
||||
CPPUNIT_ASSERT(f.InfoTag()->artist() != String(L"from the handler"));
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"A \x0410 B"), f.InfoTag()->artist());
|
||||
}
|
||||
|
||||
// The specification makes ISO 8859-1 the charset of a file that declares
|
||||
// none, and that is what an untouched library assumes: the UTF-8 bytes of a
|
||||
// file that never said what they are read as the Latin-1 characters they
|
||||
// also happen to be.
|
||||
void testInfoAssumedCodePageDefaultIsLatin1()
|
||||
{
|
||||
CPPUNIT_ASSERT_EQUAL(0u, RIFF::Info::Tag::assumedCodePage());
|
||||
|
||||
ByteVectorStream stream(utf8WithoutCsetRiff());
|
||||
RIFF::WAV::File f(&stream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(f.hasInfoTag());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"Caf\xC3\xA9"), f.InfoTag()->artist());
|
||||
}
|
||||
|
||||
// Software that writes UTF-8 without declaring it is what the option is for.
|
||||
// Stated once, it is what every file that declares nothing is read with.
|
||||
void testInfoAssumedCodePageAcceptsUtf8()
|
||||
{
|
||||
const RestoreAssumedCodePage restore;
|
||||
|
||||
RIFF::Info::Tag::setAssumedCodePage(65001);
|
||||
CPPUNIT_ASSERT_EQUAL(65001u, RIFF::Info::Tag::assumedCodePage());
|
||||
|
||||
{
|
||||
ByteVectorStream stream(utf8WithoutCsetRiff());
|
||||
RIFF::WAV::File f(&stream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"Caf\xE9"), f.InfoTag()->artist());
|
||||
}
|
||||
|
||||
// An INFO list read without its file, as in Tag(const ByteVector &data),
|
||||
// declares nothing either, so it is read the same way.
|
||||
const ByteVector list = ByteVector("INFO", 4) +
|
||||
riffChunk("IART", ByteVector("Caf\xC3\xA9", 5) + ByteVector(1, '\0'));
|
||||
RIFF::Info::Tag tag(list);
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"Caf\xE9"), tag.artist());
|
||||
|
||||
// Stating 0 again is going back to what the specification says.
|
||||
RIFF::Info::Tag::setAssumedCodePage(0);
|
||||
CPPUNIT_ASSERT_EQUAL(0u, RIFF::Info::Tag::assumedCodePage());
|
||||
ByteVectorStream stream(utf8WithoutCsetRiff());
|
||||
RIFF::WAV::File f(&stream);
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"Caf\xC3\xA9"), f.InfoTag()->artist());
|
||||
}
|
||||
|
||||
// The other real world answer is an ANSI code page: such a file keeps its
|
||||
// typographic characters in 0x80-0x9f, where ISO 8859-1 has control
|
||||
// characters that no amount of faithful decoding turns into text.
|
||||
void testInfoAssumedCodePageWindows1252()
|
||||
{
|
||||
const RestoreAssumedCodePage restore;
|
||||
|
||||
RIFF::Info::Tag::setAssumedCodePage(1252);
|
||||
CPPUNIT_ASSERT_EQUAL(1252u, RIFF::Info::Tag::assumedCodePage());
|
||||
|
||||
ByteVectorStream stream(riffWithInfo({{"IART", ByteVector("A \x80 B", 5)}}));
|
||||
RIFF::WAV::File f(&stream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"A \x20AC B"), f.InfoTag()->artist());
|
||||
}
|
||||
|
||||
// The option is for files that declare nothing. A file declaring a code page
|
||||
// keeps it, whether or not this library has a table for it, and a CSET whose
|
||||
// wCodePage is zero is the specification's way of declaring nothing, so that
|
||||
// is where the assumption applies.
|
||||
void testInfoAssumedCodePageOnlyWhenUndeclared()
|
||||
{
|
||||
const RestoreAssumedCodePage restore;
|
||||
|
||||
RIFF::Info::Tag::setAssumedCodePage(65001);
|
||||
|
||||
{
|
||||
ByteVectorStream stream(windows1252Riff());
|
||||
RIFF::WAV::File f(&stream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"Caf\xE9 \x20AC"), f.InfoTag()->album());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"A \x20AC B"), f.InfoTag()->artist());
|
||||
}
|
||||
|
||||
{
|
||||
ByteVectorStream stream(unsupportedCsetRiff());
|
||||
RIFF::WAV::File f(&stream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"A \xAE B"), f.InfoTag()->artist());
|
||||
}
|
||||
|
||||
{
|
||||
ByteVectorStream stream(riffWithInfoCset(
|
||||
{{"IART", ByteVector("Caf\xC3\xA9", 5)}}, ByteVector(8, '\0')));
|
||||
RIFF::WAV::File f(&stream);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT_EQUAL(String(L"Caf\xE9"), f.InfoTag()->artist());
|
||||
}
|
||||
}
|
||||
|
||||
// A code page this library cannot decode would quietly do nothing if it were
|
||||
// accepted as the assumption, so it is refused and the previous one stays.
|
||||
void testInfoAssumedCodePageRejectsUnsupported()
|
||||
{
|
||||
const RestoreAssumedCodePage restore;
|
||||
|
||||
RIFF::Info::Tag::setAssumedCodePage(1252);
|
||||
CPPUNIT_ASSERT_EQUAL(1252u, RIFF::Info::Tag::assumedCodePage());
|
||||
|
||||
RIFF::Info::Tag::setAssumedCodePage(932);
|
||||
CPPUNIT_ASSERT_EQUAL(1252u, RIFF::Info::Tag::assumedCodePage());
|
||||
}
|
||||
#endif // TAGLIB_WITH_CHARSETTABLES
|
||||
|
||||
void testStripTags()
|
||||
{
|
||||
ScopedFileCopy copy("empty", ".wav");
|
||||
@@ -291,7 +1124,9 @@ public:
|
||||
CPPUNIT_ASSERT_EQUAL(String("Title1"), f.InfoTag()->title());
|
||||
|
||||
f.save();
|
||||
CPPUNIT_ASSERT_EQUAL(static_cast<offset_t>(15898), f.length());
|
||||
// Saving the INFO tag also writes the 16 byte CSET chunk (8 byte header
|
||||
// plus the 8 byte payload) which declares that the tag is UTF-8.
|
||||
CPPUNIT_ASSERT_EQUAL(static_cast<offset_t>(15914), f.length());
|
||||
CPPUNIT_ASSERT_EQUAL(static_cast<offset_t>(-1), f.find("Title2"));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user