diff --git a/src/typstsharp.tests/Tests.cs b/src/typstsharp.tests/Tests.cs
index 4a5b967..7cfc7ff 100644
--- a/src/typstsharp.tests/Tests.cs
+++ b/src/typstsharp.tests/Tests.cs
@@ -900,6 +900,65 @@ public async Task StreamingToAFileAsynchronouslyReturnsCompilerWarnings()
}
}
+ ///
+ /// 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.
+ ///
+ [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);
+ }
+
+ ///
+ /// 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.
+ ///
+ [Test]
+ public async Task ResultPagesEnumerateInIndexOrderThroughBothPaths()
+ {
+ using var compiler = TypstCompiler.FromSource(TwoPageSource);
+ var svg = compiler.CompileSvg();
+
+ var byForeach = new List();
+ 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();
+ 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()
{
diff --git a/src/typstsharp/TypstCompiler.cs b/src/typstsharp/TypstCompiler.cs
index bbf1b22..ec57a3e 100644
--- a/src/typstsharp/TypstCompiler.cs
+++ b/src/typstsharp/TypstCompiler.cs
@@ -670,6 +670,40 @@ public Task SaveAsync(string path, CancellationToken cancellationToken = default
File.WriteAllBytesAsync(path, Bytes, cancellationToken);
}
+///
+/// Walks the pages of a compile result without allocating.
+///
+///
+/// and hold their pages behind
+/// . Returning that list's own enumerator would box it, because the
+/// list hands it back as an rather than as its own struct. Indexing
+/// instead costs one interface call per page and nothing on the heap.
+///
+/// The page type: an SVG string or the bytes of a PNG.
+public struct PageEnumerator : IEnumerator
+{
+ private readonly IReadOnlyList _pages;
+ private int _index;
+
+ internal PageEnumerator(IReadOnlyList 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()
+ {
+ }
+}
+
///
/// Represents the result of compiling a document to SVG format (one SVG string per page).
/// Supports implicit conversion to (returning the primary page SVG).
@@ -678,7 +712,15 @@ public sealed record SvgResult(IReadOnlyList Pages, IReadOnlyList Pages.Count;
public string this[int index] => Pages[index];
- public IEnumerator GetEnumerator() => Pages.GetEnumerator();
+
+ ///
+ /// Returns a struct enumerator, which foreach binds to in preference to the interface.
+ /// Forwarding straight to Pages.GetEnumerator() would hand back the underlying list's
+ /// enumerator through and box it once per enumeration.
+ ///
+ public PageEnumerator GetEnumerator() => new(Pages);
+
+ IEnumerator IEnumerable.GetEnumerator() => Pages.GetEnumerator();
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => Pages.GetEnumerator();
///
@@ -740,7 +782,15 @@ public sealed record PngResult(IReadOnlyList Pages, IReadOnlyList Pages.Count;
public byte[] this[int index] => Pages[index];
- public IEnumerator GetEnumerator() => Pages.GetEnumerator();
+
+ ///
+ /// Returns a struct enumerator, which foreach binds to in preference to the interface.
+ /// Forwarding straight to Pages.GetEnumerator() would hand back the underlying list's
+ /// enumerator through and box it once per enumeration.
+ ///
+ public PageEnumerator GetEnumerator() => new(Pages);
+
+ IEnumerator IEnumerable.GetEnumerator() => Pages.GetEnumerator();
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => Pages.GetEnumerator();
///