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(); ///