2 Commits

Author SHA1 Message Date
9061092b64 feat: basic render. 2026-07-21 23:07:55 +08:00
8964e760ee feat: FFI rust library.
Signed-off-by: jackfiled <xcrenchangjun@outlook.com>
2026-07-21 21:12:09 +08:00
12 changed files with 3540 additions and 0 deletions

View File

@@ -14,6 +14,7 @@
<Folder Name="/src/">
<Project Path="src/YaeBlog.Abstractions/YaeBlog.Abstractions.csproj" />
<Project Path="src/YaeBlog.Tests/YaeBlog.Tests.csproj" />
<Project Path="src/YaeBlog.Typst/YaeBlog.Typst.csproj" />
<Project Path="src/YaeBlog/YaeBlog.csproj" />
</Folder>
<Folder Name="/third-party/" />

View File

@@ -0,0 +1,18 @@
using YaeBlog.Typst;
namespace YaeBlog.Tests;
public class CallRustTests
{
[Fact]
public void ProcessStringTest()
{
RustString test1 = RustCaller.ProcessString("123");
Assert.NotNull(test1.Value);
Assert.Equal("Process 123", test1.Value);
RustString test2 = RustCaller.ProcessString("世界");
Assert.NotNull(test2.Value);
Assert.Equal("Process 世界", test2.Value);
}
}

View File

@@ -24,6 +24,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\YaeBlog.Typst\YaeBlog.Typst.csproj" />
<ProjectReference Include="..\YaeBlog\YaeBlog.csproj" />
</ItemGroup>

View File

@@ -0,0 +1,9 @@
using System.Runtime.InteropServices;
namespace YaeBlog.Typst;
public static partial class RustCaller
{
[LibraryImport("yaeblog_typst", EntryPoint = "process_string", StringMarshalling = StringMarshalling.Utf8)]
public static partial RustString ProcessString(string value);
}

View File

@@ -0,0 +1,27 @@
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.Marshalling;
namespace YaeBlog.Typst;
[NativeMarshalling(typeof(RustStringMarshaller))]
public struct RustString
{
public string? Value;
}
/// <summary>
/// Customize string marshaller used to handle `CString` returned value in Rust side.
/// </summary>
[CustomMarshaller(typeof(RustString), MarshalMode.ManagedToUnmanagedOut, typeof(RustStringMarshaller))]
internal static unsafe partial class RustStringMarshaller
{
public static RustString ConvertToManaged(byte* ptr)
{
return new RustString { Value = Utf8StringMarshaller.ConvertToManaged(ptr) };
}
[LibraryImport("yaeblog_typst", EntryPoint = "free_rust_string")]
private static partial void FreeRustString(byte* ptr);
public static void Free(byte* ptr) => FreeRustString(ptr);
}

View File

@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<PropertyGroup>
<RustProjectPath>$([System.IO.Path]::Combine($(MSBuildProjectDirectory), `..`, `yaeblog-typst`))</RustProjectPath>
</PropertyGroup>
<Target Name="BuildRustProject" BeforeTargets="Build">
<Exec Command="cargo build --release" WorkingDirectory="$(RustProjectPath)"/>
</Target>
<ItemGroup>
<Content Include="../yaeblog-typst/target/release/libyaeblog_typst.so"
Link="runtimes/linux-x64/native/libyaeblog_typst.so" CopyToOutputDirectory="Always"/>
</ItemGroup>
</Project>

6
src/yaeblog-typst/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
/target
/tmp
.idea/
*.S
*.wav
.DS_Store

3107
src/yaeblog-typst/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
[package]
name = "yaeblog-typst"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
anyhow = "1.0.104"
libc = "0.2.187"
typst = "0.15.1"
typst-html = "0.15.1"
typst-kit = { version = "0.15.1", features = ["datetime", "system-packages", "embedded-fonts"]}

View File

