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); } }