Skip to content
Closed
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
35 changes: 35 additions & 0 deletions src/typstsharp.tests/Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,41 @@ public async Task ErrorWithNullByteIsHandledCorrectly()
await Assert.That(ex!.Message).Contains("foo\0bar");
}

/// <summary>
/// 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.
/// </summary>
[Test]
public async Task EmptySourceIsDistinguishedFromNoSourceAtAll()
{
using var compiler = TypstCompiler.FromSource("");
using var document = compiler.CompileToDocument();

await Assert.That(document.GetOutputLength()).IsGreaterThan(0);
}

/// <summary>
/// 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.
/// </summary>
[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()
{
Expand Down
100 changes: 65 additions & 35 deletions src/typstsharp/TypstCompiler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Dictionary<string, string>>(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<Dictionary<string, string>>(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,
Expand All @@ -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);
}
}

Expand Down
Loading