From 8cd4ac10c225630d2299fbf105c24e8e0abe8964 Mon Sep 17 00:00:00 2001 From: Marc Sallin Date: Sun, 6 Sep 2026 21:51:56 +0200 Subject: [PATCH] perf: enumerate result pages without boxing SvgResult and PngResult forwarded GetEnumerator to the list behind their Pages property, which hands its enumerator back as an IEnumerator and so boxes the struct: 80 bytes per pass over an SVG and a PNG result. A struct enumerator, which foreach binds to in preference to the interface, removes that. Both the foreach path and the interface path go through it, so the two agree on how an exhausted or not-yet-started enumerator behaves. Binary-breaking: a return type is part of the method signature and foreach binds to the concrete method, so an assembly compiled against an earlier version has to be recompiled. Source-compatible. --- RELEASENOTES.md | 1 + src/typstsharp.tests/Tests.cs | 59 ++++++++++++++++++++++ src/typstsharp/TypstCompiler.cs | 88 +++++++++++++++++++++++++++++++-- 3 files changed, 144 insertions(+), 4 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index a9d1263..2ed83c8 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -6,6 +6,7 @@ - Fixed `ua-1` (PDF/UA-1, the accessibility standard) being rejected by `pdfStandards`. The accepted names are now taken from `typst_pdf::PdfStandard` itself, so every standard Typst supports is accepted, including ones added by later Typst releases. The documented `v-` prefix on plain PDF versions still works, and applies only to them: `v-a-2b` is not a spelling of `a-2b`. ### Changed +- **Breaking (binary):** `SvgResult.GetEnumerator()` and `PngResult.GetEnumerator()` now return `PageEnumerator` rather than `IEnumerator`, so that `foreach` over a result no longer boxes the enumerator. Source-compatible: `foreach`, LINQ, `Count` and the indexer all keep compiling. Because a return type is part of the method signature and the compiler emits a direct call for pattern-based `foreach`, an assembly compiled against an earlier version throws `MissingMethodException` on that `foreach` until it is recompiled. - **Breaking:** an invalid combination of PDF standards now fails the compilation instead of silently producing an ordinary PDF. The validation error from `PdfStandards::new` was discarded and export fell back to the default, so a pipeline could believe it was writing PDF/A while it was not. Combinations such as two PDF/A levels, or a PDF/A level that contradicts the requested PDF version, now throw with the message and hints from Typst. Callers passing a contradictory combination today receive a document and will receive an exception after this change. - Note for PDF/A and PDF/UA: the exporter deliberately writes no timestamp, so the document has to carry its own date (`#set document(date: ...)`) and, for PDF/UA, a title and language. - `compiler.CompilePdf(Stream)`, `compiler.CompilePdfAsync(Stream)`, `compiler.CompilePdf(string outputFile)` and `compiler.CompilePdfAsync(string outputFile)` now stream the document straight from native memory to the destination and return the compiler warnings, rather than returning a `PdfResult` that had to be materialised on the managed heap first. Use `compiler.CompilePdf()` when you want the bytes. 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..1effdaa 100644 --- a/src/typstsharp/TypstCompiler.cs +++ b/src/typstsharp/TypstCompiler.cs @@ -670,6 +670,70 @@ 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. +/// +/// Both the foreach path and the interface path go through this type, so they agree. The +/// trade is that neither detects a page list mutated while it is being walked, which a +/// enumerator would have reported on the interface path alone. A compile +/// result is not something a caller is expected to mutate. +/// +/// +/// The page type: an SVG string or the bytes of a PNG. +public struct PageEnumerator : IEnumerator +{ + private readonly IReadOnlyList _pages; + private readonly int _count; + private int _index; + + internal PageEnumerator(IReadOnlyList pages) + { + _pages = pages; + _count = pages.Count; + _index = -1; + } + + public readonly T Current => _pages[_index]; + + /// + /// The boxed accessor is the one hand-written enumerator code reaches for, so it holds to the + /// documented contract and reports an index outside the enumeration as + /// rather than letting the list decide. + /// + readonly object? System.Collections.IEnumerator.Current => (uint)_index < (uint)_count + ? Current + : throw new InvalidOperationException("Enumeration has either not started or has already finished."); + + /// + /// The index stops at the end rather than running on, so that repeated calls on an exhausted + /// enumerator cannot eventually overflow it back into range. + /// + public bool MoveNext() + { + int next = _index + 1; + if (next >= _count) + { + _index = _count; + return false; + } + + _index = next; + return true; + } + + 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,8 +742,16 @@ public sealed record SvgResult(IReadOnlyList Pages, IReadOnlyList Pages.Count; public string this[int index] => Pages[index]; - public IEnumerator GetEnumerator() => Pages.GetEnumerator(); - System.Collections.IEnumerator System.Collections.IEnumerable.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() => new PageEnumerator(Pages); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => new PageEnumerator(Pages); /// /// Implicitly converts the to a containing the primary SVG page. @@ -740,8 +812,16 @@ public sealed record PngResult(IReadOnlyList Pages, IReadOnlyList Pages.Count; public byte[] this[int index] => Pages[index]; - public IEnumerator GetEnumerator() => Pages.GetEnumerator(); - System.Collections.IEnumerator System.Collections.IEnumerable.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() => new PageEnumerator(Pages); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => new PageEnumerator(Pages); /// /// Implicitly converts the to of the primary PNG page.