diff --git a/src/typstsharp.tests/NativeStringManagerTests.cs b/src/typstsharp.tests/NativeStringManagerTests.cs new file mode 100644 index 0000000..53e172a --- /dev/null +++ b/src/typstsharp.tests/NativeStringManagerTests.cs @@ -0,0 +1,77 @@ + +namespace typstsharp.tests; + +#if DEBUG +public class NativeStringManagerTests +{ + [Test] + public async Task EnsureAdded() + { + using var manager = new NativeStringManager(); + await Assert.That(manager.CurrentCount).IsEqualTo(0); + + var ptr = manager.GetNativeUtf8FromString("test"); + await Assert.That(manager.CurrentCount).IsEqualTo(1); + await Assert.That(manager.DebugNativeStringPointers[0]).IsEqualTo(ptr); + } + + [Test] + public async Task EnsureCorrectCount() + { + var expectedCount = Math.Min(NativeStringManager.DefaultCapacity, 10); + using var manager = new NativeStringManager(); + for(var i=0; i < expectedCount; ++i) + { + _ = manager.GetNativeUtf8FromString($"test{i}"); + } + await Assert.That(manager.CurrentCount).IsEqualTo(expectedCount); + await Assert.That(manager.DebugNativeStringPointers.Length).IsEqualTo(expectedCount); + await Assert.That(manager.NativeStringPointersArray).IsNotNull(); + await Assert.That(manager.NativeStringPointersArray[expectedCount - 1]).IsNotEqualTo(IntPtr.Zero); + await Assert.That(manager.NativeStringPointersArray[expectedCount]).IsEqualTo(IntPtr.Zero); + } + + [Test] + [Arguments(16)] + [Arguments(64)] + [Arguments(256)] + [Arguments(1024)] + public async Task EnsureContainsAllPointers(int pointerCount) + { + var pointers = new List(pointerCount); + using var manager = new NativeStringManager(); + for(var i=0; i < pointerCount; ++i) + { + var ptr = manager.GetNativeUtf8FromString($"test{i}"); + pointers.Add(ptr); + } + + await Assert.That(manager.DebugNativeStringPointers.Length).IsEqualTo(pointers.Count); + + var managerPointers = manager.DebugNativeStringPointers.ToArray(); + await Assert.That(managerPointers).IsEquivalentTo(pointers); + } + + [Test] + [Arguments(1)] + [Arguments(16)] + [Arguments(128)] + [Arguments(1024)] + public async Task EnsureDispose(int pointerCount) + { + var manager = new NativeStringManager(); + var pointers = new List(pointerCount); + for(var i=0; i < pointerCount; ++i) + { + var ptr = manager.GetNativeUtf8FromString($"test{i}"); + pointers.Add(ptr); + } + await Assert.That(manager.CurrentCount).IsEqualTo(pointerCount); + + manager.Dispose(); + await Assert.That(manager.CurrentCount).IsEqualTo(0); + await Assert.That(manager.NativeStringPointersArray).IsNull(); + await Assert.That(manager.DisposedPointers).IsEquivalentTo(pointers); + } +} +#endif \ No newline at end of file diff --git a/src/typstsharp/NativeStringManager.cs b/src/typstsharp/NativeStringManager.cs new file mode 100644 index 0000000..38ad99e --- /dev/null +++ b/src/typstsharp/NativeStringManager.cs @@ -0,0 +1,82 @@ +using System.Buffers; +using System.Runtime.InteropServices; + +namespace typstsharp; + +public sealed class NativeStringManager : IDisposable +{ + public const int DefaultCapacity = 64; + + private IntPtr[]? _nativeStringPointers; + private int _currentIndex = 0; + + public NativeStringManager() + { + _nativeStringPointers = ArrayPool.Shared.Rent(DefaultCapacity); + _nativeStringPointers.AsSpan().Clear(); + } + + public IntPtr GetNativeUtf8FromString(string? str) + { + var ptr = Marshal.StringToCoTaskMemUTF8(str); + AddPointer(ptr); + + return ptr; + } + + private void AddPointer(IntPtr ptr) + { + if (_nativeStringPointers is null) + { + throw new ObjectDisposedException(nameof(NativeStringManager)); + } + + if (_currentIndex >= _nativeStringPointers.Length) + { + var tempArray = _nativeStringPointers; + _nativeStringPointers = ArrayPool.Shared.Rent(_nativeStringPointers.Length * 2); + _nativeStringPointers.AsSpan().Clear(); + tempArray.AsSpan(0, _currentIndex).CopyTo(_nativeStringPointers); + + ArrayPool.Shared.Return(tempArray); + } + + _nativeStringPointers[_currentIndex++] = ptr; + } + + public void Dispose() + { + InternalDispose(); + GC.SuppressFinalize(this); + } + + private void InternalDispose() + { + if (_nativeStringPointers is { Length: > 0 } && _currentIndex > 0) + { + for (var i = 0; i < _currentIndex; i++) + { + Marshal.FreeCoTaskMem(_nativeStringPointers[i]); +#if DEBUG + DisposedPointers.Add(_nativeStringPointers[i]); +#endif + } + + ArrayPool.Shared.Return(_nativeStringPointers); + _currentIndex = 0; + _nativeStringPointers = null; + } + } + + ~NativeStringManager() + { + InternalDispose(); + } + +#if DEBUG + public List DisposedPointers { get; } = new(); + public int CurrentCount => _currentIndex; + public ReadOnlySpan DebugNativeStringPointers => _nativeStringPointers.AsSpan(0, _currentIndex); + public IntPtr[]? NativeStringPointersArray => _nativeStringPointers; +#endif +} \ No newline at end of file diff --git a/src/typstsharp/TypstCompiler.cs b/src/typstsharp/TypstCompiler.cs index 6bc55b8..5b5e48b 100644 --- a/src/typstsharp/TypstCompiler.cs +++ b/src/typstsharp/TypstCompiler.cs @@ -1,19 +1,19 @@ using System.Runtime.InteropServices; using System.Text.Json; -using System.Text.Json.Serialization; namespace typstsharp; -public record Fonts( +public sealed record Fonts( bool IncludeSystemFonts = true, IEnumerable? FontPaths = null ); public unsafe class TypstCompiler : IDisposable { - public static string EmptyDictionaryJson => "{}"; + private const string EmptyDictionaryJson = "{}"; private CsBindgen.Compiler* _compiler; private bool _disposed = false; + private static readonly JsonSerializerOptions sourceGenOptions = new() { TypeInfoResolver = SourceGenerationContext.Default @@ -25,8 +25,10 @@ public unsafe class TypstCompiler : IDisposable /// The path to the Typst source file to compile. /// Font settings, including system fonts and custom font paths. /// Initial system inputs (legacy, prefer SetSysInputs). + /// Root directory. /// Thrown when the Typst compiler fails to initialize. - public TypstCompiler(string inputPath, Fonts? fonts = null, Dictionary? sysInputs = null, string? root = null, string? packagePath = null) + public TypstCompiler(string inputPath, Fonts? fonts = null, Dictionary? sysInputs = null, + string? root = null, string? packagePath = null) : this(inputPath, null, fonts, sysInputs, root, packagePath) { } @@ -39,7 +41,8 @@ public TypstCompiler(string inputPath, Fonts? fonts = null, DictionarySystem inputs. /// Root directory. /// A new instance. - public static TypstCompiler FromSource(string source, Fonts? fonts = null, Dictionary? sysInputs = null, string? root = null, string? packagePath = null) + public static TypstCompiler FromSource(string source, Fonts? fonts = null, + Dictionary? sysInputs = null, string? root = null, string? packagePath = null) { return new TypstCompiler(null, source, fonts, sysInputs, root, packagePath); } @@ -52,69 +55,58 @@ public static TypstCompiler FromSource(string source, Fonts? fonts = null, Dicti /// System inputs. /// Root directory. /// A new instance. - public static TypstCompiler FromFile(string path, Fonts? fonts = null, Dictionary? sysInputs = null, string? root = null, string? packagePath = null) + public static TypstCompiler FromFile(string path, Fonts? fonts = null, Dictionary? sysInputs = null, + string? root = null, string? packagePath = null) { return new TypstCompiler(path, null, fonts, sysInputs, root, packagePath); } - - - private TypstCompiler(string? inputPath, string? inputSource, Fonts? fonts, Dictionary? sysInputs, string? root, string? packagePath = null) + private TypstCompiler(string? inputPath, string? inputSource, Fonts? fonts, Dictionary? sysInputs, + string? root, string? packagePath = null) { fonts ??= new Fonts(); var fontPaths = fonts.FontPaths ?? []; bool ignoreSystemFonts = !fonts.IncludeSystemFonts; - var inputPathPtr = inputPath != null ? Marshal.StringToCoTaskMemUTF8(inputPath) : IntPtr.Zero; - var inputSourcePtr = inputSource != null ? Marshal.StringToCoTaskMemUTF8(inputSource) : IntPtr.Zero; - - IntPtr rootPtr = IntPtr.Zero; - if (!string.IsNullOrWhiteSpace(root)) - { - rootPtr = Marshal.StringToCoTaskMemUTF8(root); - } + using var nativeStringManager = new NativeStringManager(); + var inputPathPtr = nativeStringManager.GetNativeUtf8FromString(inputPath); + var inputSourcePtr = nativeStringManager.GetNativeUtf8FromString(inputSource); - var fontPathsList = fontPaths.ToList(); + var rootPtr = !string.IsNullOrWhiteSpace(root) + ? nativeStringManager.GetNativeUtf8FromString(root) + : IntPtr.Zero; + + var fontPathsList = fontPaths as IList ?? fontPaths.ToList(); var fontPathPtrs = new IntPtr[fontPathsList.Count]; for (int i = 0; i < fontPathsList.Count; i++) { - fontPathPtrs[i] = Marshal.StringToCoTaskMemUTF8(fontPathsList[i]); + fontPathPtrs[i] = nativeStringManager.GetNativeUtf8FromString(fontPathsList[i]); } - var packagePathPtr = packagePath != null ? Marshal.StringToCoTaskMemUTF8(packagePath) : IntPtr.Zero; + var packagePathPtr = nativeStringManager.GetNativeUtf8FromString(packagePath); - var sysInputsJson = sysInputs == null ? "{}" : JsonSerializer.Serialize>(sysInputs, sourceGenOptions); - var sysInputsPtr = Marshal.StringToCoTaskMemUTF8(sysInputsJson); + var sysInputsJson = sysInputs == null + ? EmptyDictionaryJson + : JsonSerializer.Serialize>(sysInputs, sourceGenOptions); + var sysInputsPtr = nativeStringManager.GetNativeUtf8FromString(sysInputsJson); - try + fixed (IntPtr* fontPathsRawPtr = fontPathPtrs) { - fixed (IntPtr* fontPathsRawPtr = fontPathPtrs) - { - IntPtr* fontPathsPtr = fontPathsList.Count == 0 ? null : fontPathsRawPtr; - _compiler = CsBindgen.NativeMethods.create_compiler( - (byte*)rootPtr, - (byte*)inputPathPtr, - (byte*)inputSourcePtr, - (byte**)fontPathsPtr, - (nuint)fontPathsList.Count, - (byte*)packagePathPtr, - (byte*)sysInputsPtr, - ignoreSystemFonts); - } - - if (_compiler == null) - { - throw new Exception("Failed to create Typst compiler."); - } + IntPtr* fontPathsPtr = fontPathPtrs.Length == 0 ? null : fontPathsRawPtr; + _compiler = CsBindgen.NativeMethods.create_compiler( + (byte*)rootPtr, + (byte*)inputPathPtr, + (byte*)inputSourcePtr, + (byte**)fontPathsPtr, + (nuint)fontPathPtrs.Length, + (byte*)packagePathPtr, + (byte*)sysInputsPtr, + ignoreSystemFonts); } - finally + + if (_compiler == null) { - 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); + throw new Exception("Failed to create Typst compiler."); } } @@ -127,58 +119,52 @@ public CompileOutcome Compile(string format = "pdf", float ppi = 144.0f, IEnumer { EnsureNotDisposed(); - IntPtr formatPtr = Marshal.StringToCoTaskMemUTF8(format); + using var nativeStringManager = new NativeStringManager(); + IntPtr formatPtr = nativeStringManager.GetNativeUtf8FromString(format); string standardsStr = pdfStandards != null ? string.Join(",", pdfStandards) : ""; - IntPtr standardsPtr = Marshal.StringToCoTaskMemUTF8(standardsStr); + IntPtr standardsPtr = nativeStringManager.GetNativeUtf8FromString(standardsStr); + var native = CsBindgen.NativeMethods.compile(_compiler, (byte*)formatPtr, ppi, (byte*)standardsPtr); try { - var native = CsBindgen.NativeMethods.compile(_compiler, (byte*)formatPtr, ppi, (byte*)standardsPtr); - try + if (native.error != null) { - if (native.error != null) - { - var error = Marshal.PtrToStringUTF8((nint)native.error) ?? "Unknown Typst error"; - throw new InvalidOperationException(error); - } + var error = Marshal.PtrToStringUTF8((nint)native.error) ?? "Unknown Typst error"; + throw new InvalidOperationException(error); + } - var managedBuffers = new List((int)native.buffers_len); - if (native.buffers != null) + var managedBuffers = new List((int)native.buffers_len); + if (native.buffers != null) + { + for (nuint i = 0; i < native.buffers_len; i++) { - for (nuint i = 0; i < native.buffers_len; i++) + var buffer = native.buffers[i]; + var managed = new byte[checked((int)buffer.len)]; + if (buffer.len > 0 && buffer.ptr != null) { - var buffer = native.buffers[i]; - var managed = new byte[checked((int)buffer.len)]; - if (buffer.len > 0 && buffer.ptr != null) - { - Marshal.Copy((IntPtr)buffer.ptr, managed, 0, managed.Length); - } - managedBuffers.Add(managed); + Marshal.Copy((IntPtr)buffer.ptr, managed, 0, managed.Length); } - } - var managedWarnings = new List((int)native.warnings_len); - if (native.warnings != null) - { - for (nuint i = 0; i < native.warnings_len; i++) - { - var warning = native.warnings[i]; - managedWarnings.Add(Marshal.PtrToStringUTF8((nint)warning.message) ?? string.Empty); - } + managedBuffers.Add(managed); } - - return new CompileOutcome(managedBuffers, managedWarnings); } - finally + + var managedWarnings = new List((int)native.warnings_len); + if (native.warnings != null) { - CsBindgen.NativeMethods.free_compile_result(native); - CsBindgen.NativeMethods.reset_world(); + for (nuint i = 0; i < native.warnings_len; i++) + { + var warning = native.warnings[i]; + managedWarnings.Add(Marshal.PtrToStringUTF8((nint)warning.message) ?? string.Empty); + } } + + return new CompileOutcome(managedBuffers, managedWarnings); } finally { - if (formatPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(formatPtr); - if (standardsPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(standardsPtr); + CsBindgen.NativeMethods.free_compile_result(native); + CsBindgen.NativeMethods.reset_world(); } } @@ -190,7 +176,8 @@ public record TypstWarning(string Message); /// The output format (e.g., "pdf"). This parameter is currently not used by the underlying engine but is kept for future compatibility. /// The pixels per inch for the output. This parameter is currently not used by the underlying engine but is kept for future compatibility. /// A tuple containing a list of byte arrays for each page and a list of warnings. - public (List pages, List warnings) CompileToPages(string format = "pdf", float ppi = 144.0f, IEnumerable? pdfStandards = null) + public (List pages, List warnings) CompileToPages(string format = "pdf", float ppi = 144.0f, + IEnumerable? pdfStandards = null) { var outcome = Compile(format, ppi, pdfStandards); var pages = new List(outcome.Buffers); @@ -234,21 +221,16 @@ public void Compile(string outputFile, string format, float ppi = 144.0f, IEnume /// Thrown if the inputs fail to be set in the native compiler. public void SetSysInputs(Dictionary inputs) { - if (_disposed) throw new ObjectDisposedException(nameof(TypstCompiler)); + EnsureNotDisposed(); + using var nativeStringManager = new NativeStringManager(); var sysInputsJson = JsonSerializer.Serialize>(inputs, sourceGenOptions); - var sysInputsPtr = Marshal.StringToCoTaskMemUTF8(sysInputsJson); - try - { - var ok = CsBindgen.NativeMethods.set_sys_inputs(_compiler, (byte*)sysInputsPtr); - if (!ok) - { - throw new Exception("Failed to set system inputs"); - } - } - finally + var sysInputsPtr = nativeStringManager.GetNativeUtf8FromString(sysInputsJson); + + var ok = CsBindgen.NativeMethods.set_sys_inputs(_compiler, (byte*)sysInputsPtr); + if (!ok) { - Marshal.FreeCoTaskMem(sysInputsPtr); + throw new Exception("Failed to set system inputs"); } } @@ -290,4 +272,4 @@ private void EnsureNotDisposed() } public sealed record CompileOutcome(IReadOnlyList Buffers, IReadOnlyList Warnings); -public sealed record AllocationSnapshot(ulong BufferCount, ulong BufferBytes, ulong WarningCount, ulong WarningBytes); +public sealed record AllocationSnapshot(ulong BufferCount, ulong BufferBytes, ulong WarningCount, ulong WarningBytes); \ No newline at end of file