Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions src/typstsharp.tests/Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -900,6 +900,65 @@ public async Task StreamingToAFileAsynchronouslyReturnsCompilerWarnings()
}
}

/// <summary>
/// foreach binds to the struct enumerator rather than the interface, so walking the pages of a
/// result costs nothing on the heap. Both results forward to a list held behind
/// IReadOnlyList, whose own enumerator would be boxed once per enumeration.
/// </summary>
[Test]
public async Task EnumeratingResultPagesDoesNotAllocate()
{
using var compiler = TypstCompiler.FromSource(TwoPageSource);
var svg = compiler.CompileSvg();
var png = compiler.CompilePng();

// Warm up so that nothing on the first pass is counted.
foreach (var page in svg) { _ = page; }
foreach (var page in png) { _ = page; }

// No await may sit between these two reads: the counter is per thread.
long before = GC.GetAllocatedBytesForCurrentThread();
foreach (var page in svg) { _ = page; }
foreach (var page in png) { _ = page; }
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;

await Assert.That(allocated).IsEqualTo(0);
}

/// <summary>
/// The struct enumerator must yield exactly what the indexer does, and the interface path that
/// LINQ and IEnumerable callers take has to keep working alongside it.
/// </summary>
[Test]
public async Task ResultPagesEnumerateInIndexOrderThroughBothPaths()
{
using var compiler = TypstCompiler.FromSource(TwoPageSource);
var svg = compiler.CompileSvg();

var byForeach = new List<string>();
foreach (var page in svg)
{
byForeach.Add(page);
}

var byIndexer = Enumerable.Range(0, svg.Count).Select(i => svg[i]).ToList();
var byLinq = svg.ToList();

await Assert.That(byForeach.Count).IsEqualTo(2);
await Assert.That(byForeach.SequenceEqual(byIndexer)).IsTrue();
await Assert.That(byLinq.SequenceEqual(byIndexer)).IsTrue();

var png = compiler.CompilePng();
var pngByForeach = new List<byte[]>();
foreach (var page in png)
{
pngByForeach.Add(page);
}

await Assert.That(pngByForeach.Count).IsEqualTo(png.Count);
await Assert.That(pngByForeach.SequenceEqual(png.ToList())).IsTrue();
}

[Test]
public async Task WarningsFromADocumentCannotBeMutatedByCallers()
{
Expand Down
54 changes: 52 additions & 2 deletions src/typstsharp/TypstCompiler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,40 @@ public Task SaveAsync(string path, CancellationToken cancellationToken = default
File.WriteAllBytesAsync(path, Bytes, cancellationToken);
}

/// <summary>
/// Walks the pages of a compile result without allocating.
/// </summary>
/// <remarks>
/// <see cref="SvgResult"/> and <see cref="PngResult"/> hold their pages behind
/// <see cref="IReadOnlyList{T}"/>. Returning that list's own enumerator would box it, because the
/// list hands it back as an <see cref="IEnumerator{T}"/> rather than as its own struct. Indexing
/// instead costs one interface call per page and nothing on the heap.
/// </remarks>
/// <typeparam name="T">The page type: an SVG string or the bytes of a PNG.</typeparam>
public struct PageEnumerator<T> : IEnumerator<T>
{
private readonly IReadOnlyList<T> _pages;
private int _index;

internal PageEnumerator(IReadOnlyList<T> pages)
{
_pages = pages;
_index = -1;
}

public readonly T Current => _pages[_index];

readonly object? System.Collections.IEnumerator.Current => Current;

public bool MoveNext() => ++_index < _pages.Count;

public void Reset() => _index = -1;

public readonly void Dispose()
{
}
}

/// <summary>
/// Represents the result of compiling a document to SVG format (one SVG string per page).
/// Supports implicit conversion to <see cref="string"/> (returning the primary page SVG).
Expand All @@ -678,7 +712,15 @@ public sealed record SvgResult(IReadOnlyList<string> Pages, IReadOnlyList<string
{
public int Count => Pages.Count;
public string this[int index] => Pages[index];
public IEnumerator<string> GetEnumerator() => Pages.GetEnumerator();

/// <summary>
/// Returns a struct enumerator, which <c>foreach</c> binds to in preference to the interface.
/// Forwarding straight to <c>Pages.GetEnumerator()</c> would hand back the underlying list's
/// enumerator through <see cref="IEnumerator{T}"/> and box it once per enumeration.
/// </summary>
public PageEnumerator<string> GetEnumerator() => new(Pages);

IEnumerator<string> IEnumerable<string>.GetEnumerator() => Pages.GetEnumerator();
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => Pages.GetEnumerator();

/// <summary>
Expand Down Expand Up @@ -740,7 +782,15 @@ public sealed record PngResult(IReadOnlyList<byte[]> Pages, IReadOnlyList<string
{
public int Count => Pages.Count;
public byte[] this[int index] => Pages[index];
public IEnumerator<byte[]> GetEnumerator() => Pages.GetEnumerator();

/// <summary>
/// Returns a struct enumerator, which <c>foreach</c> binds to in preference to the interface.
/// Forwarding straight to <c>Pages.GetEnumerator()</c> would hand back the underlying list's
/// enumerator through <see cref="IEnumerator{T}"/> and box it once per enumeration.
/// </summary>
public PageEnumerator<byte[]> GetEnumerator() => new(Pages);

IEnumerator<byte[]> IEnumerable<byte[]>.GetEnumerator() => Pages.GetEnumerator();
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => Pages.GetEnumerator();

/// <summary>
Expand Down
Loading