@@ -0,0 +1,85 @@
use std::ffi::{CStr, CString, c_char, c_void};
#[repr(C)]
pub struct ExternResult<T> {
pub content: T,
pub error_message: *mut c_char,
pub succeeded: bool,
}
impl<T> ExternResult<T> where T : Default {
pub fn from_result(r: anyhow::Result<T>) -> Self {
match r {
Ok(c) => {
ExternResult {
content: c,
error_message: std::ptr::null_mut(),
succeeded: true
}
},
Err(e) => {
ExternResult {
content: T::default(),
error_message: return_str(e.to_string()),
succeeded: false
}
}
}
}
}
pub fn extract_str<'a>(str: *const c_char) -> anyhow::Result<&'a str> {
let c_str = unsafe { CStr::from_ptr(str) };
let result = c_str.to_str()?;
Ok(result)
}
pub fn return_str(str: String) -> *mut c_char {
let returned_c_str = match CString::new(str) {
Ok(s) => s,
Err(_) => {
return std::ptr::null_mut();
}
};
returned_c_str.into_raw()
}
#[unsafe(no_mangle)]
pub extern "C" fn free_rust_string(ptr: *mut c_char) {
if ptr.is_null() {
return;
}
unsafe {
// Retake the ownership of string and drop it.
let _ = CString::from_raw(ptr);
}
}
#[unsafe(no_mangle)]
pub extern "C" fn process_string(str: *const c_char) -> *mut c_char {
let rust_str = match extract_str(str) {
Ok(s) => s,
Err(e) => {
return return_str(format!("Encounter error: {e}"))
}
};
let mut returned_str = String::new();
returned_str.push_str("Process ");
returned_str.push_str(rust_str);
return_str(returned_str)
}
pub fn into_handler<T>(handler: Box<T>) -> *mut c_void {
Box::into_raw(handler) as *mut c_void
}
pub fn from_handler<'a, T>(handler: *mut c_void) -> &'a mut T {
unsafe {
&mut *(handler as *mut T)
}
}

View File

