AIFF: Support historic NAME/AUTH/(c)/ANNO text chunks (#1488)

(defaulting to Mac OS Roman charset (Windows codepage 10000),
overridable by user)

Before this commit, only ID3 tags were supported, but AIFF also historically
features 4 text chunks, see
https://web.archive.org/web/20180613135101/https://www-mmsp.ece.mcgill.ca/Documents/AudioFormats/AIFF/Docs/AIFF-1.3.pdf,
chapter "Text Chunks - Name, Author, Copyright, Annotation".
This commit is contained in:
MinnieTheMoocher
2026-11-03 19:56:12 +01:00
committed by GitHub
parent 52d0f7cc5e
commit ba4eba9d41
9 changed files with 959 additions and 70 deletions
+303
View File
@@ -28,13 +28,104 @@
#include "tbytevectorlist.h"
#include "tag.h"
#include "taglib_config.h"
#include "aifffile.h"
#include "aiffnativetag.h"
#include "tbytevectorstream.h"
#include "tpropertymap.h"
#include <cppunit/extensions/HelperMacros.h>
#include "utils.h"
using namespace std;
using namespace TagLib;
#ifdef TAGLIB_WITH_CHARSETTABLES
namespace
{
// Puts back the code page a native tag assumes, so that a test stating
// another one does not carry it into the next test.
class RestoreAssumedCodePage
{
public:
RestoreAssumedCodePage() :
m_previous(RIFF::AIFF::NativeTag::assumedCodePage())
{
}
~RestoreAssumedCodePage()
{
RIFF::AIFF::NativeTag::setAssumedCodePage(m_previous);
}
private:
const unsigned int m_previous;
};
// One chunk of an AIFF file: its id, its size, its payload, and the pad
// byte that brings an odd sized payload to an even length.
ByteVector aiffChunk(const char *id, const ByteVector &payload)
{
ByteVector chunk = ByteVector(id, 4) + ByteVector::fromUInt(payload.size()) +
payload;
if(payload.size() % 2)
chunk.append(static_cast<char>(0));
return chunk;
}
// A minimal AIFF file: a COMM chunk describing one silent channel, and the
// NAME chunk holding \a name unless it is empty.
ByteVector aiffWithName(const ByteVector &name)
{
ByteVector body = aiffChunk("COMM",
ByteVector("\x00\x01\x00\x00\x00\x00\x00\x10"
"\x40\x0E\xAC\x44\x00\x00\x00\x00"
"\x00\x00", 18));
if(!name.isEmpty())
body.append(aiffChunk("NAME", name));
return ByteVector("FORM") + ByteVector::fromUInt(body.size() + 4) +
ByteVector("AIFF") + body;
}
// The payload of the first chunk called \a name in the AIFF file \a data,
// checked against the format itself instead of against TagLib: the FORM
// header's size has to cover exactly the file, and the chunks have to tile
// it, so a file TagLib has just written is validated independently of how
// TagLib would read it back.
ByteVector checkedRawChunk(const ByteVector &data, const char *name)
{
const ByteVector wanted(name);
CPPUNIT_ASSERT(data.size() >= 12u);
CPPUNIT_ASSERT(data.startsWith("FORM"));
const offset_t declaredEnd = static_cast<offset_t>(data.toUInt(4u)) + 8;
CPPUNIT_ASSERT_EQUAL(declaredEnd, static_cast<offset_t>(data.size()));
ByteVector found;
offset_t offset = 12;
while(offset + 8 <= declaredEnd) {
const ByteVector header = data.mid(static_cast<unsigned int>(offset), 8);
CPPUNIT_ASSERT_EQUAL(8u, header.size());
const unsigned int size = header.toUInt(4u);
const ByteVector payload = data.mid(static_cast<unsigned int>(offset + 8), size);
CPPUNIT_ASSERT_EQUAL(size, payload.size());
if(found.isEmpty() && header.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);
}
CPPUNIT_ASSERT_EQUAL(declaredEnd, offset);
return found;
}
} // namespace
#endif // TAGLIB_WITH_CHARSETTABLES
class TestAIFF : public CppUnit::TestFixture
{
CPPUNIT_TEST_SUITE(TestAIFF);
@@ -43,6 +134,13 @@ class TestAIFF : public CppUnit::TestFixture
CPPUNIT_TEST(testSaveID3v2);
CPPUNIT_TEST(testSaveID3v23);
CPPUNIT_TEST(testDuplicateID3v2);
CPPUNIT_TEST(testNativeFields);
CPPUNIT_TEST(testSaveNativeFields);
#ifdef TAGLIB_WITH_CHARSETTABLES
CPPUNIT_TEST(testNativeMacRomanRead);
CPPUNIT_TEST(testNativeMacRomanWrite);
CPPUNIT_TEST(testNativeCodePageOverride);
#endif // TAGLIB_WITH_CHARSETTABLES
CPPUNIT_TEST(testFuzzedFile1);
CPPUNIT_TEST(testFuzzedFile2);
CPPUNIT_TEST_SUITE_END();
@@ -146,6 +244,211 @@ public:
CPPUNIT_ASSERT_EQUAL(static_cast<offset_t>(-1), f.find("Title2"));
}
void testNativeFields()
{
// The four text chunks of a file that predates ID3v2.
RIFF::AIFF::File f(TEST_FILE_PATH_C("alaw.aifc"));
CPPUNIT_ASSERT(f.hasNativeTag());
CPPUNIT_ASSERT(!f.nativeTag()->isEmpty());
CPPUNIT_ASSERT_EQUAL(String("woodblock"), f.nativeTag()->title());
CPPUNIT_ASSERT_EQUAL(String("Prosonus"), f.nativeTag()->artist());
CPPUNIT_ASSERT_EQUAL(String("Copyright 1991, Prosonus"),
f.nativeTag()->fieldText("(c) "));
CPPUNIT_ASSERT(f.nativeTag()->comment().isEmpty());
// The native tag has no field behind these.
CPPUNIT_ASSERT(f.nativeTag()->album().isEmpty());
CPPUNIT_ASSERT_EQUAL(0u, f.nativeTag()->year());
f.nativeTag()->setAlbum("Album");
f.nativeTag()->setYear(2026);
CPPUNIT_ASSERT(f.nativeTag()->album().isEmpty());
CPPUNIT_ASSERT_EQUAL(0u, f.nativeTag()->year());
// The four chunks with their property keys, and nothing unsupported.
const PropertyMap props = f.nativeTag()->properties();
CPPUNIT_ASSERT_EQUAL(3u, props.size());
CPPUNIT_ASSERT_EQUAL(String("woodblock"), props.value("TITLE").front());
CPPUNIT_ASSERT_EQUAL(String("Prosonus"), props.value("ARTIST").front());
CPPUNIT_ASSERT_EQUAL(String("Copyright 1991, Prosonus"),
props.value("COPYRIGHT").front());
// A file with no native chunks holds an empty tag and says so.
RIFF::AIFF::File empty(TEST_FILE_PATH_C("empty.aiff"));
CPPUNIT_ASSERT(!empty.hasNativeTag());
CPPUNIT_ASSERT(empty.nativeTag()->isEmpty());
// Only the four text chunks are fields of this class: COMM is the audio
// layout, so neither reading nor writing it as a field does anything.
RIFF::AIFF::NativeTag::FieldDataMap fields;
fields["NAME"] = ByteVector("Title");
fields["COMM"] = ByteVector("not a field");
RIFF::AIFF::NativeTag tag(fields);
CPPUNIT_ASSERT_EQUAL(String("Title"), tag.title());
CPPUNIT_ASSERT(tag.fieldText("COMM").isEmpty());
tag.setFieldText("COMM", "ignored");
CPPUNIT_ASSERT(tag.fieldText("COMM").isEmpty());
CPPUNIT_ASSERT_EQUAL(1u, tag.properties().size());
}
void testSaveNativeFields()
{
ScopedFileCopy copy("empty", ".aiff");
const string newname = copy.fileName();
{
RIFF::AIFF::File f(newname.c_str());
CPPUNIT_ASSERT(!f.hasNativeTag());
f.nativeTag()->setTitle("TitleXXX");
f.nativeTag()->setArtist("Artist XXX");
f.nativeTag()->setFieldText("(c) ", "Copyright XXX");
f.nativeTag()->setComment("Comment XXX");
CPPUNIT_ASSERT(f.save());
CPPUNIT_ASSERT(f.hasNativeTag());
// The native tag is its own tag: writing it writes no ID3v2 chunk.
CPPUNIT_ASSERT(!f.hasID3v2Tag());
}
{
RIFF::AIFF::File f(newname.c_str());
CPPUNIT_ASSERT(f.hasNativeTag());
CPPUNIT_ASSERT_EQUAL(String("TitleXXX"), f.nativeTag()->title());
CPPUNIT_ASSERT_EQUAL(String("Artist XXX"), f.nativeTag()->artist());
CPPUNIT_ASSERT_EQUAL(String("Copyright XXX"),
f.nativeTag()->fieldText("(c) "));
CPPUNIT_ASSERT_EQUAL(String("Comment XXX"), f.nativeTag()->comment());
// A field that loses its text loses its chunk; the others stay.
f.nativeTag()->setTitle("");
CPPUNIT_ASSERT(f.save());
}
{
RIFF::AIFF::File f(newname.c_str());
CPPUNIT_ASSERT(f.hasNativeTag());
CPPUNIT_ASSERT(f.nativeTag()->title().isEmpty());
CPPUNIT_ASSERT_EQUAL(String("Artist XXX"), f.nativeTag()->artist());
}
{
RIFF::AIFF::File f(newname.c_str());
f.nativeTag()->setArtist("");
f.nativeTag()->setFieldText("(c) ", "");
f.nativeTag()->setComment("");
CPPUNIT_ASSERT(f.save());
CPPUNIT_ASSERT(!f.hasNativeTag());
}
{
RIFF::AIFF::File f(newname.c_str());
CPPUNIT_ASSERT(!f.hasNativeTag());
CPPUNIT_ASSERT(f.nativeTag()->isEmpty());
}
// Saving a file whose native chunks one never touched keeps them.
ScopedFileCopy aifcCopy("alaw", ".aifc");
{
RIFF::AIFF::File f(aifcCopy.fileName().c_str());
CPPUNIT_ASSERT(f.save());
}
{
RIFF::AIFF::File f(aifcCopy.fileName().c_str());
CPPUNIT_ASSERT(f.hasNativeTag());
CPPUNIT_ASSERT_EQUAL(String("woodblock"), f.nativeTag()->title());
CPPUNIT_ASSERT_EQUAL(String("Prosonus"), f.nativeTag()->artist());
}
}
#ifdef TAGLIB_WITH_CHARSETTABLES
void testNativeMacRomanRead()
{
RestoreAssumedCodePage restore;
// No code page stated is what a native tag assumes when the caller has
// said nothing, which is what this build starts with: the tag then reads
// Mac OS Roman.
CPPUNIT_ASSERT_EQUAL(0u, RIFF::AIFF::NativeTag::assumedCodePage());
{
ByteVectorStream stream(aiffWithName(ByteVector("Caf\x8E", 4)));
RIFF::AIFF::File f(&stream, false);
CPPUNIT_ASSERT_EQUAL(String(L"Caf\u00E9"), f.nativeTag()->title());
}
// Stating the Mac OS Roman code page itself reads the very same byte.
RIFF::AIFF::NativeTag::setAssumedCodePage(10000);
{
ByteVectorStream stream(aiffWithName(ByteVector("Caf\x8E", 4)));
RIFF::AIFF::File f(&stream, false);
CPPUNIT_ASSERT_EQUAL(String(L"Caf\u00E9"), f.nativeTag()->title());
}
// The caller saying its files are ISO 8859-1 reads that byte as that
// charset's character instead.
RIFF::AIFF::NativeTag::setAssumedCodePage(28591);
{
ByteVectorStream stream(aiffWithName(ByteVector("Caf\x8E", 4)));
RIFF::AIFF::File f(&stream, false);
CPPUNIT_ASSERT_EQUAL(String(L"Caf\u008E"), f.nativeTag()->title());
}
// Going back to no code page stated is going back to Mac OS Roman.
RIFF::AIFF::NativeTag::setAssumedCodePage(0);
CPPUNIT_ASSERT_EQUAL(0u, RIFF::AIFF::NativeTag::assumedCodePage());
{
ByteVectorStream stream(aiffWithName(ByteVector("Caf\x8E", 4)));
RIFF::AIFF::File f(&stream, false);
CPPUNIT_ASSERT_EQUAL(String(L"Caf\u00E9"), f.nativeTag()->title());
}
}
void testNativeMacRomanWrite()
{
RestoreAssumedCodePage restore;
ByteVectorStream stream(aiffWithName(ByteVector()));
{
RIFF::AIFF::File f(&stream, false);
CPPUNIT_ASSERT(!f.hasNativeTag());
f.nativeTag()->setTitle(L"Caf\u00E9");
CPPUNIT_ASSERT(f.save());
CPPUNIT_ASSERT(f.hasNativeTag());
}
// The bytes the assumed charset writes are Mac OS Roman, not ISO 8859-1.
CPPUNIT_ASSERT_EQUAL(ByteVector("Caf\x8E", 4),
checkedRawChunk(*stream.data(), "NAME"));
// A tag read and written under one code page keeps its text as it was.
ByteVectorStream reopened(*stream.data());
RIFF::AIFF::File f(&reopened, false);
CPPUNIT_ASSERT_EQUAL(String(L"Caf\u00E9"), f.nativeTag()->title());
}
void testNativeCodePageOverride()
{
RestoreAssumedCodePage restore;
// The caller says its files are UTF-8, so that is what both the writing
// and the reading of the tag does.
RIFF::AIFF::NativeTag::setAssumedCodePage(65001);
ByteVectorStream stream(aiffWithName(ByteVector()));
{
RIFF::AIFF::File f(&stream, false);
f.nativeTag()->setTitle(L"Caf\u00E9");
CPPUNIT_ASSERT(f.save());
}
CPPUNIT_ASSERT_EQUAL(ByteVector("Caf\xC3\xA9", 5),
checkedRawChunk(*stream.data(), "NAME"));
ByteVectorStream reopened(*stream.data());
RIFF::AIFF::File f(&reopened, false);
CPPUNIT_ASSERT_EQUAL(String(L"Caf\u00E9"), f.nativeTag()->title());
// A code page this library cannot convert is refused, so the one in
// effect stays.
RIFF::AIFF::NativeTag::setAssumedCodePage(999999);
CPPUNIT_ASSERT_EQUAL(65001u, RIFF::AIFF::NativeTag::assumedCodePage());
}
#endif // TAGLIB_WITH_CHARSETTABLES
void testFuzzedFile1()
{
RIFF::AIFF::File f(TEST_FILE_PATH_C("segfault.aif"));