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.