@@ -0,0 +1,122 @@
mod ffi_utils;
mod world;
use crate::ffi_utils::{ExternResult, extract_str, from_handler, into_handler, return_str};
use crate::world::SystemWorld;
use anyhow::anyhow;
use std::ffi::{c_char, c_void};
use std::path::PathBuf;
use std::str::FromStr;
use typst::diag::{EcoVec, SourceDiagnostic, Warned};
use typst_html::{HtmlDocument, HtmlOptions};
struct TypstCompiler {
world: SystemWorld,
}
impl TypstCompiler {
fn new(work_dir: &str) -> anyhow::Result<Self> {
let path = PathBuf::from_str(work_dir)?;
Ok(Self {
world: SystemWorld::new(path),
})
}
fn compile(&mut self, input: &str) -> anyhow::Result<String> {
self.world.set_current_file(input);
let Warned { output, warnings } = typst::compile::<HtmlDocument>(&mut self.world);
let document = match output {
Ok(d) => Ok(d),
Err(e) => Err(anyhow!(typst_to_err(e))),
}?;
let content = export_html(&document)?;
Ok(content)
}
}
fn typst_to_err(e: EcoVec<SourceDiagnostic>) -> String {
let mut message = String::new();
message.push_str("Failed to compile: \n");
for err in e {
message.push_str(format!(" - {} \n", err.message.as_str()).as_str());
}
message
}
fn export_html(document: &HtmlDocument) -> anyhow::Result<String> {
let options = HtmlOptions { pretty: false };
match typst_html::html(document, &options) {
Ok(o) => Ok(o),
Err(e) => Err(anyhow!(typst_to_err(e))),
}
}
fn create_compiler(work_dir: *const c_char) -> anyhow::Result<Box<TypstCompiler>> {
let work_dir = extract_str(work_dir)?;
let compiler = TypstCompiler::new(work_dir)?;
Ok(Box::new(compiler))
}
pub extern "C" fn init_compiler(work_dir: *const c_char) -> ExternResult<*mut c_void> {
let compiler = create_compiler(work_dir);
let handler = compiler.map(into_handler);
ExternResult::from_result(handler)
}
fn typst_compile_inner(
compiler: &mut TypstCompiler,
str: *const c_char,
) -> anyhow::Result<*mut c_char> {
let input = extract_str(str)?;
compiler.compile(input).map(return_str)
}
pub extern "C" fn typst_compile(
handler: *mut c_void,
str: *const c_char,
) -> ExternResult<*mut c_char> {
let compiler = from_handler::<TypstCompiler>(handler);
ExternResult::from_result(typst_compile_inner(compiler, str))
}
pub extern "C" fn free_compiler(handler: *mut c_void) {
if handler.is_null() {
return;
}
unsafe {
// Drop it!
let _ = Box::from_raw(handler as *mut TypstCompiler);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn smoke_test() {
let mut compiler = TypstCompiler::new(".").unwrap();
let result = compiler.compile("= 123").unwrap();
assert!(result.contains("<h2>123</h2>"))
}
#[test]
fn math_test() {
let mut compiler = TypstCompiler::new(".").unwrap();
let result = compiler.compile(
"$1 + 1 = 2$").unwrap();
assert!(result.contains("math"));
}
}

View File

@@ -0,0 +1,127 @@
use std::path::PathBuf;
use std::sync::LazyLock;
use typst::diag::{FileError, FileResult, PackageError};
use typst::foundations::{Bytes, Datetime, Duration};
use typst::syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot};
use typst::text::{Font, FontBook};
use typst::utils::LazyHash;
use typst::{Feature, Features, Library, LibraryExt, World};
use typst_kit::datetime::Time;
use typst_kit::files::{FileLoader, FileStore, FsRoot};
use typst_kit::fonts::{FontStore, embedded};
pub struct SystemWorld {
work_dir: PathBuf,
library: LazyHash<Library>,
files: FileStore<SystemFiles>,
fonts: LazyLock<FontStore, Box<dyn Fn() -> FontStore + Send + Sync>>,
now: Time,
}
const DEFAULT_FEATURES: [Feature; 1] = [Feature::Html];
impl SystemWorld {
pub fn new(work_dir: PathBuf) -> Self {
let features = DEFAULT_FEATURES.iter().copied().map(Into::into).collect();
let library = Library::builder().with_features(features).build();
Self {
work_dir: work_dir.clone(),
library: LazyHash::new(library),
files: FileStore::new(SystemFiles::new(work_dir)),
fonts: LazyLock::new(Box::new(|| {
let mut fonts = FontStore::new();
fonts.extend(embedded());
fonts
})),
now: Time::system(),
}
}
pub fn set_current_file(&mut self, content: &str) {
let current_file = &mut self.files.loader_mut().current_file;
let bytes = content.as_bytes();
current_file.clear();
current_file.resize(bytes.len(), 0);
current_file.copy_from_slice(content.as_bytes())
}
}
impl World for SystemWorld {
fn library(&self) -> &LazyHash<Library> {
&self.library
}
fn book(&self) -> &LazyHash<FontBook> {
self.fonts.book()
}
fn main(&self) -> FileId {
*MAIN_ID
}
fn source(&self, id: FileId) -> FileResult<Source> {
self.files.source(id)
}
fn file(&self, id: FileId) -> FileResult<Bytes> {
self.files.file(id)
}
fn font(&self, index: usize) -> Option<Font> {
self.fonts.font(index)
}
fn today(&self, offset: Option<Duration>) -> Option<Datetime> {
self.now.today(offset)
}
}
/// Provides project files from a configured directory and package files from
/// standard locations.
struct SystemFiles {
pub main: FileId,
project: FsRoot,
current_file: Vec<u8>,
}
static MAIN_ID: LazyLock<FileId> = LazyLock::new(|| {
FileId::unique(RootedPath::new(
VirtualRoot::Project,
VirtualPath::new("<main>").unwrap(),
))
});
impl SystemFiles {
pub fn new(work_dir: PathBuf) -> Self {
Self {
main: *MAIN_ID,
project: FsRoot::new(work_dir),
current_file: Vec::new(),
}
}
pub fn resolve(&self, id: FileId) -> FileResult<PathBuf> {
Ok(self.root(id)?.resolve(id.vpath())?)
}
fn root(&self, id: FileId) -> FileResult<FsRoot> {
Ok(match id.root() {
VirtualRoot::Project => Ok(self.project.clone()),
VirtualRoot::Package(_) => Err(FileError::Package(PackageError::Other(None))),
}?)
}
}
impl FileLoader for SystemFiles {
fn load(&self, id: FileId) -> FileResult<Bytes> {
if id == *MAIN_ID {
Ok(Bytes::new(self.current_file.clone()))
} else {
self.root(id)?.load(id.vpath())
}
}
}