sys.inputs is the one remaining input that still forces a UTF-16 round trip, and for a large payload it dominates the managed allocation of a compile.
The dictionary is serialized to a JSON string and then transcoded into unmanaged memory (TypstCompiler.cs:566):
var sysInputsJson = sysInputs == null ? "{}" : JsonSerializer.Serialize<Dictionary<string, string>>(sysInputs, sourceGenOptions);
var sysInputsPtr = Marshal.StringToCoTaskMemUTF8(sysInputsJson);
SetSysInputs repeats it verbatim (TypstCompiler.cs:1159), and the Rust side unwraps it as a C string before handing it to serde (lib.rs:267 and lib.rs:310):
let sys_inputs_str = unsafe { CStr::from_ptr(sys_inputs).to_str().unwrap_or("{}") };
...
let inputs: Dict = serde_json::from_str(sys_inputs_str).unwrap_or_default();
This is the transport #34 deliberately left alone, and that reasoning still holds: a path cannot contain a NUL and JSON escapes one rather than emitting the byte, so NUL truncation is not a correctness risk here. The problem is allocation, not truncation.
What it costs
The interesting case is a caller whose input is a document, rather than a handful of short scalars. I generate shipping labels: one sys.inputs entry holds a JSON array of label field dictionaries, and the template reads it with json(bytes(sys.inputs.at("data"))). At 1000 labels that value is about 0.7 MB of UTF-8.
Following one such value through:
- My own
JsonSerializer.Serialize(...) produces a UTF-16 string, roughly 1.4 MB.
JsonSerializer.Serialize<Dictionary<string, string>> re-encodes that string as a JSON string value, so every " in my payload becomes \". Another UTF-16 string, slightly larger again.
Marshal.StringToCoTaskMemUTF8 copies the whole thing into unmanaged memory.
serde_json::from_str allocates the value a fourth time as an EcoString inside the Dict.
So the payload is JSON-encoded twice, JSON-parsed twice, and exists as four full copies before Typst has looked at it. Steps 1 and 2 are both large enough to land on the LOH, and step 3 is invisible to MemoryDiagnoser, so the true cost is worse than it measures. In my benchmark a 1000-label document allocates 18.9 MB managed per compile, and this path is the single largest contributor.
The double encoding is the part that grates most. I hand you JSON, you escape it into a JSON string, Rust parses the outer JSON to recover exactly the bytes I started with, and then my template parses the inner JSON a second time.
What I would like
Mirror what #34 did for input_source: take the sys inputs as raw UTF-8 with an explicit length, and let the caller supply the complete sys.inputs object.
public void SetSysInputs(ReadOnlySpan<byte> utf8Json);
public static TypstCompiler FromFile(
string path,
Fonts? fonts = null,
ReadOnlySpan<byte> sysInputsUtf8 = default,
...);
That lets me write straight into a pooled buffer with Utf8JsonWriter and hand you the span, which removes both UTF-16 strings and the unmanaged copy. On the Rust side it is serde_json::from_slice over a borrowed slice instead of from_str over a CStr.
Two things fall out of it that are worth more than the allocation saving:
- The existing
Dictionary<string, string> overloads get cheaper for free, because JsonSerializer.SerializeToUtf8Bytes replaces Serialize plus StringToCoTaskMemUTF8. Every current user benefits without changing a line.
- Because the caller controls the whole JSON object, a value can be a real nested object rather than an escaped string. If
Dict deserializes nested values the way I expect, my template drops its inner json(bytes(...)) parse entirely. I have not verified that against typst's Dict deserializer, so treat it as a hoped-for bonus rather than part of the ask.
create_compiler and set_sys_inputs are internal in generated code and ship in the same package as the native library, so changing the FFI signature is not a public break. The Dictionary overloads stay as they are.
I will put a PR together unless you would rather shape the API differently first — in particular whether the span should be the whole sys.inputs object, as above, or a per-key SetSysInput(string key, ReadOnlySpan<byte> utf8Value). I prefer the former because it is one call and one buffer, but the latter keeps the "values are strings" contract more visible.
Implementation hints
Notes for whoever picks this up, me included.
Branch from develop, not main. Targets are net8;net9;net10.0 (src/typstsharp/typstsharp.csproj), so ReadOnlySpan<byte> needs no shims and there is no netstandard2.0 to keep happy.
Rust — src/typst_core/src/lib.rs. Change sys_inputs: *const c_char to sys_inputs: *const u8, sys_inputs_len: usize on both create_compiler (~:193) and set_sys_inputs (:383). Replace the CStr::from_ptr(...).to_str() at :267 and :393 with std::slice::from_raw_parts, guarding null and zero length, and swap serde_json::from_str at :310 for from_slice. Copy the shape of input_source / input_source_len immediately above at :261 — it is the same pattern and already reviewed. Update the # Safety doc block at :170–:176, which currently promises sys_inputs is NUL-terminated, and the matching sentence on set_sys_inputs.
Do not hand-edit src/typstsharp/Bindings.g.cs. It is csbindgen output; the header says so. It is regenerated by src/typst_core/build.rs, which runs csbindgen::Builder::default().input_extern_file("src/lib.rs").csharp_dll_name("typst_core").generate_csharp_file("../typstsharp/Bindings.g.cs"). Run cargo build in src/typst_core and commit the regenerated file. The declarations to expect changes in are at Bindings.g.cs:62 and :125.
C# — src/typstsharp/TypstCompiler.cs. Three touch points: the private constructor at :477, the serialize/marshal pair at :566–:571, and SetSysInputs at :1148. Pin the buffer with fixed exactly as inputSourcePtr already is inside the same try block, and drop sysInputsPtr from the finally that calls Marshal.FreeCoTaskMem (:631). Keep sourceGenOptions and the source-generated context in src/typstsharp/JsonSerialisation.cs; SerializeToUtf8Bytes uses it unchanged.
Watch the empty case. Today sysInputs == null sends the literal "{}"; with a length-carrying transport, default/empty span must be treated as "no inputs" rather than being passed to serde as an empty slice, which would error instead of yielding an empty Dict.
Tests. C# lives in src/typstsharp.tests, Rust integration tests in src/typst_core/tests (compile.rs, input_path.rs). Worth covering: a value containing quotes, backslashes and non-ASCII text, to prove the escaping change did not alter what the template observes; a value in the hundreds of KB; and an empty/omitted inputs case. A test asserting sys.inputs content is easy to write by compiling a one-line source that emits sys.inputs.at("k").
sys.inputsis the one remaining input that still forces a UTF-16 round trip, and for a large payload it dominates the managed allocation of a compile.The dictionary is serialized to a JSON
stringand then transcoded into unmanaged memory (TypstCompiler.cs:566):SetSysInputsrepeats it verbatim (TypstCompiler.cs:1159), and the Rust side unwraps it as a C string before handing it to serde (lib.rs:267andlib.rs:310):This is the transport #34 deliberately left alone, and that reasoning still holds: a path cannot contain a NUL and JSON escapes one rather than emitting the byte, so NUL truncation is not a correctness risk here. The problem is allocation, not truncation.
What it costs
The interesting case is a caller whose input is a document, rather than a handful of short scalars. I generate shipping labels: one
sys.inputsentry holds a JSON array of label field dictionaries, and the template reads it withjson(bytes(sys.inputs.at("data"))). At 1000 labels that value is about 0.7 MB of UTF-8.Following one such value through:
JsonSerializer.Serialize(...)produces a UTF-16string, roughly 1.4 MB.JsonSerializer.Serialize<Dictionary<string, string>>re-encodes that string as a JSON string value, so every"in my payload becomes\". Another UTF-16string, slightly larger again.Marshal.StringToCoTaskMemUTF8copies the whole thing into unmanaged memory.serde_json::from_strallocates the value a fourth time as anEcoStringinside theDict.So the payload is JSON-encoded twice, JSON-parsed twice, and exists as four full copies before Typst has looked at it. Steps 1 and 2 are both large enough to land on the LOH, and step 3 is invisible to
MemoryDiagnoser, so the true cost is worse than it measures. In my benchmark a 1000-label document allocates 18.9 MB managed per compile, and this path is the single largest contributor.The double encoding is the part that grates most. I hand you JSON, you escape it into a JSON string, Rust parses the outer JSON to recover exactly the bytes I started with, and then my template parses the inner JSON a second time.
What I would like
Mirror what #34 did for
input_source: take the sys inputs as raw UTF-8 with an explicit length, and let the caller supply the completesys.inputsobject.That lets me write straight into a pooled buffer with
Utf8JsonWriterand hand you the span, which removes both UTF-16 strings and the unmanaged copy. On the Rust side it isserde_json::from_sliceover a borrowed slice instead offrom_strover aCStr.Two things fall out of it that are worth more than the allocation saving:
Dictionary<string, string>overloads get cheaper for free, becauseJsonSerializer.SerializeToUtf8BytesreplacesSerializeplusStringToCoTaskMemUTF8. Every current user benefits without changing a line.Dictdeserializes nested values the way I expect, my template drops its innerjson(bytes(...))parse entirely. I have not verified that against typst'sDictdeserializer, so treat it as a hoped-for bonus rather than part of the ask.create_compilerandset_sys_inputsareinternalin generated code and ship in the same package as the native library, so changing the FFI signature is not a public break. TheDictionaryoverloads stay as they are.I will put a PR together unless you would rather shape the API differently first — in particular whether the span should be the whole
sys.inputsobject, as above, or a per-keySetSysInput(string key, ReadOnlySpan<byte> utf8Value). I prefer the former because it is one call and one buffer, but the latter keeps the "values are strings" contract more visible.Implementation hints
Notes for whoever picks this up, me included.
Branch from
develop, notmain. Targets arenet8;net9;net10.0(src/typstsharp/typstsharp.csproj), soReadOnlySpan<byte>needs no shims and there is nonetstandard2.0to keep happy.Rust —
src/typst_core/src/lib.rs. Changesys_inputs: *const c_chartosys_inputs: *const u8, sys_inputs_len: usizeon bothcreate_compiler(~:193) andset_sys_inputs(:383). Replace theCStr::from_ptr(...).to_str()at:267and:393withstd::slice::from_raw_parts, guarding null and zero length, and swapserde_json::from_strat:310forfrom_slice. Copy the shape ofinput_source/input_source_lenimmediately above at:261— it is the same pattern and already reviewed. Update the# Safetydoc block at:170–:176, which currently promisessys_inputsis NUL-terminated, and the matching sentence onset_sys_inputs.Do not hand-edit
src/typstsharp/Bindings.g.cs. It is csbindgen output; the header says so. It is regenerated bysrc/typst_core/build.rs, which runscsbindgen::Builder::default().input_extern_file("src/lib.rs").csharp_dll_name("typst_core").generate_csharp_file("../typstsharp/Bindings.g.cs"). Runcargo buildinsrc/typst_coreand commit the regenerated file. The declarations to expect changes in are atBindings.g.cs:62and:125.C# —
src/typstsharp/TypstCompiler.cs. Three touch points: the private constructor at:477, the serialize/marshal pair at:566–:571, andSetSysInputsat:1148. Pin the buffer withfixedexactly asinputSourcePtralready is inside the sametryblock, and dropsysInputsPtrfrom thefinallythat callsMarshal.FreeCoTaskMem(:631). KeepsourceGenOptionsand the source-generated context insrc/typstsharp/JsonSerialisation.cs;SerializeToUtf8Bytesuses it unchanged.Watch the empty case. Today
sysInputs == nullsends the literal"{}"; with a length-carrying transport,default/empty span must be treated as "no inputs" rather than being passed to serde as an empty slice, which would error instead of yielding an emptyDict.Tests. C# lives in
src/typstsharp.tests, Rust integration tests insrc/typst_core/tests(compile.rs,input_path.rs). Worth covering: a value containing quotes, backslashes and non-ASCII text, to prove the escaping change did not alter what the template observes; a value in the hundreds of KB; and an empty/omitted inputs case. A test assertingsys.inputscontent is easy to write by compiling a one-line source that emitssys.inputs.at("k").