Browse Source
* Harden cmap/name/post parsing against malformed fonts
Fonts are untrusted input, but several pre-existing table parsers throw out
of the GlyphTypeface constructor on hostile data, denying the whole font
instead of degrading the affected (cosmetic) table.
- name: wrap NameTable.Load in try/catch so a malformed table falls back to a
default family name (same outcome as an absent name), and bounds-check
NameRecord.GetValue's (offset,length) slice — the record array is validated
at load but each record's storage slice is read later during construction.
- post: wrap PostTable.Load so a malformed cosmetic-hint table degrades to
defaults rather than denying the font.
- cmap format 12/13: clamp the declared length and group count to the buffer,
computing the group span in long to avoid the nGroups*12 int overflow a
hostile count would wrap to a negative slice length.
- cmap format 4 selection: score a Windows Symbol subtable worse than any
Unicode subtable so ASCII resolves regardless of subtable order, while a
Symbol-only font still selects its only subtable.
maxp/cmap remain fatal by design. Behavioural hardening only; public API
unchanged. Backportable independently of the glyph-outline stack.
* Clamp cmap range contents and harden binary reader primitives
Format 12/13 group contents are attacker-controlled: TryGetRange used to
hand the raw uint32 end straight to per-codepoint consumers, so a group
with endCharCode 0x7FFFFFFF turned range enumeration into an unbounded
(for int.MaxValue, non-terminating) loop. Clamp ranges to the Unicode
range and map inverted/out-of-range groups to empty ranges so later
groups still enumerate.
Give the format 4 constructor the same treatment its format 12 sibling
already received: clamp the declared length and segment count to what
the buffer actually holds instead of slicing unchecked (debug asserts
replaced by the clamps).
In BigEndianBinaryReader, bounds-check array reads before allocating
(a hostile length is rejected instead of attempting the allocation),
make EnsureAvailable overflow-proof, and clamp ReadBytes against
negative counts.
* Add font parsing infrastructure for glyph outline/color drawing
Introduces shared utility types that subsequent PRs will use to
implement GetGlyphOutline (glyf table) and GetGlyphDrawing
(COLR v0/v1) on GlyphTypeface:
- ObjectPool<T>: thread-safe object pool used by Decyclers
- Decycler<T> / CycleGuard<T> / DecyclerException: generic
cycle-detection and depth-limiting utility for recursive
font-table traversal (composite glyphs, paint graphs)
- FontVariationSettings: parameter type for the upcoming
GetGlyphOutline / GetGlyphDrawing overloads
- IGlyphDrawing / GlyphDrawingType: return-type contract for color
glyph drawings (outline, color layers, SVG, bitmap)
- CharacterToGlyphMapDictionary: lightweight, allocation-free
IReadOnlyDictionary<int, ushort> view over the cmap, plus
CharacterToGlyphMap.AsReadOnlyDictionary() to expose it
No behavior change; types are not yet consumed in this PR.
* Apply suggestions from code review
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Validate ObjectPool maxSize and add unit tests
The ObjectPool<T> constructor previously accepted any maxSize value.
A value of 0 or below silently caused every Return to be discarded,
which is hard to diagnose at the call site.
- Reject maxSize < 1 with ArgumentOutOfRangeException at construction.
- Document the new precondition in the XML comment.
- Add ObjectPoolTests covering: factory null guard, maxSize bounds,
Rent/Return basics, Return ignoring null, max-size enforcement,
validator invocation and rejection, validator-as-reset, plus
concurrent stress tests for cap enforcement and parallel use.
* Validate Decycler maxDepth and add unit tests
The Decycler<T> constructor previously accepted any maxDepth value.
A value of 0 or below made the very first Enter throw
DepthLimitExceeded, which presents as a cycle/depth error at use time
instead of pointing at the construction site.
- Reject maxDepth < 1 with ArgumentOutOfRangeException at construction.
- Document the new precondition in the XML comment.
- Add DecyclerTests covering: maxDepth bounds, Enter/Exit lifecycle,
nested depth, cycle detection, depth-limit-exceeded, depth-vs-cycle
ordering, no state mutation on failed Enter, Reset semantics,
guard idempotency, MaxDepth/CurrentDepth properties, and a struct
type other than int.
* Add tests for CharacterToGlyphMap.AsReadOnlyDictionary
Covers the IReadOnlyDictionary<int, ushort> view returned by
AsReadOnlyDictionary, using the existing Inter font asset:
- ContainsKey agrees with the underlying map for both mapped and
unmapped code points.
- Indexer returns the same glyph id as map.GetGlyph for several
representative ASCII characters.
- Indexer throws KeyNotFoundException for an unmapped code point.
- TryGetValue: positive and negative paths.
- Count is positive and matches the enumerated pair count.
- Enumeration yields (key, value) pairs that round-trip through the
underlying CharacterToGlyphMap.
- Keys enumeration and pair-key enumeration agree.
- Two AsReadOnlyDictionary() calls produce functionally equivalent
views (no identity assumption).
Tests live alongside the existing CharacterToGlyphMap tests in
GlyphTypefaceTests so they can reuse the CustomPlatformTypeface
helper.
* Redesign FontVariationSettings; add GlyphDrawingOptions
FontVariationSettings is changed from a public record to a sealed class
with internal factory methods (FromCoordinates / FromInstance), a
FrozenDictionary backing for NormalizedCoordinates, and structural
Equals/GetHashCode. The type now exclusively models variable-font axis
configuration; palette and bitmap-strike concerns are moved out.
GlyphDrawingOptions is a new public sealed record that carries the
drawing options that are independent of axis configuration: an optional
CPAL palette index (≥ 0) and an optional bitmap-strike pixel size (≥ 1).
Both properties validate at init time and the type participates in
record-equality and with-expressions.
Tests are added for both types (48 new assertions).
* Make FontVariationSettings factories public
FromCoordinates and FromInstance are promoted from internal to public.
They were kept internal while the consuming public API was being designed;
that design is now settled (GlyphTypeface.CreateVariationSettings /
VariationAxes / VariationInstances, planned for PR2).
Class-level remarks are updated to describe the typeface-agnostic design
rationale and point to GlyphTypeface.CreateVariationSettings as the
preferred entry point for user-space axis values.
* Tighten contracts and docs on font-parsing infra
Polish pass on the shared infrastructure types so the contract surface is
unambiguous before PR2 (GetGlyphOutline) and PR3 (GetGlyphDrawing) build
on it:
- IGlyphDrawing now exposes GlyphDrawingType Type so callers can branch
on format without downcasting. The GlyphDrawingType enum was already
defined but unreferenced by the interface; this closes the loop.
PR3's ColorGlyphDrawing and ColorGlyphV1Drawing already implement the
property, so the rebase is a no-op.
- IGlyphDrawing.Bounds and Draw(origin) get explicit coordinate-space
docs (drawing-space, Y-down; origin is the post-flipped glyph anchor).
- Decycler<T> gets a class-level remarks block stating it is not
thread-safe (one instance per traversal) and that Enter throws on
cycle / depth-limit so callers can catch at the outermost site.
- FontVariationSettings.Default documents that null and Default are
interchangeable for parameters typed as FontVariationSettings?.
- ObjectPool.Return documents that callers must not return the same
item twice without an intervening Rent — the pool does not detect
duplicate returns by design.
Tests:
- ObjectPool: validator must not be invoked on Rent (pins the
validate-on-return contract).
- GlyphDrawingOptions: parameterless ctor equals Default (record
equality should make them interchangeable).
- FontVariationSettings: FromCoordinates with both coordinates AND
instanceIndex populated (combined form documented in the API).
83 tests pass (was 76).
* Reshape FontVariationSettings for cache-key use
Switch from a class wrapping FrozenDictionary + nullable instance index
to a readonly struct backed by a sorted ImmutableArray of a dedicated
FontVariationCoordinate record-struct. Driven by the type's role as a
field on FontCollectionKey:
- Hash code is computed once at construction and cached. Lookups in the
font-resolution cache no longer pay an OrderBy allocation per call.
- Equality is a parallel walk over two sorted arrays — no hash lookup,
no TryGetValue per element. Short-circuits on cached-hash mismatch.
- TryGetCoordinate / GetCoordinateOrDefault are linear scans, faster
than hash lookup for the typical handful-of-axes case.
- default(FontVariationSettings) is the "no variation" value. The old
Default singleton goes away; nullable-handling at the cache layer
becomes unnecessary.
- InstanceIndex is dropped from the type. Named-instance selection is
a typeface-level concern that resolves to coordinates; the runtime
settings carry coordinates only, matching CSS / HarfBuzz / DirectWrite
conventions.
Tests rewritten: 34 cases covering struct semantics, sorting,
TryGetCoordinate, hash caching, equality short-circuits, and the
FontVariationCoordinate record's own equality.
* Apply suggestions from code review
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Fix auto suggestion
* Add synthetic-font test harness
SyntheticFont / BigEndianBuffer build and mutate in-memory sfnt fonts so the
table parsers can be exercised against hand-crafted malformed input without a
platform shaper. Shared by every parser PR's malformed-font tests.
* Characterize cmap/name/post degradation on malformed input
Regression guards over the synthetic-font harness: a truncated post/name table
degrades to defaults / a fallback family name instead of denying the font, the
cmap format-12 nGroups*12 overflow is clamped, and Format-4 selection prefers a
Unicode subtable over Windows Symbol regardless of directory order.
* Fix dictionary wrapper contract, guard double-exit, and zero-coordinate canonicalization
CharacterToGlyphMapDictionary mixed two membership predicates: Keys and
Count filtered by ContainsGlyph while the indexer, TryGetValue and the
enumerator used TryGetGlyph, so glyph-0 mappings produced keys the
indexer rejected. Everything now answers through TryGetGlyph, and the
Count walk (O(codepoints) over the mapped ranges) is computed once and
cached.
Decycler.Exit decremented the depth unconditionally: CycleGuard is a
copyable ref struct, so a copied guard could exit the same id twice and
grant later traversals extra depth budget. Depth is now only returned
when the id was actually removed from the visited set.
FontVariationSettings.FromCoordinates kept zero-valued coordinates, so
an explicitly-default axis (wght=0 normalized) hashed and compared as a
distinct value from settings omitting the axis - producing duplicate
variation clones and font-collection cache entries downstream. Both
factories now canonicalize zeros away (after the duplicate-axis check,
which the dictionary overload previously skipped entirely).
* Document the cmap dictionary view and tighten Decycler/ObjectPool internals
Add XML docs to CharacterToGlyphMapDictionary's members. Collapse
Decycler.Enter's Contains-then-Add into a single HashSet.Add (a false
return means the id was already visited - a cycle). Make ObjectPool's
lock-free count fast-check a volatile read.
* Simplify FontVariationSettings.Equals to a span SequenceEqual
Map the coordinate comparison onto the inner array via the span overload
(Coordinates normalizes default to Empty, so both spans are valid). The
span SequenceEqual avoids the boxing the LINQ overload would incur on
this dictionary-key type, and FontVariationCoordinate's record-struct
value equality drives the element compare. The cached-hash early-out is
kept.
* Add pre-allocation validation in NameTable.Load so malformed name tables are rejected before creating the names array.
* Make PostTable.Load to catch only the malformed-data exception raised by this parsing path, instead of catching everything.
* Raise ArgumentOutOfRangeException for negative count
* Validate tableLength for CmapFormat12Or13Table ctor
* Fixes GlyphDrawingOptions ArgumentOutOfRangeException nameof usage
* Fix CmapFormat4Table ctor: clamp parallel-array offsets to tableLength
When a malformed subtable declares a length that drives _segCount to 0,
startCodeOffset = endCodeOffset + 0*2 + 2 (reservedPad) can exceed
tableLength, causing Slice() to throw and denying the font entirely.
Apply Math.Min(offset, tableLength) to each derived array offset so that
zero-length slices always start at a valid position, degrading silently
to an empty mapping instead of crashing.
* Revert change
* Only handle specific exceptions in NameTable.Load
Guard against negative count in BigEndianBuffer.Zeros
* Speak user space in the public variation API
Normalized coordinates are font-relative - the same value means different
user positions under different fvar ranges and avar maps - so they cannot
be authored in a style before font resolution and do not interpolate
meaningfully. Every mainstream API (CSS font-variation-settings,
DirectWrite, HarfBuzz, Skia) traffics in user-space values, and a public
normalized type named like theirs invites passing wght=700 into a [-1,1]
slot.
- new public currency: FontVariation (tag=value in designer units) and an
immutable FontVariationSettings with Parse/ToString ("wght=700,wdth=85"),
order-independent structural equality, a cached hash, and CSS last-wins
duplicate handling - usable as a style value and a cache key
- the normalized pair moves out of public view: FontVariationCoordinate
becomes internal NormalizedVariationCoordinate and the normalized
settings struct becomes internal NormalizedVariationPosition, unchanged
in behavior
- the user-space -> normalized conversion stays a per-font, application-
time concern; the fvar/avar reader lands in a follow-up PR
* Accept infinite font variation values
Infinities clamp to the axis range at apply time like any out-of-range
value, so only NaN is rejected now. Parse also uses explicit number
styles instead of NumberStyles.Float since the value text is pre-trimmed
and the whitespace flags were redundant.
* Guard NameRecord.GetValue against decoder exceptions
The selected encodings use replacement fallback and never throw on
malformed bytes, but degrade to an empty value anyway so an
exception-throwing fallback can never deny the font.
* Use nameof(value) in GlyphDrawingOptions setter exceptions
Matches the .NET runtime convention for property setters, also used
elsewhere in this repo (e.g. FormattedText).
---------
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Julien Lebosquain <julien@lebosquain.net>
pull/22095/head
committed by
GitHub
25 changed files with 3497 additions and 51 deletions
@ -0,0 +1,279 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Collections.Immutable; |
||||
|
using System.Globalization; |
||||
|
using System.Text; |
||||
|
using Avalonia.Media.Fonts; |
||||
|
|
||||
|
namespace Avalonia.Media |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// A single variation axis setting in designer units, e.g. <c>wght = 700</c>.
|
||||
|
/// </summary>
|
||||
|
/// <param name="Tag">The OpenType axis tag (e.g. <c>wght</c>, <c>wdth</c>, <c>opsz</c>).</param>
|
||||
|
/// <param name="Value">
|
||||
|
/// The axis position in the font's user coordinate space — the same units the font's
|
||||
|
/// <c>fvar</c> table declares for the axis (weight 100–900, width percentages, optical
|
||||
|
/// sizes in points). Values are clamped to the axis range and normalized per font when
|
||||
|
/// the settings are applied; axes a font does not declare are ignored.
|
||||
|
/// </param>
|
||||
|
public readonly record struct FontVariation(OpenTypeTag Tag, double Value) |
||||
|
{ |
||||
|
/// <summary>Returns the <c>tag=value</c> form, e.g. <c>wght=700</c>.</summary>
|
||||
|
public override string ToString() => |
||||
|
string.Create(CultureInfo.InvariantCulture, $"{Tag}={Value}"); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// An immutable, order-independent set of user-space variation axis values that
|
||||
|
/// configures variable fonts (fonts with an <c>fvar</c> table) for rendering.
|
||||
|
/// </summary>
|
||||
|
/// <remarks>
|
||||
|
/// <para>
|
||||
|
/// Values are expressed in the font's user coordinate space (<c>wght = 700</c>), the
|
||||
|
/// same space CSS <c>font-variation-settings</c>, DirectWrite and HarfBuzz use.
|
||||
|
/// Normalization to the OpenType <c>[-1, 1]</c> range — including the <c>avar</c>
|
||||
|
/// mapping — happens per font when the settings are applied, so one settings value is
|
||||
|
/// meaningful across fonts: each font clamps to its own axis ranges and ignores axes
|
||||
|
/// it does not declare.
|
||||
|
/// </para>
|
||||
|
/// <para>
|
||||
|
/// Instances have structural equality with a cached hash code and are usable as cache
|
||||
|
/// keys. Variations are stored sorted by axis tag, so equality is order-independent;
|
||||
|
/// when the same tag is given more than once, the last value wins (CSS behavior).
|
||||
|
/// <c>null</c> and <see cref="Empty"/> both mean "design defaults".
|
||||
|
/// </para>
|
||||
|
/// <para>
|
||||
|
/// The string form accepted by <see cref="Parse"/> (and produced by
|
||||
|
/// <see cref="ToString"/>) is a comma-separated list of <c>tag=value</c> pairs, e.g.
|
||||
|
/// <c>"wght=700, wdth=85"</c>, usable directly in XAML.
|
||||
|
/// </para>
|
||||
|
/// </remarks>
|
||||
|
public sealed class FontVariationSettings : IEquatable<FontVariationSettings> |
||||
|
{ |
||||
|
private readonly ImmutableArray<FontVariation> _variations; |
||||
|
private readonly int _hashCode; |
||||
|
|
||||
|
private FontVariationSettings(ImmutableArray<FontVariation> sortedVariations) |
||||
|
{ |
||||
|
_variations = sortedVariations; |
||||
|
_hashCode = ComputeHashCode(sortedVariations); |
||||
|
} |
||||
|
|
||||
|
/// <summary>Gets the empty settings — the font's design defaults.</summary>
|
||||
|
public static FontVariationSettings Empty { get; } = |
||||
|
new(ImmutableArray<FontVariation>.Empty); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets the variations, sorted by axis tag ascending. Duplicate tags passed at
|
||||
|
/// construction have already been collapsed to their last value.
|
||||
|
/// </summary>
|
||||
|
public ImmutableArray<FontVariation> Variations => _variations; |
||||
|
|
||||
|
/// <summary>Gets a value indicating whether no axis is set.</summary>
|
||||
|
public bool IsEmpty => _variations.IsEmpty; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Creates settings from variation values. When a tag appears more than once, the
|
||||
|
/// last occurrence wins.
|
||||
|
/// </summary>
|
||||
|
/// <exception cref="ArgumentNullException"><paramref name="variations"/> is <c>null</c>.</exception>
|
||||
|
/// <exception cref="ArgumentException">A value is <c>NaN</c>.</exception>
|
||||
|
public FontVariationSettings(IEnumerable<FontVariation> variations) |
||||
|
{ |
||||
|
if (variations is null) |
||||
|
{ |
||||
|
throw new ArgumentNullException(nameof(variations)); |
||||
|
} |
||||
|
|
||||
|
var builder = ImmutableArray.CreateBuilder<FontVariation>(); |
||||
|
|
||||
|
foreach (var variation in variations) |
||||
|
{ |
||||
|
// Infinite values are usable — they clamp to the axis range like any other
|
||||
|
// out-of-range value when the settings are applied. NaN has no such meaning.
|
||||
|
if (double.IsNaN(variation.Value)) |
||||
|
{ |
||||
|
throw new ArgumentException( |
||||
|
$"Value for axis '{variation.Tag}' must not be NaN.", |
||||
|
nameof(variations)); |
||||
|
} |
||||
|
|
||||
|
// Last-wins for duplicate tags, matching CSS font-variation-settings.
|
||||
|
var replaced = false; |
||||
|
|
||||
|
for (var i = 0; i < builder.Count; i++) |
||||
|
{ |
||||
|
if (builder[i].Tag == variation.Tag) |
||||
|
{ |
||||
|
builder[i] = variation; |
||||
|
replaced = true; |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (!replaced) |
||||
|
{ |
||||
|
builder.Add(variation); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
builder.Sort(static (a, b) => ((uint)a.Tag).CompareTo((uint)b.Tag)); |
||||
|
|
||||
|
_variations = builder.ToImmutable(); |
||||
|
_hashCode = ComputeHashCode(_variations); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Looks up the value for an axis.
|
||||
|
/// </summary>
|
||||
|
/// <param name="tag">The axis tag.</param>
|
||||
|
/// <param name="value">The axis value, or <c>0</c> when the axis is not set.</param>
|
||||
|
/// <returns><c>true</c> when the axis is set; <c>false</c> otherwise.</returns>
|
||||
|
public bool TryGetValue(OpenTypeTag tag, out double value) |
||||
|
{ |
||||
|
foreach (var variation in _variations) |
||||
|
{ |
||||
|
if (variation.Tag == tag) |
||||
|
{ |
||||
|
value = variation.Value; |
||||
|
return true; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
value = 0; |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Parses a comma-separated list of <c>tag=value</c> pairs, e.g.
|
||||
|
/// <c>"wght=700, wdth=85"</c>. Whitespace around pairs, tags and values is
|
||||
|
/// ignored; an empty string yields <see cref="Empty"/>; duplicate tags collapse
|
||||
|
/// to the last value.
|
||||
|
/// </summary>
|
||||
|
/// <exception cref="FormatException">A pair is not <c>tag=value</c>, a tag is not
|
||||
|
/// a valid four-character OpenType tag, or a value is not an invariant number
|
||||
|
/// (<c>NaN</c> is rejected; infinite values are accepted and clamp to the axis
|
||||
|
/// range when applied).</exception>
|
||||
|
public static FontVariationSettings Parse(string s) |
||||
|
{ |
||||
|
if (s is null) |
||||
|
{ |
||||
|
throw new ArgumentNullException(nameof(s)); |
||||
|
} |
||||
|
|
||||
|
if (string.IsNullOrWhiteSpace(s)) |
||||
|
{ |
||||
|
return Empty; |
||||
|
} |
||||
|
|
||||
|
var variations = new List<FontVariation>(); |
||||
|
|
||||
|
foreach (var part in s.Split(',')) |
||||
|
{ |
||||
|
var pair = part.AsSpan().Trim(); |
||||
|
|
||||
|
if (pair.IsEmpty) |
||||
|
{ |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
var separator = pair.IndexOf('='); |
||||
|
|
||||
|
if (separator <= 0 || separator == pair.Length - 1) |
||||
|
{ |
||||
|
throw new FormatException( |
||||
|
$"Invalid font variation '{pair.ToString()}': expected tag=value."); |
||||
|
} |
||||
|
|
||||
|
var tagText = pair.Slice(0, separator).Trim(); |
||||
|
var valueText = pair.Slice(separator + 1).Trim(); |
||||
|
|
||||
|
if (tagText.IsEmpty || tagText.Length > 4) |
||||
|
{ |
||||
|
throw new FormatException( |
||||
|
$"Invalid font variation axis tag '{tagText.ToString()}'."); |
||||
|
} |
||||
|
|
||||
|
// The value text is already trimmed, so no whitespace styles — NumberStyles.Float
|
||||
|
// would silently re-allow leading/trailing whitespace inside the number itself.
|
||||
|
const NumberStyles valueStyles = |
||||
|
NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint | NumberStyles.AllowExponent; |
||||
|
|
||||
|
if (!double.TryParse(valueText, valueStyles, CultureInfo.InvariantCulture, out var value) || |
||||
|
double.IsNaN(value)) |
||||
|
{ |
||||
|
throw new FormatException( |
||||
|
$"Invalid font variation value '{valueText.ToString()}' for axis '{tagText.ToString()}'."); |
||||
|
} |
||||
|
|
||||
|
variations.Add(new FontVariation(OpenTypeTag.Parse(tagText.ToString()), value)); |
||||
|
} |
||||
|
|
||||
|
return variations.Count == 0 ? Empty : new FontVariationSettings(variations); |
||||
|
} |
||||
|
|
||||
|
/// <summary>Returns the parseable string form, e.g. <c>wght=700,wdth=85</c>.</summary>
|
||||
|
public override string ToString() |
||||
|
{ |
||||
|
if (_variations.IsEmpty) |
||||
|
{ |
||||
|
return string.Empty; |
||||
|
} |
||||
|
|
||||
|
var builder = new StringBuilder(); |
||||
|
|
||||
|
foreach (var variation in _variations) |
||||
|
{ |
||||
|
if (builder.Length > 0) |
||||
|
{ |
||||
|
builder.Append(','); |
||||
|
} |
||||
|
|
||||
|
builder.Append(variation.ToString()); |
||||
|
} |
||||
|
|
||||
|
return builder.ToString(); |
||||
|
} |
||||
|
|
||||
|
/// <inheritdoc/>
|
||||
|
public bool Equals(FontVariationSettings? other) |
||||
|
{ |
||||
|
if (other is null) |
||||
|
{ |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
if (ReferenceEquals(this, other)) |
||||
|
{ |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
if (_hashCode != other._hashCode) |
||||
|
{ |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
return _variations.AsSpan().SequenceEqual(other._variations.AsSpan()); |
||||
|
} |
||||
|
|
||||
|
/// <inheritdoc/>
|
||||
|
public override bool Equals(object? obj) => Equals(obj as FontVariationSettings); |
||||
|
|
||||
|
/// <inheritdoc/>
|
||||
|
public override int GetHashCode() => _hashCode; |
||||
|
|
||||
|
private static int ComputeHashCode(ImmutableArray<FontVariation> variations) |
||||
|
{ |
||||
|
var hash = new HashCode(); |
||||
|
|
||||
|
foreach (var variation in variations) |
||||
|
{ |
||||
|
hash.Add(variation.Tag); |
||||
|
hash.Add(variation.Value); |
||||
|
} |
||||
|
|
||||
|
return hash.ToHashCode(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,186 @@ |
|||||
|
using System; |
||||
|
using System.Collections; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Diagnostics.CodeAnalysis; |
||||
|
|
||||
|
namespace Avalonia.Media.Fonts.Tables.Cmap |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// Provides a read-only dictionary view over a <see cref="CharacterToGlyphMap"/>.
|
||||
|
/// </summary>
|
||||
|
internal sealed class CharacterToGlyphMapDictionary : IReadOnlyDictionary<int, ushort> |
||||
|
{ |
||||
|
private readonly CharacterToGlyphMap _map; |
||||
|
private List<CodepointRange>? _cachedRanges; |
||||
|
private int _cachedCount = -1; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Initializes a new instance of the <see cref="CharacterToGlyphMapDictionary"/> class that wraps the specified <see cref="CharacterToGlyphMap"/>.
|
||||
|
/// </summary>
|
||||
|
/// <param name="map"></param>
|
||||
|
public CharacterToGlyphMapDictionary(CharacterToGlyphMap map) |
||||
|
{ |
||||
|
_map = map; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets the glyph ID corresponding to the specified code point. If the code point does not have a corresponding glyph ID in the map, a <see cref="KeyNotFoundException"/> is thrown.
|
||||
|
/// </summary>
|
||||
|
/// <param name="key"></param>
|
||||
|
/// <returns></returns>
|
||||
|
/// <exception cref="KeyNotFoundException"></exception>
|
||||
|
public ushort this[int key] |
||||
|
{ |
||||
|
get |
||||
|
{ |
||||
|
if (!_map.TryGetGlyph(key, out var glyphId)) |
||||
|
{ |
||||
|
throw new KeyNotFoundException($"The code point {key} was not found in the character map."); |
||||
|
} |
||||
|
|
||||
|
return glyphId; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Yields the code points that have corresponding glyph IDs in the map. The order of the code points is not guaranteed to match the order of the glyph IDs returned by <see cref="Values"/>.
|
||||
|
/// </summary>
|
||||
|
public IEnumerable<int> Keys |
||||
|
{ |
||||
|
get |
||||
|
{ |
||||
|
foreach (var range in GetRanges()) |
||||
|
{ |
||||
|
for (int codePoint = range.Start; codePoint <= range.End; codePoint++) |
||||
|
{ |
||||
|
// Membership must match TryGetValue (TryGetGlyph), not ContainsGlyph:
|
||||
|
// the two predicates disagree for mappings that resolve to glyph 0, and
|
||||
|
// Keys yielding a key the indexer rejects breaks the dictionary contract.
|
||||
|
if (_map.TryGetGlyph(codePoint, out _)) |
||||
|
{ |
||||
|
yield return codePoint; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Yields the glyph IDs corresponding to the code points in the map. The order of the glyph IDs is not guaranteed to match the order of the code points returned by <see cref="Keys"/>.
|
||||
|
/// </summary>
|
||||
|
public IEnumerable<ushort> Values |
||||
|
{ |
||||
|
get |
||||
|
{ |
||||
|
foreach (var range in GetRanges()) |
||||
|
{ |
||||
|
for (int codePoint = range.Start; codePoint <= range.End; codePoint++) |
||||
|
{ |
||||
|
if (_map.TryGetGlyph(codePoint, out var glyphId)) |
||||
|
{ |
||||
|
yield return glyphId; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Returns the number of code point to glyph ID mappings in the map. This is computed by iterating over all code points in the mapped ranges and counting those that have a corresponding glyph ID.
|
||||
|
/// The result is cached after the first computation for efficiency.
|
||||
|
/// </summary>
|
||||
|
public int Count |
||||
|
{ |
||||
|
get |
||||
|
{ |
||||
|
int cachedCount = _cachedCount; |
||||
|
if (cachedCount >= 0) |
||||
|
{ |
||||
|
return cachedCount; |
||||
|
} |
||||
|
|
||||
|
int count = 0; |
||||
|
foreach (var range in GetRanges()) |
||||
|
{ |
||||
|
for (int codePoint = range.Start; codePoint <= range.End; codePoint++) |
||||
|
{ |
||||
|
if (_map.TryGetGlyph(codePoint, out _)) |
||||
|
{ |
||||
|
count++; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
_cachedCount = count; |
||||
|
return count; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Determines whether the map contains a mapping for the specified code point. This is implemented by calling <see cref="CharacterToGlyphMap.TryGetGlyph"/> and returning true if it returns true, regardless of the glyph ID returned.
|
||||
|
/// </summary>
|
||||
|
/// <param name="key"></param>
|
||||
|
/// <returns></returns>
|
||||
|
public bool ContainsKey(int key) |
||||
|
{ |
||||
|
return _map.TryGetGlyph(key, out _); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Attempts to get the glyph ID corresponding to the specified code point. This is implemented by calling <see cref="CharacterToGlyphMap.TryGetGlyph"/> and returning its result directly, passing through the glyph ID if found.
|
||||
|
/// </summary>
|
||||
|
/// <param name="key">The code point for which to get the glyph ID.</param>
|
||||
|
/// <param name="value">When this method returns, contains the glyph ID associated with the specified code point, if the code point is found; otherwise, the default value for the type of the value parameter. This parameter is passed uninitialized.</param>
|
||||
|
/// <returns>true if the map contains a mapping for the specified code point; otherwise, false.</returns>
|
||||
|
public bool TryGetValue(int key, [MaybeNullWhen(false)] out ushort value) |
||||
|
{ |
||||
|
return _map.TryGetGlyph(key, out value); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Returns an enumerator that iterates through the code point to glyph ID mappings in the map. The enumerator yields <see cref="KeyValuePair{TKey, TValue}"/> instances where the key is a code point and the value is the corresponding glyph ID. The order of the mappings is not guaranteed to match the order of the code points or glyph IDs returned by <see cref="Keys"/> or <see cref="Values"/>.
|
||||
|
/// </summary>
|
||||
|
/// <returns>An enumerator that can be used to iterate through the code point to glyph ID mappings.</returns>
|
||||
|
public IEnumerator<KeyValuePair<int, ushort>> GetEnumerator() |
||||
|
{ |
||||
|
foreach (var range in GetRanges()) |
||||
|
{ |
||||
|
for (int codePoint = range.Start; codePoint <= range.End; codePoint++) |
||||
|
{ |
||||
|
if (_map.TryGetGlyph(codePoint, out var glyphId)) |
||||
|
{ |
||||
|
yield return new KeyValuePair<int, ushort>(codePoint, glyphId); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
IEnumerator IEnumerable.GetEnumerator() |
||||
|
{ |
||||
|
return GetEnumerator(); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets the list of code point ranges that have mappings in the map. This is implemented by calling <see cref="CharacterToGlyphMap.GetMappedRanges"/> and caching the result for efficiency, since the ranges are immutable and expensive to compute.
|
||||
|
/// </summary>
|
||||
|
/// <returns></returns>
|
||||
|
private List<CodepointRange> GetRanges() |
||||
|
{ |
||||
|
var cachedRanges = _cachedRanges; |
||||
|
if (cachedRanges != null) |
||||
|
{ |
||||
|
return cachedRanges; |
||||
|
} |
||||
|
|
||||
|
var ranges = new List<CodepointRange>(); |
||||
|
var enumerator = _map.GetMappedRanges(); |
||||
|
while (enumerator.MoveNext()) |
||||
|
{ |
||||
|
ranges.Add(enumerator.Current); |
||||
|
} |
||||
|
|
||||
|
_cachedRanges = ranges; |
||||
|
return ranges; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,173 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
|
||||
|
namespace Avalonia.Media.Fonts.Tables |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// Errors that can occur during graph traversal with cycle detection.
|
||||
|
/// </summary>
|
||||
|
internal enum DecyclerError |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// A cycle was detected in the graph.
|
||||
|
/// </summary>
|
||||
|
CycleDetected, |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// The maximum depth limit was exceeded.
|
||||
|
/// </summary>
|
||||
|
DepthLimitExceeded |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Exception thrown when a decycler error occurs.
|
||||
|
/// </summary>
|
||||
|
internal class DecyclerException : Exception |
||||
|
{ |
||||
|
public DecyclerError Error { get; } |
||||
|
|
||||
|
public DecyclerException(DecyclerError error, string message) : base(message) |
||||
|
{ |
||||
|
Error = error; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// A guard that tracks entry into a node and ensures proper cleanup.
|
||||
|
/// </summary>
|
||||
|
/// <typeparam name="T">The type of the node identifier.</typeparam>
|
||||
|
internal ref struct CycleGuard<T> where T : struct |
||||
|
{ |
||||
|
private readonly Decycler<T> _decycler; |
||||
|
private readonly T _id; |
||||
|
private bool _exited; |
||||
|
|
||||
|
internal CycleGuard(Decycler<T> decycler, T id) |
||||
|
{ |
||||
|
_decycler = decycler; |
||||
|
_id = id; |
||||
|
_exited = false; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Exits the guard, removing the node ID from the visited set.
|
||||
|
/// </summary>
|
||||
|
public void Dispose() |
||||
|
{ |
||||
|
if (!_exited) |
||||
|
{ |
||||
|
_decycler.Exit(_id); |
||||
|
_exited = true; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Tracks visited nodes to detect cycles in a graph (composite glyphs, paint graphs, etc.).
|
||||
|
/// Uses a depth limit to prevent stack overflow during recursive traversal.
|
||||
|
/// </summary>
|
||||
|
/// <remarks>
|
||||
|
/// <para>
|
||||
|
/// Instances are <b>not thread-safe</b>: <see cref="Enter"/>, <see cref="Exit"/>, and
|
||||
|
/// <see cref="Reset"/> all mutate shared state without synchronization. The intended
|
||||
|
/// usage pattern is "one instance per traversal" — rent an instance from a pool at
|
||||
|
/// the start of a single-threaded walk and return it when done. Sharing one
|
||||
|
/// <see cref="Decycler{T}"/> across concurrent traversals will corrupt the visited
|
||||
|
/// set.
|
||||
|
/// </para>
|
||||
|
/// <para>
|
||||
|
/// <see cref="Enter"/> uses exceptions to signal cycle / depth-limit failures.
|
||||
|
/// Callers that traverse user-supplied data (e.g. font tables) should wrap the
|
||||
|
/// outermost traversal in a <c>try</c> / <c>catch (DecyclerException)</c> and treat
|
||||
|
/// the failure as "stop traversing this subgraph".
|
||||
|
/// </para>
|
||||
|
/// </remarks>
|
||||
|
/// <typeparam name="T">The type of the node identifier.</typeparam>
|
||||
|
internal class Decycler<T> where T : struct |
||||
|
{ |
||||
|
private readonly HashSet<T> _visited; |
||||
|
private readonly int _maxDepth; |
||||
|
private int _currentDepth; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Creates a new Decycler with the specified maximum depth.
|
||||
|
/// </summary>
|
||||
|
/// <param name="maxDepth">Maximum traversal depth before <see cref="Enter"/> throws a
|
||||
|
/// <see cref="DecyclerException"/>. Must be at least 1.</param>
|
||||
|
/// <exception cref="ArgumentOutOfRangeException"><paramref name="maxDepth"/> is less than 1.</exception>
|
||||
|
public Decycler(int maxDepth) |
||||
|
{ |
||||
|
if (maxDepth < 1) |
||||
|
{ |
||||
|
throw new ArgumentOutOfRangeException(nameof(maxDepth), maxDepth, "maxDepth must be at least 1."); |
||||
|
} |
||||
|
|
||||
|
_visited = new HashSet<T>(); |
||||
|
_maxDepth = maxDepth; |
||||
|
_currentDepth = 0; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Attempts to enter a node with the given ID.
|
||||
|
/// Returns a guard that will automatically exit when disposed.
|
||||
|
/// </summary>
|
||||
|
/// <param name="id">The node identifier to enter.</param>
|
||||
|
/// <returns>A guard that will clean up on disposal.</returns>
|
||||
|
/// <exception cref="DecyclerException">Thrown if a cycle is detected or depth limit exceeded.</exception>
|
||||
|
public CycleGuard<T> Enter(T id) |
||||
|
{ |
||||
|
if (_currentDepth >= _maxDepth) |
||||
|
{ |
||||
|
throw new DecyclerException( |
||||
|
DecyclerError.DepthLimitExceeded, |
||||
|
$"Graph depth limit of {_maxDepth} exceeded"); |
||||
|
} |
||||
|
|
||||
|
// HashSet.Add returns false if the item was already in the set, which indicates a cycle.
|
||||
|
if (!_visited.Add(id)) |
||||
|
{ |
||||
|
throw new DecyclerException( |
||||
|
DecyclerError.CycleDetected, |
||||
|
"Cycle detected in graph"); |
||||
|
} |
||||
|
|
||||
|
_currentDepth++; |
||||
|
|
||||
|
return new CycleGuard<T>(this, id); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Exits a node, removing it from the visited set.
|
||||
|
/// Called automatically by CycleGuard.Dispose().
|
||||
|
/// </summary>
|
||||
|
/// <param name="id">The node identifier to exit.</param>
|
||||
|
internal void Exit(T id) |
||||
|
{ |
||||
|
// CycleGuard is a copyable ref struct, so a copied guard can double-exit; only
|
||||
|
// give depth budget back for an id that was actually in the visited set.
|
||||
|
if (_visited.Remove(id)) |
||||
|
{ |
||||
|
_currentDepth--; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Returns the current traversal depth.
|
||||
|
/// </summary>
|
||||
|
public int CurrentDepth => _currentDepth; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Returns the maximum allowed traversal depth.
|
||||
|
/// </summary>
|
||||
|
public int MaxDepth => _maxDepth; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Resets the decycler to its initial state, clearing all visited nodes.
|
||||
|
/// </summary>
|
||||
|
public void Reset() |
||||
|
{ |
||||
|
_visited.Clear(); |
||||
|
_currentDepth = 0; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,70 @@ |
|||||
|
using System; |
||||
|
|
||||
|
namespace Avalonia.Media |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// Options that influence how a single glyph is drawn, independent of variable-font
|
||||
|
/// axis configuration: which CPAL palette to use for color glyphs, and which bitmap
|
||||
|
/// strike size to prefer for bitmap-based glyphs.
|
||||
|
/// </summary>
|
||||
|
/// <remarks>
|
||||
|
/// All properties are optional. <c>null</c> means "use the font's default" (palette 0
|
||||
|
/// for color glyphs; no bitmap strike preference). Concerns specific to variable fonts
|
||||
|
/// (axis coordinates, named instances) live on <see cref="FontVariationSettings"/>.
|
||||
|
/// </remarks>
|
||||
|
public sealed record GlyphDrawingOptions |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// Singleton instance representing "use the font's defaults".
|
||||
|
/// </summary>
|
||||
|
public static GlyphDrawingOptions Default { get; } = new(); |
||||
|
|
||||
|
private readonly int? _paletteIndex; |
||||
|
private readonly int? _pixelSize; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets the optional <c>CPAL</c> palette index used to resolve colors for
|
||||
|
/// COLR v0 / COLR v1 glyphs.
|
||||
|
/// </summary>
|
||||
|
/// <remarks>
|
||||
|
/// When <c>null</c>, the font's default palette (palette 0) is used.
|
||||
|
/// </remarks>
|
||||
|
/// <exception cref="ArgumentOutOfRangeException">Set to a negative value.</exception>
|
||||
|
public int? PaletteIndex |
||||
|
{ |
||||
|
get => _paletteIndex; |
||||
|
init |
||||
|
{ |
||||
|
if (value is { } v && v < 0) |
||||
|
{ |
||||
|
throw new ArgumentOutOfRangeException(nameof(value), v, "PaletteIndex must be non-negative."); |
||||
|
} |
||||
|
|
||||
|
_paletteIndex = value; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets the optional pixel size used to select a bitmap strike from <c>sbix</c>,
|
||||
|
/// <c>CBDT</c> or <c>EBDT</c> tables.
|
||||
|
/// </summary>
|
||||
|
/// <remarks>
|
||||
|
/// When <c>null</c>, no bitmap strike is selected and the font's outline (or
|
||||
|
/// color-layer) representation is used instead.
|
||||
|
/// </remarks>
|
||||
|
/// <exception cref="ArgumentOutOfRangeException">Set to a value less than 1.</exception>
|
||||
|
public int? PixelSize |
||||
|
{ |
||||
|
get => _pixelSize; |
||||
|
init |
||||
|
{ |
||||
|
if (value is { } v && v < 1) |
||||
|
{ |
||||
|
throw new ArgumentOutOfRangeException(nameof(value), v, "PixelSize must be at least 1."); |
||||
|
} |
||||
|
|
||||
|
_pixelSize = value; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,16 @@ |
|||||
|
namespace Avalonia.Media |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// Specifies the format used to render a glyph, such as outline, color layers, SVG, or bitmap.
|
||||
|
/// </summary>
|
||||
|
/// <remarks>Use this enumeration to determine or specify how a glyph should be drawn, depending on the
|
||||
|
/// font's supported formats. The value corresponds to the glyph's representation in the font file, which may affect
|
||||
|
/// rendering capabilities and visual appearance.</remarks>
|
||||
|
public enum GlyphDrawingType |
||||
|
{ |
||||
|
Outline, // glyf / CFF / CFF2
|
||||
|
ColorLayers, // COLR/CPAL
|
||||
|
Svg, // SVG table
|
||||
|
Bitmap // sbix / CBDT / EBDT
|
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,46 @@ |
|||||
|
namespace Avalonia.Media |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// Represents a glyph that knows how to draw itself into a <see cref="DrawingContext"/>.
|
||||
|
/// </summary>
|
||||
|
/// <remarks>
|
||||
|
/// Implementations are produced by the per-format renderers (color layers from
|
||||
|
/// COLR/CPAL, bitmap strikes from sbix/CBDT, etc.) and shielded behind this contract
|
||||
|
/// so callers can render any color glyph without caring which font-table format
|
||||
|
/// produced it. For plain outline glyphs, use <c>GlyphTypeface.GetGlyphOutline</c>
|
||||
|
/// instead — outlines are returned as <see cref="Geometry"/> rather than via this
|
||||
|
/// interface.
|
||||
|
/// </remarks>
|
||||
|
public interface IGlyphDrawing |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// Gets the format this drawing was produced from. Callers can use this to
|
||||
|
/// branch on rendering behaviour without downcasting.
|
||||
|
/// </summary>
|
||||
|
GlyphDrawingType Type { get; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets the axis-aligned bounding rectangle of the drawing, in drawing-space
|
||||
|
/// coordinates (Y-down).
|
||||
|
/// </summary>
|
||||
|
/// <remarks>
|
||||
|
/// The rectangle is relative to the drawing's local origin. To get bounds at a
|
||||
|
/// specific paint location, translate by the origin passed to <see cref="Draw"/>.
|
||||
|
/// Implementations are expected to compute this once (e.g. from the font's clip
|
||||
|
/// box or layer extents) and cache it.
|
||||
|
/// </remarks>
|
||||
|
Rect Bounds { get; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Draws the glyph into <paramref name="context"/> at <paramref name="origin"/>.
|
||||
|
/// </summary>
|
||||
|
/// <param name="context">The drawing context to render to.</param>
|
||||
|
/// <param name="origin">
|
||||
|
/// The drawing-space point (Y-down) at which the glyph's local origin should
|
||||
|
/// land. For text rendering this is typically the pen position on the baseline.
|
||||
|
/// Implementations apply the Y-flip from font-space (Y-up) internally, so
|
||||
|
/// callers don't need to flip <paramref name="origin"/> themselves.
|
||||
|
/// </param>
|
||||
|
void Draw(DrawingContext context, Point origin); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,275 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Collections.Immutable; |
||||
|
using Avalonia.Media.Fonts; |
||||
|
|
||||
|
namespace Avalonia.Media |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// A single axis tag / normalized-coordinate pair within a
|
||||
|
/// <see cref="NormalizedVariationPosition"/>.
|
||||
|
/// </summary>
|
||||
|
/// <param name="Axis">The OpenType axis tag (e.g. <c>wght</c>, <c>wdth</c>).</param>
|
||||
|
/// <param name="NormalizedValue">
|
||||
|
/// The axis position in the OpenType normalized range <c>[-1.0, 1.0]</c>, as
|
||||
|
/// produced by applying the font's <c>avar</c> table to a user-space value.
|
||||
|
/// </param>
|
||||
|
internal readonly record struct NormalizedVariationCoordinate(OpenTypeTag Axis, float NormalizedValue); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// A variable font's position in OpenType normalized coordinate space: the internal
|
||||
|
/// currency between a typeface and its variation tables.
|
||||
|
/// </summary>
|
||||
|
/// <remarks>
|
||||
|
/// <para>
|
||||
|
/// Normalized coordinates are font-relative — the same value means different
|
||||
|
/// user-space positions under different <c>fvar</c> ranges and <c>avar</c> maps — so
|
||||
|
/// they are deliberately not public API. The public currency is the user-space
|
||||
|
/// <see cref="FontVariationSettings"/>; a position is derived from it per font, and
|
||||
|
/// keys the per-typeface variation caches.
|
||||
|
/// </para>
|
||||
|
/// <para>
|
||||
|
/// <see cref="NormalizedVariationPosition"/> is a value type with structural
|
||||
|
/// equality. The all-zero <see langword="default"/> value represents "no variation"
|
||||
|
/// — the font's design defaults. The <see cref="IsDefault"/> property tests for this
|
||||
|
/// case.
|
||||
|
/// </para>
|
||||
|
/// <para>
|
||||
|
/// Coordinates are stored in a single <see cref="ImmutableArray{T}"/> sorted by
|
||||
|
/// axis tag. Equality, hash and axis lookup are all linear scans over the array;
|
||||
|
/// for typical axis counts (one to a handful) this is faster than a hash-based
|
||||
|
/// dictionary and allocates nothing on the lookup path. The hash code is computed
|
||||
|
/// at construction and cached.
|
||||
|
/// </para>
|
||||
|
/// </remarks>
|
||||
|
internal readonly struct NormalizedVariationPosition : IEquatable<NormalizedVariationPosition> |
||||
|
{ |
||||
|
private readonly ImmutableArray<NormalizedVariationCoordinate> _coordinates; |
||||
|
private readonly int _hashCode; |
||||
|
|
||||
|
private NormalizedVariationPosition(ImmutableArray<NormalizedVariationCoordinate> sortedCoordinates) |
||||
|
{ |
||||
|
_coordinates = sortedCoordinates; |
||||
|
_hashCode = ComputeHashCode(sortedCoordinates); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets the axis coordinates, sorted by <see cref="OpenTypeTag"/> ascending.
|
||||
|
/// </summary>
|
||||
|
/// <remarks>
|
||||
|
/// Always returns a non-default (possibly empty) <see cref="ImmutableArray{T}"/>.
|
||||
|
/// Callers can iterate, index, or pass it to span-based APIs without first
|
||||
|
/// checking <see cref="ImmutableArray{T}.IsDefault"/>.
|
||||
|
/// </remarks>
|
||||
|
public ImmutableArray<NormalizedVariationCoordinate> Coordinates => |
||||
|
_coordinates.IsDefault ? ImmutableArray<NormalizedVariationCoordinate>.Empty : _coordinates; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets a value indicating whether this is the default ("no variation")
|
||||
|
/// position — equivalent to <c>default(NormalizedVariationPosition)</c>.
|
||||
|
/// </summary>
|
||||
|
public bool IsDefault => _coordinates.IsDefaultOrEmpty; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Creates a <see cref="NormalizedVariationPosition"/> from an axis-tag →
|
||||
|
/// normalized-coordinate map.
|
||||
|
/// </summary>
|
||||
|
/// <param name="normalizedCoordinates">
|
||||
|
/// Axis coordinates in the OpenType-normalized range <c>[-1.0, 1.0]</c>. The
|
||||
|
/// dictionary is copied into a sorted internal store.
|
||||
|
/// </param>
|
||||
|
/// <returns>
|
||||
|
/// <c>default(NormalizedVariationPosition)</c> when the input is empty or every
|
||||
|
/// coordinate is <c>0</c> (the axis default); otherwise a position carrying the
|
||||
|
/// sorted non-zero coordinates.
|
||||
|
/// </returns>
|
||||
|
/// <exception cref="ArgumentNullException"><paramref name="normalizedCoordinates"/> is <c>null</c>.</exception>
|
||||
|
/// <exception cref="ArgumentOutOfRangeException">
|
||||
|
/// A coordinate value is <c>NaN</c> or outside <c>[-1, 1]</c>.
|
||||
|
/// </exception>
|
||||
|
/// <exception cref="ArgumentException">
|
||||
|
/// The dictionary enumerates two entries for the same axis.
|
||||
|
/// </exception>
|
||||
|
public static NormalizedVariationPosition FromCoordinates( |
||||
|
IReadOnlyDictionary<OpenTypeTag, float> normalizedCoordinates) |
||||
|
{ |
||||
|
if (normalizedCoordinates is null) |
||||
|
{ |
||||
|
throw new ArgumentNullException(nameof(normalizedCoordinates)); |
||||
|
} |
||||
|
|
||||
|
if (normalizedCoordinates.Count == 0) |
||||
|
{ |
||||
|
return default; |
||||
|
} |
||||
|
|
||||
|
var builder = ImmutableArray.CreateBuilder<NormalizedVariationCoordinate>(normalizedCoordinates.Count); |
||||
|
|
||||
|
foreach (var kvp in normalizedCoordinates) |
||||
|
{ |
||||
|
ValidateCoordinate(kvp.Value, kvp.Key, nameof(normalizedCoordinates)); |
||||
|
builder.Add(new NormalizedVariationCoordinate(kvp.Key, kvp.Value)); |
||||
|
} |
||||
|
|
||||
|
return CreateFromValidated(builder, nameof(normalizedCoordinates)); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Creates a <see cref="NormalizedVariationPosition"/> from a span of coordinates.
|
||||
|
/// </summary>
|
||||
|
/// <param name="normalizedCoordinates">
|
||||
|
/// Axis coordinates in the OpenType-normalized range <c>[-1.0, 1.0]</c>. Each
|
||||
|
/// axis must appear at most once; the span is copied into a sorted internal
|
||||
|
/// store.
|
||||
|
/// </param>
|
||||
|
/// <returns>
|
||||
|
/// <c>default(NormalizedVariationPosition)</c> when the span is empty or every
|
||||
|
/// coordinate is <c>0</c> (the axis default); otherwise a position carrying the
|
||||
|
/// sorted non-zero coordinates.
|
||||
|
/// </returns>
|
||||
|
/// <exception cref="ArgumentOutOfRangeException">
|
||||
|
/// A coordinate value is <c>NaN</c> or outside <c>[-1, 1]</c>.
|
||||
|
/// </exception>
|
||||
|
/// <exception cref="ArgumentException">
|
||||
|
/// The span contains two entries for the same axis.
|
||||
|
/// </exception>
|
||||
|
public static NormalizedVariationPosition FromCoordinates( |
||||
|
ReadOnlySpan<NormalizedVariationCoordinate> normalizedCoordinates) |
||||
|
{ |
||||
|
if (normalizedCoordinates.IsEmpty) |
||||
|
{ |
||||
|
return default; |
||||
|
} |
||||
|
|
||||
|
var builder = ImmutableArray.CreateBuilder<NormalizedVariationCoordinate>(normalizedCoordinates.Length); |
||||
|
|
||||
|
foreach (var coord in normalizedCoordinates) |
||||
|
{ |
||||
|
ValidateCoordinate(coord.NormalizedValue, coord.Axis, nameof(normalizedCoordinates)); |
||||
|
builder.Add(coord); |
||||
|
} |
||||
|
|
||||
|
return CreateFromValidated(builder, nameof(normalizedCoordinates)); |
||||
|
} |
||||
|
|
||||
|
private static NormalizedVariationPosition CreateFromValidated( |
||||
|
ImmutableArray<NormalizedVariationCoordinate>.Builder builder, string paramName) |
||||
|
{ |
||||
|
builder.Sort(static (a, b) => ((uint)a.Axis).CompareTo((uint)b.Axis)); |
||||
|
|
||||
|
for (var i = 1; i < builder.Count; i++) |
||||
|
{ |
||||
|
if (builder[i].Axis == builder[i - 1].Axis) |
||||
|
{ |
||||
|
throw new ArgumentException( |
||||
|
$"Duplicate axis '{builder[i].Axis}' in coordinates.", |
||||
|
paramName); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// A normalized value of 0 is the axis default: dropping it keeps explicitly-default
|
||||
|
// positions structurally equal to positions that omit the axis, so both produce one
|
||||
|
// cache key (and one variation clone) instead of two. Done after the duplicate check
|
||||
|
// so that duplicates still throw regardless of their values.
|
||||
|
for (var i = builder.Count - 1; i >= 0; i--) |
||||
|
{ |
||||
|
if (builder[i].NormalizedValue == 0f) |
||||
|
{ |
||||
|
builder.RemoveAt(i); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (builder.Count == 0) |
||||
|
{ |
||||
|
return default; |
||||
|
} |
||||
|
|
||||
|
return new NormalizedVariationPosition(builder.ToImmutable()); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Looks up the normalized value for a single axis.
|
||||
|
/// </summary>
|
||||
|
/// <param name="axis">The axis tag to look up.</param>
|
||||
|
/// <param name="normalizedValue">The axis's normalized value, or <c>0</c> when
|
||||
|
/// the axis is not present.</param>
|
||||
|
/// <returns><c>true</c> when the axis is present; <c>false</c> otherwise.</returns>
|
||||
|
public bool TryGetCoordinate(OpenTypeTag axis, out float normalizedValue) |
||||
|
{ |
||||
|
if (!_coordinates.IsDefault) |
||||
|
{ |
||||
|
foreach (var coord in _coordinates) |
||||
|
{ |
||||
|
if (coord.Axis == axis) |
||||
|
{ |
||||
|
normalizedValue = coord.NormalizedValue; |
||||
|
return true; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
normalizedValue = 0f; |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Returns the normalized value for a single axis, or <paramref name="fallback"/>
|
||||
|
/// if the axis is not present.
|
||||
|
/// </summary>
|
||||
|
public float GetCoordinateOrDefault(OpenTypeTag axis, float fallback = 0f) |
||||
|
=> TryGetCoordinate(axis, out var value) ? value : fallback; |
||||
|
|
||||
|
/// <inheritdoc/>
|
||||
|
public bool Equals(NormalizedVariationPosition other) |
||||
|
{ |
||||
|
// Cheap reject via the cached hash first; then an allocation-free element-wise
|
||||
|
// compare. Coordinates normalizes default → Empty, so the spans are always valid,
|
||||
|
// and the span overload (not LINQ SequenceEqual) avoids boxing the ImmutableArray
|
||||
|
// to IEnumerable — this type is used as a dictionary key. NormalizedVariationCoordinate
|
||||
|
// is a record struct, so the per-element compare uses its (Axis, NormalizedValue)
|
||||
|
// value equality.
|
||||
|
if (_hashCode != other._hashCode) |
||||
|
{ |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
return Coordinates.AsSpan().SequenceEqual(other.Coordinates.AsSpan()); |
||||
|
} |
||||
|
|
||||
|
/// <inheritdoc/>
|
||||
|
public override bool Equals(object? obj) => obj is NormalizedVariationPosition other && Equals(other); |
||||
|
|
||||
|
/// <inheritdoc/>
|
||||
|
public override int GetHashCode() => _hashCode; |
||||
|
|
||||
|
public static bool operator ==(NormalizedVariationPosition left, NormalizedVariationPosition right) => left.Equals(right); |
||||
|
|
||||
|
public static bool operator !=(NormalizedVariationPosition left, NormalizedVariationPosition right) => !left.Equals(right); |
||||
|
|
||||
|
private static void ValidateCoordinate(float value, OpenTypeTag axis, string paramName) |
||||
|
{ |
||||
|
if (float.IsNaN(value) || value < -1f || value > 1f) |
||||
|
{ |
||||
|
throw new ArgumentOutOfRangeException( |
||||
|
paramName, value, |
||||
|
$"Normalized coordinate for axis '{axis}' must be in [-1, 1]; was {value}."); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static int ComputeHashCode(ImmutableArray<NormalizedVariationCoordinate> coordinates) |
||||
|
{ |
||||
|
if (coordinates.IsDefaultOrEmpty) |
||||
|
{ |
||||
|
return 0; |
||||
|
} |
||||
|
|
||||
|
var hash = new HashCode(); |
||||
|
foreach (var coord in coordinates) |
||||
|
{ |
||||
|
hash.Add(coord.Axis); |
||||
|
hash.Add(coord.NormalizedValue); |
||||
|
} |
||||
|
return hash.ToHashCode(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,96 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Concurrent; |
||||
|
using System.Runtime.CompilerServices; |
||||
|
using System.Threading; |
||||
|
|
||||
|
namespace Avalonia.Utilities |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// Provides a thread-safe object pool with size limits and object validation.
|
||||
|
/// </summary>
|
||||
|
/// <typeparam name="T">The type of objects to pool.</typeparam>
|
||||
|
internal sealed class ObjectPool<T> where T : class |
||||
|
{ |
||||
|
private readonly Func<T> _factory; |
||||
|
private readonly Func<T, bool>? _validator; |
||||
|
private readonly ConcurrentBag<T> _items; |
||||
|
private readonly int _maxSize; |
||||
|
private int _count; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Initializes a new instance of the <see cref="ObjectPool{T}"/> class.
|
||||
|
/// </summary>
|
||||
|
/// <param name="factory">Factory function to create new instances.</param>
|
||||
|
/// <param name="validator">Optional validator to clean and validate objects before returning to the pool. Return false to discard the object.</param>
|
||||
|
/// <param name="maxSize">Maximum number of objects to keep in the pool. Must be at least 1. Default is 32.</param>
|
||||
|
/// <exception cref="ArgumentNullException"><paramref name="factory"/> is <c>null</c>.</exception>
|
||||
|
/// <exception cref="ArgumentOutOfRangeException"><paramref name="maxSize"/> is less than 1.</exception>
|
||||
|
public ObjectPool(Func<T> factory, Func<T, bool>? validator = null, int maxSize = 32) |
||||
|
{ |
||||
|
if (maxSize < 1) |
||||
|
{ |
||||
|
throw new ArgumentOutOfRangeException(nameof(maxSize), maxSize, "maxSize must be at least 1."); |
||||
|
} |
||||
|
|
||||
|
_factory = factory ?? throw new ArgumentNullException(nameof(factory)); |
||||
|
_validator = validator; |
||||
|
_maxSize = maxSize; |
||||
|
_items = new ConcurrentBag<T>(); |
||||
|
_count = 0; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Rents an object from the pool or creates a new one if the pool is empty.
|
||||
|
/// </summary>
|
||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)] |
||||
|
public T Rent() |
||||
|
{ |
||||
|
if (_items.TryTake(out var item)) |
||||
|
{ |
||||
|
System.Threading.Interlocked.Decrement(ref _count); |
||||
|
return item; |
||||
|
} |
||||
|
|
||||
|
return _factory(); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Returns an object to the pool if it passes validation and the pool is not full.
|
||||
|
/// </summary>
|
||||
|
/// <remarks>
|
||||
|
/// Callers must not return the same item more than once without an intervening
|
||||
|
/// <see cref="Rent"/>. The pool does not detect duplicate returns; doing so would
|
||||
|
/// place the same instance into the pool twice and let two callers rent it
|
||||
|
/// concurrently. The pool also does not call <see cref="IDisposable.Dispose"/> on
|
||||
|
/// items it drops (e.g. when full or when the validator returns <c>false</c>);
|
||||
|
/// any cleanup belongs in the validator or in the caller.
|
||||
|
/// </remarks>
|
||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)] |
||||
|
public void Return(T item) |
||||
|
{ |
||||
|
if (item == null) |
||||
|
return; |
||||
|
|
||||
|
// Validate and clean the object
|
||||
|
if (_validator != null && !_validator(item)) |
||||
|
return; |
||||
|
|
||||
|
// Check if pool is full (fast check without lock)
|
||||
|
if (Volatile.Read(ref _count) >= _maxSize) |
||||
|
return; |
||||
|
|
||||
|
// Try to increment count, but check again in case of race condition
|
||||
|
var currentCount = System.Threading.Interlocked.Increment(ref _count); |
||||
|
|
||||
|
if (currentCount <= _maxSize) |
||||
|
{ |
||||
|
_items.Add(item); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
// Pool is full, decrement and discard
|
||||
|
System.Threading.Interlocked.Decrement(ref _count); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,148 @@ |
|||||
|
using System; |
||||
|
using Avalonia.Media; |
||||
|
using Avalonia.Media.Fonts; |
||||
|
using Xunit; |
||||
|
|
||||
|
namespace Avalonia.Base.UnitTests.Media |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// The user-space variation settings value: parse round-trips, order-independent
|
||||
|
/// structural equality, last-wins duplicate handling, and the lookups — the contract
|
||||
|
/// that lets the type serve as a style value and a cache key.
|
||||
|
/// </summary>
|
||||
|
public class FontVariationSettingsTests |
||||
|
{ |
||||
|
private static readonly OpenTypeTag s_wght = OpenTypeTag.Parse("wght"); |
||||
|
private static readonly OpenTypeTag s_wdth = OpenTypeTag.Parse("wdth"); |
||||
|
private static readonly OpenTypeTag s_opsz = OpenTypeTag.Parse("opsz"); |
||||
|
|
||||
|
[Fact] |
||||
|
public void Empty_Is_Empty_And_Round_Trips() |
||||
|
{ |
||||
|
Assert.True(FontVariationSettings.Empty.IsEmpty); |
||||
|
Assert.Empty(FontVariationSettings.Empty.Variations); |
||||
|
Assert.Equal(string.Empty, FontVariationSettings.Empty.ToString()); |
||||
|
Assert.Equal(FontVariationSettings.Empty, FontVariationSettings.Parse("")); |
||||
|
Assert.Equal(FontVariationSettings.Empty, FontVariationSettings.Parse(" ")); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Parse_Reads_Comma_Separated_Tag_Value_Pairs() |
||||
|
{ |
||||
|
var settings = FontVariationSettings.Parse(" wght = 700 , wdth=85.5 "); |
||||
|
|
||||
|
Assert.Equal(2, settings.Variations.Length); |
||||
|
Assert.True(settings.TryGetValue(s_wght, out var wght)); |
||||
|
Assert.Equal(700, wght); |
||||
|
Assert.True(settings.TryGetValue(s_wdth, out var wdth)); |
||||
|
Assert.Equal(85.5, wdth); |
||||
|
} |
||||
|
|
||||
|
[Theory] |
||||
|
[InlineData("wght")] |
||||
|
[InlineData("wght=")] |
||||
|
[InlineData("=700")] |
||||
|
[InlineData("weight=700")] |
||||
|
[InlineData("wght=seven")] |
||||
|
[InlineData("wght=NaN")] |
||||
|
public void Parse_Rejects_Malformed_Input(string input) |
||||
|
{ |
||||
|
Assert.Throws<FormatException>(() => FontVariationSettings.Parse(input)); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void ToString_Round_Trips_Through_Parse() |
||||
|
{ |
||||
|
var settings = FontVariationSettings.Parse("opsz=14.25,wght=650"); |
||||
|
var roundTripped = FontVariationSettings.Parse(settings.ToString()); |
||||
|
|
||||
|
Assert.Equal(settings, roundTripped); |
||||
|
Assert.Equal("opsz=14.25,wght=650", settings.ToString()); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Equality_Is_Order_Independent_With_Matching_Hashes() |
||||
|
{ |
||||
|
var a = new FontVariationSettings(new[] |
||||
|
{ |
||||
|
new FontVariation(s_wght, 700), |
||||
|
new FontVariation(s_opsz, 36), |
||||
|
}); |
||||
|
var b = new FontVariationSettings(new[] |
||||
|
{ |
||||
|
new FontVariation(s_opsz, 36), |
||||
|
new FontVariation(s_wght, 700), |
||||
|
}); |
||||
|
|
||||
|
Assert.Equal(a, b); |
||||
|
Assert.Equal(a.GetHashCode(), b.GetHashCode()); |
||||
|
Assert.NotEqual(a, FontVariationSettings.Parse("wght=700")); |
||||
|
Assert.False(a.Equals(null)); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Duplicate_Tags_Collapse_To_The_Last_Value() |
||||
|
{ |
||||
|
// CSS font-variation-settings behavior: the last occurrence wins.
|
||||
|
var settings = FontVariationSettings.Parse("wght=400,wght=700"); |
||||
|
|
||||
|
Assert.Equal(1, settings.Variations.Length); |
||||
|
Assert.True(settings.TryGetValue(s_wght, out var wght)); |
||||
|
Assert.Equal(700, wght); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Variations_Are_Sorted_By_Tag() |
||||
|
{ |
||||
|
var settings = FontVariationSettings.Parse("wght=700,opsz=14,wdth=85"); |
||||
|
|
||||
|
Assert.Equal(s_opsz, settings.Variations[0].Tag); |
||||
|
Assert.Equal(s_wdth, settings.Variations[1].Tag); |
||||
|
Assert.Equal(s_wght, settings.Variations[2].Tag); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Constructor_Rejects_NaN_And_Null() |
||||
|
{ |
||||
|
Assert.Throws<ArgumentException>(() => |
||||
|
new FontVariationSettings(new[] { new FontVariation(s_wght, double.NaN) })); |
||||
|
Assert.Throws<ArgumentNullException>(() => new FontVariationSettings(null!)); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Infinite_Values_Are_Accepted_And_Round_Trip() |
||||
|
{ |
||||
|
// Infinities clamp to the axis range when applied, like any out-of-range value.
|
||||
|
var settings = new FontVariationSettings(new[] |
||||
|
{ |
||||
|
new FontVariation(s_wght, double.PositiveInfinity), |
||||
|
new FontVariation(s_opsz, double.NegativeInfinity), |
||||
|
}); |
||||
|
|
||||
|
Assert.True(settings.TryGetValue(s_wght, out var wght)); |
||||
|
Assert.Equal(double.PositiveInfinity, wght); |
||||
|
|
||||
|
var roundTripped = FontVariationSettings.Parse(settings.ToString()); |
||||
|
|
||||
|
Assert.Equal(settings, roundTripped); |
||||
|
Assert.True(roundTripped.TryGetValue(s_opsz, out var opsz)); |
||||
|
Assert.Equal(double.NegativeInfinity, opsz); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void TryGetValue_Misses_Report_False_And_Zero() |
||||
|
{ |
||||
|
var settings = FontVariationSettings.Parse("wght=700"); |
||||
|
|
||||
|
Assert.False(settings.TryGetValue(s_opsz, out var value)); |
||||
|
Assert.Equal(0, value); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Variation_ToString_Is_The_Pair_Form() |
||||
|
{ |
||||
|
Assert.Equal("wght=700", new FontVariation(s_wght, 700).ToString()); |
||||
|
Assert.Equal("opsz=14.25", new FontVariation(s_opsz, 14.25).ToString()); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,257 @@ |
|||||
|
using Avalonia.UnitTests; |
||||
|
using Xunit; |
||||
|
|
||||
|
namespace Avalonia.Base.UnitTests.Media.Fonts |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// Characterization tests for the cmap / name / post robustness of the GlyphTypeface parsers,
|
||||
|
/// using the <see cref="SyntheticFont"/> / <see cref="BigEndianBuffer"/> harness. A present-but-
|
||||
|
/// malformed cosmetic table must degrade to the same fallback as an absent one rather than denying
|
||||
|
/// the whole font, and cmap parsing must not overflow or mis-select a subtable.
|
||||
|
/// </summary>
|
||||
|
public class MalformedCmapNamePostTests |
||||
|
{ |
||||
|
// ── present-but-malformed cosmetic tables must not deny the font ──
|
||||
|
//
|
||||
|
// A *missing* 'post' / 'name' table is handled gracefully (see the two "Missing_*" tests
|
||||
|
// below), and so is a *present-but-malformed* (truncated) one: the loader isolates the
|
||||
|
// over-reading parse and degrades to the same fallback as an absent table.
|
||||
|
|
||||
|
[Fact] |
||||
|
public void Truncated_Post_Table_Does_Not_Deny_The_Font() |
||||
|
{ |
||||
|
// Keep only the 4-byte version field; reading the rest of the header over-runs.
|
||||
|
var font = SyntheticFont.FromAsset(SyntheticFont.Assets.InterRegular).Truncate("post", 4); |
||||
|
|
||||
|
var typeface = font.TryCreateGlyphTypeface(); |
||||
|
|
||||
|
// A malformed 'post' (underline / italic-angle / fixed-pitch hints only) degrades to
|
||||
|
// defaults; the rest of the font — including the intact 'name' — still loads.
|
||||
|
Assert.NotNull(typeface); |
||||
|
Assert.Equal("Inter", typeface!.FamilyName); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Truncated_Name_Table_Falls_Back_To_Unknown_Family() |
||||
|
{ |
||||
|
// Keep only format + count; reading stringOffset and the record array over-runs.
|
||||
|
var font = SyntheticFont.FromAsset(SyntheticFont.Assets.InterRegular).Truncate("name", 4); |
||||
|
|
||||
|
var typeface = font.TryCreateGlyphTypeface(); |
||||
|
|
||||
|
// A malformed 'name' degrades to no name table, so the family name falls back to "unknown"
|
||||
|
// instead of denying a renderable font.
|
||||
|
Assert.NotNull(typeface); |
||||
|
Assert.Equal("unknown", typeface!.FamilyName); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Missing_Post_Table_Still_Loads_The_Font() |
||||
|
{ |
||||
|
// Documents the already-correct path: an *absent* cosmetic table is tolerated.
|
||||
|
var font = SyntheticFont.FromAsset(SyntheticFont.Assets.InterRegular).Remove("post"); |
||||
|
|
||||
|
var typeface = font.TryCreateGlyphTypeface(); |
||||
|
|
||||
|
Assert.NotNull(typeface); |
||||
|
Assert.Equal("Inter", typeface!.FamilyName); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Missing_Name_Table_Falls_Back_To_Unknown_Family() |
||||
|
{ |
||||
|
// Documents the already-correct path: an *absent* name table yields a usable typeface
|
||||
|
// with a fallback family name.
|
||||
|
var font = SyntheticFont.FromAsset(SyntheticFont.Assets.InterRegular).Remove("name"); |
||||
|
|
||||
|
var typeface = font.TryCreateGlyphTypeface(); |
||||
|
|
||||
|
Assert.NotNull(typeface); |
||||
|
Assert.Equal("unknown", typeface!.FamilyName); |
||||
|
} |
||||
|
|
||||
|
// ── cmap format 12 nGroups*12 overflow denies the whole font ──
|
||||
|
|
||||
|
[Fact] |
||||
|
public void Cmap_Format12_NGroups_Overflow_Does_Not_Deny_The_Font() |
||||
|
{ |
||||
|
// numGroups = 0x20000000 would make `numGroups * 12` overflow int32 to a negative slice
|
||||
|
// length and throw out of the (throwing) CmapTable.Load.
|
||||
|
var font = SyntheticFont.FromAsset(SyntheticFont.Assets.InterRegular) |
||||
|
.Replace("cmap", BuildCmapWithOverflowingFormat12(numGroups: 0x20000000)); |
||||
|
|
||||
|
var typeface = font.TryCreateGlyphTypeface(); |
||||
|
|
||||
|
// The group count is now computed in long and clamped to what the table holds, so the bad
|
||||
|
// subtable yields empty coverage rather than throwing — the font still loads.
|
||||
|
Assert.NotNull(typeface); |
||||
|
} |
||||
|
|
||||
|
// ── cmap format 12 hostile group *contents* must not hang range enumeration ──
|
||||
|
|
||||
|
[Fact] |
||||
|
public void Cmap_Format12_Huge_Group_End_Is_Clamped_To_The_Unicode_Range() |
||||
|
{ |
||||
|
// endCharCode = 0x7FFFFFFF used to reach the per-codepoint dictionary loops unclamped:
|
||||
|
// `cp <= end` with end == int.MaxValue is a tautology, so Count / Keys / the enumerator
|
||||
|
// never terminated. The group is now clamped to the Unicode range at the choke point.
|
||||
|
var font = SyntheticFont.FromAsset(SyntheticFont.Assets.InterRegular) |
||||
|
.Replace("cmap", BuildFormat12Cmap( |
||||
|
(Start: 0x41u, End: 0x7FFFFFFFu, StartGlyph: 1u))); |
||||
|
|
||||
|
var typeface = font.TryCreateGlyphTypeface(); |
||||
|
Assert.NotNull(typeface); |
||||
|
|
||||
|
var dictionary = typeface!.CharacterToGlyphMap.AsReadOnlyDictionary(); |
||||
|
|
||||
|
// Terminates (clamped to ≤ 0x10FFFF) and still exposes the in-range part of the group.
|
||||
|
Assert.True(dictionary.Count > 0); |
||||
|
Assert.True(dictionary.ContainsKey(0x41)); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Cmap_Format12_Inverted_Group_Is_Skipped_Not_Terminal() |
||||
|
{ |
||||
|
// The second group is inverted (start > end). It must enumerate as an empty range —
|
||||
|
// not throw, not end enumeration early, and not hide the first (valid) group.
|
||||
|
var font = SyntheticFont.FromAsset(SyntheticFont.Assets.InterRegular) |
||||
|
.Replace("cmap", BuildFormat12Cmap( |
||||
|
(Start: 0x41u, End: 0x41u, StartGlyph: 5u), |
||||
|
(Start: 0x1000u, End: 0x100u, StartGlyph: 6u))); |
||||
|
|
||||
|
var typeface = font.TryCreateGlyphTypeface(); |
||||
|
Assert.NotNull(typeface); |
||||
|
|
||||
|
var dictionary = typeface!.CharacterToGlyphMap.AsReadOnlyDictionary(); |
||||
|
|
||||
|
Assert.Equal(1, dictionary.Count); |
||||
|
Assert.True(dictionary.ContainsKey(0x41)); |
||||
|
} |
||||
|
|
||||
|
// ── cmap Format-4 subtable selection prefers Unicode over Symbol ──
|
||||
|
|
||||
|
[Theory] |
||||
|
[InlineData(true)] // Symbol subtable listed first
|
||||
|
[InlineData(false)] // Unicode subtable listed first
|
||||
|
public void Format4_Subtable_Selection_Prefers_Unicode_Over_Symbol(bool symbolFirst) |
||||
|
{ |
||||
|
// Two Windows-platform Format-4 subtables: a Symbol (encoding 0) one that maps only the
|
||||
|
// PUA codepoint 0xF041, and a Unicode-BMP (encoding 1) one that maps 'A'. The selection
|
||||
|
// now scores the Symbol encoding worse than Unicode, so the Unicode subtable wins
|
||||
|
// regardless of directory order.
|
||||
|
var font = SyntheticFont.FromAsset(SyntheticFont.Assets.InterRegular) |
||||
|
.Replace("cmap", BuildDualFormat4Cmap(symbolFirst)); |
||||
|
|
||||
|
var typeface = font.TryCreateGlyphTypeface(); |
||||
|
Assert.NotNull(typeface); |
||||
|
|
||||
|
// ASCII 'A' now resolves in BOTH orderings — encoding, not directory order, decides.
|
||||
|
Assert.True(typeface!.CharacterToGlyphMap.ContainsGlyph('A')); |
||||
|
} |
||||
|
|
||||
|
private static byte[] BuildCmapWithOverflowingFormat12(uint numGroups) |
||||
|
{ |
||||
|
// Format-12 subtable: format(2) reserved(2) length(4) language(4) numGroups(4) groups[…].
|
||||
|
// length is honest about the 16-byte buffer, so the length-slice succeeds and the
|
||||
|
// overflow surfaces at the group-array slice (the exact path under test).
|
||||
|
var subtable = new BigEndianBuffer() |
||||
|
.UInt16(12) // format
|
||||
|
.UInt16(0) // reserved
|
||||
|
.UInt32(16) // length (header only)
|
||||
|
.UInt32(0) // language
|
||||
|
.UInt32(numGroups) // numGroups
|
||||
|
.ToArray(); |
||||
|
|
||||
|
// cmap header: version(2) numTables(2), then one EncodingRecord: platform(2) encoding(2) offset(4).
|
||||
|
var cmap = new BigEndianBuffer(); |
||||
|
cmap.UInt16(0); // version
|
||||
|
cmap.UInt16(1); // numTables
|
||||
|
cmap.UInt16(3); // platformID = Windows
|
||||
|
cmap.UInt16(10); // encodingID = UCS-4 (any value works; format 12 is selected regardless)
|
||||
|
var offsetPos = cmap.ReserveOffset32(); |
||||
|
cmap.PatchUInt32(offsetPos, (uint)cmap.Position); |
||||
|
cmap.Bytes(subtable); |
||||
|
|
||||
|
return cmap.ToArray(); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Builds a cmap with a single format-12 subtable carrying the given sequential map groups
|
||||
|
/// (which must be ordered by start code for the lookup binary search).
|
||||
|
/// </summary>
|
||||
|
private static byte[] BuildFormat12Cmap(params (uint Start, uint End, uint StartGlyph)[] groups) |
||||
|
{ |
||||
|
var subtable = new BigEndianBuffer() |
||||
|
.UInt16(12) // format
|
||||
|
.UInt16(0) // reserved
|
||||
|
.UInt32((uint)(16 + groups.Length * 12)) // length
|
||||
|
.UInt32(0) // language
|
||||
|
.UInt32((uint)groups.Length); // numGroups
|
||||
|
|
||||
|
foreach (var (start, end, startGlyph) in groups) |
||||
|
{ |
||||
|
subtable.UInt32(start).UInt32(end).UInt32(startGlyph); |
||||
|
} |
||||
|
|
||||
|
var cmap = new BigEndianBuffer(); |
||||
|
cmap.UInt16(0); // version
|
||||
|
cmap.UInt16(1); // numTables
|
||||
|
cmap.UInt16(3); // platformID = Windows
|
||||
|
cmap.UInt16(10); // encodingID = UCS-4
|
||||
|
cmap.UInt32(12); // offset: header(4) + one EncodingRecord(8)
|
||||
|
cmap.Bytes(subtable.ToArray()); |
||||
|
|
||||
|
return cmap.ToArray(); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Builds a cmap with two Windows-platform Format-4 subtables — a Symbol (encoding 0) one
|
||||
|
/// mapping the PUA codepoint 0xF041 and a Unicode-BMP (encoding 1) one mapping 'A' — ordered
|
||||
|
/// per <paramref name="symbolFirst"/>.
|
||||
|
/// </summary>
|
||||
|
private static byte[] BuildDualFormat4Cmap(bool symbolFirst) |
||||
|
{ |
||||
|
var symbol = BuildSingleCharFormat4(charCode: 0xF041, glyph: 7); |
||||
|
var unicode = BuildSingleCharFormat4(charCode: 'A', glyph: 5); |
||||
|
|
||||
|
var firstSub = symbolFirst ? symbol : unicode; |
||||
|
var firstEncoding = symbolFirst ? 0 : 1; // Symbol = 0, UnicodeBMP = 1
|
||||
|
var secondSub = symbolFirst ? unicode : symbol; |
||||
|
var secondEncoding = symbolFirst ? 1 : 0; |
||||
|
|
||||
|
var cmap = new BigEndianBuffer(); |
||||
|
cmap.UInt16(0); // version
|
||||
|
cmap.UInt16(2); // numTables
|
||||
|
|
||||
|
// Two 8-byte EncodingRecords follow the 4-byte header, so the subtables start at offset 20.
|
||||
|
const int subtablesStart = 4 + 2 * 8; |
||||
|
cmap.UInt16(3); cmap.UInt16(firstEncoding); cmap.UInt32(subtablesStart); |
||||
|
cmap.UInt16(3); cmap.UInt16(secondEncoding); cmap.UInt32((uint)(subtablesStart + firstSub.Length)); |
||||
|
cmap.Bytes(firstSub); |
||||
|
cmap.Bytes(secondSub); |
||||
|
|
||||
|
return cmap.ToArray(); |
||||
|
} |
||||
|
|
||||
|
/// <summary>Builds a minimal Format-4 cmap subtable mapping a single <paramref name="charCode"/> to <paramref name="glyph"/>.</summary>
|
||||
|
private static byte[] BuildSingleCharFormat4(int charCode, int glyph) |
||||
|
{ |
||||
|
// Two segments: [charCode, charCode] and the mandatory terminal [0xFFFF, 0xFFFF].
|
||||
|
// No glyphIdArray — the glyph comes from idDelta (idRangeOffset = 0). Total length 32.
|
||||
|
return new BigEndianBuffer() |
||||
|
.UInt16(4) // format
|
||||
|
.UInt16(32) // length
|
||||
|
.UInt16(0) // language
|
||||
|
.UInt16(4) // segCountX2 (segCount = 2)
|
||||
|
.UInt16(4) // searchRange
|
||||
|
.UInt16(1) // entrySelector
|
||||
|
.UInt16(0) // rangeShift
|
||||
|
.UInt16(charCode).UInt16(0xFFFF) // endCode[2]
|
||||
|
.UInt16(0) // reservedPad
|
||||
|
.UInt16(charCode).UInt16(0xFFFF) // startCode[2]
|
||||
|
.UInt16((glyph - charCode) & 0xFFFF).UInt16(1) // idDelta[2]
|
||||
|
.UInt16(0).UInt16(0) // idRangeOffset[2]
|
||||
|
.ToArray(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,245 @@ |
|||||
|
using System; |
||||
|
using Avalonia.Media.Fonts.Tables; |
||||
|
using Xunit; |
||||
|
|
||||
|
namespace Avalonia.Base.UnitTests.Media.Fonts.Tables |
||||
|
{ |
||||
|
public class DecyclerTests |
||||
|
{ |
||||
|
[Theory] |
||||
|
[InlineData(0)] |
||||
|
[InlineData(-1)] |
||||
|
[InlineData(int.MinValue)] |
||||
|
public void Constructor_Throws_When_MaxDepth_Is_Less_Than_One(int maxDepth) |
||||
|
{ |
||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => new Decycler<int>(maxDepth)); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Constructor_Accepts_MaxDepth_Of_One() |
||||
|
{ |
||||
|
var decycler = new Decycler<int>(maxDepth: 1); |
||||
|
|
||||
|
Assert.Equal(0, decycler.CurrentDepth); |
||||
|
Assert.Equal(1, decycler.MaxDepth); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Enter_Increments_CurrentDepth() |
||||
|
{ |
||||
|
var decycler = new Decycler<int>(maxDepth: 4); |
||||
|
|
||||
|
using var guard = decycler.Enter(1); |
||||
|
|
||||
|
Assert.Equal(1, decycler.CurrentDepth); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Disposing_Guard_Restores_CurrentDepth() |
||||
|
{ |
||||
|
var decycler = new Decycler<int>(maxDepth: 4); |
||||
|
|
||||
|
using (decycler.Enter(1)) |
||||
|
{ |
||||
|
Assert.Equal(1, decycler.CurrentDepth); |
||||
|
} |
||||
|
|
||||
|
Assert.Equal(0, decycler.CurrentDepth); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Nested_Enters_Stack_Depth_And_Unwind_In_Reverse() |
||||
|
{ |
||||
|
var decycler = new Decycler<int>(maxDepth: 4); |
||||
|
|
||||
|
using (decycler.Enter(1)) |
||||
|
{ |
||||
|
Assert.Equal(1, decycler.CurrentDepth); |
||||
|
|
||||
|
using (decycler.Enter(2)) |
||||
|
{ |
||||
|
Assert.Equal(2, decycler.CurrentDepth); |
||||
|
|
||||
|
using (decycler.Enter(3)) |
||||
|
{ |
||||
|
Assert.Equal(3, decycler.CurrentDepth); |
||||
|
} |
||||
|
|
||||
|
Assert.Equal(2, decycler.CurrentDepth); |
||||
|
} |
||||
|
|
||||
|
Assert.Equal(1, decycler.CurrentDepth); |
||||
|
} |
||||
|
|
||||
|
Assert.Equal(0, decycler.CurrentDepth); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Re_Entering_Visited_Id_Throws_CycleDetected() |
||||
|
{ |
||||
|
var decycler = new Decycler<int>(maxDepth: 4); |
||||
|
|
||||
|
using var outer = decycler.Enter(1); |
||||
|
|
||||
|
var ex = Assert.Throws<DecyclerException>(() => decycler.Enter(1)); |
||||
|
|
||||
|
Assert.Equal(DecyclerError.CycleDetected, ex.Error); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Same_Id_Can_Be_Re_Entered_After_Exit() |
||||
|
{ |
||||
|
var decycler = new Decycler<int>(maxDepth: 4); |
||||
|
|
||||
|
using (decycler.Enter(1)) |
||||
|
{ |
||||
|
} |
||||
|
|
||||
|
// The id is no longer in the visited set; re-entering must succeed.
|
||||
|
using var guard = decycler.Enter(1); |
||||
|
|
||||
|
Assert.Equal(1, decycler.CurrentDepth); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Enter_Beyond_MaxDepth_Throws_DepthLimitExceeded() |
||||
|
{ |
||||
|
var decycler = new Decycler<int>(maxDepth: 2); |
||||
|
|
||||
|
using var a = decycler.Enter(1); |
||||
|
using var b = decycler.Enter(2); |
||||
|
|
||||
|
var ex = Assert.Throws<DecyclerException>(() => decycler.Enter(3)); |
||||
|
|
||||
|
Assert.Equal(DecyclerError.DepthLimitExceeded, ex.Error); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void DepthLimit_Check_Runs_Before_Cycle_Check() |
||||
|
{ |
||||
|
// When both conditions could apply (depth is exhausted AND the id is
|
||||
|
// already visited), the depth check fires first. This matters because
|
||||
|
// it means a misconfigured depth cap surfaces as a depth error rather
|
||||
|
// than masquerading as a cycle.
|
||||
|
var decycler = new Decycler<int>(maxDepth: 1); |
||||
|
|
||||
|
using var guard = decycler.Enter(1); |
||||
|
|
||||
|
var ex = Assert.Throws<DecyclerException>(() => decycler.Enter(1)); |
||||
|
|
||||
|
Assert.Equal(DecyclerError.DepthLimitExceeded, ex.Error); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Failed_Enter_Does_Not_Mutate_State() |
||||
|
{ |
||||
|
var decycler = new Decycler<int>(maxDepth: 1); |
||||
|
|
||||
|
using var guard = decycler.Enter(1); |
||||
|
|
||||
|
Assert.Throws<DecyclerException>(() => decycler.Enter(2)); |
||||
|
|
||||
|
// The failed Enter must not have incremented depth or registered the id.
|
||||
|
Assert.Equal(1, decycler.CurrentDepth); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Reset_Clears_Visited_And_Depth() |
||||
|
{ |
||||
|
var decycler = new Decycler<int>(maxDepth: 4); |
||||
|
|
||||
|
var guard = decycler.Enter(1); |
||||
|
// Skip the disposal: simulate an abandoned traversal that needs to be
|
||||
|
// cleaned up by Reset (the validator path on the pool).
|
||||
|
_ = guard; |
||||
|
|
||||
|
decycler.Reset(); |
||||
|
|
||||
|
Assert.Equal(0, decycler.CurrentDepth); |
||||
|
|
||||
|
// The previously visited id must be enterable again after reset.
|
||||
|
using var fresh = decycler.Enter(1); |
||||
|
|
||||
|
Assert.Equal(1, decycler.CurrentDepth); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Reset_Is_Safe_To_Call_On_Empty_Decycler() |
||||
|
{ |
||||
|
var decycler = new Decycler<int>(maxDepth: 4); |
||||
|
|
||||
|
decycler.Reset(); |
||||
|
decycler.Reset(); |
||||
|
|
||||
|
Assert.Equal(0, decycler.CurrentDepth); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Guard_Dispose_Is_Idempotent() |
||||
|
{ |
||||
|
var decycler = new Decycler<int>(maxDepth: 4); |
||||
|
|
||||
|
var guard = decycler.Enter(1); |
||||
|
|
||||
|
guard.Dispose(); |
||||
|
guard.Dispose(); // second call must not double-decrement.
|
||||
|
|
||||
|
Assert.Equal(0, decycler.CurrentDepth); |
||||
|
|
||||
|
// Depth must not be negative; entering a new node still works.
|
||||
|
using var next = decycler.Enter(2); |
||||
|
|
||||
|
Assert.Equal(1, decycler.CurrentDepth); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Copied_Guard_Cannot_Double_Exit() |
||||
|
{ |
||||
|
// CycleGuard is a copyable ref struct, so each copy carries its own _exited flag —
|
||||
|
// the idempotence of Dispose on one copy doesn't protect against the other copy
|
||||
|
// exiting again. Exit only returns depth budget for ids actually in the visited set.
|
||||
|
var decycler = new Decycler<int>(maxDepth: 4); |
||||
|
|
||||
|
var guard = decycler.Enter(1); |
||||
|
var copy = guard; |
||||
|
|
||||
|
guard.Dispose(); |
||||
|
copy.Dispose(); // second exit for id 1, via an un-exited copy.
|
||||
|
|
||||
|
Assert.Equal(0, decycler.CurrentDepth); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void MaxDepth_Property_Reflects_Constructor_Argument() |
||||
|
{ |
||||
|
var decycler = new Decycler<int>(maxDepth: 17); |
||||
|
|
||||
|
Assert.Equal(17, decycler.MaxDepth); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void DecyclerException_Carries_Error_Code_And_Message() |
||||
|
{ |
||||
|
var ex = new DecyclerException(DecyclerError.CycleDetected, "boom"); |
||||
|
|
||||
|
Assert.Equal(DecyclerError.CycleDetected, ex.Error); |
||||
|
Assert.Equal("boom", ex.Message); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Works_With_Other_Struct_Types() |
||||
|
{ |
||||
|
// Decycler<T> is constrained to struct; the typical instantiations are
|
||||
|
// int (composite-glyph ids) and ushort (paint-graph glyph ids). Verify
|
||||
|
// ushort works end-to-end.
|
||||
|
var decycler = new Decycler<ushort>(maxDepth: 4); |
||||
|
|
||||
|
using (decycler.Enter(1)) |
||||
|
{ |
||||
|
Assert.Throws<DecyclerException>(() => decycler.Enter((ushort)1)); |
||||
|
} |
||||
|
|
||||
|
Assert.Equal(0, decycler.CurrentDepth); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,148 @@ |
|||||
|
using System; |
||||
|
using Avalonia.Media; |
||||
|
using Xunit; |
||||
|
|
||||
|
namespace Avalonia.Base.UnitTests.Media |
||||
|
{ |
||||
|
public class GlyphDrawingOptionsTests |
||||
|
{ |
||||
|
[Fact] |
||||
|
public void Default_Has_Null_PaletteIndex_And_Null_PixelSize() |
||||
|
{ |
||||
|
var options = GlyphDrawingOptions.Default; |
||||
|
|
||||
|
Assert.Null(options.PaletteIndex); |
||||
|
Assert.Null(options.PixelSize); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Default_Is_A_Singleton() |
||||
|
{ |
||||
|
Assert.Same(GlyphDrawingOptions.Default, GlyphDrawingOptions.Default); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Parameterless_Constructor_Produces_An_Instance_Equal_To_Default() |
||||
|
{ |
||||
|
// The record's equality contract should treat a freshly-constructed
|
||||
|
// instance with no overrides as equal to the Default singleton, even
|
||||
|
// though they're distinct instances. This keeps callers from having
|
||||
|
// to compare against Default by reference.
|
||||
|
var fresh = new GlyphDrawingOptions(); |
||||
|
|
||||
|
Assert.NotSame(GlyphDrawingOptions.Default, fresh); |
||||
|
Assert.Equal(GlyphDrawingOptions.Default, fresh); |
||||
|
Assert.Equal(GlyphDrawingOptions.Default.GetHashCode(), fresh.GetHashCode()); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void PaletteIndex_Accepts_Null() |
||||
|
{ |
||||
|
var options = new GlyphDrawingOptions { PaletteIndex = null }; |
||||
|
|
||||
|
Assert.Null(options.PaletteIndex); |
||||
|
} |
||||
|
|
||||
|
[Theory] |
||||
|
[InlineData(0)] |
||||
|
[InlineData(1)] |
||||
|
[InlineData(99)] |
||||
|
public void PaletteIndex_Accepts_NonNegative_Values(int value) |
||||
|
{ |
||||
|
var options = new GlyphDrawingOptions { PaletteIndex = value }; |
||||
|
|
||||
|
Assert.Equal(value, options.PaletteIndex); |
||||
|
} |
||||
|
|
||||
|
[Theory] |
||||
|
[InlineData(-1)] |
||||
|
[InlineData(int.MinValue)] |
||||
|
public void PaletteIndex_Rejects_Negative_Values(int value) |
||||
|
{ |
||||
|
Assert.Throws<ArgumentOutOfRangeException>( |
||||
|
() => new GlyphDrawingOptions { PaletteIndex = value }); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void PixelSize_Accepts_Null() |
||||
|
{ |
||||
|
var options = new GlyphDrawingOptions { PixelSize = null }; |
||||
|
|
||||
|
Assert.Null(options.PixelSize); |
||||
|
} |
||||
|
|
||||
|
[Theory] |
||||
|
[InlineData(1)] |
||||
|
[InlineData(16)] |
||||
|
[InlineData(256)] |
||||
|
public void PixelSize_Accepts_Positive_Values(int value) |
||||
|
{ |
||||
|
var options = new GlyphDrawingOptions { PixelSize = value }; |
||||
|
|
||||
|
Assert.Equal(value, options.PixelSize); |
||||
|
} |
||||
|
|
||||
|
[Theory] |
||||
|
[InlineData(0)] |
||||
|
[InlineData(-1)] |
||||
|
[InlineData(int.MinValue)] |
||||
|
public void PixelSize_Rejects_Values_Below_One(int value) |
||||
|
{ |
||||
|
// A pixel size of zero is meaningless for a bitmap strike; reject it at
|
||||
|
// construction time rather than letting it propagate to renderer code.
|
||||
|
Assert.Throws<ArgumentOutOfRangeException>( |
||||
|
() => new GlyphDrawingOptions { PixelSize = value }); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Equality_Is_Structural_Across_Two_Records_With_Same_Values() |
||||
|
{ |
||||
|
var a = new GlyphDrawingOptions { PaletteIndex = 1, PixelSize = 16 }; |
||||
|
var b = new GlyphDrawingOptions { PaletteIndex = 1, PixelSize = 16 }; |
||||
|
|
||||
|
Assert.NotSame(a, b); |
||||
|
Assert.Equal(a, b); |
||||
|
Assert.Equal(a.GetHashCode(), b.GetHashCode()); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Equality_Distinguishes_Different_PaletteIndex() |
||||
|
{ |
||||
|
var a = new GlyphDrawingOptions { PaletteIndex = 0 }; |
||||
|
var b = new GlyphDrawingOptions { PaletteIndex = 1 }; |
||||
|
|
||||
|
Assert.NotEqual(a, b); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Equality_Distinguishes_Different_PixelSize() |
||||
|
{ |
||||
|
var a = new GlyphDrawingOptions { PixelSize = 16 }; |
||||
|
var b = new GlyphDrawingOptions { PixelSize = 32 }; |
||||
|
|
||||
|
Assert.NotEqual(a, b); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void With_Expression_Produces_A_Modified_Copy() |
||||
|
{ |
||||
|
// `record` participation is part of the public contract; ensure callers
|
||||
|
// can use `with` to derive a tweaked instance.
|
||||
|
var original = new GlyphDrawingOptions { PaletteIndex = 1, PixelSize = 16 }; |
||||
|
var tweaked = original with { PaletteIndex = 2 }; |
||||
|
|
||||
|
Assert.Equal(1, original.PaletteIndex); |
||||
|
Assert.Equal(2, tweaked.PaletteIndex); |
||||
|
Assert.Equal(16, tweaked.PixelSize); |
||||
|
Assert.NotEqual(original, tweaked); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void With_Expression_Validates_New_Values() |
||||
|
{ |
||||
|
var original = new GlyphDrawingOptions { PaletteIndex = 1 }; |
||||
|
|
||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => original with { PaletteIndex = -5 }); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,401 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using Avalonia.Media; |
||||
|
using Avalonia.Media.Fonts; |
||||
|
using Xunit; |
||||
|
|
||||
|
namespace Avalonia.Base.UnitTests.Media |
||||
|
{ |
||||
|
public class NormalizedVariationPositionTests |
||||
|
{ |
||||
|
private static readonly OpenTypeTag Wght = OpenTypeTag.Parse("wght"); |
||||
|
private static readonly OpenTypeTag Wdth = OpenTypeTag.Parse("wdth"); |
||||
|
private static readonly OpenTypeTag Ital = OpenTypeTag.Parse("ital"); |
||||
|
|
||||
|
[Fact] |
||||
|
public void Default_Struct_Is_The_No_Variation_Case() |
||||
|
{ |
||||
|
var settings = default(NormalizedVariationPosition); |
||||
|
|
||||
|
Assert.True(settings.IsDefault); |
||||
|
Assert.Empty(settings.Coordinates); |
||||
|
Assert.Equal(0, settings.GetHashCode()); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Default_Structs_Are_Equal() |
||||
|
{ |
||||
|
// Two zero-initialized structs must compare equal, even though they're
|
||||
|
// distinct values on the stack. This is the substitute for the previous
|
||||
|
// singleton Default.
|
||||
|
Assert.Equal(default(NormalizedVariationPosition), default(NormalizedVariationPosition)); |
||||
|
Assert.True(default(NormalizedVariationPosition) == default(NormalizedVariationPosition)); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Coordinates_Property_Returns_Empty_Not_Default_For_Default_Struct() |
||||
|
{ |
||||
|
// The IsDefault → Empty normalization in the property lets callers iterate
|
||||
|
// / index without first checking ImmutableArray.IsDefault.
|
||||
|
var settings = default(NormalizedVariationPosition); |
||||
|
|
||||
|
Assert.False(settings.Coordinates.IsDefault); |
||||
|
Assert.Equal(0, settings.Coordinates.Length); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void FromCoordinates_Dictionary_Throws_On_Null() |
||||
|
{ |
||||
|
Assert.Throws<ArgumentNullException>( |
||||
|
() => NormalizedVariationPosition.FromCoordinates((IReadOnlyDictionary<OpenTypeTag, float>)null!)); |
||||
|
} |
||||
|
|
||||
|
[Theory] |
||||
|
[InlineData(float.NaN)] |
||||
|
[InlineData(-1.0001f)] |
||||
|
[InlineData(1.0001f)] |
||||
|
[InlineData(float.PositiveInfinity)] |
||||
|
[InlineData(float.NegativeInfinity)] |
||||
|
public void FromCoordinates_Dictionary_Rejects_Out_Of_Range_Or_NaN(float value) |
||||
|
{ |
||||
|
var coords = new Dictionary<OpenTypeTag, float> { [Wght] = value }; |
||||
|
|
||||
|
Assert.Throws<ArgumentOutOfRangeException>( |
||||
|
() => NormalizedVariationPosition.FromCoordinates(coords)); |
||||
|
} |
||||
|
|
||||
|
[Theory] |
||||
|
[InlineData(-1f)] |
||||
|
[InlineData(1f)] |
||||
|
public void FromCoordinates_Dictionary_Accepts_Boundary_Values(float value) |
||||
|
{ |
||||
|
var coords = new Dictionary<OpenTypeTag, float> { [Wght] = value }; |
||||
|
|
||||
|
var settings = NormalizedVariationPosition.FromCoordinates(coords); |
||||
|
|
||||
|
Assert.Single(settings.Coordinates); |
||||
|
Assert.Equal(Wght, settings.Coordinates[0].Axis); |
||||
|
Assert.Equal(value, settings.Coordinates[0].NormalizedValue); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void FromCoordinates_Drops_Zero_Coordinates() |
||||
|
{ |
||||
|
// 0 is the axis default: an explicit wght=0 must produce the same value (and the
|
||||
|
// same variation-cache key downstream) as settings that omit the axis entirely.
|
||||
|
var fromDictionary = NormalizedVariationPosition.FromCoordinates( |
||||
|
new Dictionary<OpenTypeTag, float> { [Wght] = 0f }); |
||||
|
|
||||
|
Assert.True(fromDictionary.IsDefault); |
||||
|
Assert.Equal(default(NormalizedVariationPosition), fromDictionary); |
||||
|
|
||||
|
Span<NormalizedVariationCoordinate> coords = |
||||
|
[ |
||||
|
new NormalizedVariationCoordinate(Wght, 0f), |
||||
|
new NormalizedVariationCoordinate(Wdth, -0.25f), |
||||
|
]; |
||||
|
|
||||
|
var fromSpan = NormalizedVariationPosition.FromCoordinates(coords); |
||||
|
|
||||
|
Assert.Single(fromSpan.Coordinates); |
||||
|
Assert.Equal(Wdth, fromSpan.Coordinates[0].Axis); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void FromCoordinates_Span_Rejects_Duplicate_Axes_Even_When_Zero_Valued() |
||||
|
{ |
||||
|
// Canonicalization must not weaken validation: the duplicate check runs before
|
||||
|
// zero-valued coordinates are dropped.
|
||||
|
Assert.Throws<ArgumentException>(static () => |
||||
|
{ |
||||
|
Span<NormalizedVariationCoordinate> coords = |
||||
|
[ |
||||
|
new NormalizedVariationCoordinate(OpenTypeTag.Parse("wght"), 0f), |
||||
|
new NormalizedVariationCoordinate(OpenTypeTag.Parse("wght"), 0.5f), |
||||
|
]; |
||||
|
NormalizedVariationPosition.FromCoordinates(coords); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void FromCoordinates_Dictionary_Empty_Returns_Default_Struct() |
||||
|
{ |
||||
|
var settings = NormalizedVariationPosition.FromCoordinates(new Dictionary<OpenTypeTag, float>()); |
||||
|
|
||||
|
Assert.True(settings.IsDefault); |
||||
|
Assert.Equal(default(NormalizedVariationPosition), settings); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void FromCoordinates_Dictionary_Sorts_By_Axis_Tag() |
||||
|
{ |
||||
|
// Insertion order shouldn't matter — coordinates land sorted by (uint)tag
|
||||
|
// so equality and hashing are insertion-order-independent.
|
||||
|
var unordered = new Dictionary<OpenTypeTag, float> |
||||
|
{ |
||||
|
[Wght] = 0.5f, |
||||
|
[Ital] = 1f, |
||||
|
[Wdth] = -0.25f, |
||||
|
}; |
||||
|
|
||||
|
var settings = NormalizedVariationPosition.FromCoordinates(unordered); |
||||
|
|
||||
|
Assert.Equal(3, settings.Coordinates.Length); |
||||
|
// Sorted: ital (0x6974616c), wdth (0x77647468), wght (0x77676874).
|
||||
|
// Lexically by the 4-char ASCII tag uint, that's ital < wdth < wght.
|
||||
|
Assert.Equal(Ital, settings.Coordinates[0].Axis); |
||||
|
Assert.Equal(Wdth, settings.Coordinates[1].Axis); |
||||
|
Assert.Equal(Wght, settings.Coordinates[2].Axis); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void FromCoordinates_Dictionary_Defensively_Copies_The_Input() |
||||
|
{ |
||||
|
var mutable = new Dictionary<OpenTypeTag, float> { [Wght] = 0.5f }; |
||||
|
|
||||
|
var settings = NormalizedVariationPosition.FromCoordinates(mutable); |
||||
|
|
||||
|
mutable[Wght] = 0.9f; |
||||
|
mutable[Wdth] = -0.25f; |
||||
|
|
||||
|
Assert.Single(settings.Coordinates); |
||||
|
Assert.Equal(0.5f, settings.Coordinates[0].NormalizedValue); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void FromCoordinates_Span_Empty_Returns_Default_Struct() |
||||
|
{ |
||||
|
var settings = NormalizedVariationPosition.FromCoordinates(ReadOnlySpan<NormalizedVariationCoordinate>.Empty); |
||||
|
|
||||
|
Assert.True(settings.IsDefault); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void FromCoordinates_Span_Sorts_And_Validates() |
||||
|
{ |
||||
|
Span<NormalizedVariationCoordinate> coords = |
||||
|
[ |
||||
|
new NormalizedVariationCoordinate(Wght, 0.5f), |
||||
|
new NormalizedVariationCoordinate(Ital, 1f), |
||||
|
new NormalizedVariationCoordinate(Wdth, -0.25f), |
||||
|
]; |
||||
|
|
||||
|
var settings = NormalizedVariationPosition.FromCoordinates(coords); |
||||
|
|
||||
|
Assert.Equal(3, settings.Coordinates.Length); |
||||
|
Assert.Equal(Ital, settings.Coordinates[0].Axis); |
||||
|
Assert.Equal(Wdth, settings.Coordinates[1].Axis); |
||||
|
Assert.Equal(Wght, settings.Coordinates[2].Axis); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void FromCoordinates_Span_Rejects_Duplicate_Axes() |
||||
|
{ |
||||
|
Assert.Throws<ArgumentException>(static () => |
||||
|
{ |
||||
|
Span<NormalizedVariationCoordinate> coords = |
||||
|
[ |
||||
|
new NormalizedVariationCoordinate(OpenTypeTag.Parse("wght"), 0.5f), |
||||
|
new NormalizedVariationCoordinate(OpenTypeTag.Parse("wght"), -0.5f), |
||||
|
]; |
||||
|
NormalizedVariationPosition.FromCoordinates(coords); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void FromCoordinates_Span_Rejects_Out_Of_Range_Value() |
||||
|
{ |
||||
|
Assert.Throws<ArgumentOutOfRangeException>(static () => |
||||
|
{ |
||||
|
Span<NormalizedVariationCoordinate> coords = [new NormalizedVariationCoordinate(OpenTypeTag.Parse("wght"), 2f)]; |
||||
|
NormalizedVariationPosition.FromCoordinates(coords); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void TryGetCoordinate_Returns_True_For_Present_Axis() |
||||
|
{ |
||||
|
var settings = NormalizedVariationPosition.FromCoordinates( |
||||
|
new Dictionary<OpenTypeTag, float> { [Wght] = 0.5f, [Wdth] = -0.25f }); |
||||
|
|
||||
|
Assert.True(settings.TryGetCoordinate(Wght, out var w)); |
||||
|
Assert.Equal(0.5f, w); |
||||
|
|
||||
|
Assert.True(settings.TryGetCoordinate(Wdth, out var wd)); |
||||
|
Assert.Equal(-0.25f, wd); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void TryGetCoordinate_Returns_False_For_Absent_Axis() |
||||
|
{ |
||||
|
var settings = NormalizedVariationPosition.FromCoordinates( |
||||
|
new Dictionary<OpenTypeTag, float> { [Wght] = 0.5f }); |
||||
|
|
||||
|
Assert.False(settings.TryGetCoordinate(Ital, out var v)); |
||||
|
Assert.Equal(0f, v); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void TryGetCoordinate_Returns_False_For_Default_Struct() |
||||
|
{ |
||||
|
var settings = default(NormalizedVariationPosition); |
||||
|
|
||||
|
Assert.False(settings.TryGetCoordinate(Wght, out var v)); |
||||
|
Assert.Equal(0f, v); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void GetCoordinateOrDefault_Returns_Fallback_For_Absent_Axis() |
||||
|
{ |
||||
|
var settings = NormalizedVariationPosition.FromCoordinates( |
||||
|
new Dictionary<OpenTypeTag, float> { [Wght] = 0.5f }); |
||||
|
|
||||
|
Assert.Equal(0.5f, settings.GetCoordinateOrDefault(Wght)); |
||||
|
Assert.Equal(0f, settings.GetCoordinateOrDefault(Ital)); |
||||
|
Assert.Equal(-1f, settings.GetCoordinateOrDefault(Ital, -1f)); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Equality_Is_Reflexive() |
||||
|
{ |
||||
|
var settings = NormalizedVariationPosition.FromCoordinates( |
||||
|
new Dictionary<OpenTypeTag, float> { [Wght] = 0.5f }); |
||||
|
|
||||
|
Assert.True(settings.Equals(settings)); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Equality_Is_Structural_For_Identical_Coordinates() |
||||
|
{ |
||||
|
var a = NormalizedVariationPosition.FromCoordinates( |
||||
|
new Dictionary<OpenTypeTag, float> { [Wght] = 0.5f, [Wdth] = -0.25f }); |
||||
|
var b = NormalizedVariationPosition.FromCoordinates( |
||||
|
new Dictionary<OpenTypeTag, float> { [Wght] = 0.5f, [Wdth] = -0.25f }); |
||||
|
|
||||
|
Assert.True(a.Equals(b)); |
||||
|
Assert.True(b.Equals(a)); |
||||
|
Assert.Equal(a.GetHashCode(), b.GetHashCode()); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Equality_Ignores_Insertion_Order() |
||||
|
{ |
||||
|
var a = NormalizedVariationPosition.FromCoordinates( |
||||
|
new Dictionary<OpenTypeTag, float> { [Wght] = 0.5f, [Wdth] = -0.25f }); |
||||
|
var b = NormalizedVariationPosition.FromCoordinates( |
||||
|
new Dictionary<OpenTypeTag, float> { [Wdth] = -0.25f, [Wght] = 0.5f }); |
||||
|
|
||||
|
Assert.True(a.Equals(b)); |
||||
|
Assert.Equal(a.GetHashCode(), b.GetHashCode()); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Equality_Differs_When_A_Coordinate_Value_Differs() |
||||
|
{ |
||||
|
var a = NormalizedVariationPosition.FromCoordinates( |
||||
|
new Dictionary<OpenTypeTag, float> { [Wght] = 0.5f }); |
||||
|
var b = NormalizedVariationPosition.FromCoordinates( |
||||
|
new Dictionary<OpenTypeTag, float> { [Wght] = 0.6f }); |
||||
|
|
||||
|
Assert.False(a.Equals(b)); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Equality_Differs_When_A_Coordinate_Key_Differs() |
||||
|
{ |
||||
|
var a = NormalizedVariationPosition.FromCoordinates( |
||||
|
new Dictionary<OpenTypeTag, float> { [Wght] = 0.5f }); |
||||
|
var b = NormalizedVariationPosition.FromCoordinates( |
||||
|
new Dictionary<OpenTypeTag, float> { [Wdth] = 0.5f }); |
||||
|
|
||||
|
Assert.False(a.Equals(b)); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Equality_Differs_When_Coordinate_Counts_Differ() |
||||
|
{ |
||||
|
// The second coordinate must be non-zero: zero coordinates canonicalize away in
|
||||
|
// FromCoordinates, which would make these two values deliberately equal.
|
||||
|
var a = NormalizedVariationPosition.FromCoordinates( |
||||
|
new Dictionary<OpenTypeTag, float> { [Wght] = 0.5f }); |
||||
|
var b = NormalizedVariationPosition.FromCoordinates( |
||||
|
new Dictionary<OpenTypeTag, float> { [Wght] = 0.5f, [Wdth] = -0.25f }); |
||||
|
|
||||
|
Assert.False(a.Equals(b)); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Equality_Differs_Between_Default_And_Populated() |
||||
|
{ |
||||
|
var a = default(NormalizedVariationPosition); |
||||
|
var b = NormalizedVariationPosition.FromCoordinates( |
||||
|
new Dictionary<OpenTypeTag, float> { [Wght] = 0.5f }); |
||||
|
|
||||
|
Assert.False(a.Equals(b)); |
||||
|
Assert.False(b.Equals(a)); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Hash_Is_Cached_And_Stable_Across_Equal_Instances() |
||||
|
{ |
||||
|
// The hash is computed once at construction. Two structurally-equal settings
|
||||
|
// must have the same hash regardless of how the coordinates were inserted.
|
||||
|
var a = NormalizedVariationPosition.FromCoordinates( |
||||
|
new Dictionary<OpenTypeTag, float> { [Wght] = 0.5f, [Wdth] = -0.25f, [Ital] = 1f }); |
||||
|
var b = NormalizedVariationPosition.FromCoordinates( |
||||
|
new Dictionary<OpenTypeTag, float> { [Ital] = 1f, [Wght] = 0.5f, [Wdth] = -0.25f }); |
||||
|
|
||||
|
var hashA = a.GetHashCode(); |
||||
|
// Subsequent calls return the same cached value.
|
||||
|
Assert.Equal(hashA, a.GetHashCode()); |
||||
|
Assert.Equal(hashA, b.GetHashCode()); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Equality_Operators_Match_Equals() |
||||
|
{ |
||||
|
var a = NormalizedVariationPosition.FromCoordinates( |
||||
|
new Dictionary<OpenTypeTag, float> { [Ital] = 1f }); |
||||
|
var b = NormalizedVariationPosition.FromCoordinates( |
||||
|
new Dictionary<OpenTypeTag, float> { [Ital] = 1f }); |
||||
|
var c = NormalizedVariationPosition.FromCoordinates( |
||||
|
new Dictionary<OpenTypeTag, float> { [Ital] = 0f }); |
||||
|
|
||||
|
Assert.True(a == b); |
||||
|
Assert.False(a != b); |
||||
|
Assert.False(a == c); |
||||
|
Assert.True(a != c); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Equality_With_Boxed_Object() |
||||
|
{ |
||||
|
// Cache-key paths box rarely, but Equals(object) must still be correct.
|
||||
|
var a = NormalizedVariationPosition.FromCoordinates( |
||||
|
new Dictionary<OpenTypeTag, float> { [Wght] = 0.5f }); |
||||
|
object boxed = NormalizedVariationPosition.FromCoordinates( |
||||
|
new Dictionary<OpenTypeTag, float> { [Wght] = 0.5f }); |
||||
|
|
||||
|
Assert.True(a.Equals(boxed)); |
||||
|
Assert.False(a.Equals((object?)null)); |
||||
|
Assert.False(a.Equals("not a settings")); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void NormalizedVariationCoordinate_Has_Structural_Equality() |
||||
|
{ |
||||
|
// The coordinate record-struct provides equality for free; verify it
|
||||
|
// behaves as expected so callers can use it in their own comparisons.
|
||||
|
var a = new NormalizedVariationCoordinate(Wght, 0.5f); |
||||
|
var b = new NormalizedVariationCoordinate(Wght, 0.5f); |
||||
|
var c = new NormalizedVariationCoordinate(Wght, 0.6f); |
||||
|
var d = new NormalizedVariationCoordinate(Wdth, 0.5f); |
||||
|
|
||||
|
Assert.Equal(a, b); |
||||
|
Assert.NotEqual(a, c); |
||||
|
Assert.NotEqual(a, d); |
||||
|
Assert.True(a == b); |
||||
|
Assert.True(a != c); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,244 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Threading.Tasks; |
||||
|
using Avalonia.Utilities; |
||||
|
using Xunit; |
||||
|
|
||||
|
namespace Avalonia.Base.UnitTests.Utilities |
||||
|
{ |
||||
|
public class ObjectPoolTests |
||||
|
{ |
||||
|
private sealed class Item |
||||
|
{ |
||||
|
public int State; |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Constructor_Throws_When_Factory_Is_Null() |
||||
|
{ |
||||
|
Assert.Throws<ArgumentNullException>(() => new ObjectPool<Item>(factory: null!)); |
||||
|
} |
||||
|
|
||||
|
[Theory] |
||||
|
[InlineData(0)] |
||||
|
[InlineData(-1)] |
||||
|
[InlineData(int.MinValue)] |
||||
|
public void Constructor_Throws_When_MaxSize_Is_Less_Than_One(int maxSize) |
||||
|
{ |
||||
|
Assert.Throws<ArgumentOutOfRangeException>( |
||||
|
() => new ObjectPool<Item>(() => new Item(), maxSize: maxSize)); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Constructor_Accepts_MaxSize_Of_One() |
||||
|
{ |
||||
|
var pool = new ObjectPool<Item>(() => new Item(), maxSize: 1); |
||||
|
|
||||
|
var item = pool.Rent(); |
||||
|
|
||||
|
Assert.NotNull(item); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Rent_Creates_New_Item_When_Pool_Is_Empty() |
||||
|
{ |
||||
|
var pool = new ObjectPool<Item>(() => new Item()); |
||||
|
|
||||
|
var first = pool.Rent(); |
||||
|
var second = pool.Rent(); |
||||
|
|
||||
|
Assert.NotNull(first); |
||||
|
Assert.NotNull(second); |
||||
|
Assert.NotSame(first, second); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Returned_Item_Is_Reused_By_Subsequent_Rent() |
||||
|
{ |
||||
|
var pool = new ObjectPool<Item>(() => new Item()); |
||||
|
|
||||
|
var item = pool.Rent(); |
||||
|
pool.Return(item); |
||||
|
|
||||
|
var rented = pool.Rent(); |
||||
|
|
||||
|
Assert.Same(item, rented); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Return_Ignores_Null() |
||||
|
{ |
||||
|
var pool = new ObjectPool<Item>(() => new Item()); |
||||
|
|
||||
|
pool.Return(null!); |
||||
|
|
||||
|
// Pool stays empty, so the next Rent must produce a fresh item.
|
||||
|
var item = pool.Rent(); |
||||
|
|
||||
|
Assert.NotNull(item); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Return_Drops_Item_When_Pool_Is_Full() |
||||
|
{ |
||||
|
var pool = new ObjectPool<Item>(() => new Item(), maxSize: 2); |
||||
|
|
||||
|
var a = new Item(); |
||||
|
var b = new Item(); |
||||
|
var c = new Item(); |
||||
|
|
||||
|
pool.Return(a); |
||||
|
pool.Return(b); |
||||
|
pool.Return(c); // pool full -> dropped
|
||||
|
|
||||
|
var rented = new HashSet<Item> |
||||
|
{ |
||||
|
pool.Rent(), |
||||
|
pool.Rent(), |
||||
|
}; |
||||
|
|
||||
|
// The two rented items must come from {a, b}; c was dropped.
|
||||
|
Assert.Contains(a, rented); |
||||
|
Assert.Contains(b, rented); |
||||
|
Assert.DoesNotContain(c, rented); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Validator_Is_Invoked_On_Return() |
||||
|
{ |
||||
|
var validatorCalls = 0; |
||||
|
var pool = new ObjectPool<Item>( |
||||
|
factory: () => new Item(), |
||||
|
validator: _ => |
||||
|
{ |
||||
|
validatorCalls++; |
||||
|
return true; |
||||
|
}); |
||||
|
|
||||
|
var item = pool.Rent(); |
||||
|
pool.Return(item); |
||||
|
|
||||
|
Assert.Equal(1, validatorCalls); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Validator_Is_Not_Invoked_On_Rent() |
||||
|
{ |
||||
|
// The validator's job is to prepare an item for re-use *before* it goes back
|
||||
|
// into the pool. Running it on Rent would either duplicate the work or imply
|
||||
|
// a different contract (validate-on-take). Pin the current contract.
|
||||
|
var validatorCalls = 0; |
||||
|
var pool = new ObjectPool<Item>( |
||||
|
factory: () => new Item(), |
||||
|
validator: _ => |
||||
|
{ |
||||
|
validatorCalls++; |
||||
|
return true; |
||||
|
}); |
||||
|
|
||||
|
_ = pool.Rent(); // fresh from factory; validator must not run
|
||||
|
var item = pool.Rent(); |
||||
|
pool.Return(item); // one validator call here
|
||||
|
_ = pool.Rent(); // pulled from pool; validator must not run again
|
||||
|
|
||||
|
Assert.Equal(1, validatorCalls); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Return_Drops_Item_When_Validator_Returns_False() |
||||
|
{ |
||||
|
var pool = new ObjectPool<Item>( |
||||
|
factory: () => new Item(), |
||||
|
validator: _ => false); |
||||
|
|
||||
|
var item = pool.Rent(); |
||||
|
pool.Return(item); |
||||
|
|
||||
|
// Validator rejected the item, so the pool is empty and the next
|
||||
|
// Rent produces a fresh instance.
|
||||
|
var rented = pool.Rent(); |
||||
|
|
||||
|
Assert.NotSame(item, rented); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Validator_Can_Reset_Item_State_Before_Pooling() |
||||
|
{ |
||||
|
var pool = new ObjectPool<Item>( |
||||
|
factory: () => new Item(), |
||||
|
validator: i => |
||||
|
{ |
||||
|
i.State = 0; |
||||
|
return true; |
||||
|
}); |
||||
|
|
||||
|
var item = pool.Rent(); |
||||
|
item.State = 42; |
||||
|
pool.Return(item); |
||||
|
|
||||
|
var rented = pool.Rent(); |
||||
|
|
||||
|
Assert.Same(item, rented); |
||||
|
Assert.Equal(0, rented.State); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Pool_Stays_Within_MaxSize_Under_Concurrent_Returns() |
||||
|
{ |
||||
|
const int maxSize = 8; |
||||
|
const int returnsPerThread = 100; |
||||
|
const int threadCount = 16; |
||||
|
|
||||
|
var pool = new ObjectPool<Item>(() => new Item(), maxSize: maxSize); |
||||
|
|
||||
|
Parallel.For(0, threadCount, _ => |
||||
|
{ |
||||
|
for (var i = 0; i < returnsPerThread; i++) |
||||
|
{ |
||||
|
pool.Return(new Item { State = 1 }); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// Drain the pool. Items that came from the pool will still have State==1;
|
||||
|
// factory-created items will have the default State==0.
|
||||
|
var pooled = 0; |
||||
|
var seen = new HashSet<Item>(); |
||||
|
|
||||
|
for (var i = 0; i < maxSize * 2; i++) |
||||
|
{ |
||||
|
var item = pool.Rent(); |
||||
|
if (!seen.Add(item)) |
||||
|
{ |
||||
|
Assert.Fail("ObjectPool returned the same instance twice without an intervening Return."); |
||||
|
} |
||||
|
|
||||
|
if (item.State == 1) |
||||
|
{ |
||||
|
pooled++; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Assert.True(pooled <= maxSize, |
||||
|
$"Expected to observe at most {maxSize} pooled items, observed {pooled}."); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Rent_And_Return_Survive_Parallel_Use_Without_Losing_Or_Duplicating_Items() |
||||
|
{ |
||||
|
const int iterations = 5_000; |
||||
|
const int threadCount = 8; |
||||
|
|
||||
|
var pool = new ObjectPool<Item>(() => new Item(), maxSize: 32); |
||||
|
|
||||
|
Parallel.For(0, threadCount, _ => |
||||
|
{ |
||||
|
for (var i = 0; i < iterations; i++) |
||||
|
{ |
||||
|
var item = pool.Rent(); |
||||
|
Assert.NotNull(item); |
||||
|
pool.Return(item); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,175 @@ |
|||||
|
using System; |
||||
|
using System.Buffers.Binary; |
||||
|
|
||||
|
namespace Avalonia.UnitTests |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// A growable big-endian byte writer for hand-crafting OpenType table sub-structures
|
||||
|
/// (ItemVariationStores, cmap subtables, COLR paint graphs, ...) in tests.
|
||||
|
/// </summary>
|
||||
|
/// <remarks>
|
||||
|
/// OpenType is big-endian and pervasively offset-based: a header field holds the byte
|
||||
|
/// offset of a sub-table that is written later. <see cref="ReserveOffset16"/> /
|
||||
|
/// <see cref="ReserveOffset32"/> write a placeholder and return its position so the
|
||||
|
/// real value can be back-patched with <see cref="PatchUInt16"/> / <see cref="PatchUInt32"/>
|
||||
|
/// once the target's position is known (via <see cref="Position"/>). All multi-byte
|
||||
|
/// writes are big-endian.
|
||||
|
/// </remarks>
|
||||
|
public sealed class BigEndianBuffer |
||||
|
{ |
||||
|
private byte[] _buffer; |
||||
|
private int _length; |
||||
|
|
||||
|
public BigEndianBuffer(int initialCapacity = 64) |
||||
|
{ |
||||
|
_buffer = new byte[Math.Max(4, initialCapacity)]; |
||||
|
} |
||||
|
|
||||
|
/// <summary>The number of bytes written so far — also the offset the next write lands at.</summary>
|
||||
|
public int Position => _length; |
||||
|
|
||||
|
public BigEndianBuffer UInt8(int value) |
||||
|
{ |
||||
|
EnsureCapacity(1); |
||||
|
_buffer[_length++] = checked((byte)value); |
||||
|
return this; |
||||
|
} |
||||
|
|
||||
|
public BigEndianBuffer Int8(int value) |
||||
|
{ |
||||
|
EnsureCapacity(1); |
||||
|
_buffer[_length++] = unchecked((byte)(sbyte)value); |
||||
|
return this; |
||||
|
} |
||||
|
|
||||
|
public BigEndianBuffer UInt16(int value) |
||||
|
{ |
||||
|
EnsureCapacity(2); |
||||
|
BinaryPrimitives.WriteUInt16BigEndian(_buffer.AsSpan(_length), checked((ushort)value)); |
||||
|
_length += 2; |
||||
|
return this; |
||||
|
} |
||||
|
|
||||
|
public BigEndianBuffer Int16(int value) |
||||
|
{ |
||||
|
EnsureCapacity(2); |
||||
|
BinaryPrimitives.WriteInt16BigEndian(_buffer.AsSpan(_length), checked((short)value)); |
||||
|
_length += 2; |
||||
|
return this; |
||||
|
} |
||||
|
|
||||
|
public BigEndianBuffer UInt24(int value) |
||||
|
{ |
||||
|
EnsureCapacity(3); |
||||
|
_buffer[_length++] = (byte)((value >> 16) & 0xFF); |
||||
|
_buffer[_length++] = (byte)((value >> 8) & 0xFF); |
||||
|
_buffer[_length++] = (byte)(value & 0xFF); |
||||
|
return this; |
||||
|
} |
||||
|
|
||||
|
public BigEndianBuffer UInt32(uint value) |
||||
|
{ |
||||
|
EnsureCapacity(4); |
||||
|
BinaryPrimitives.WriteUInt32BigEndian(_buffer.AsSpan(_length), value); |
||||
|
_length += 4; |
||||
|
return this; |
||||
|
} |
||||
|
|
||||
|
public BigEndianBuffer Int32(int value) |
||||
|
{ |
||||
|
EnsureCapacity(4); |
||||
|
BinaryPrimitives.WriteInt32BigEndian(_buffer.AsSpan(_length), value); |
||||
|
_length += 4; |
||||
|
return this; |
||||
|
} |
||||
|
|
||||
|
/// <summary>Writes an F2DOT14 fixed-point value (the variation-coordinate / region format).</summary>
|
||||
|
public BigEndianBuffer F2Dot14(double value) |
||||
|
=> Int16((int)Math.Round(value * 16384.0)); |
||||
|
|
||||
|
/// <summary>Writes a 16.16 fixed-point value.</summary>
|
||||
|
public BigEndianBuffer Fixed(double value) |
||||
|
=> Int32((int)Math.Round(value * 65536.0)); |
||||
|
|
||||
|
/// <summary>Writes a 4-character tag (space-padded / truncated to 4 bytes).</summary>
|
||||
|
public BigEndianBuffer Tag(string tag) |
||||
|
{ |
||||
|
Span<byte> bytes = stackalloc byte[4] { 0x20, 0x20, 0x20, 0x20 }; |
||||
|
var n = Math.Min(4, tag.Length); |
||||
|
for (var i = 0; i < n; i++) |
||||
|
{ |
||||
|
bytes[i] = (byte)tag[i]; |
||||
|
} |
||||
|
|
||||
|
return Bytes(bytes); |
||||
|
} |
||||
|
|
||||
|
public BigEndianBuffer Bytes(ReadOnlySpan<byte> bytes) |
||||
|
{ |
||||
|
EnsureCapacity(bytes.Length); |
||||
|
bytes.CopyTo(_buffer.AsSpan(_length)); |
||||
|
_length += bytes.Length; |
||||
|
return this; |
||||
|
} |
||||
|
|
||||
|
/// <summary>Writes <paramref name="count"/> zero bytes (padding / placeholder data).</summary>
|
||||
|
public BigEndianBuffer Zeros(int count) |
||||
|
{ |
||||
|
if (count < 0) |
||||
|
throw new ArgumentOutOfRangeException(nameof(count), count, "count must be non-negative."); |
||||
|
|
||||
|
EnsureCapacity(count); |
||||
|
_length += count; // already zero-initialized
|
||||
|
return this; |
||||
|
} |
||||
|
|
||||
|
/// <summary>Writes a placeholder big-endian <c>uint16</c> and returns its position for later patching.</summary>
|
||||
|
public int ReserveOffset16() |
||||
|
{ |
||||
|
var pos = _length; |
||||
|
UInt16(0); |
||||
|
return pos; |
||||
|
} |
||||
|
|
||||
|
/// <summary>Writes a placeholder big-endian <c>uint32</c> and returns its position for later patching.</summary>
|
||||
|
public int ReserveOffset32() |
||||
|
{ |
||||
|
var pos = _length; |
||||
|
UInt32(0); |
||||
|
return pos; |
||||
|
} |
||||
|
|
||||
|
/// <summary>Back-patches a big-endian <c>uint16</c> at a previously reserved position.</summary>
|
||||
|
public BigEndianBuffer PatchUInt16(int position, int value) |
||||
|
{ |
||||
|
BinaryPrimitives.WriteUInt16BigEndian(_buffer.AsSpan(position, 2), checked((ushort)value)); |
||||
|
return this; |
||||
|
} |
||||
|
|
||||
|
/// <summary>Back-patches a big-endian <c>uint32</c> at a previously reserved position.</summary>
|
||||
|
public BigEndianBuffer PatchUInt32(int position, uint value) |
||||
|
{ |
||||
|
BinaryPrimitives.WriteUInt32BigEndian(_buffer.AsSpan(position, 4), value); |
||||
|
return this; |
||||
|
} |
||||
|
|
||||
|
public byte[] ToArray() => _buffer.AsSpan(0, _length).ToArray(); |
||||
|
|
||||
|
private void EnsureCapacity(int additional) |
||||
|
{ |
||||
|
var required = _length + additional; |
||||
|
if (required <= _buffer.Length) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
var newCapacity = _buffer.Length * 2; |
||||
|
while (newCapacity < required) |
||||
|
{ |
||||
|
newCapacity *= 2; |
||||
|
} |
||||
|
|
||||
|
Array.Resize(ref _buffer, newCapacity); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,366 @@ |
|||||
|
using System; |
||||
|
using System.Buffers.Binary; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Diagnostics.CodeAnalysis; |
||||
|
using System.IO; |
||||
|
using System.Linq; |
||||
|
using Avalonia.Media; |
||||
|
using Avalonia.Media.Fonts; |
||||
|
using Avalonia.Platform; |
||||
|
|
||||
|
namespace Avalonia.UnitTests |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// An editable, in-memory sfnt font for robustness / malformed-input tests.
|
||||
|
/// </summary>
|
||||
|
/// <remarks>
|
||||
|
/// <para>
|
||||
|
/// The font subsystem reads every table through
|
||||
|
/// <see cref="IFontMemory.TryGetTable"/>, and each OpenType table is self-contained
|
||||
|
/// (its internal offsets are relative to the table start, never to the file). That
|
||||
|
/// means a faithful test font is just a <c>tag → bytes</c> map: <see cref="SyntheticFont"/>
|
||||
|
/// parses a real font's sfnt table directory into one, lets a test mutate a single
|
||||
|
/// table (truncate it, remove it, or patch a specific offset / count to a hostile
|
||||
|
/// value), and hands the result back as an <see cref="IPlatformTypeface"/> that serves
|
||||
|
/// the (possibly corrupted) tables verbatim.
|
||||
|
/// </para>
|
||||
|
/// <para>
|
||||
|
/// Seeding from a real font (rather than hand-building every required table) keeps the
|
||||
|
/// base font valid for free, so a test can corrupt exactly one thing and attribute any
|
||||
|
/// behaviour change to that corruption. Use <see cref="ToPlatformTypeface"/> for the
|
||||
|
/// common path (it drives <see cref="GlyphTypeface"/> and the table parsers directly);
|
||||
|
/// use <see cref="ToBytes"/> when a test specifically needs the real
|
||||
|
/// <c>UnmanagedFontMemory</c> sfnt-directory parser in the loop.
|
||||
|
/// </para>
|
||||
|
/// </remarks>
|
||||
|
public sealed class SyntheticFont |
||||
|
{ |
||||
|
// Tables in declaration order is irrelevant to the consumers (they look up by tag),
|
||||
|
// but a stable order keeps ToBytes() deterministic.
|
||||
|
private readonly Dictionary<OpenTypeTag, byte[]> _tables; |
||||
|
private readonly uint _sfntVersion; |
||||
|
|
||||
|
private SyntheticFont(uint sfntVersion, Dictionary<OpenTypeTag, byte[]> tables) |
||||
|
{ |
||||
|
_sfntVersion = sfntVersion; |
||||
|
_tables = tables; |
||||
|
} |
||||
|
|
||||
|
/// <summary>Well-known embedded test font asset URIs, all in Avalonia.Base.UnitTests.</summary>
|
||||
|
public static class Assets |
||||
|
{ |
||||
|
private const string Prefix = "resm:Avalonia.Base.UnitTests.Assets."; |
||||
|
private const string Suffix = "?assembly=Avalonia.Base.UnitTests"; |
||||
|
|
||||
|
/// <summary>Static TrueType (<c>glyf</c>) font.</summary>
|
||||
|
public const string InterRegular = Prefix + "Inter-Regular.ttf" + Suffix; |
||||
|
|
||||
|
/// <summary>Variable TrueType font: carries <c>fvar</c>/<c>avar</c>/<c>gvar</c>/<c>HVAR</c>/<c>MVAR</c>.</summary>
|
||||
|
public const string InterVariable = Prefix + "InterVariable.ttf" + Suffix; |
||||
|
|
||||
|
/// <summary>PostScript (CFF / Type2) <c>.otf</c>.</summary>
|
||||
|
public const string CffTest = Prefix + "CffTest.otf" + Suffix; |
||||
|
|
||||
|
/// <summary>CID-keyed CFF <c>.otf</c> (FDSelect / FDArray).</summary>
|
||||
|
public const string CidTest = Prefix + "CidTest.otf" + Suffix; |
||||
|
|
||||
|
/// <summary>Variable CFF2 <c>.otf</c> (blend / vstore).</summary>
|
||||
|
public const string AdobeVfPrototype = Prefix + "AdobeVFPrototype-Subset.otf" + Suffix; |
||||
|
} |
||||
|
|
||||
|
/// <summary>Loads and parses one of the embedded test font assets.</summary>
|
||||
|
public static SyntheticFont FromAsset(string assetUri) |
||||
|
{ |
||||
|
var assetLoader = new StandardAssetLoader(); |
||||
|
using var stream = assetLoader.Open(new Uri(assetUri)); |
||||
|
return FromStream(stream); |
||||
|
} |
||||
|
|
||||
|
/// <summary>Parses a font from a stream.</summary>
|
||||
|
public static SyntheticFont FromStream(Stream stream) |
||||
|
{ |
||||
|
using var ms = new MemoryStream(); |
||||
|
stream.CopyTo(ms); |
||||
|
return FromBytes(ms.ToArray()); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Parses the sfnt table directory of <paramref name="font"/> into an editable
|
||||
|
/// table set. Each table is copied into its own array so later mutation can't
|
||||
|
/// alias the source bytes. TrueType Collections (<c>ttcf</c>) are not supported.
|
||||
|
/// </summary>
|
||||
|
public static SyntheticFont FromBytes(ReadOnlySpan<byte> font) |
||||
|
{ |
||||
|
if (font.Length < 12) |
||||
|
{ |
||||
|
throw new ArgumentException("Not a valid sfnt: shorter than the offset table.", nameof(font)); |
||||
|
} |
||||
|
|
||||
|
var sfntVersion = BinaryPrimitives.ReadUInt32BigEndian(font); |
||||
|
|
||||
|
// 'ttcf' — a font collection. Out of scope: callers pass single-font assets.
|
||||
|
if (sfntVersion == 0x74746366) |
||||
|
{ |
||||
|
throw new NotSupportedException("TrueType Collections are not supported by SyntheticFont."); |
||||
|
} |
||||
|
|
||||
|
int numTables = BinaryPrimitives.ReadUInt16BigEndian(font.Slice(4)); |
||||
|
|
||||
|
var tables = new Dictionary<OpenTypeTag, byte[]>(numTables); |
||||
|
|
||||
|
for (var i = 0; i < numTables; i++) |
||||
|
{ |
||||
|
var record = 12 + i * 16; |
||||
|
if (record + 16 > font.Length) |
||||
|
{ |
||||
|
throw new ArgumentException("Not a valid sfnt: table directory exceeds the file.", nameof(font)); |
||||
|
} |
||||
|
|
||||
|
var tag = new OpenTypeTag(BinaryPrimitives.ReadUInt32BigEndian(font.Slice(record))); |
||||
|
var offset = (int)BinaryPrimitives.ReadUInt32BigEndian(font.Slice(record + 8)); |
||||
|
var length = (int)BinaryPrimitives.ReadUInt32BigEndian(font.Slice(record + 12)); |
||||
|
|
||||
|
if (offset < 0 || length < 0 || (long)offset + length > font.Length) |
||||
|
{ |
||||
|
throw new ArgumentException($"Not a valid sfnt: table '{tag}' is out of range.", nameof(font)); |
||||
|
} |
||||
|
|
||||
|
tables[tag] = font.Slice(offset, length).ToArray(); |
||||
|
} |
||||
|
|
||||
|
return new SyntheticFont(sfntVersion, tables); |
||||
|
} |
||||
|
|
||||
|
/// <summary>The tags present in the font.</summary>
|
||||
|
public IReadOnlyCollection<OpenTypeTag> Tags => _tables.Keys; |
||||
|
|
||||
|
/// <summary>Whether the named table is present.</summary>
|
||||
|
public bool Contains(string tag) => _tables.ContainsKey(OpenTypeTag.Parse(tag)); |
||||
|
|
||||
|
/// <summary>Returns a copy of the named table's current bytes.</summary>
|
||||
|
public byte[] GetTable(string tag) |
||||
|
{ |
||||
|
var bytes = Require(tag); |
||||
|
return (byte[])bytes.Clone(); |
||||
|
} |
||||
|
|
||||
|
/// <summary>The current byte length of the named table.</summary>
|
||||
|
public int TableLength(string tag) => Require(tag).Length; |
||||
|
|
||||
|
/// <summary>Replaces the named table's bytes wholesale (adding it if absent).</summary>
|
||||
|
public SyntheticFont Replace(string tag, byte[] bytes) |
||||
|
{ |
||||
|
_tables[OpenTypeTag.Parse(tag)] = bytes ?? throw new ArgumentNullException(nameof(bytes)); |
||||
|
return this; |
||||
|
} |
||||
|
|
||||
|
/// <summary>Removes the named table from the font (no-op if absent).</summary>
|
||||
|
public SyntheticFont Remove(string tag) |
||||
|
{ |
||||
|
_tables.Remove(OpenTypeTag.Parse(tag)); |
||||
|
return this; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Truncates the named table to <paramref name="newLength"/> bytes — the canonical
|
||||
|
/// "table is shorter than its header claims" corruption.
|
||||
|
/// </summary>
|
||||
|
public SyntheticFont Truncate(string tag, int newLength) |
||||
|
{ |
||||
|
var bytes = Require(tag); |
||||
|
if (newLength < 0 || newLength > bytes.Length) |
||||
|
{ |
||||
|
throw new ArgumentOutOfRangeException(nameof(newLength), newLength, |
||||
|
$"Truncation length must be in [0, {bytes.Length}] for table '{tag}'."); |
||||
|
} |
||||
|
|
||||
|
_tables[OpenTypeTag.Parse(tag)] = bytes.AsSpan(0, newLength).ToArray(); |
||||
|
return this; |
||||
|
} |
||||
|
|
||||
|
/// <summary>Overwrites a single byte at <paramref name="offset"/> within the named table.</summary>
|
||||
|
public SyntheticFont PatchUInt8(string tag, int offset, byte value) |
||||
|
{ |
||||
|
EditableSlice(tag, offset, 1)[0] = value; |
||||
|
return this; |
||||
|
} |
||||
|
|
||||
|
/// <summary>Overwrites a big-endian <c>uint16</c> at <paramref name="offset"/> within the named table.</summary>
|
||||
|
public SyntheticFont PatchUInt16(string tag, int offset, ushort value) |
||||
|
{ |
||||
|
BinaryPrimitives.WriteUInt16BigEndian(EditableSlice(tag, offset, 2), value); |
||||
|
return this; |
||||
|
} |
||||
|
|
||||
|
/// <summary>Overwrites a big-endian <c>uint32</c> at <paramref name="offset"/> within the named table.</summary>
|
||||
|
public SyntheticFont PatchUInt32(string tag, int offset, uint value) |
||||
|
{ |
||||
|
BinaryPrimitives.WriteUInt32BigEndian(EditableSlice(tag, offset, 4), value); |
||||
|
return this; |
||||
|
} |
||||
|
|
||||
|
/// <summary>Runs an arbitrary edit against the named table's backing array.</summary>
|
||||
|
public SyntheticFont Mutate(string tag, Action<byte[]> edit) |
||||
|
{ |
||||
|
edit(Require(tag)); |
||||
|
return this; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Returns an <see cref="IPlatformTypeface"/> that serves the current (possibly
|
||||
|
/// corrupted) tables. This is the primary seam: it drives the
|
||||
|
/// <see cref="GlyphTypeface"/> constructor and every table parser without an sfnt
|
||||
|
/// round-trip. Each call snapshots the current table set, so a typeface is
|
||||
|
/// unaffected by later mutation of this <see cref="SyntheticFont"/>.
|
||||
|
/// </summary>
|
||||
|
public IPlatformTypeface ToPlatformTypeface(string familyName = "Synthetic") |
||||
|
{ |
||||
|
var snapshot = new Dictionary<OpenTypeTag, byte[]>(_tables.Count); |
||||
|
foreach (var kvp in _tables) |
||||
|
{ |
||||
|
snapshot[kvp.Key] = (byte[])kvp.Value.Clone(); |
||||
|
} |
||||
|
|
||||
|
return new SyntheticPlatformTypeface(snapshot, familyName); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Attempts to build a <see cref="GlyphTypeface"/> over the current tables via
|
||||
|
/// <see cref="GlyphTypeface.TryCreate"/> — returns <c>null</c> when the font is
|
||||
|
/// rejected (the swallow-and-deny path: any parse exception during construction is caught
|
||||
|
/// and turned into a null result).
|
||||
|
/// </summary>
|
||||
|
public GlyphTypeface? TryCreateGlyphTypeface(FontSimulations simulations = FontSimulations.None) |
||||
|
=> GlyphTypeface.TryCreate(ToPlatformTypeface(), simulations); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Builds a <see cref="GlyphTypeface"/> via the public constructor, which does
|
||||
|
/// <b>not</b> swallow parse exceptions — use this to assert whether a corruption
|
||||
|
/// throws out of construction (vs. degrading gracefully).
|
||||
|
/// </summary>
|
||||
|
public GlyphTypeface CreateGlyphTypeface(FontSimulations simulations = FontSimulations.None) |
||||
|
=> new GlyphTypeface(ToPlatformTypeface(), simulations); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Re-assembles the current table set into a valid sfnt byte array (offset table +
|
||||
|
/// 4-byte-aligned tables, directory sorted by tag). Table checksums are written as
|
||||
|
/// zero — the loader does not validate them. Use this only when a test needs the
|
||||
|
/// real <c>UnmanagedFontMemory</c> directory parser in the loop; otherwise prefer
|
||||
|
/// <see cref="ToPlatformTypeface"/>.
|
||||
|
/// </summary>
|
||||
|
public byte[] ToBytes() |
||||
|
{ |
||||
|
var ordered = _tables.OrderBy(kvp => (uint)kvp.Key).ToArray(); |
||||
|
var numTables = ordered.Length; |
||||
|
|
||||
|
var directorySize = 12 + numTables * 16; |
||||
|
var totalSize = directorySize; |
||||
|
foreach (var kvp in ordered) |
||||
|
{ |
||||
|
totalSize += Align4(kvp.Value.Length); |
||||
|
} |
||||
|
|
||||
|
var font = new byte[totalSize]; |
||||
|
var span = font.AsSpan(); |
||||
|
|
||||
|
// Offset table.
|
||||
|
BinaryPrimitives.WriteUInt32BigEndian(span, _sfntVersion); |
||||
|
BinaryPrimitives.WriteUInt16BigEndian(span.Slice(4), (ushort)numTables); |
||||
|
|
||||
|
var maxPow2 = 1; |
||||
|
var entrySelector = 0; |
||||
|
while (maxPow2 * 2 <= numTables) |
||||
|
{ |
||||
|
maxPow2 *= 2; |
||||
|
entrySelector++; |
||||
|
} |
||||
|
var searchRange = maxPow2 * 16; |
||||
|
BinaryPrimitives.WriteUInt16BigEndian(span.Slice(6), (ushort)searchRange); |
||||
|
BinaryPrimitives.WriteUInt16BigEndian(span.Slice(8), (ushort)entrySelector); |
||||
|
BinaryPrimitives.WriteUInt16BigEndian(span.Slice(10), (ushort)(numTables * 16 - searchRange)); |
||||
|
|
||||
|
// Directory records + table bodies.
|
||||
|
var dataOffset = directorySize; |
||||
|
for (var i = 0; i < numTables; i++) |
||||
|
{ |
||||
|
var (tag, bytes) = ordered[i]; |
||||
|
var record = 12 + i * 16; |
||||
|
|
||||
|
BinaryPrimitives.WriteUInt32BigEndian(span.Slice(record), (uint)tag); |
||||
|
BinaryPrimitives.WriteUInt32BigEndian(span.Slice(record + 4), 0u); // checksum (not validated)
|
||||
|
BinaryPrimitives.WriteUInt32BigEndian(span.Slice(record + 8), (uint)dataOffset); |
||||
|
BinaryPrimitives.WriteUInt32BigEndian(span.Slice(record + 12), (uint)bytes.Length); |
||||
|
|
||||
|
bytes.AsSpan().CopyTo(span.Slice(dataOffset)); |
||||
|
dataOffset += Align4(bytes.Length); |
||||
|
} |
||||
|
|
||||
|
return font; |
||||
|
} |
||||
|
|
||||
|
private byte[] Require(string tag) |
||||
|
{ |
||||
|
if (!_tables.TryGetValue(OpenTypeTag.Parse(tag), out var bytes)) |
||||
|
{ |
||||
|
throw new InvalidOperationException($"Font has no '{tag}' table."); |
||||
|
} |
||||
|
|
||||
|
return bytes; |
||||
|
} |
||||
|
|
||||
|
private Span<byte> EditableSlice(string tag, int offset, int size) |
||||
|
{ |
||||
|
var bytes = Require(tag); |
||||
|
if (offset < 0 || (long)offset + size > bytes.Length) |
||||
|
{ |
||||
|
throw new ArgumentOutOfRangeException(nameof(offset), offset, |
||||
|
$"[{offset}, {offset + size}) is out of range for table '{tag}' (length {bytes.Length})."); |
||||
|
} |
||||
|
|
||||
|
return bytes.AsSpan(offset, size); |
||||
|
} |
||||
|
|
||||
|
private static int Align4(int length) => (length + 3) & ~3; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// An <see cref="IPlatformTypeface"/> that serves a fixed <c>tag → bytes</c> map.
|
||||
|
/// No SkiaSharp face, no real stream — just enough for the managed font pipeline.
|
||||
|
/// </summary>
|
||||
|
private sealed class SyntheticPlatformTypeface : IPlatformTypeface |
||||
|
{ |
||||
|
private readonly Dictionary<OpenTypeTag, byte[]> _tables; |
||||
|
|
||||
|
public SyntheticPlatformTypeface(Dictionary<OpenTypeTag, byte[]> tables, string familyName) |
||||
|
{ |
||||
|
_tables = tables; |
||||
|
FamilyName = familyName; |
||||
|
} |
||||
|
|
||||
|
public string FamilyName { get; } |
||||
|
public FontWeight Weight => FontWeight.Normal; |
||||
|
public FontStyle Style => FontStyle.Normal; |
||||
|
public FontStretch Stretch => FontStretch.Normal; |
||||
|
public FontSimulations FontSimulations => FontSimulations.None; |
||||
|
|
||||
|
public bool TryGetTable(OpenTypeTag tag, out ReadOnlyMemory<byte> table) |
||||
|
{ |
||||
|
if (_tables.TryGetValue(tag, out var bytes)) |
||||
|
{ |
||||
|
table = bytes; |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
table = default; |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
public bool TryGetStream([NotNullWhen(true)] out Stream? stream) |
||||
|
{ |
||||
|
stream = null; |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
public void Dispose() { } |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue