diff --git a/src/typstsharp.tests/Tests.cs b/src/typstsharp.tests/Tests.cs
index 4a5b967..97184fb 100644
--- a/src/typstsharp.tests/Tests.cs
+++ b/src/typstsharp.tests/Tests.cs
@@ -343,6 +343,41 @@ public async Task ErrorWithNullByteIsHandledCorrectly()
await Assert.That(ex!.Message).Contains("foo\0bar");
}
+ ///
+ /// A null pointer tells the native side there is no in-memory source at all, so an empty
+ /// document has to arrive as a real pointer with length 0 rather than as nothing.
+ ///
+ [Test]
+ public async Task EmptySourceIsDistinguishedFromNoSourceAtAll()
+ {
+ using var compiler = TypstCompiler.FromSource("");
+ using var document = compiler.CompileToDocument();
+
+ await Assert.That(document.GetOutputLength()).IsGreaterThan(0);
+ }
+
+ ///
+ /// The source crosses the boundary as UTF-8 bytes with an explicit length. A source whose UTF-8
+ /// byte count differs from its char count, and one large enough that the encoded buffer is not a
+ /// trivial allocation, are where a mistake in that encoding would surface.
+ ///
+ [Test]
+ public async Task LargeSourceWithMultiByteCharactersIsCompiledInFull()
+ {
+ var builder = new StringBuilder("= Grüezi mitenand\n\n");
+ for (int i = 0; i < 2000; i++)
+ {
+ builder.Append("Paragraph ").Append(i).Append(" über Zürich.\n\n");
+ }
+ builder.Append("= Schluss\n");
+
+ using var compiler = TypstCompiler.FromSource(builder.ToString());
+ var plainText = GetPlainText(compiler.CompilePdf());
+
+ await Assert.That(plainText).Contains("Grüezi mitenand");
+ await Assert.That(plainText).Contains("Schluss");
+ }
+
[Test]
public async Task SourceAfterNullByteIsNotTruncated()
{
diff --git a/src/typstsharp/TypstCompiler.cs b/src/typstsharp/TypstCompiler.cs
index bbf1b22..6096c42 100644
--- a/src/typstsharp/TypstCompiler.cs
+++ b/src/typstsharp/TypstCompiler.cs
@@ -235,54 +235,81 @@ private unsafe TypstCompiler(string? inputPath, string? inputSource, Fonts? font
root = Path.GetDirectoryName(inputPath);
}
- var inputPathPtr = inputPath != null ? Marshal.StringToCoTaskMemUTF8(inputPath) : IntPtr.Zero;
-
- // The source goes over as raw UTF-8 bytes with an explicit length. A Typst
- // document may contain NUL bytes, and a NUL-terminated string would be
- // silently truncated at the first one.
- byte[]? inputSourceBytes = null;
+ // Every one of these is native memory that the finally block below releases, so they are
+ // declared out here and allocated inside the try. Allocating them before it would leak
+ // whatever had been allocated already if a later step threw, and several of them can:
+ // fontPaths may be a lazy sequence supplied by the caller, and sysInputs is serialized.
+ IntPtr inputPathPtr = IntPtr.Zero;
+ IntPtr inputSourcePtr = IntPtr.Zero;
nuint inputSourceLen = 0;
- if (inputSource != null)
- {
- var encoded = Encoding.UTF8.GetBytes(inputSource);
- inputSourceLen = (nuint)encoded.Length;
- // `fixed` over an empty array yields a null pointer, which the native
- // side reads as "no source at all". A one-byte placeholder keeps an
- // empty document distinguishable; the length passed stays 0.
- inputSourceBytes = encoded.Length == 0 ? new byte[1] : encoded;
- }
-
IntPtr rootPtr = IntPtr.Zero;
- if (!string.IsNullOrWhiteSpace(root))
- {
- rootPtr = Marshal.StringToCoTaskMemUTF8(root);
- }
+ IntPtr[] fontPathPtrs = [];
+ int fontPathCount = 0;
+ IntPtr packagePathPtr = IntPtr.Zero;
+ IntPtr sysInputsPtr = IntPtr.Zero;
- var fontPathsList = fontPaths.ToList();
- var fontPathPtrs = new IntPtr[fontPathsList.Count];
- for (int i = 0; i < fontPathsList.Count; i++)
+ try
{
- fontPathPtrs[i] = Marshal.StringToCoTaskMemUTF8(fontPathsList[i]);
- }
+ if (inputPath != null)
+ {
+ inputPathPtr = Marshal.StringToCoTaskMemUTF8(inputPath);
+ }
- var packagePathPtr = packagePath != null ? Marshal.StringToCoTaskMemUTF8(packagePath) : IntPtr.Zero;
+ // The source goes over as raw UTF-8 bytes with an explicit length. A Typst
+ // document may contain NUL bytes, and a NUL-terminated string would be
+ // silently truncated at the first one.
+ if (inputSource != null)
+ {
+ // Encoding straight into native memory keeps a document-sized array off the managed
+ // heap; a source of any size would otherwise be copied there, and a large one would
+ // land on the large object heap, only to be garbage as soon as the call returns.
+ int byteCount = Encoding.UTF8.GetByteCount(inputSource);
+
+ // A null pointer reads as "no source at all" on the native side, so an empty
+ // document still needs one real byte behind the pointer; the length stays 0.
+ inputSourcePtr = Marshal.AllocCoTaskMem(byteCount == 0 ? 1 : byteCount);
+ if (byteCount > 0)
+ {
+ fixed (char* chars = inputSource)
+ {
+ Encoding.UTF8.GetBytes(chars, inputSource.Length, (byte*)inputSourcePtr, byteCount);
+ }
+ }
- var sysInputsJson = sysInputs == null ? "{}" : JsonSerializer.Serialize>(sysInputs, sourceGenOptions);
- var sysInputsPtr = Marshal.StringToCoTaskMemUTF8(sysInputsJson);
+ inputSourceLen = (nuint)byteCount;
+ }
+
+ if (!string.IsNullOrWhiteSpace(root))
+ {
+ rootPtr = Marshal.StringToCoTaskMemUTF8(root);
+ }
+
+ var fontPathsList = fontPaths.ToList();
+ fontPathCount = fontPathsList.Count;
+ fontPathPtrs = new IntPtr[fontPathCount];
+ for (int i = 0; i < fontPathCount; i++)
+ {
+ fontPathPtrs[i] = Marshal.StringToCoTaskMemUTF8(fontPathsList[i]);
+ }
+
+ if (packagePath != null)
+ {
+ packagePathPtr = Marshal.StringToCoTaskMemUTF8(packagePath);
+ }
+
+ var sysInputsJson = sysInputs == null ? "{}" : JsonSerializer.Serialize>(sysInputs, sourceGenOptions);
+ sysInputsPtr = Marshal.StringToCoTaskMemUTF8(sysInputsJson);
- try
- {
fixed (IntPtr* fontPathsRawPtr = fontPathPtrs)
- fixed (byte* inputSourcePtr = inputSourceBytes)
{
- IntPtr* fontPathsPtr = fontPathsList.Count == 0 ? null : fontPathsRawPtr;
+ IntPtr* fontPathsPtr = fontPathCount == 0 ? null : fontPathsRawPtr;
_compiler = CsBindgen.NativeMethods.create_compiler(
(byte*)rootPtr,
(byte*)inputPathPtr,
- inputSourcePtr,
+ (byte*)inputSourcePtr,
inputSourceLen,
(byte**)fontPathsPtr,
- (nuint)fontPathsList.Count,
+ (nuint)fontPathCount,
(byte*)packagePathPtr,
(byte*)sysInputsPtr,
ignoreSystemFonts,
@@ -296,11 +323,14 @@ private unsafe TypstCompiler(string? inputPath, string? inputSource, Fonts? font
}
finally
{
+ // FreeCoTaskMem ignores a null pointer, so the entries of a partly filled font path
+ // array need no guard of their own.
if (rootPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(rootPtr);
if (inputPathPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(inputPathPtr);
+ if (inputSourcePtr != IntPtr.Zero) Marshal.FreeCoTaskMem(inputSourcePtr);
foreach (var ptr in fontPathPtrs) Marshal.FreeCoTaskMem(ptr);
if (packagePathPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(packagePathPtr);
- Marshal.FreeCoTaskMem(sysInputsPtr);
+ if (sysInputsPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(sysInputsPtr);
}
}