Skip to content
Open
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
1 change: 1 addition & 0 deletions RELEASENOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` rather than `IEnumerator<T>`, 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.
Expand Down
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
88 changes: 84 additions & 4 deletions src/typstsharp/TypstCompiler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,70 @@ 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.
/// <para>
/// Both the <c>foreach</c> 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
/// <see cref="List{T}"/> enumerator would have reported on the interface path alone. A compile
/// result is not something a caller is expected to mutate.
/// </para>
/// </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 readonly int _count;
private int _index;

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

public readonly T Current => _pages[_index];

/// <summary>
/// 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
/// <see cref="InvalidOperationException"/> rather than letting the list decide.
/// </summary>
readonly object? System.Collections.IEnumerator.Current => (uint)_index < (uint)_count
? Current
: throw new InvalidOperationException("Enumeration has either not started or has already finished.");

/// <summary>
/// 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.
/// </summary>
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()
{
}
}

/// <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,8 +742,16 @@ 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();
System.Collections.IEnumerator System.Collections.IEnumerable.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() => new PageEnumerator<string>(Pages);
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => new PageEnumerator<string>(Pages);

/// <summary>
/// Implicitly converts the <see cref="SvgResult"/> to a <see cref="string"/> containing the primary SVG page.
Expand Down Expand Up @@ -740,8 +812,16 @@ 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();
System.Collections.IEnumerator System.Collections.IEnumerable.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() => new PageEnumerator<byte[]>(Pages);
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => new PageEnumerator<byte[]>(Pages);

/// <summary>
/// Implicitly converts the <see cref="PngResult"/> to <see cref="byte[]"/> of the primary PNG page.
Expand Down
Loading