ImageSharp/SixLabors.Fonts/Tables/General/Svg/SvgTable.cs
2026-08-03 22:31:27 +02:00

288 lines
12 KiB
C#

// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using System;
using System.IO;
using System.IO.Compression;
using System.Runtime.CompilerServices;
namespace SixLabors.Fonts.Tables.General.Svg {
/// <summary>
/// Represents the SVG table which contains SVG documents for glyph rendering.
/// <see href="https://learn.microsoft.com/en-us/typography/opentype/spec/svg"/>
/// </summary>
internal class SvgTable : Table
{
/// <summary>
/// The table name identifier for the SVG table.
/// </summary>
internal const string TableName = "SVG ";
/// <summary>
/// The raw byte data containing the SVG document payloads.
/// </summary>
private readonly byte[] tableData;
/// <summary>
/// The offset from the beginning of the SVG table to the SVG Document Index.
/// </summary>
private readonly uint svgDocIndexOffset;
/// <summary>
/// The absolute offset of the start of the table data buffer within the font stream.
/// </summary>
private readonly uint tableBaseOffset;
/// <summary>
/// The array of SVG Document Index entries, sorted by start glyph ID.
/// </summary>
private readonly SvgDocumentIndexEntry[] entries;
/// <summary>
/// Initializes a new instance of the <see cref="SvgTable"/> class.
/// </summary>
/// <param name="tableData">The raw byte data containing the SVG document payloads.</param>
/// <param name="svgDocIndexOffset">The offset from the beginning of the SVG table to the SVG Document Index.</param>
/// <param name="tableBaseOffset">The absolute offset of the start of the table data buffer within the font stream.</param>
/// <param name="entries">The array of SVG Document Index entries.</param>
private SvgTable(byte[] tableData, uint svgDocIndexOffset, uint tableBaseOffset, SvgDocumentIndexEntry[] entries)
{
this.tableData = tableData;
this.svgDocIndexOffset = svgDocIndexOffset;
this.tableBaseOffset = tableBaseOffset;
this.entries = entries;
}
/// <summary>
/// Loads the SVG table from the specified font reader.
/// </summary>
/// <param name="fontReader">The font reader to read the table from.</param>
/// <returns>The <see cref="SvgTable"/>, or <see langword="null"/> if the table is not present in the font.</returns>
public static SvgTable? Load(FontReader fontReader)
{
if (!fontReader.TryGetReaderAtTablePosition(TableName, out BigEndianBinaryReader? binaryReader))
{
return null;
}
using (binaryReader)
{
return Load(binaryReader);
}
}
/// <summary>
/// Loads the SVG table from the specified binary reader.
/// </summary>
/// <param name="reader">The binary reader positioned at the start of the SVG table.</param>
/// <returns>The <see cref="SvgTable"/>, or <see langword="null"/> if the table is not valid in the font.</returns>
public static SvgTable? Load(BigEndianBinaryReader reader)
{
// HEADER
// | Type | Name | Description |
// | ---------| ------------------| ----------------------------------------------------------|
// | uint16 | version | Table version number(starts at 0). |
// | Offset32 | svgDocIndexOffset | Offset(from beginning of SVG table) to SVG Document Index.|
// | uint32 | reserved | Reserved; set to 0
ushort version = reader.ReadUInt16();
if (version != 0)
{
throw new NotSupportedException($"Only SVG table version 0 is supported. Found version {version}.");
}
uint svgDocIndexOffset = reader.ReadUInt32();
_ = reader.ReadUInt32(); // reserved
// SVG Document Index
// | Type | Name | Description |
// | ------------------| -----------| ------------------------------------------------------------|
// | uint16 | numEntries | Number of entries in the SVG Document Index. |
// | Entry[numEntries] | entries | Array of SVG Document Index Entries(sorted by startGlyphID).|
reader.Seek(svgDocIndexOffset, SeekOrigin.Begin);
ushort numEntries = reader.ReadUInt16();
if (numEntries == 0)
{
// The spec says the number of entries must be non-zero.
return null;
}
SvgDocumentIndexEntry[] entries = new SvgDocumentIndexEntry[numEntries];
// SVG Document Index Entry
// | Type | Name | Description |
// | ---------| -------------| -----------------------------------------------------------------------------------------|
// | uint16 | startGlyphID | First glyph ID in this range(inclusive). |
// | uint16 | endGlyphID | Last glyph ID in this range(inclusive). |
// | Offset32 | svgDocOffset | Offset from the beginning of the SVG Document Index to an SVG document. Must be non-zero.|
// Track min relative offset from the Document Index and absolute max end.
uint minRelOffset = uint.MaxValue;
uint maxEnd = 0;
for (int i = 0; i < numEntries; i++)
{
ushort startGlyphId = reader.ReadUInt16();
ushort endGlyphId = reader.ReadUInt16();
uint svgDocOffset = reader.ReadUInt32();
uint svgDocLength = reader.ReadUInt32();
if (svgDocOffset == 0 || svgDocLength == 0)
{
throw new InvalidFontFileException("SVG table contains an entry with zero offset or length.");
}
if (svgDocOffset < minRelOffset)
{
minRelOffset = svgDocOffset;
}
// Track the farthest byte we need to cover in the table buffer.
uint absEnd = svgDocIndexOffset + svgDocOffset + svgDocLength;
if (absEnd > maxEnd)
{
maxEnd = absEnd;
}
entries[i] = new SvgDocumentIndexEntry(startGlyphId, endGlyphId, svgDocOffset, svgDocLength);
}
// Read exactly the covered range.
uint tableStart = svgDocIndexOffset + minRelOffset;
int byteCount = (int)(maxEnd - tableStart);
reader.Seek(tableStart, SeekOrigin.Begin);
byte[] tableData = reader.ReadBytes(byteCount);
return new SvgTable(tableData, svgDocIndexOffset, tableStart, entries);
}
/// <summary>
/// Returns true if the SVG Document Index contains a document for <paramref name="glyphId"/>.
/// </summary>
/// <param name="glyphId">The glyph identifier to look up.</param>
/// <returns><see langword="true"/> if a document exists for the glyph; otherwise, <see langword="false"/>.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool ContainsGlyph(ushort glyphId)
=> this.TryFindEntry(glyphId, out _);
/// <summary>
/// Gets the encoded document slice for a glyph without opening a stream.
/// </summary>
/// <param name="glyphId">The glyph identifier to look up.</param>
/// <param name="start">When this method returns, contains the start offset within the table data buffer.</param>
/// <param name="length">When this method returns, contains the length of the SVG document in bytes.</param>
/// <returns><see langword="true"/> if a document was found for the glyph; otherwise, <see langword="false"/>.</returns>
public bool TryGetDocumentSpan(ushort glyphId, out int start, out int length)
{
if (this.TryFindEntry(glyphId, out SvgDocumentIndexEntry e))
{
start = (int)((this.svgDocIndexOffset + e.SvgDocOffset) - this.tableBaseOffset);
length = (int)e.SvgDocLength;
return true;
}
start = 0;
length = 0;
return false;
}
/// <summary>
/// Opens a decoding stream for the SVG document associated with the specified glyph.
/// If the payload is gzip-compressed (RFC 1952), wraps it in a <see cref="GZipStream"/>;
/// otherwise returns the raw memory stream. The caller owns the returned stream.
/// </summary>
/// <param name="glyphId">The glyph identifier to look up.</param>
/// <param name="stream">When this method returns, contains the decoded SVG document stream, or <see cref="Stream.Null"/> if not found.</param>
/// <returns><see langword="true"/> if a document was found and a stream was opened; otherwise, <see langword="false"/>.</returns>
public bool TryOpenDecodedDocumentStream(ushort glyphId, out Stream stream)
{
if (!this.TryOpenEncodedDocumentStream(glyphId, out Stream encoded))
{
stream = Stream.Null;
return false;
}
if (encoded is MemoryStream ms && ms.Length >= 2)
{
long pos = ms.Position;
int b0 = ms.ReadByte();
int b1 = ms.ReadByte();
ms.Position = pos;
// Start of GZIP (RFC1952)
if (b0 == 0x1F && b1 == 0x8B)
{
stream = new GZipStream(ms, CompressionMode.Decompress, leaveOpen: false);
return true;
}
}
stream = encoded; // plain UTF-8 XML
return true;
}
/// <summary>
/// Attempts to open a raw (potentially gzip-compressed) memory stream for the SVG document
/// associated with the specified glyph.
/// </summary>
/// <param name="glyphId">The glyph identifier to look up.</param>
/// <param name="stream">When this method returns, contains the encoded SVG document stream, or <see cref="Stream.Null"/> if not found.</param>
/// <returns><see langword="true"/> if a document was found and a stream was opened; otherwise, <see langword="false"/>.</returns>
private bool TryOpenEncodedDocumentStream(ushort glyphId, out Stream stream)
{
if (this.TryFindEntry(glyphId, out SvgDocumentIndexEntry e))
{
int start = (int)((this.svgDocIndexOffset + e.SvgDocOffset) - this.tableBaseOffset);
int length = (int)e.SvgDocLength;
stream = new MemoryStream(this.tableData, start, length, writable: false);
return true;
}
stream = Stream.Null;
return false;
}
/// <summary>
/// Performs a binary search on the SVG Document Index entries to find the entry
/// whose glyph ID range contains the specified glyph.
/// </summary>
/// <param name="glyphId">The glyph identifier to search for.</param>
/// <param name="entry">When this method returns, contains the matching index entry, or the default value if not found.</param>
/// <returns><see langword="true"/> if a matching entry was found; otherwise, <see langword="false"/>.</returns>
private bool TryFindEntry(ushort glyphId, out SvgDocumentIndexEntry entry)
{
int lo = 0;
int hi = this.entries.Length - 1;
int candidate = -1;
while (lo <= hi)
{
int mid = (int)((uint)(lo + hi) >> 1);
ushort start = this.entries[mid].StartGlyphId;
if (start <= glyphId)
{
candidate = mid;
lo = mid + 1;
}
else
{
hi = mid - 1;
}
}
if (candidate >= 0)
{
SvgDocumentIndexEntry e = this.entries[candidate];
if (glyphId <= e.EndGlyphId)
{
entry = e;
return true;
}
}
entry = default;
return false;
}
}
}