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

@ -109,12 +109,40 @@ pub trait StatefulRequest {
#[allow(missing_docs)]
mod polymorphic {
use serde::{Deserialize, Serialize};
use super::prelude::*;
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)]
pub struct OnExportRequest {
pub path: PathBuf,
pub kind: ExportKind,
}
#[derive(Debug, Clone)]

View file

@ -36,7 +36,9 @@ clap_complete_fig.workspace = true
clap_mangen.workspace = true
typst.workspace = true
typst-svg.workspace = true
typst-pdf.workspace = true
typst-render.workspace = true
typst-assets = { workspace = true, features = ["fonts"] }
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 self::{
render::{PdfExportActor, PdfExportConfig},
render::{ExportActor, ExportConfig},
typ_client::{CompileClientActor, CompileDriver, CompileHandler},
typ_server::CompileServerActor,
};
@ -30,12 +30,12 @@ impl TypstLanguageServer {
let (doc_tx, doc_rx) = watch::channel(None);
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(
PdfExportActor::new(
ExportActor::new(
doc_rx.clone(),
render_tx.subscribe(),
PdfExportConfig {
ExportConfig {
substitute_pattern: self.config.output_path.clone(),
entry: entry.clone(),
mode: self.config.export_pdf,

View file

@ -7,52 +7,61 @@ use std::{
use anyhow::Context;
use log::{error, info};
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use tinymist_query::{ExportKind, PageSelection};
use tokio::sync::{
broadcast::{self, error::RecvError},
oneshot, watch,
};
use typst::foundations::Smart;
use typst::{foundations::Smart, layout::Frame};
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)]
pub enum RenderActorRequest {
OnTyped,
// todo: bad arch...
DoExport(Arc<Mutex<Option<oneshot::Sender<Option<PathBuf>>>>>),
Oneshot(OneshotRendering),
OnSaved(PathBuf),
ChangeExportPath(PdfPathVars),
ChangeConfig(PdfExportConfig),
ChangeExportPath(PathVars),
ChangeConfig(ExportConfig),
}
#[derive(Debug, Clone)]
pub struct PdfPathVars {
pub struct PathVars {
pub entry: EntryState,
}
#[derive(Debug, Clone, Default)]
pub struct PdfExportConfig {
pub struct ExportConfig {
pub substitute_pattern: String,
pub entry: EntryState,
pub mode: ExportPdfMode,
pub mode: ExportMode,
}
pub struct PdfExportActor {
pub struct ExportActor {
render_rx: broadcast::Receiver<RenderActorRequest>,
document: watch::Receiver<Option<Arc<TypstDocument>>>,
pub substitute_pattern: String,
pub entry: EntryState,
pub mode: ExportPdfMode,
pub mode: ExportMode,
pub kind: ExportKind,
}
impl PdfExportActor {
impl ExportActor {
pub fn new(
document: watch::Receiver<Option<Arc<TypstDocument>>>,
render_rx: broadcast::Receiver<RenderActorRequest>,
config: PdfExportConfig,
config: ExportConfig,
) -> Self {
Self {
render_rx,
@ -60,27 +69,29 @@ impl PdfExportActor {
substitute_pattern: config.substitute_pattern,
entry: config.entry,
mode: config.mode,
kind: ExportKind::Pdf,
}
}
pub async fn run(mut self) {
let kind = &self.kind;
loop {
tokio::select! {
req = self.render_rx.recv() => {
let req = match req {
Ok(req) => req,
Err(RecvError::Closed) => {
info!("render actor channel closed");
info!("RenderActor(@{kind:?}): channel closed");
break;
}
Err(RecvError::Lagged(_)) => {
info!("render actor channel lagged");
info!("RenderActor(@{kind:?}): channel lagged");
continue;
}
};
info!("PdfRenderActor: received request: {req:?}", req = req);
info!("RenderActor: received request: {req:?}", req = req);
match req {
RenderActorRequest::ChangeConfig(cfg) => {
self.substitute_pattern = cfg.substitute_pattern;
@ -91,18 +102,18 @@ impl PdfExportActor {
self.entry = cfg.entry;
}
_ => {
let sender = match &req {
RenderActorRequest::DoExport(sender) => Some(sender.clone()),
let cb = match &req {
RenderActorRequest::Oneshot(oneshot) => Some(oneshot.callback.clone()),
_ => None,
};
let resp = self.check_mode_and_export(req).await;
if let Some(sender) = sender {
let Some(sender) = sender.lock().take() else {
error!("PdfRenderActor: sender is None");
if let Some(cb) = cb {
let Some(cb) = cb.lock().take() else {
error!("RenderActor(@{kind:?}): oneshot.callback is None");
continue;
};
if let Err(e) = sender.send(resp) {
error!("PdfRenderActor: failed to send response: {err:?}", err = e);
if let Err(e) = cb.send(resp) {
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> {
let Some(document) = self.document.borrow().clone() else {
info!("PdfRenderActor: document is not ready");
info!("RenderActor: document is not ready");
return None;
};
let eq_mode = match req {
RenderActorRequest::OnTyped => ExportPdfMode::OnType,
RenderActorRequest::DoExport(..) => ExportPdfMode::OnSave,
RenderActorRequest::OnSaved(..) => ExportPdfMode::OnSave,
RenderActorRequest::OnTyped => ExportMode::OnType,
RenderActorRequest::Oneshot(..) => ExportMode::OnSave,
RenderActorRequest::OnSaved(..) => ExportMode::OnSave,
_ => 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,
let root = self.entry.root();
let main = self.entry.main();
info!(
"PdfRenderActor: check path {:?} and root {:?} with output directory {}",
"RenderActor: check path {:?} and root {:?} with output directory {}",
main, root, self.substitute_pattern
);
@ -145,78 +163,131 @@ impl PdfExportActor {
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 || 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 {
return match self.export_pdf(&document, &root, &path).await {
return match self.export(kind, &document, &root, &path).await {
Ok(pdf) => Some(pdf),
Err(err) => {
error!("PdfRenderActor: failed to export PDF: {err}", err = err);
error!("RenderActor({kind:?}): failed to export {err}", err = err);
None
}
};
}
fn get_mode(mode: ExportPdfMode) -> ExportPdfMode {
if mode == ExportPdfMode::Auto {
return ExportPdfMode::Never;
fn get_mode(mode: ExportMode) -> ExportMode {
if mode == ExportMode::Auto {
return ExportMode::Never;
}
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
}
async fn export_pdf(
async fn export(
&self,
kind: &ExportKind,
doc: &TypstDocument,
root: &Path,
path: &Path,
) -> anyhow::Result<PathBuf> {
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() {
return Err(anyhow::anyhow!("path is relative: {to:?}"));
return Err(anyhow::anyhow!(
"RenderActor({kind:?}): path is relative: {to:?}"
));
}
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");
info!("exporting PDF {path:?} to {to:?}");
let to = to.with_extension(kind.extension());
info!("RenderActor({kind:?}): exporting {path:?} to {to:?}");
if let Some(e) = to.parent() {
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())
// todo: timestamp world.now()
let data = typst_pdf::pdf(doc, Smart::Auto, None);
static DEFAULT_FRAME: Lazy<Frame> = Lazy::new(Frame::default);
let data = match kind {
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)
}
}

View file

@ -35,7 +35,7 @@ use log::{error, info, trace};
use parking_lot::Mutex;
use tinymist_query::{
analysis::{Analysis, AnalysisContext, AnaylsisResources},
CompilerQueryRequest, CompilerQueryResponse, DiagnosticsMap, FoldRequestFeature,
CompilerQueryRequest, CompilerQueryResponse, DiagnosticsMap, ExportKind, FoldRequestFeature,
OnExportRequest, OnSaveExportRequest, PositionEncoding, SemanticRequest, StatefulRequest,
VersionedDocument,
};
@ -57,9 +57,9 @@ use typst_ts_core::{
};
use super::typ_server::CompileClient as TsCompileClient;
use super::{render::PdfExportConfig, typ_server::CompileServerActor};
use super::{render::ExportConfig, typ_server::CompileServerActor};
use crate::{
actor::render::{PdfPathVars, RenderActorRequest},
actor::render::{OneshotRendering, PathVars, RenderActorRequest},
utils,
};
use crate::{
@ -317,7 +317,7 @@ impl CompileClientActor {
);
self.render_tx
.send(RenderActorRequest::ChangeExportPath(PdfPathVars {
.send(RenderActorRequest::ChangeExportPath(PathVars {
entry: next.clone(),
}))
.unwrap();
@ -345,7 +345,7 @@ impl CompileClientActor {
if res.is_err() {
self.render_tx
.send(RenderActorRequest::ChangeExportPath(PdfPathVars {
.send(RenderActorRequest::ChangeExportPath(PathVars {
entry: prev.clone(),
}))
.unwrap();
@ -371,11 +371,11 @@ impl CompileClientActor {
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 _ = self
.render_tx
.send(RenderActorRequest::ChangeConfig(PdfExportConfig {
.send(RenderActorRequest::ChangeConfig(ExportConfig {
substitute_pattern: config.substitute_pattern,
// root: self.root.get().cloned().flatten(),
entry,
@ -405,8 +405,8 @@ impl CompileClientActor {
}
match query {
CompilerQueryRequest::OnExport(OnExportRequest { path }) => {
Ok(CompilerQueryResponse::OnExport(self.on_export(path)?))
CompilerQueryRequest::OnExport(OnExportRequest { kind, path }) => {
Ok(CompilerQueryResponse::OnExport(self.on_export(kind, path)?))
}
CompilerQueryRequest::OnSaveExport(OnSaveExportRequest { 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());
let (tx, rx) = oneshot::channel();
let task = Arc::new(Mutex::new(Some(tx)));
let callback = Arc::new(Mutex::new(Some(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"))?;
let res: Option<PathBuf> = utils::threaded_receive(rx)?;

View file

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

View file

@ -57,12 +57,13 @@ use lsp_types::request::{
use lsp_types::*;
use parking_lot::{Mutex, RwLock};
use paste::paste;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value as JsonValue};
use state::MemoryFileMeta;
use tinymist_query::{
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 typst::diag::StrResult;
@ -75,7 +76,7 @@ pub type MaySyncResult<'a> = Result<JsonValue, BoxFuture<'a, JsonValue>>;
use world::SharedFontResolver;
pub use world::{CompileFontOpts, CompileOnceOpts, CompileOpts};
use crate::actor::render::PdfExportConfig;
use crate::actor::render::ExportConfig;
use crate::init::*;
use crate::tools::package::InitTask;
@ -665,6 +666,8 @@ impl TypstLanguageServer {
ExecuteCmdMap::from_iter([
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.pinMain", Self::pin_document),
redirected_command!("tinymist.focusMain", Self::focus_document),
@ -688,25 +691,29 @@ impl TypstLanguageServer {
Ok(Some(handler(self, arguments)?))
}
/// Export the current document as a PDF file. The client is responsible for
/// passing the correct file URI.
///
/// # Errors
/// Errors if a provided file URI is not a valid file URI.
/// Export the current document as a PDF file.
pub fn export_pdf(&self, arguments: Vec<JsonValue>) -> LspResult<JsonValue> {
if arguments.is_empty() {
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"))?;
self.export(ExportKind::Pdf, arguments)
}
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"))?;
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 {
Some(JsonValue::String(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"));
}
Some(s.into())
s.into()
}
Some(JsonValue::Null) => None,
_ => {
return Err(invalid_params(
"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)
}
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
impl TypstLanguageServer {
fn did_open(&mut self, params: DidOpenTextDocumentParams) -> LspResult<()> {
@ -914,10 +942,10 @@ impl TypstLanguageServer {
if config.output_path != self.config.output_path
|| config.export_pdf != self.config.export_pdf
{
let config = PdfExportConfig {
let config = ExportConfig {
substitute_pattern: self.config.output_path.clone(),
mode: self.config.export_pdf,
..PdfExportConfig::default()
..ExportConfig::default()
};
self.primary().change_export_pdf(config.clone());