Update 1.3.0

This commit is contained in:
2026-04-06 23:46:44 +00:00
parent 37dc7d5610
commit 9839f8609b
24 changed files with 751 additions and 192 deletions
+26 -10
View File
@@ -1,7 +1,7 @@
use crate::world::MemoryWorld;
use std::collections::HashMap;
use typst::diag::{SourceDiagnostic, Warned};
use typst::layout::PagedDocument;
use typst_layout::PagedDocument;
use typst_pdf::{pdf, PdfOptions};
use typst_render::render;
@@ -16,15 +16,15 @@ impl TypstCompiler {
&self,
text: String,
files: HashMap<String, Vec<u8>>,
) -> Result<(Vec<String>, String), Vec<SourceDiagnostic>> {
) -> Result<(Vec<String>, String), Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>> {
let world = MemoryWorld::new(text, files);
match typst::compile::<PagedDocument>(&world) {
Warned {
output: Ok(doc),
warnings: _,
} => {
let svgs = doc.pages.iter().map(typst_svg::svg).collect();
let thumbnail = if let Some(page) = doc.pages.first() {
let svgs = doc.pages().iter().map(typst_svg::svg).collect();
let thumbnail = if let Some(page) = doc.pages().first() {
typst_svg::svg(page)
} else {
String::new()
@@ -35,7 +35,11 @@ impl TypstCompiler {
output: Err(errors),
warnings: _,
} => {
let diag = errors.into_iter().collect();
use typst::World;
let diag = errors.into_iter().map(|d| {
let range = d.span.id().and_then(|id| world.source(id).ok()).and_then(|s| s.range(d.span));
(d, range)
}).collect();
Err(diag)
}
}
@@ -45,7 +49,7 @@ impl TypstCompiler {
&self,
text: String,
files: HashMap<String, Vec<u8>>,
) -> Result<Vec<u8>, Vec<SourceDiagnostic>> {
) -> Result<Vec<u8>, Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>> {
let world = MemoryWorld::new(text, files);
match typst::compile::<PagedDocument>(&world) {
Warned {
@@ -61,7 +65,13 @@ impl TypstCompiler {
Warned {
output: Err(errors),
warnings: _,
} => Err(errors.into_iter().collect()),
} => {
use typst::World;
Err(errors.into_iter().map(|d| {
let range = d.span.id().and_then(|id| world.source(id).ok()).and_then(|s| s.range(d.span));
(d, range)
}).collect())
},
}
}
@@ -69,14 +79,14 @@ impl TypstCompiler {
&self,
text: String,
files: HashMap<String, Vec<u8>>,
) -> Result<Vec<u8>, Vec<SourceDiagnostic>> {
) -> Result<Vec<u8>, Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>> {
let world = MemoryWorld::new(text, files);
match typst::compile::<PagedDocument>(&world) {
Warned {
output: Ok(doc),
warnings: _,
} => {
if let Some(page) = doc.pages.first() {
if let Some(page) = doc.pages().first() {
let pixmap = render(page, 2.0);
if let Ok(encoded) = pixmap.encode_png() {
return Ok(encoded);
@@ -87,7 +97,13 @@ impl TypstCompiler {
Warned {
output: Err(errors),
warnings: _,
} => Err(errors.into_iter().collect()),
} => {
use typst::World;
Err(errors.into_iter().map(|d| {
let range = d.span.id().and_then(|id| world.source(id).ok()).and_then(|s| s.range(d.span));
(d, range)
}).collect())
},
}
}
}
+11 -1
View File
@@ -233,6 +233,7 @@ pub async fn upload_file(
let (_, folder_id) = doc_exists.unwrap();
let mut uploaded_filename = String::new();
let mut font_family = None;
if let Some(field) = multipart.next_field().await.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? {
let file_name = field.file_name().unwrap_or("unnamed").to_string();
@@ -241,6 +242,12 @@ pub async fn upload_file(
let file_id = Uuid::new_v4().to_string();
if file_name.to_lowercase().ends_with(".ttf") || file_name.to_lowercase().ends_with(".otf") {
if let Some(font) = typst::text::Font::iter(typst::foundations::Bytes::new(data.clone())).next() {
font_family = Some(font.info().family.clone());
}
}
sqlx::query("INSERT INTO files (id, owner_id, document_id, folder_id, name, mime_type, data) VALUES ($1, $2, $3, $4, $5, $6, $7)")
.bind(&file_id)
.bind(&user_id)
@@ -256,5 +263,8 @@ pub async fn upload_file(
uploaded_filename = file_name;
}
Ok(Json(serde_json::json!({"filename": uploaded_filename})))
Ok(Json(serde_json::json!({
"filename": uploaded_filename,
"font_family": font_family
})))
}
+42 -1
View File
@@ -63,6 +63,7 @@ pub async fn upload_file_global(
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let mut uploaded_files = vec![];
let mut font_families = vec![];
while let Some(field) = multipart.next_field().await.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? {
let file_name = field.file_name().unwrap_or("unnamed").to_string();
@@ -71,6 +72,13 @@ pub async fn upload_file_global(
let file_id = Uuid::new_v4().to_string();
let mut font_family = None;
if file_name.to_lowercase().ends_with(".ttf") || file_name.to_lowercase().ends_with(".otf") {
if let Some(font) = typst::text::Font::iter(typst::foundations::Bytes::new(data.clone())).next() {
font_family = Some(font.info().family.clone());
}
}
sqlx::query("INSERT INTO files (id, owner_id, folder_id, name, mime_type, data) VALUES ($1, $2, $3, $4, $5, $6)")
.bind(&file_id)
.bind(&user_id)
@@ -83,9 +91,13 @@ pub async fn upload_file_global(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
uploaded_files.push(file_name);
font_families.push(font_family);
}
Ok(Json(serde_json::json!({"files": uploaded_files})))
Ok(Json(serde_json::json!({
"files": uploaded_files,
"font_families": font_families
})))
}
pub async fn get_file_data(
@@ -135,6 +147,35 @@ pub async fn delete_file(
Ok(StatusCode::NO_CONTENT)
}
pub async fn list_fonts(
State(state): State<AppState>,
jar: SignedCookieJar,
) -> Result<Json<Vec<String>>, (StatusCode, String)> {
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let files = sqlx::query_as::<_, (String,)>(
"SELECT name FROM files WHERE owner_id = $1"
)
.bind(&user_id)
.fetch_all(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let mut fonts = Vec::new();
for (name,) in files {
if name.to_lowercase().ends_with(".ttf") || name.to_lowercase().ends_with(".otf") {
if let Some(stem) = std::path::Path::new(&name).file_stem() {
if let Some(stem_str) = stem.to_str() {
fonts.push(stem_str.to_string());
}
}
}
}
Ok(Json(fonts))
}
#[derive(Deserialize)]
pub struct UpdateFileRequest {
pub name: Option<String>,
+152 -1
View File
@@ -64,6 +64,8 @@ pub struct CompileResponse {
pub struct Diagnostic {
pub message: String,
pub severity: String,
pub from: Option<usize>,
pub to: Option<usize>,
}
pub async fn yjs_handler(
@@ -232,9 +234,11 @@ pub async fn compile_handler(
Err(diags) => {
let errors = diags
.into_iter()
.map(|d| Diagnostic {
.map(|(d, range)| Diagnostic {
message: d.message.to_string(),
severity: format!("{:?}", d.severity),
from: range.as_ref().map(|r| r.start),
to: range.as_ref().map(|r| r.end),
})
.collect();
Json(CompileResponse {
@@ -464,3 +468,150 @@ pub async fn pandoc_import_handler(
)
.into_response()
}
pub async fn lsp_handler(
ws: axum::extract::ws::WebSocketUpgrade,
Path(id): Path<String>,
State(state): State<AppState>,
jar: axum_extra::extract::cookie::SignedCookieJar,
) -> impl IntoResponse {
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
let doc = match sqlx::query_as::<_, crate::models::Document>("SELECT id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at FROM documents WHERE id = $1").bind(&id).fetch_optional(&state.db).await {
Ok(Some(d)) => d,
_ => return (StatusCode::NOT_FOUND, "Document not found").into_response(),
};
let mut has_access = false;
if let Some(uid) = &user_id_opt {
if &doc.owner_id == uid {
has_access = true;
} else if let Ok(Some(_)) = sqlx::query_as::<_, (String,)>("SELECT role FROM collaborators WHERE document_id = $1 AND user_id = $2")
.bind(&id)
.bind(uid)
.fetch_optional(&state.db)
.await
{
has_access = true;
}
}
if !has_access {
if let Some(pr) = &doc.public_role {
if pr == "viewer" || pr == "editor" {
has_access = true;
}
}
}
if !has_access {
return (StatusCode::FORBIDDEN, "Forbidden").into_response();
}
let mut files_map = std::collections::HashMap::new();
if let Ok(files) = sqlx::query_as::<_, (String, Vec<u8>)>("SELECT name, data FROM files WHERE owner_id = $1")
.bind(doc.owner_id)
.fetch_all(&state.db)
.await
{
for (name, data) in files {
files_map.insert(name, data);
}
}
ws.on_upgrade(move |socket| async move {
use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command;
use std::process::Stdio;
let temp_dir = tempfile::tempdir().unwrap();
for (name, data) in files_map {
let path = temp_dir.path().join(&name);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&path, data);
}
let mut child = Command::new("tinymist")
.arg("lsp")
.arg("--font-path")
.arg(temp_dir.path())
.current_dir(temp_dir.path())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.expect("Failed to start tinymist lsp");
let mut stdin = child.stdin.take().unwrap();
let stdout = child.stdout.take().unwrap();
let mut stdout_reader = BufReader::new(stdout);
let (mut ws_tx, mut ws_rx) = socket.split();
let root_uri = format!("file://{}", temp_dir.path().display());
let init_msg = serde_json::json!({
"type": "init",
"rootUri": root_uri
});
use futures_util::SinkExt;
let _ = ws_tx.send(axum::extract::ws::Message::Text(init_msg.to_string().into())).await;
let ws_to_lsp = tokio::spawn(async move {
while let Some(Ok(axum::extract::ws::Message::Text(msg))) = ws_rx.next().await {
let content_length = format!("Content-Length: {}\r\n\r\n", msg.len());
if stdin.write_all(content_length.as_bytes()).await.is_err() {
break;
}
if stdin.write_all(msg.as_bytes()).await.is_err() {
break;
}
}
});
let lsp_to_ws = tokio::spawn(async move {
loop {
let mut content_length = 0;
let mut header = String::new();
loop {
let mut char_buf = [0; 1];
if stdout_reader.read_exact(&mut char_buf).await.is_err() {
return;
}
header.push(char_buf[0] as char);
if header.ends_with("\r\n\r\n") {
break;
}
}
for line in header.split("\r\n") {
if line.starts_with("Content-Length: ") {
if let Ok(len) = line["Content-Length: ".len()..].trim().parse::<usize>() {
content_length = len;
}
}
}
if content_length == 0 { continue; }
let mut body = vec![0; content_length];
if stdout_reader.read_exact(&mut body).await.is_err() {
break;
}
if let Ok(text) = String::from_utf8(body) {
use futures_util::SinkExt;
if ws_tx.send(axum::extract::ws::Message::Text(text.into())).await.is_err() {
break;
}
}
}
});
tokio::select! {
_ = ws_to_lsp => {}
_ = lsp_to_ws => {}
_ = child.wait() => {}
}
})
}
+2
View File
@@ -69,6 +69,7 @@ async fn main() {
.route("/export/{format}", post(export_handler))
.route("/export/pandoc/{format}", post(handlers::pandoc_export_handler))
.route("/import/pandoc", post(handlers::pandoc_import_handler))
.route("/lsp/{id}", get(handlers::lsp_handler))
.route("/auth/register", post(auth::register))
.route("/auth/login", post(auth::login))
.route("/auth/logout", post(auth::logout))
@@ -77,6 +78,7 @@ async fn main() {
.route("/auth/change-password", put(auth::change_password))
.route("/folders", get(folders::list_folders).post(folders::create_folder))
.route("/folders/{id}", delete(folders::delete_folder).patch(folders::update_folder))
.route("/fonts", get(files::list_fonts))
.route("/files", get(files::list_files).post(files::upload_file_global))
.route("/files/{id}", delete(files::delete_file).patch(files::update_file))
.route("/files/{id}/data", get(files::get_file_data))
+35 -31
View File
@@ -2,13 +2,13 @@ use chrono::Datelike;
use std::collections::HashMap;
use typst::diag::{FileError, FileResult};
use typst::foundations::{Bytes, Datetime};
use typst::syntax::{FileId, Source, VirtualPath};
use typst_kit::download::{Downloader, ProgressSink};
use typst_kit::package::PackageStorage;
use typst::foundations::{Bytes, Datetime, Duration};
use typst::syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot};
use typst::text::{Font, FontBook};
use typst::World;
use typst::{Library, LibraryExt};
use typst_kit::downloader::SystemDownloader;
use typst_kit::packages::SystemPackages;
pub struct MemoryWorld {
library: typst::utils::LazyHash<Library>,
@@ -17,15 +17,18 @@ pub struct MemoryWorld {
files: HashMap<String, Vec<u8>>,
book: typst::utils::LazyHash<FontBook>,
fonts: Vec<Font>,
packages: PackageStorage,
packages: SystemPackages,
}
impl MemoryWorld {
pub fn new(text: String, files: HashMap<String, Vec<u8>>) -> Self {
let main = FileId::new(None, VirtualPath::new("main.typ"));
let main = FileId::new(RootedPath::new(
VirtualRoot::Project,
VirtualPath::new("main.typ").unwrap(),
));
let source = Source::new(main, text);
let downloader = Downloader::new("TypstDrive (typst-kit)");
let packages = PackageStorage::new(None, None, downloader);
let downloader = SystemDownloader::new("TypstDrive (typst-kit)");
let packages = SystemPackages::new(downloader);
let mut book = FontBook::new();
let mut fonts = Vec::new();
@@ -41,7 +44,7 @@ impl MemoryWorld {
// Add custom fonts from files
for (name, data) in &files {
if name.ends_with(".ttf") || name.ends_with(".otf") {
if name.to_lowercase().ends_with(".ttf") || name.to_lowercase().ends_with(".otf") {
for font in Font::iter(Bytes::new(data.clone())) {
let info = font.info().clone();
book.push(info.clone());
@@ -87,39 +90,39 @@ impl World for MemoryWorld {
fn source(&self, id: FileId) -> FileResult<Source> {
if id == self.main {
Ok(self.source.clone())
} else if let Some(package) = id.package() {
let dir = self
} else if let VirtualRoot::Package(package) = id.root() {
let root = self
.packages
.prepare_package(package, &mut ProgressSink)
.obtain(package)
.map_err(|e| FileError::Other(Some(e.to_string().into())))?;
let path = id.vpath().resolve(&dir).ok_or_else(|| FileError::NotFound(id.vpath().as_rootless_path().into()))?;
let data = std::fs::read(&path).map_err(|_| FileError::NotFound(id.vpath().as_rootless_path().into()))?;
let text = String::from_utf8(data).map_err(|_| FileError::InvalidUtf8)?;
let data = root.load(id.vpath())?;
let text = std::str::from_utf8(&data)
.map_err(|_| FileError::InvalidUtf8)?
.to_owned();
Ok(Source::new(id, text))
} else {
Err(FileError::NotFound(
id.vpath().as_rootless_path().into(),
))
Err(FileError::NotFound(id.vpath().get_without_slash().into()))
}
}
fn file(&self, id: FileId) -> FileResult<Bytes> {
if id == self.main {
Ok(Bytes::from_string(self.source.text().to_string()))
} else if let Some(package) = id.package() {
let dir = self
} else if let VirtualRoot::Package(package) = id.root() {
let root = self
.packages
.prepare_package(package, &mut ProgressSink)
.obtain(package)
.map_err(|e| FileError::Other(Some(e.to_string().into())))?;
let path = id.vpath().resolve(&dir).ok_or_else(|| FileError::NotFound(id.vpath().as_rootless_path().into()))?;
let data = std::fs::read(&path).map_err(|_| FileError::NotFound(id.vpath().as_rootless_path().into()))?;
Ok(Bytes::new(data))
} else if let Some(data) = self.files.get(&id.vpath().as_rootless_path().to_string_lossy().to_string().replace("\\", "/")) {
root.load(id.vpath())
} else if let Some(data) = self.files.get(
&id.vpath()
.get_without_slash()
.to_string()
.replace("\\", "/"),
) {
Ok(Bytes::new(data.clone()))
} else {
Err(FileError::NotFound(
id.vpath().as_rootless_path().into(),
))
Err(FileError::NotFound(id.vpath().get_without_slash().into()))
}
}
@@ -127,11 +130,12 @@ impl World for MemoryWorld {
self.fonts.get(index).cloned()
}
fn today(&self, offset: Option<i64>) -> Option<Datetime> {
fn today(&self, offset: Option<Duration>) -> Option<Datetime> {
let now = chrono::Local::now();
let date = if let Some(offset) = offset {
let offset = chrono::FixedOffset::east_opt(offset as i32)?;
now.with_timezone(&offset).date_naive()
let offset_secs = offset.hours() as i32 * 3600;
let offset_chrono = chrono::FixedOffset::east_opt(offset_secs)?;
now.with_timezone(&offset_chrono).date_naive()
} else {
now.date_naive()
};