feat: add svg and png export (#101)

This commit is contained in:
Myriad-Dreamin 2024-03-26 11:25:46 +08:00 committed by GitHub
parent e649ad308f
commit 413c2e8deb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 298 additions and 136 deletions

View file

@ -43,6 +43,8 @@ codespan-reporting = "0.11"
typst = "0.11.0" typst = "0.11.0"
typst-ide = "0.11.0" typst-ide = "0.11.0"
typst-pdf = "0.11.0" typst-pdf = "0.11.0"
typst-svg = "0.11.0"
typst-render = "0.11.0"
typst-assets = "0.11.0" typst-assets = "0.11.0"
reflexo = { version = "0.5.0-rc2", default-features = false, features = [ reflexo = { version = "0.5.0-rc2", default-features = false, features = [
"flat-vector", "flat-vector",
@ -107,6 +109,8 @@ undocumented_unsafe_blocks = "warn"
typst = { git = "https://github.com/Myriad-Dreamin/typst.git", branch = "tinymist-v0.11.0" } typst = { git = "https://github.com/Myriad-Dreamin/typst.git", branch = "tinymist-v0.11.0" }
typst-ide = { git = "https://github.com/Myriad-Dreamin/typst.git", branch = "tinymist-v0.11.0" } typst-ide = { git = "https://github.com/Myriad-Dreamin/typst.git", branch = "tinymist-v0.11.0" }
typst-svg = { git = "https://github.com/Myriad-Dreamin/typst.git", branch = "tinymist-v0.11.0" }
typst-render = { git = "https://github.com/Myriad-Dreamin/typst.git", branch = "tinymist-v0.11.0" }
typst-pdf = { git = "https://github.com/Myriad-Dreamin/typst.git", branch = "tinymist-v0.11.0" } typst-pdf = { git = "https://github.com/Myriad-Dreamin/typst.git", branch = "tinymist-v0.11.0" }
# typst-syntax = { git = "https://github.com/Myriad-Dreamin/typst.git", branch = "tinymist-v0.11.0" } # typst-syntax = { git = "https://github.com/Myriad-Dreamin/typst.git", branch = "tinymist-v0.11.0" }

View file

@ -109,12 +109,40 @@ pub trait StatefulRequest {
#[allow(missing_docs)] #[allow(missing_docs)]
mod polymorphic { mod polymorphic {
use serde::{Deserialize, Serialize};
use super::prelude::*; use super::prelude::*;
use super::*; use super::*;
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum PageSelection {
#[serde(rename = "first")]
First,
#[serde(rename = "merged")]
Merged,
}
#[derive(Debug, Clone)]
pub enum ExportKind {
Pdf,
Svg { page: PageSelection },
Png { page: PageSelection },
}
impl ExportKind {
pub fn extension(&self) -> &str {
match self {
Self::Pdf => "pdf",
Self::Svg { .. } => "svg",
Self::Png { .. } => "png",
}
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct OnExportRequest { pub struct OnExportRequest {
pub path: PathBuf, pub path: PathBuf,
pub kind: ExportKind,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]

View file

@ -36,7 +36,9 @@ clap_complete_fig.workspace = true
clap_mangen.workspace = true clap_mangen.workspace = true
typst.workspace = true typst.workspace = true
typst-svg.workspace = true
typst-pdf.workspace = true typst-pdf.workspace = true
typst-render.workspace = true
typst-assets = { workspace = true, features = ["fonts"] } typst-assets = { workspace = true, features = ["fonts"] }
typst-ts-core = { workspace = true, default-features = false, features = [ typst-ts-core = { workspace = true, default-features = false, features = [

View file

@ -14,7 +14,7 @@ use typst_ts_compiler::{
use typst_ts_core::config::compiler::EntryState; use typst_ts_core::config::compiler::EntryState;
use self::{ use self::{
render::{PdfExportActor, PdfExportConfig}, render::{ExportActor, ExportConfig},
typ_client::{CompileClientActor, CompileDriver, CompileHandler}, typ_client::{CompileClientActor, CompileDriver, CompileHandler},
typ_server::CompileServerActor, typ_server::CompileServerActor,
}; };
@ -30,12 +30,12 @@ impl TypstLanguageServer {
let (doc_tx, doc_rx) = watch::channel(None); let (doc_tx, doc_rx) = watch::channel(None);
let (render_tx, _) = broadcast::channel(10); let (render_tx, _) = broadcast::channel(10);
// Run the PDF export actor before preparing cluster to avoid loss of events // Run the Export actor before preparing cluster to avoid loss of events
tokio::spawn( tokio::spawn(
PdfExportActor::new( ExportActor::new(
doc_rx.clone(), doc_rx.clone(),
render_tx.subscribe(), render_tx.subscribe(),
PdfExportConfig { ExportConfig {
substitute_pattern: self.config.output_path.clone(), substitute_pattern: self.config.output_path.clone(),
entry: entry.clone(), entry: entry.clone(),
mode: self.config.export_pdf, mode: self.config.export_pdf,

View file

@ -7,52 +7,61 @@ use std::{
use anyhow::Context; use anyhow::Context;
use log::{error, info}; use log::{error, info};
use once_cell::sync::Lazy;
use parking_lot::Mutex; use parking_lot::Mutex;
use tinymist_query::{ExportKind, PageSelection};
use tokio::sync::{ use tokio::sync::{
broadcast::{self, error::RecvError}, broadcast::{self, error::RecvError},
oneshot, watch, oneshot, watch,
}; };
use typst::foundations::Smart; use typst::{foundations::Smart, layout::Frame};
use typst_ts_core::{config::compiler::EntryState, path::PathClean, ImmutPath, TypstDocument}; use typst_ts_core::{config::compiler::EntryState, path::PathClean, ImmutPath, TypstDocument};
use crate::ExportPdfMode; use crate::ExportMode;
#[derive(Debug, Clone)]
pub struct OneshotRendering {
pub kind: Option<ExportKind>,
// todo: bad arch...
pub callback: Arc<Mutex<Option<oneshot::Sender<Option<PathBuf>>>>>,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum RenderActorRequest { pub enum RenderActorRequest {
OnTyped, OnTyped,
// todo: bad arch... Oneshot(OneshotRendering),
DoExport(Arc<Mutex<Option<oneshot::Sender<Option<PathBuf>>>>>),
OnSaved(PathBuf), OnSaved(PathBuf),
ChangeExportPath(PdfPathVars), ChangeExportPath(PathVars),
ChangeConfig(PdfExportConfig), ChangeConfig(ExportConfig),
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PdfPathVars { pub struct PathVars {
pub entry: EntryState, pub entry: EntryState,
} }
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct PdfExportConfig { pub struct ExportConfig {
pub substitute_pattern: String, pub substitute_pattern: String,
pub entry: EntryState, pub entry: EntryState,
pub mode: ExportPdfMode, pub mode: ExportMode,
} }
pub struct PdfExportActor { pub struct ExportActor {
render_rx: broadcast::Receiver<RenderActorRequest>, render_rx: broadcast::Receiver<RenderActorRequest>,
document: watch::Receiver<Option<Arc<TypstDocument>>>, document: watch::Receiver<Option<Arc<TypstDocument>>>,
pub substitute_pattern: String, pub substitute_pattern: String,
pub entry: EntryState, pub entry: EntryState,
pub mode: ExportPdfMode, pub mode: ExportMode,
pub kind: ExportKind,
} }
impl PdfExportActor { impl ExportActor {
pub fn new( pub fn new(
document: watch::Receiver<Option<Arc<TypstDocument>>>, document: watch::Receiver<Option<Arc<TypstDocument>>>,
render_rx: broadcast::Receiver<RenderActorRequest>, render_rx: broadcast::Receiver<RenderActorRequest>,
config: PdfExportConfig, config: ExportConfig,
) -> Self { ) -> Self {
Self { Self {
render_rx, render_rx,
@ -60,27 +69,29 @@ impl PdfExportActor {
substitute_pattern: config.substitute_pattern, substitute_pattern: config.substitute_pattern,
entry: config.entry, entry: config.entry,
mode: config.mode, mode: config.mode,
kind: ExportKind::Pdf,
} }
} }
pub async fn run(mut self) { pub async fn run(mut self) {
let kind = &self.kind;
loop { loop {
tokio::select! { tokio::select! {
req = self.render_rx.recv() => { req = self.render_rx.recv() => {
let req = match req { let req = match req {
Ok(req) => req, Ok(req) => req,
Err(RecvError::Closed) => { Err(RecvError::Closed) => {
info!("render actor channel closed"); info!("RenderActor(@{kind:?}): channel closed");
break; break;
} }
Err(RecvError::Lagged(_)) => { Err(RecvError::Lagged(_)) => {
info!("render actor channel lagged"); info!("RenderActor(@{kind:?}): channel lagged");
continue; continue;
} }
}; };
info!("PdfRenderActor: received request: {req:?}", req = req); info!("RenderActor: received request: {req:?}", req = req);
match req { match req {
RenderActorRequest::ChangeConfig(cfg) => { RenderActorRequest::ChangeConfig(cfg) => {
self.substitute_pattern = cfg.substitute_pattern; self.substitute_pattern = cfg.substitute_pattern;
@ -91,18 +102,18 @@ impl PdfExportActor {
self.entry = cfg.entry; self.entry = cfg.entry;
} }
_ => { _ => {
let sender = match &req { let cb = match &req {
RenderActorRequest::DoExport(sender) => Some(sender.clone()), RenderActorRequest::Oneshot(oneshot) => Some(oneshot.callback.clone()),
_ => None, _ => None,
}; };
let resp = self.check_mode_and_export(req).await; let resp = self.check_mode_and_export(req).await;
if let Some(sender) = sender { if let Some(cb) = cb {
let Some(sender) = sender.lock().take() else { let Some(cb) = cb.lock().take() else {
error!("PdfRenderActor: sender is None"); error!("RenderActor(@{kind:?}): oneshot.callback is None");
continue; continue;
}; };
if let Err(e) = sender.send(resp) { if let Err(e) = cb.send(resp) {
error!("PdfRenderActor: failed to send response: {err:?}", err = e); error!("RenderActor(@{kind:?}): failed to send response: {err:?}", err = e);
} }
} }
} }
@ -110,28 +121,35 @@ impl PdfExportActor {
} }
} }
} }
info!("PdfRenderActor: stopped"); info!("RenderActor(@{kind:?}): stopped");
} }
async fn check_mode_and_export(&self, req: RenderActorRequest) -> Option<PathBuf> { async fn check_mode_and_export(&self, req: RenderActorRequest) -> Option<PathBuf> {
let Some(document) = self.document.borrow().clone() else { let Some(document) = self.document.borrow().clone() else {
info!("PdfRenderActor: document is not ready"); info!("RenderActor: document is not ready");
return None; return None;
}; };
let eq_mode = match req { let eq_mode = match req {
RenderActorRequest::OnTyped => ExportPdfMode::OnType, RenderActorRequest::OnTyped => ExportMode::OnType,
RenderActorRequest::DoExport(..) => ExportPdfMode::OnSave, RenderActorRequest::Oneshot(..) => ExportMode::OnSave,
RenderActorRequest::OnSaved(..) => ExportPdfMode::OnSave, RenderActorRequest::OnSaved(..) => ExportMode::OnSave,
_ => unreachable!(), _ => unreachable!(),
}; };
let kind = if let RenderActorRequest::Oneshot(oneshot) = &req {
oneshot.kind.as_ref()
} else {
None
};
let kind = kind.unwrap_or(&self.kind);
// pub entry: EntryState, // pub entry: EntryState,
let root = self.entry.root(); let root = self.entry.root();
let main = self.entry.main(); let main = self.entry.main();
info!( info!(
"PdfRenderActor: check path {:?} and root {:?} with output directory {}", "RenderActor: check path {:?} and root {:?} with output directory {}",
main, root, self.substitute_pattern main, root, self.substitute_pattern
); );
@ -145,78 +163,131 @@ impl PdfExportActor {
let path = main.vpath().resolve(&root)?; let path = main.vpath().resolve(&root)?;
let should_do = matches!(req, RenderActorRequest::DoExport(..)); let should_do = matches!(req, RenderActorRequest::Oneshot(..));
let should_do = should_do || get_mode(self.mode) == eq_mode; let should_do = should_do || get_mode(self.mode) == eq_mode;
let should_do = should_do || validate_document(&req, self.mode, &document); let should_do = should_do
|| 'validate_doc: {
let mode = self.mode;
info!(
"RenderActor: validating document for export mode {mode:?} title is {title}",
title = document.title.is_some()
);
if mode == ExportMode::OnDocumentHasTitle {
break 'validate_doc document.title.is_some()
&& matches!(req, RenderActorRequest::OnSaved(..));
}
false
};
if should_do { if should_do {
return match self.export_pdf(&document, &root, &path).await { return match self.export(kind, &document, &root, &path).await {
Ok(pdf) => Some(pdf), Ok(pdf) => Some(pdf),
Err(err) => { Err(err) => {
error!("PdfRenderActor: failed to export PDF: {err}", err = err); error!("RenderActor({kind:?}): failed to export {err}", err = err);
None None
} }
}; };
} }
fn get_mode(mode: ExportPdfMode) -> ExportPdfMode { fn get_mode(mode: ExportMode) -> ExportMode {
if mode == ExportPdfMode::Auto { if mode == ExportMode::Auto {
return ExportPdfMode::Never; return ExportMode::Never;
} }
mode mode
} }
fn validate_document(
req: &RenderActorRequest,
mode: ExportPdfMode,
document: &TypstDocument,
) -> bool {
info!(
"PdfRenderActor: validating document for export mode {mode:?} title is {title}",
title = document.title.is_some()
);
if mode == ExportPdfMode::OnDocumentHasTitle {
return document.title.is_some() && matches!(req, RenderActorRequest::OnSaved(..));
}
false
}
None None
} }
async fn export_pdf( async fn export(
&self, &self,
kind: &ExportKind,
doc: &TypstDocument, doc: &TypstDocument,
root: &Path, root: &Path,
path: &Path, path: &Path,
) -> anyhow::Result<PathBuf> { ) -> anyhow::Result<PathBuf> {
let Some(to) = substitute_path(&self.substitute_pattern, root, path) else { let Some(to) = substitute_path(&self.substitute_pattern, root, path) else {
return Err(anyhow::anyhow!("failed to substitute path")); return Err(anyhow::anyhow!(
"RenderActor({kind:?}): failed to substitute path"
));
}; };
if to.is_relative() { if to.is_relative() {
return Err(anyhow::anyhow!("path is relative: {to:?}")); return Err(anyhow::anyhow!(
"RenderActor({kind:?}): path is relative: {to:?}"
));
} }
if to.is_dir() { if to.is_dir() {
return Err(anyhow::anyhow!("path is a directory: {to:?}")); return Err(anyhow::anyhow!(
"RenderActor({kind:?}): path is a directory: {to:?}"
));
} }
let to = to.with_extension("pdf"); let to = to.with_extension(kind.extension());
info!("exporting PDF {path:?} to {to:?}"); info!("RenderActor({kind:?}): exporting {path:?} to {to:?}");
if let Some(e) = to.parent() { if let Some(e) = to.parent() {
if !e.exists() { if !e.exists() {
std::fs::create_dir_all(e).context("failed to create directory")?; std::fs::create_dir_all(e).with_context(|| {
format!("RenderActor({kind:?}): failed to create directory")
})?;
} }
} }
// todo: Some(pdf_uri.as_str()) static DEFAULT_FRAME: Lazy<Frame> = Lazy::new(Frame::default);
// todo: timestamp world.now() let data = match kind {
let data = typst_pdf::pdf(doc, Smart::Auto, None); ExportKind::Pdf => {
// todo: Some(pdf_uri.as_str())
// todo: timestamp world.now()
typst_pdf::pdf(doc, Smart::Auto, None)
}
ExportKind::Svg {
page: PageSelection::First,
} => typst_svg::svg(
doc.pages
.first()
.map(|f| &f.frame)
.unwrap_or(&*DEFAULT_FRAME),
)
.into_bytes(),
ExportKind::Svg {
page: PageSelection::Merged,
} => typst_svg::svg_merged(doc, typst::layout::Abs::zero()).into_bytes(),
ExportKind::Png {
page: PageSelection::First,
} => {
let pixmap = typst_render::render(
doc.pages
.first()
.map(|f| &f.frame)
.unwrap_or(&*DEFAULT_FRAME),
3.,
typst::visualize::Color::WHITE,
);
pixmap
.encode_png()
.map_err(|err| anyhow::anyhow!("failed to encode PNG ({err})"))?
}
ExportKind::Png {
page: PageSelection::Merged,
} => {
let pixmap = typst_render::render_merged(
doc,
3.,
typst::visualize::Color::WHITE,
typst::layout::Abs::zero(),
typst::visualize::Color::WHITE,
);
pixmap
.encode_png()
.map_err(|err| anyhow::anyhow!("failed to encode PNG ({err})"))?
}
};
std::fs::write(&to, data).context("failed to export PDF")?; std::fs::write(&to, data)
.with_context(|| format!("RenderActor({kind:?}): failed to export"))?;
info!("PDF export complete"); info!("RenderActor({kind:?}): export complete");
Ok(to) Ok(to)
} }
} }

View file

@ -35,7 +35,7 @@ use log::{error, info, trace};
use parking_lot::Mutex; use parking_lot::Mutex;
use tinymist_query::{ use tinymist_query::{
analysis::{Analysis, AnalysisContext, AnaylsisResources}, analysis::{Analysis, AnalysisContext, AnaylsisResources},
CompilerQueryRequest, CompilerQueryResponse, DiagnosticsMap, FoldRequestFeature, CompilerQueryRequest, CompilerQueryResponse, DiagnosticsMap, ExportKind, FoldRequestFeature,
OnExportRequest, OnSaveExportRequest, PositionEncoding, SemanticRequest, StatefulRequest, OnExportRequest, OnSaveExportRequest, PositionEncoding, SemanticRequest, StatefulRequest,
VersionedDocument, VersionedDocument,
}; };
@ -57,9 +57,9 @@ use typst_ts_core::{
}; };
use super::typ_server::CompileClient as TsCompileClient; use super::typ_server::CompileClient as TsCompileClient;
use super::{render::PdfExportConfig, typ_server::CompileServerActor}; use super::{render::ExportConfig, typ_server::CompileServerActor};
use crate::{ use crate::{
actor::render::{PdfPathVars, RenderActorRequest}, actor::render::{OneshotRendering, PathVars, RenderActorRequest},
utils, utils,
}; };
use crate::{ use crate::{
@ -317,7 +317,7 @@ impl CompileClientActor {
); );
self.render_tx self.render_tx
.send(RenderActorRequest::ChangeExportPath(PdfPathVars { .send(RenderActorRequest::ChangeExportPath(PathVars {
entry: next.clone(), entry: next.clone(),
})) }))
.unwrap(); .unwrap();
@ -345,7 +345,7 @@ impl CompileClientActor {
if res.is_err() { if res.is_err() {
self.render_tx self.render_tx
.send(RenderActorRequest::ChangeExportPath(PdfPathVars { .send(RenderActorRequest::ChangeExportPath(PathVars {
entry: prev.clone(), entry: prev.clone(),
})) }))
.unwrap(); .unwrap();
@ -371,11 +371,11 @@ impl CompileClientActor {
self.inner.wait().add_memory_changes(event); self.inner.wait().add_memory_changes(event);
} }
pub(crate) fn change_export_pdf(&self, config: PdfExportConfig) { pub(crate) fn change_export_pdf(&self, config: ExportConfig) {
let entry = self.entry.lock().clone(); let entry = self.entry.lock().clone();
let _ = self let _ = self
.render_tx .render_tx
.send(RenderActorRequest::ChangeConfig(PdfExportConfig { .send(RenderActorRequest::ChangeConfig(ExportConfig {
substitute_pattern: config.substitute_pattern, substitute_pattern: config.substitute_pattern,
// root: self.root.get().cloned().flatten(), // root: self.root.get().cloned().flatten(),
entry, entry,
@ -405,8 +405,8 @@ impl CompileClientActor {
} }
match query { match query {
CompilerQueryRequest::OnExport(OnExportRequest { path }) => { CompilerQueryRequest::OnExport(OnExportRequest { kind, path }) => {
Ok(CompilerQueryResponse::OnExport(self.on_export(path)?)) Ok(CompilerQueryResponse::OnExport(self.on_export(kind, path)?))
} }
CompilerQueryRequest::OnSaveExport(OnSaveExportRequest { path }) => { CompilerQueryRequest::OnSaveExport(OnSaveExportRequest { path }) => {
self.on_save_export(path)?; self.on_save_export(path)?;
@ -431,15 +431,18 @@ impl CompileClientActor {
} }
} }
fn on_export(&self, path: PathBuf) -> anyhow::Result<Option<PathBuf>> { fn on_export(&self, kind: ExportKind, path: PathBuf) -> anyhow::Result<Option<PathBuf>> {
// todo: we currently doesn't respect the path argument...
info!("CompileActor: on export: {}", path.display()); info!("CompileActor: on export: {}", path.display());
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
let task = Arc::new(Mutex::new(Some(tx))); let callback = Arc::new(Mutex::new(Some(tx)));
self.render_tx self.render_tx
.send(RenderActorRequest::DoExport(task)) .send(RenderActorRequest::Oneshot(OneshotRendering {
kind: Some(kind),
callback,
}))
.map_err(map_string_err("failed to send to sync_render"))?; .map_err(map_string_err("failed to send to sync_render"))?;
let res: Option<PathBuf> = utils::threaded_receive(rx)?; let res: Option<PathBuf> = utils::threaded_receive(rx)?;

View file

@ -40,20 +40,19 @@ pub enum ExperimentalFormatterMode {
Enable, Enable,
} }
/// The mode of PDF export. /// The mode of PDF/SVG/PNG export.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub enum ExportPdfMode { pub enum ExportMode {
#[default] #[default]
Auto, Auto,
/// Select best solution automatically. (Recommended) /// Select best solution automatically. (Recommended)
Never, Never,
/// Export PDF on saving the document, i.e. on `textDocument/didSave` /// Export on saving the document, i.e. on `textDocument/didSave` events.
/// events.
OnSave, OnSave,
/// Export PDF on typing, i.e. on `textDocument/didChange` events. /// Export on typing, i.e. on `textDocument/didChange` events.
OnType, OnType,
/// Export PDFs when a document has a title, which is useful to filter out /// Export when a document has a title, which is useful to filter out
/// template files. /// template files.
OnDocumentHasTitle, OnDocumentHasTitle,
} }
@ -101,7 +100,7 @@ pub struct Config {
/// The output directory for PDF export. /// The output directory for PDF export.
pub output_path: String, pub output_path: String,
/// The mode of PDF export. /// The mode of PDF export.
pub export_pdf: ExportPdfMode, pub export_pdf: ExportMode,
/// Specifies the root path of the project manually. /// Specifies the root path of the project manually.
pub root_path: Option<PathBuf>, pub root_path: Option<PathBuf>,
/// Dynamic configuration for semantic tokens. /// Dynamic configuration for semantic tokens.
@ -207,12 +206,12 @@ impl Config {
let export_pdf = update let export_pdf = update
.get("exportPdf") .get("exportPdf")
.map(ExportPdfMode::deserialize) .map(ExportMode::deserialize)
.and_then(Result::ok); .and_then(Result::ok);
if let Some(export_pdf) = export_pdf { if let Some(export_pdf) = export_pdf {
self.export_pdf = export_pdf; self.export_pdf = export_pdf;
} else { } else {
self.export_pdf = ExportPdfMode::default(); self.export_pdf = ExportMode::default();
} }
let root_path = update.get("rootPath"); let root_path = update.get("rootPath");
@ -683,7 +682,7 @@ mod tests {
config.update(&update).unwrap(); config.update(&update).unwrap();
assert_eq!(config.output_path, "out"); assert_eq!(config.output_path, "out");
assert_eq!(config.export_pdf, ExportPdfMode::OnSave); assert_eq!(config.export_pdf, ExportMode::OnSave);
assert_eq!(config.root_path, Some(PathBuf::from(root_path))); assert_eq!(config.root_path, Some(PathBuf::from(root_path)));
assert_eq!(config.semantic_tokens, SemanticTokensMode::Enable); assert_eq!(config.semantic_tokens, SemanticTokensMode::Enable);
assert_eq!(config.formatter, ExperimentalFormatterMode::Enable); assert_eq!(config.formatter, ExperimentalFormatterMode::Enable);

View file

@ -57,12 +57,13 @@ use lsp_types::request::{
use lsp_types::*; use lsp_types::*;
use parking_lot::{Mutex, RwLock}; use parking_lot::{Mutex, RwLock};
use paste::paste; use paste::paste;
use serde::Serialize; use serde::{Deserialize, Serialize};
use serde_json::{Map, Value as JsonValue}; use serde_json::{Map, Value as JsonValue};
use state::MemoryFileMeta; use state::MemoryFileMeta;
use tinymist_query::{ use tinymist_query::{
get_semantic_tokens_options, get_semantic_tokens_registration, get_semantic_tokens_options, get_semantic_tokens_registration,
get_semantic_tokens_unregistration, DiagnosticsMap, SemanticTokenContext, get_semantic_tokens_unregistration, DiagnosticsMap, ExportKind, PageSelection,
SemanticTokenContext,
}; };
use tokio::sync::mpsc; use tokio::sync::mpsc;
use typst::diag::StrResult; use typst::diag::StrResult;
@ -75,7 +76,7 @@ pub type MaySyncResult<'a> = Result<JsonValue, BoxFuture<'a, JsonValue>>;
use world::SharedFontResolver; use world::SharedFontResolver;
pub use world::{CompileFontOpts, CompileOnceOpts, CompileOpts}; pub use world::{CompileFontOpts, CompileOnceOpts, CompileOpts};
use crate::actor::render::PdfExportConfig; use crate::actor::render::ExportConfig;
use crate::init::*; use crate::init::*;
use crate::tools::package::InitTask; use crate::tools::package::InitTask;
@ -665,6 +666,8 @@ impl TypstLanguageServer {
ExecuteCmdMap::from_iter([ ExecuteCmdMap::from_iter([
redirected_command!("tinymist.exportPdf", Self::export_pdf), redirected_command!("tinymist.exportPdf", Self::export_pdf),
redirected_command!("tinymist.exportSvg", Self::export_svg),
redirected_command!("tinymist.exportPng", Self::export_png),
redirected_command!("tinymist.doClearCache", Self::clear_cache), redirected_command!("tinymist.doClearCache", Self::clear_cache),
redirected_command!("tinymist.pinMain", Self::pin_document), redirected_command!("tinymist.pinMain", Self::pin_document),
redirected_command!("tinymist.focusMain", Self::focus_document), redirected_command!("tinymist.focusMain", Self::focus_document),
@ -688,25 +691,29 @@ impl TypstLanguageServer {
Ok(Some(handler(self, arguments)?)) Ok(Some(handler(self, arguments)?))
} }
/// Export the current document as a PDF file. The client is responsible for /// Export the current document as a PDF file.
/// passing the correct file URI.
///
/// # Errors
/// Errors if a provided file URI is not a valid file URI.
pub fn export_pdf(&self, arguments: Vec<JsonValue>) -> LspResult<JsonValue> { pub fn export_pdf(&self, arguments: Vec<JsonValue>) -> LspResult<JsonValue> {
if arguments.is_empty() { self.export(ExportKind::Pdf, arguments)
return Err(invalid_params("Missing file URI argument")); }
}
let Some(file_uri) = arguments.first().and_then(|v| v.as_str()) else {
return Err(invalid_params("Missing file URI as first argument"));
};
let file_uri =
Url::parse(file_uri).map_err(|_| invalid_params("Parameter is not a valid URI"))?;
let path = file_uri
.to_file_path()
.map_err(|_| invalid_params("URI is not a file URI"))?;
let res = run_query!(self.OnExport(path))?; /// Export the current document as a Svg file.
pub fn export_svg(&self, arguments: Vec<JsonValue>) -> LspResult<JsonValue> {
let opts = parse_opts(arguments.get(1))?;
self.export(ExportKind::Svg { page: opts.page }, arguments)
}
/// Export the current document as a Png file.
pub fn export_png(&self, arguments: Vec<JsonValue>) -> LspResult<JsonValue> {
let opts = parse_opts(arguments.get(1))?;
self.export(ExportKind::Png { page: opts.page }, arguments)
}
/// Export the current document as some format. The client is responsible
/// for passing the correct absolute path of typst document.
pub fn export(&self, kind: ExportKind, arguments: Vec<JsonValue>) -> LspResult<JsonValue> {
let path = parse_path(arguments.first())?.as_ref().to_owned();
let res = run_query!(self.OnExport(path, kind))?;
let res = serde_json::to_value(res).map_err(|_| internal_error("Cannot serialize path"))?; let res = serde_json::to_value(res).map_err(|_| internal_error("Cannot serialize path"))?;
Ok(res) Ok(res)
@ -841,7 +848,22 @@ impl TypstLanguageServer {
} }
} }
fn parse_path_or_null(v: Option<&JsonValue>) -> LspResult<Option<ImmutPath>> { #[derive(Debug, Clone, Serialize, Deserialize)]
struct ExportOpts {
page: PageSelection,
}
fn parse_opts(v: Option<&JsonValue>) -> LspResult<ExportOpts> {
Ok(match v {
Some(opts) => serde_json::from_value::<ExportOpts>(opts.clone())
.map_err(|_| invalid_params("The third argument is not a valid object"))?,
_ => ExportOpts {
page: PageSelection::First,
},
})
}
fn parse_path(v: Option<&JsonValue>) -> LspResult<ImmutPath> {
let new_entry = match v { let new_entry = match v {
Some(JsonValue::String(s)) => { Some(JsonValue::String(s)) => {
let s = Path::new(s); let s = Path::new(s);
@ -849,9 +871,8 @@ fn parse_path_or_null(v: Option<&JsonValue>) -> LspResult<Option<ImmutPath>> {
return Err(invalid_params("entry should be absolute")); return Err(invalid_params("entry should be absolute"));
} }
Some(s.into()) s.into()
} }
Some(JsonValue::Null) => None,
_ => { _ => {
return Err(invalid_params( return Err(invalid_params(
"The first parameter is not a valid path or null", "The first parameter is not a valid path or null",
@ -862,6 +883,13 @@ fn parse_path_or_null(v: Option<&JsonValue>) -> LspResult<Option<ImmutPath>> {
Ok(new_entry) Ok(new_entry)
} }
fn parse_path_or_null(v: Option<&JsonValue>) -> LspResult<Option<ImmutPath>> {
match v {
Some(JsonValue::Null) => Ok(None),
v => Ok(Some(parse_path(v)?)),
}
}
/// Document Synchronization /// Document Synchronization
impl TypstLanguageServer { impl TypstLanguageServer {
fn did_open(&mut self, params: DidOpenTextDocumentParams) -> LspResult<()> { fn did_open(&mut self, params: DidOpenTextDocumentParams) -> LspResult<()> {
@ -914,10 +942,10 @@ impl TypstLanguageServer {
if config.output_path != self.config.output_path if config.output_path != self.config.output_path
|| config.export_pdf != self.config.export_pdf || config.export_pdf != self.config.export_pdf
{ {
let config = PdfExportConfig { let config = ExportConfig {
substitute_pattern: self.config.output_path.clone(), substitute_pattern: self.config.output_path.clone(),
mode: self.config.export_pdf, mode: self.config.export_pdf,
..PdfExportConfig::default() ..ExportConfig::default()
}; };
self.primary().change_export_pdf(config.clone()); self.primary().change_export_pdf(config.clone());

View file

@ -99,7 +99,7 @@ async function startClient(context: ExtensionContext): Promise<void> {
}); });
context.subscriptions.push( context.subscriptions.push(
commands.registerCommand("tinymist.exportCurrentPdf", commandExportCurrentPdf) commands.registerCommand("tinymist.exportCurrentPdf", () => commandExport("Pdf"))
); );
context.subscriptions.push( context.subscriptions.push(
commands.registerCommand("typst-lsp.pinMainToCurrent", () => commandPinMain(true)) commands.registerCommand("typst-lsp.pinMainToCurrent", () => commandPinMain(true))
@ -107,7 +107,9 @@ async function startClient(context: ExtensionContext): Promise<void> {
context.subscriptions.push( context.subscriptions.push(
commands.registerCommand("typst-lsp.unpinMain", () => commandPinMain(false)) commands.registerCommand("typst-lsp.unpinMain", () => commandPinMain(false))
); );
context.subscriptions.push(commands.registerCommand("tinymist.showPdf", commandShowPdf)); context.subscriptions.push(
commands.registerCommand("tinymist.showPdf", () => commandShow("Pdf"))
);
context.subscriptions.push(commands.registerCommand("tinymist.clearCache", commandClearCache)); context.subscriptions.push(commands.registerCommand("tinymist.clearCache", commandClearCache));
context.subscriptions.push( context.subscriptions.push(
commands.registerCommand("tinymist.runCodeLens", commandRunCodeLens) commands.registerCommand("tinymist.runCodeLens", commandRunCodeLens)
@ -195,19 +197,18 @@ function validateServer(path: string): { valid: true } | { valid: false; message
} }
} }
async function commandExportCurrentPdf(): Promise<string | undefined> { async function commandExport(mode: string, extraOpts?: any): Promise<string | undefined> {
const activeEditor = window.activeTextEditor; const activeEditor = window.activeTextEditor;
if (activeEditor === undefined) { if (activeEditor === undefined) {
return; return;
} }
const uri = activeEditor.document.uri.toString(); const uri = activeEditor.document.uri.fsPath;
const res = await client?.sendRequest<string | null>("workspace/executeCommand", { const res = await client?.sendRequest<string | null>("workspace/executeCommand", {
command: "tinymist.exportPdf", command: `tinymist.export${mode}`,
arguments: [uri], arguments: [uri, ...(extraOpts ? [extraOpts] : [])],
}); });
console.log("export pdf", res);
if (res === null) { if (res === null) {
return undefined; return undefined;
} }
@ -218,25 +219,25 @@ async function commandExportCurrentPdf(): Promise<string | undefined> {
* Implements the functionality for the 'Show PDF' button shown in the editor title * Implements the functionality for the 'Show PDF' button shown in the editor title
* if a `.typ` file is opened. * if a `.typ` file is opened.
*/ */
async function commandShowPdf(): Promise<void> { async function commandShow(kind: string, extraOpts?: any): Promise<void> {
const activeEditor = window.activeTextEditor; const activeEditor = window.activeTextEditor;
if (activeEditor === undefined) { if (activeEditor === undefined) {
return; return;
} }
// only create pdf if it does not exist yet // only create pdf if it does not exist yet
const pdfPath = await commandExportCurrentPdf(); const exportPath = await commandExport(kind, extraOpts);
if (pdfPath === undefined) { if (exportPath === undefined) {
// show error message // show error message
await window.showErrorMessage("Failed to create PDF"); await window.showErrorMessage("Failed to create");
return; return;
} }
const pdfUri = Uri.file(pdfPath); const exportUri = Uri.file(exportPath);
// here we can be sure that the pdf exists // here we can be sure that the pdf exists
await commands.executeCommand("vscode.open", pdfUri, ViewColumn.Beside); await commands.executeCommand("vscode.open", exportUri, ViewColumn.Beside);
} }
async function commandClearCache(): Promise<void> { async function commandClearCache(): Promise<void> {
@ -443,17 +444,43 @@ async function commandRunCodeLens(...args: string[]): Promise<void> {
break; break;
} }
case "export-pdf": { case "export-pdf": {
await commandShowPdf(); await commandShow("Pdf");
break; break;
} }
case "export-as": { case "export-as": {
const fmt = await vscode.window.showQuickPick(["pdf"], { enum FastKind {
title: "Format to export as", PDF = "PDF",
}); SVG = "SVG (First Page)",
SVGMerged = "SVG (Merged)",
if (fmt === "pdf") { PNG = "PNG (First Page)",
await commandShowPdf(); PNGMerged = "PNG (Merged)",
} }
const fmt = await vscode.window.showQuickPick(
[FastKind.PDF, FastKind.SVG, FastKind.SVGMerged, FastKind.PNG, FastKind.PNGMerged],
{
title: "Format to export as",
}
);
switch (fmt) {
case FastKind.PDF:
await commandShow("Pdf");
break;
case FastKind.SVG:
await commandShow("Svg");
break;
case FastKind.SVGMerged:
await commandShow("Svg", { page: "merged" });
break;
case FastKind.PNG:
await commandShow("Png");
break;
case FastKind.PNGMerged:
await commandShow("Png", { page: "merged" });
break;
}
break; break;
} }
default: { default: {