mirror of
https://github.com/Myriad-Dreamin/tinymist.git
synced 2025-08-03 17:58:17 +00:00
feat: support vscode tasks for exporting pdf, svg, and png (#488)
* feat: support vscode tasks for exporting pdf, svg, and png * fix: parse errors * dev: update fill, gap arguments * fix: merged props
This commit is contained in:
parent
60f3200088
commit
140299f0ce
11 changed files with 609 additions and 50 deletions
|
@ -135,12 +135,14 @@ mod polymorphic {
|
|||
use super::prelude::*;
|
||||
use super::*;
|
||||
|
||||
#[derive(Default, Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PageSelection {
|
||||
#[default]
|
||||
First,
|
||||
Merged,
|
||||
Merged {
|
||||
gap: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
@ -152,6 +154,8 @@ mod polymorphic {
|
|||
page: PageSelection,
|
||||
},
|
||||
Png {
|
||||
ppi: Option<f64>,
|
||||
fill: Option<String>,
|
||||
page: PageSelection,
|
||||
},
|
||||
}
|
||||
|
|
|
@ -20,20 +20,28 @@ use crate::tool::package::InitTask;
|
|||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
struct ExportOpts {
|
||||
creation_timestamp: Option<String>,
|
||||
fill: Option<String>,
|
||||
ppi: Option<f64>,
|
||||
page: PageSelection,
|
||||
}
|
||||
|
||||
/// Here are implemented the handlers for each command.
|
||||
impl LanguageState {
|
||||
/// Export the current document as PDF file(s).
|
||||
pub fn export_pdf(&mut self, req_id: RequestId, args: Vec<JsonValue>) -> ScheduledResult {
|
||||
self.export(
|
||||
req_id,
|
||||
ExportKind::Pdf {
|
||||
creation_timestamp: self.config.compile.determine_creation_timestamp(),
|
||||
},
|
||||
args,
|
||||
)
|
||||
pub fn export_pdf(&mut self, req_id: RequestId, mut args: Vec<JsonValue>) -> ScheduledResult {
|
||||
let opts = get_arg_or_default!(args[1] as ExportOpts);
|
||||
|
||||
let creation_timestamp = if let Some(value) = opts.creation_timestamp {
|
||||
Some(
|
||||
parse_source_date_epoch(&value)
|
||||
.map_err(|e| invalid_params(format!("Cannot parse creation timestamp: {e}")))?,
|
||||
)
|
||||
} else {
|
||||
self.config.compile.determine_creation_timestamp()
|
||||
};
|
||||
|
||||
self.export(req_id, ExportKind::Pdf { creation_timestamp }, args)
|
||||
}
|
||||
|
||||
/// Export the current document as Svg file(s).
|
||||
|
@ -45,7 +53,15 @@ impl LanguageState {
|
|||
/// Export the current document as Png file(s).
|
||||
pub fn export_png(&mut self, req_id: RequestId, mut args: Vec<JsonValue>) -> ScheduledResult {
|
||||
let opts = get_arg_or_default!(args[1] as ExportOpts);
|
||||
self.export(req_id, ExportKind::Png { page: opts.page }, args)
|
||||
self.export(
|
||||
req_id,
|
||||
ExportKind::Png {
|
||||
fill: opts.fill,
|
||||
ppi: opts.ppi,
|
||||
page: opts.page,
|
||||
},
|
||||
args,
|
||||
)
|
||||
}
|
||||
|
||||
/// Export the current document as some format. The client is responsible
|
||||
|
|
|
@ -1,12 +1,18 @@
|
|||
//! The actor that handles various document export, like PDF and SVG export.
|
||||
|
||||
use std::str::FromStr;
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
use anyhow::{bail, Context};
|
||||
use once_cell::sync::Lazy;
|
||||
use tinymist_query::{ExportKind, PageSelection};
|
||||
use tokio::sync::mpsc;
|
||||
use typst::{foundations::Smart, layout::Abs, layout::Frame, visualize::Color};
|
||||
use typst::{
|
||||
foundations::Smart,
|
||||
layout::{Abs, Frame},
|
||||
syntax::{ast, SyntaxNode},
|
||||
visualize::Color,
|
||||
};
|
||||
use typst_ts_compiler::{EntryReader, EntryState, TaskInputs};
|
||||
use typst_ts_core::TypstDatetime;
|
||||
|
||||
|
@ -205,12 +211,52 @@ impl ExportConfig {
|
|||
typst_pdf::pdf(doc, Smart::Auto, timestamp)
|
||||
}
|
||||
Svg { page: First } => typst_svg::svg(first_frame()).into_bytes(),
|
||||
Svg { page: Merged } => typst_svg::svg_merged(doc, Abs::zero()).into_bytes(),
|
||||
Png { page: First } => typst_render::render(first_frame(), 3., Color::WHITE)
|
||||
.encode_png()
|
||||
.map_err(|err| anyhow::anyhow!("failed to encode PNG ({err})"))?,
|
||||
Png { page: Merged } => {
|
||||
typst_render::render_merged(doc, 3., Color::WHITE, Abs::zero(), Color::WHITE)
|
||||
Svg {
|
||||
page: Merged { .. },
|
||||
} => typst_svg::svg_merged(doc, Abs::zero()).into_bytes(),
|
||||
Png {
|
||||
ppi,
|
||||
fill,
|
||||
page: First,
|
||||
} => {
|
||||
let ppi = ppi.unwrap_or(144.) as f32;
|
||||
if ppi <= 1e-6 {
|
||||
bail!("invalid ppi: {ppi}");
|
||||
}
|
||||
|
||||
let fill = if let Some(fill) = fill {
|
||||
parse_color(fill).map_err(|err| anyhow::anyhow!("invalid fill ({err})"))?
|
||||
} else {
|
||||
Color::WHITE
|
||||
};
|
||||
|
||||
typst_render::render(first_frame(), ppi / 72., fill)
|
||||
.encode_png()
|
||||
.map_err(|err| anyhow::anyhow!("failed to encode PNG ({err})"))?
|
||||
}
|
||||
Png {
|
||||
ppi,
|
||||
fill,
|
||||
page: Merged { gap },
|
||||
} => {
|
||||
let ppi = ppi.unwrap_or(144.) as f32;
|
||||
if ppi <= 1e-6 {
|
||||
bail!("invalid ppi: {ppi}");
|
||||
}
|
||||
|
||||
let fill = if let Some(fill) = fill {
|
||||
parse_color(fill).map_err(|err| anyhow::anyhow!("invalid fill ({err})"))?
|
||||
} else {
|
||||
Color::WHITE
|
||||
};
|
||||
|
||||
let gap = if let Some(gap) = gap {
|
||||
parse_length(gap).map_err(|err| anyhow::anyhow!("invalid gap ({err})"))?
|
||||
} else {
|
||||
Abs::zero()
|
||||
};
|
||||
|
||||
typst_render::render_merged(doc, ppi / 72., fill, gap, fill)
|
||||
.encode_png()
|
||||
.map_err(|err| anyhow::anyhow!("failed to encode PNG ({err})"))?
|
||||
}
|
||||
|
@ -226,6 +272,53 @@ impl ExportConfig {
|
|||
}
|
||||
}
|
||||
|
||||
fn parse_color(fill: String) -> anyhow::Result<Color> {
|
||||
match fill.as_str() {
|
||||
"black" => Ok(Color::BLACK),
|
||||
"white" => Ok(Color::WHITE),
|
||||
"red" => Ok(Color::RED),
|
||||
"green" => Ok(Color::GREEN),
|
||||
"blue" => Ok(Color::BLUE),
|
||||
hex if hex.starts_with('#') => {
|
||||
Color::from_str(&hex[1..]).map_err(|e| anyhow::anyhow!("failed to parse color: {e}"))
|
||||
}
|
||||
_ => bail!("invalid color: {fill}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_length(gap: String) -> anyhow::Result<Abs> {
|
||||
let length = typst::syntax::parse_code(&gap);
|
||||
if length.erroneous() {
|
||||
bail!("invalid length: {gap}, errors: {:?}", length.errors());
|
||||
}
|
||||
|
||||
let length: Option<ast::Numeric> = descendants(&length).into_iter().find_map(SyntaxNode::cast);
|
||||
|
||||
let Some(length) = length else {
|
||||
bail!("not a length: {gap}");
|
||||
};
|
||||
|
||||
let (value, unit) = length.get();
|
||||
match unit {
|
||||
ast::Unit::Pt => Ok(Abs::pt(value)),
|
||||
ast::Unit::Mm => Ok(Abs::mm(value)),
|
||||
ast::Unit::Cm => Ok(Abs::cm(value)),
|
||||
ast::Unit::In => Ok(Abs::inches(value)),
|
||||
_ => bail!("invalid unit: {unit:?} in {gap}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Low performance but simple recursive iterator.
|
||||
fn descendants(node: &SyntaxNode) -> impl IntoIterator<Item = &SyntaxNode> + '_ {
|
||||
let mut res = vec![];
|
||||
for child in node.children() {
|
||||
res.push(child);
|
||||
res.extend(descendants(child));
|
||||
}
|
||||
|
||||
res
|
||||
}
|
||||
|
||||
fn log_err<T>(artifact: anyhow::Result<T>) -> Option<T> {
|
||||
match artifact {
|
||||
Ok(v) => Some(v),
|
||||
|
@ -259,4 +352,36 @@ mod tests {
|
|||
assert!(!conf.count_words);
|
||||
assert_eq!(conf.config.mode, ExportMode::Never);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_length() {
|
||||
assert_eq!(parse_length("1pt".to_owned()).unwrap(), Abs::pt(1.));
|
||||
assert_eq!(parse_length("1mm".to_owned()).unwrap(), Abs::mm(1.));
|
||||
assert_eq!(parse_length("1cm".to_owned()).unwrap(), Abs::cm(1.));
|
||||
assert_eq!(parse_length("1in".to_owned()).unwrap(), Abs::inches(1.));
|
||||
assert!(parse_length("1".to_owned()).is_err());
|
||||
assert!(parse_length("1px".to_owned()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_color() {
|
||||
assert_eq!(parse_color("black".to_owned()).unwrap(), Color::BLACK);
|
||||
assert_eq!(parse_color("white".to_owned()).unwrap(), Color::WHITE);
|
||||
assert_eq!(parse_color("red".to_owned()).unwrap(), Color::RED);
|
||||
assert_eq!(parse_color("green".to_owned()).unwrap(), Color::GREEN);
|
||||
assert_eq!(parse_color("blue".to_owned()).unwrap(), Color::BLUE);
|
||||
assert_eq!(
|
||||
parse_color("#000000".to_owned()).unwrap().to_hex(),
|
||||
"#000000"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_color("#ffffff".to_owned()).unwrap().to_hex(),
|
||||
"#ffffff"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_color("#000000cc".to_owned()).unwrap().to_hex(),
|
||||
"#000000cc"
|
||||
);
|
||||
assert!(parse_color("invalid".to_owned()).is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,13 +16,19 @@ impl<T> fmt::Debug for Derived<T> {
|
|||
macro_rules! get_arg {
|
||||
($args:ident[$idx:expr] as $ty:ty) => {{
|
||||
let arg = $args.get_mut($idx);
|
||||
let arg = arg.and_then(|x| from_value::<$ty>(x.take()).ok());
|
||||
match arg {
|
||||
let arg = match arg {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
let msg = concat!("expect ", stringify!($ty), "at args[", $idx, "]");
|
||||
let msg = concat!("expect ", stringify!($ty), " at args[", $idx, "]");
|
||||
return Err(invalid_params(msg));
|
||||
}
|
||||
};
|
||||
match from_value::<$ty>(arg.take()) {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
let msg = concat!("expect ", stringify!($ty), " at args[", $idx, "], error: ");
|
||||
return Err(invalid_params(format!("{}{}", msg, err)));
|
||||
}
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
|
|
@ -136,7 +136,7 @@ fn parse_input_pair(raw: &str) -> Result<(String, String), String> {
|
|||
}
|
||||
|
||||
/// Parses a UNIX timestamp according to <https://reproducible-builds.org/specs/source-date-epoch/>
|
||||
fn parse_source_date_epoch(raw: &str) -> Result<DateTime<Utc>, String> {
|
||||
pub fn parse_source_date_epoch(raw: &str) -> Result<DateTime<Utc>, String> {
|
||||
let timestamp: i64 = raw
|
||||
.parse()
|
||||
.map_err(|err| format!("timestamp must be decimal integer ({err})"))?;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue