From 1537bc2c52e72da9615f0512bf78c0f7cdead3f3 Mon Sep 17 00:00:00 2001 From: Nibu Date: Thu, 12 Nov 2026 14:37:48 +0000 Subject: [PATCH] Credits: credit the Ubuntu and Montserrat fonts from their embedded notices; review unreferenced fonts; stop skipping obj/bin/Library under Assets Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Credits.cs | 17 ++++ tests/Aetheria.Shared.Tests/CreditsTests.cs | 106 ++++++++++++++++++-- 2 files changed, 117 insertions(+), 6 deletions(-) diff --git a/Assets/Scripts/ServerShared/Credits.cs b/Assets/Scripts/ServerShared/Credits.cs index 3bb0af84b..cfc3028fb 100644 --- a/Assets/Scripts/ServerShared/Credits.cs +++ b/Assets/Scripts/ServerShared/Credits.cs @@ -20,6 +20,9 @@ public static class Credits public const string PlaydeadFile = "Assets/Plugins/Playdead/LICENSE.txt"; public const string NounProjectFile = "Assets/Resources/Sprites/Attribution.txt"; public const string LiberationFile = "Assets/TextMesh Pro/Fonts/LiberationSans - OFL.txt"; + // Fonts state their holder and licence in their own name tables; these are the font files the credit answers for. + public const string UbuntuFontFile = "Assets/Fonts/Ubuntu/Ubuntu-R.ttf"; + public const string MontserratFontFile = "Assets/Fonts/Ubuntu/Montserrat-Thin.ttf"; public const string UniRxFile = "Assets/Plugins/UniRx/ReadMe.txt"; public const string AmbientCgFile = "Assets/Materials/PBR/SOURCES.md"; public const string KdTreeFile = "Assets/Scripts/ServerShared/NIH/KDTree/KDTree.cs"; @@ -60,6 +63,20 @@ public static class Credits LicenceFile = LiberationFile }, new Credit + { + Name = "Ubuntu and Ubuntu Mono fonts", + Licence = "Ubuntu Font Licence 1.0", + Notice = "Copyright 2011 Canonical Ltd.", + LicenceFile = UbuntuFontFile + }, + new Credit + { + Name = "Montserrat", + Licence = "SIL Open Font License 1.1", + Notice = "Copyright(c) 2014 by Julieta Ulanovsky", + LicenceFile = MontserratFontFile + }, + new Credit { Name = "UniRx", Licence = "MIT", diff --git a/tests/Aetheria.Shared.Tests/CreditsTests.cs b/tests/Aetheria.Shared.Tests/CreditsTests.cs index ed322bef4..8d4a4a6b9 100644 --- a/tests/Aetheria.Shared.Tests/CreditsTests.cs +++ b/tests/Aetheria.Shared.Tests/CreditsTests.cs @@ -25,8 +25,6 @@ namespace Aetheria.Shared.Tests private static readonly Regex LicenceLike = new Regex( @"^(LICEN[CS]E|COPYING|NOTICE|README|SOURCES|_SOURCES|Attribution)|OFL", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly string[] SkippedDirectories = { "bin", "obj", "Library" }; - // Discovered files whose own text names no holder and no licence, so there is nothing true to credit. A new // licence-like file is not here until someone has read it and written the reason. private static readonly System.Collections.Generic.Dictionary ReviewedNotCredited = new System.Collections.Generic.Dictionary @@ -40,8 +38,6 @@ namespace Aetheria.Shared.Tests // repo, so they are not credited; each path must still exist, so this list cannot go stale unnoticed. private static readonly string[] UnlicensedWorks = { - "Assets/Fonts/Ubuntu", - "Assets/Fonts/Retro", "Assets/Plugins/Ink", "Assets/Plugins/GPUTrail", "Assets/Plugins/com.unity.uiextensions", @@ -61,13 +57,70 @@ namespace Aetheria.Shared.Tests (Credits.AmbientCgFile, new[] { "ambientCG" }), }; + // Fonts that state holder and licence in their own name tables: the credit's LicenceFile is the first font, and + // every font listed must embed the stated string (read from the file, not from the credit) and be shown by the + // credit. The ids are TrueType name ids: 0 is the copyright notice. + private static readonly (string credit, string[] fonts, string embedded)[] FontCredits = + { + (Credits.UbuntuFontFile, new[] { "Assets/Fonts/Ubuntu/Ubuntu-R.ttf", "Assets/Fonts/Ubuntu/Ubuntu-L.ttf", "Assets/Fonts/Ubuntu/Ubuntu-B.ttf", "Assets/Fonts/Ubuntu/UbuntuMono-R.ttf" }, + "Copyright 2011 Canonical Ltd."), + (Credits.MontserratFontFile, new[] { "Assets/Fonts/Ubuntu/Montserrat-Thin.ttf" }, "Copyright(c) 2014 by Julieta Ulanovsky"), + }; + + // Fonts credited through a licence file in the repo (see Holders), not through their own text. + private static readonly string[] FontsCreditedByLicenceFile = { "Assets/Fonts/Emoji/Twemoji.ttf", "Assets/TextMesh Pro/Fonts/LiberationSans.ttf" }; + + // Fonts under Assets that are not credited, each with the reason; every one must be unreferenced by any prefab, + // scene, material or asset outside Assets/Fonts (checked), so shipping it is not a decision made by accident. + private static readonly System.Collections.Generic.Dictionary ReviewedFonts = new System.Collections.Generic.Dictionary + { + ["Assets/Fonts/Retro/LRT Oksana.ttf"] = "EULA forbids more than personal use; unreferenced; decision owed", + ["Assets/Fonts/Retro/LRT Oksana Bold.ttf"] = "EULA forbids more than personal use; unreferenced; decision owed", + ["Assets/Fonts/Ubuntu/UbuntuMono-B.ttf"] = "unreferenced; same holder and licence as UbuntuMono-R, which the Ubuntu credit covers", + ["Assets/Fonts/ftn_SourceSansPro-Regular.otf"] = "unreferenced; embeds Adobe copyright under the SIL OFL 1.1; credit it if it is ever used", + }; + + private static string[] FontFiles() + { + var root = Root(); + return Directory.EnumerateFiles(Path.Combine(root, "Assets"), "*", SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(root, path).Replace('\\', '/')) + .Where(path => path.EndsWith(".ttf", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".otf", StringComparison.OrdinalIgnoreCase)) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + } + + // Every name-table record of a TrueType/OpenType file with the given name id, decoded (platform 3 as UTF-16BE, + // platform 1 as Latin-1). + private static System.Collections.Generic.List NameRecords(string relative, int nameId) + { + var d = File.ReadAllBytes(Path.Combine(Root(), relative)); + int U16(int at) => (d[at] << 8) | d[at + 1]; + long U32(int at) => ((long)d[at] << 24) | ((long)d[at + 1] << 16) | ((long)d[at + 2] << 8) | d[at + 3]; + var found = new System.Collections.Generic.List(); + for (int i = 0, n = U16(4); i < n; i++) + { + var rec = 12 + 16 * i; + if (System.Text.Encoding.ASCII.GetString(d, rec, 4) != "name") continue; + var table = (int)U32(rec + 8); + int count = U16(table + 2), strings = table + U16(table + 4); + for (var j = 0; j < count; j++) + { + var r = table + 6 + 12 * j; + if (U16(r + 6) != nameId) continue; + var text = System.Text.Encoding.GetEncoding(U16(r) == 3 ? "utf-16BE" : "latin1").GetString(d, strings + U16(r + 10), U16(r + 8)); + found.Add(text.Replace("\r", "").Trim()); + } + } + return found; + } + private static string[] LicenceLikeFiles() { var root = Root(); return Directory.EnumerateFiles(Path.Combine(root, "Assets"), "*", SearchOption.AllDirectories) .Where(path => !path.EndsWith(".meta")) .Select(path => Path.GetRelativePath(root, path).Replace('\\', '/')) - .Where(path => !path.Split('/').Any(segment => SkippedDirectories.Contains(segment))) .Where(path => LicenceLike.IsMatch(Path.GetFileName(path))) .OrderBy(path => path, StringComparer.Ordinal) .ToArray(); @@ -98,7 +151,7 @@ namespace Aetheria.Shared.Tests public void EveryCreditShowsTheHoldersItsOwnFileStates() { var credits = Load(); - Assert.Equal(Holders.Select(h => h.file).Append(Credits.NounProjectFile).OrderBy(f => f, StringComparer.Ordinal), + Assert.Equal(Holders.Select(h => h.file).Append(Credits.NounProjectFile).Concat(FontCredits.Select(f => f.credit)).OrderBy(f => f, StringComparer.Ordinal), credits.Select(credit => credit.LicenceFile).OrderBy(f => f, StringComparer.Ordinal)); foreach (var (file, stated) in Holders) { @@ -125,6 +178,47 @@ namespace Aetheria.Shared.Tests // Dash variants and case folded, so the file's en dash and the screen's hyphen compare equal. private static string Normal(string text) => text.Replace('\u2013', '-').Replace('\u2014', '-').Replace("\r", "").ToLowerInvariant(); + // The credit text is typed; the font files are the authority on who holds them. A wrong holder, or a font in the + // family that does not carry it, fails here. + [Fact] + public void FontCreditsShowWhatTheFontsThemselvesEmbed() + { + var credits = Load(); + foreach (var (creditFile, fonts, embedded) in FontCredits) + { + var notice = credits.Single(credit => credit.LicenceFile == creditFile).Notice; + Assert.Equal(creditFile, fonts[0]); + Assert.Contains(embedded, notice); + foreach (var font in fonts) + Assert.True(NameRecords(font, 0).Count > 0 && NameRecords(font, 0).All(record => record.StartsWith(embedded)), + $"{font} does not embed '{embedded}' as its copyright"); + } + Assert.Contains("Ubuntu Font Licence 1.0", NameRecords(Credits.UbuntuFontFile, 0)[0]); + Assert.Contains("Ubuntu Font Licence 1.0", credits.Single(credit => credit.LicenceFile == Credits.UbuntuFontFile).Licence); + Assert.Contains("SIL Open Font License, Version 1.1", NameRecords(Credits.MontserratFontFile, 13)[0]); + Assert.Contains("SIL Open Font License 1.1", credits.Single(credit => credit.LicenceFile == Credits.MontserratFontFile).Licence); + } + + [Fact] + public void EveryFontUnderAssetsIsCreditedOrReviewedAsUnreferenced() + { + var root = Root(); + var accounted = FontCredits.SelectMany(f => f.fonts).Concat(FontsCreditedByLicenceFile).Concat(ReviewedFonts.Keys).ToArray(); + var fonts = FontFiles(); + Assert.Equal(Array.Empty(), fonts.Where(font => !accounted.Contains(font)).ToArray()); + Assert.Equal(Array.Empty(), accounted.Where(font => !fonts.Contains(font)).ToArray()); + // A reviewed font stays out of the credits only while nothing outside Assets/Fonts uses it. + var guids = ReviewedFonts.Keys.ToDictionary(font => font, font => Regex.Match(Read(font + ".meta"), @"guid: (\w+)").Groups[1].Value); + Assert.All(guids.Values, guid => Assert.Equal(32, guid.Length)); + var users = Directory.EnumerateFiles(Path.Combine(root, "Assets"), "*", SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(root, path).Replace('\\', '/')) + .Where(path => !path.StartsWith("Assets/Fonts/") && new[] { ".prefab", ".unity", ".mat", ".asset", ".controller" }.Any(ext => path.EndsWith(ext))) + .Select(path => (path, text: Read(path))) + .ToArray(); + foreach (var (font, guid) in guids) + Assert.Equal(Array.Empty(), users.Where(user => user.text.Contains(guid)).Select(user => user.path).ToArray()); + } + [Fact] public void TwemojiComesFirst() {