Files
designcraft/crates/engine/src/cmd/export.rs
T

290 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! File › Export: Adobe PDF (Print).
use designcraft_pdf::{Marks, PdfOptions, Standard};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use crate::{EngineError, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "file.exportPdf", "Export PDF…", ["File"], None,
"{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), spreads?: bool, bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), title?, author?} → {path, bytes, pages, warnings} (no path: {base64, …})",
has_doc, export_pdf),
cmd!(noundo "file.exportEpub", "Export EPUB (Reflowable)…", ["File"], None,
"{path?, title?, author?, language?: \"en\"} → {path, bytes} (no path: {base64, bytes})",
has_doc, export_epub),
cmd!(noundo "file.printBooklet", "Print Booklet…", ["File"], None,
"{path?, type?: saddleStitch|twoUpConsecutive, spaceBetween? (pt)} — printer spreads as PDF (pages imposed in booklet order) → {path, bytes, sheets} (no path: {base64, …})",
has_doc, print_booklet),
cmd!(noundo "file.exportHtml", "Export HTML…", ["File"], None,
"{path?, title?, language?} — one self-contained page (styles inline, images embedded), stories and graphics in reading order → {path, bytes} (no path: {text, bytes})",
has_doc, export_html),
cmd!(noundo "file.exportText", "Export Text…", ["File"], None,
"{path?, format?: \"txt\"|\"rtf\" (default from the path, else txt), story?, frame?} — the story being edited or of the selected frame → {path, bytes} (no path: {text, bytes})",
has_story_target, export_text),
]
}
fn print_booklet(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "file.printBooklet";
let kind = match str_param(p, "type").unwrap_or("saddleStitch") {
"saddleStitch" => designcraft_pdf::BookletKind::SaddleStitch,
"twoUpConsecutive" | "twoUp" => designcraft_pdf::BookletKind::TwoUpConsecutive,
t => return Err(bad(ID, format!("unknown type `{t}` (saddleStitch, twoUpConsecutive)"))),
};
let opts = designcraft_pdf::BookletOptions { kind, space_between: p.get("spaceBetween").and_then(Value::as_f64).unwrap_or(0.0), title: None };
let st = s.doc()?;
let r = designcraft_pdf::export_booklet(&st.doc, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(path, &r.bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(json!({"path": path, "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings}))
}
None => Ok(json!({"base64": super::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})),
}
}
fn export_html(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let opts =
designcraft_epub::HtmlOptions { title: str_param(p, "title").map(str::to_string), language: str_param(p, "language").map(str::to_string) };
let text = designcraft_epub::export_html(&st.doc, &opts);
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(json!({"path": path, "bytes": text.len()}))
}
None => Ok(json!({"bytes": text.len(), "text": text})),
}
}
fn has_story_target(s: &Session) -> std::result::Result<(), String> {
super::text::story_of(s, &Value::Null).map(|_| ()).ok_or_else(|| "edit text or select a text frame".into())
}
fn export_text(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "file.exportText";
let sid = super::text::story_of(s, p).ok_or_else(|| bad(ID, "edit text or select a text frame"))?;
let doc = &s.doc()?.doc;
let story = doc.stories.get(&sid).ok_or_else(|| bad(ID, format!("no story {}", sid.0)))?;
let path = str_param(p, "path");
let rtf = match str_param(p, "format") {
Some("rtf") => true,
Some("txt") | Some("text") => false,
Some(f) => return Err(bad(ID, format!("unknown format `{f}` (txt, rtf)"))),
None => path.is_some_and(|x| x.to_ascii_lowercase().ends_with(".rtf")),
};
let text = if rtf { designcraft_textimport::export::rtf(doc, story) } else { designcraft_textimport::export::plain_text(story) };
match path {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(json!({"path": path, "bytes": text.len()}))
}
None => Ok(json!({"bytes": text.len(), "text": text})),
}
}
fn export_epub(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let opts = designcraft_epub::EpubOptions {
title: p.get("title").and_then(Value::as_str).map(str::to_string),
author: p.get("author").and_then(Value::as_str).map(str::to_string),
language: p.get("language").and_then(Value::as_str).unwrap_or("en").to_string(),
identifier: None,
};
let bytes = designcraft_epub::export_epub(&st.doc, &opts).map_err(|e| crate::EngineError::Other(e.to_string()))?;
match p.get("path").and_then(Value::as_str) {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(path, &bytes).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
Ok(serde_json::json!({"path": path, "bytes": bytes.len()}))
}
None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len()})),
}
}
pub(crate) fn options(p: &Value, page_count: usize) -> Result<PdfOptions> {
const ID: &str = "file.exportPdf";
let pages = match p.get("pages") {
None | Some(Value::Null) => None,
Some(Value::String(s)) => Some(designcraft_pdf::parse_page_range(s, page_count).map_err(|e| bad(ID, e.to_string()))?),
Some(Value::Array(a)) => {
let mut v = Vec::new();
for x in a {
let n = x.as_u64().filter(|n| *n >= 1 && (*n as usize) <= page_count).ok_or_else(|| bad(ID, format!("bad page {x}")))?;
v.push(n as usize - 1);
}
Some(v)
}
Some(Value::Number(n)) => {
let n = n.as_u64().filter(|n| *n >= 1 && (*n as usize) <= page_count).ok_or_else(|| bad(ID, format!("bad page {n}")))?;
Some(vec![n as usize - 1])
}
Some(v) => return Err(bad(ID, format!("bad `pages`: {v}"))),
};
let spreads = p.get("spreads").and_then(Value::as_bool).unwrap_or(false);
let bleed = p.get("bleed").and_then(Value::as_bool).unwrap_or(false);
let marks = match p.get("marks") {
Some(Value::Bool(true)) => Marks::ALL,
Some(Value::Object(m)) => {
let b = |k: &str| m.get(k).and_then(Value::as_bool).unwrap_or(false);
Marks {
crop: b("crop"),
bleed: b("bleed"),
page_info: b("pageInfo"),
weight: m.get("weight").and_then(Value::as_f64).unwrap_or(0.25),
offset: m.get("offset").and_then(Value::as_f64).unwrap_or(6.0),
}
}
_ => Marks::NONE,
};
let standard = match str_param(p, "standard") {
Some(s) => Standard::parse(s).ok_or_else(|| bad(ID, format!("unknown standard `{s}` (none, x4, a2b)")))?,
None => Standard::None,
};
Ok(PdfOptions {
pages,
spreads,
bleed,
marks,
standard,
compress_images: p.get("compressImages").and_then(Value::as_bool).unwrap_or(false),
title: str_param(p, "title").map(str::to_string),
author: str_param(p, "author").map(str::to_string),
tagged: p.get("tagged").and_then(Value::as_bool).unwrap_or(false),
..PdfOptions::default()
})
}
fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let opts = options(p, st.doc.page_count())?;
let r = designcraft_pdf::export_pdf_with_report(&st.doc, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
match str_param(p, "path") {
Some(path) => {
write_file(path, &r.bytes)?;
Ok(json!({"path": path, "bytes": r.bytes.len(), "pages": r.pages, "warnings": r.warnings}))
}
None => Ok(json!({"base64": super::base64_encode(&r.bytes), "bytes": r.bytes.len(), "pages": r.pages, "warnings": r.warnings})),
}
}
#[cfg(not(target_arch = "wasm32"))]
fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
std::fs::write(path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
fn write_file(path: &str, _bytes: &[u8]) -> Result<()> {
Err(EngineError::Other(format!("{path}: no file system on the web; omit `path` to get the bytes")))
}
#[cfg(test)]
mod text_tests {
use serde_json::json;
use crate::Session;
#[test]
fn export_story_as_text_and_rtf() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "One\nTwo"})).unwrap();
assert!(s.execute("file.exportText", &json!({"frame": r["id"]})).unwrap()["text"] == "One\r\nTwo");
let html = s.execute("file.exportHtml", &json!({})).unwrap();
assert!(html["text"].as_str().unwrap().contains("One</p>"));
let rtf = s.execute("file.exportText", &json!({"frame": r["id"], "format": "rtf"})).unwrap();
assert!(rtf["text"].as_str().unwrap().starts_with("{\\rtf1"));
let dir = std::env::temp_dir().join(format!("dc-export-text-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("story.rtf");
s.execute("file.exportText", &json!({"frame": r["id"], "path": path.to_string_lossy()})).unwrap();
assert!(std::fs::read_to_string(&path).unwrap().starts_with("{\\rtf1"));
std::fs::remove_dir_all(&dir).ok();
}
}
#[cfg(test)]
mod tagged_tests {
use serde_json::json;
use crate::Session;
#[test]
fn tagged_pdf_has_structure_and_alt_text() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Hello"})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 320, 200, 420]})).unwrap();
s.execute("object.altText", &json!({"text": "A plain box", "ids": [r["id"]]})).unwrap();
let pdf = |s: &mut Session, tagged: bool| {
let b64 = s.execute("file.exportPdf", &json!({"tagged": tagged})).unwrap()["base64"].as_str().unwrap().to_string();
String::from_utf8_lossy(&super::super::file::base64_decode(&b64)).into_owned()
};
let t = pdf(&mut s, true);
assert!(t.contains("/StructTreeRoot") && t.contains("/Figure") && t.contains("A plain box"), "tagged");
assert!(t.contains("/P") && t.contains("/MarkInfo"));
assert!(!pdf(&mut s, false).contains("/StructTreeRoot"));
}
}
#[cfg(test)]
mod variable_font_tests {
use serde_json::json;
use crate::Session;
#[test]
fn variable_font_instance_exports_to_pdf() {
let Ok(data) = std::fs::read("/System/Library/Fonts/Supplemental/Skia.ttf") else { return };
designcraft_fonts::FontDb::global().add_font(data);
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Variable"})).unwrap();
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 8})).unwrap();
s.execute("type.char", &json!({"fontFamily": "Skia", "fontStyle": "Black"})).unwrap();
let out = s.execute("file.exportPdf", &json!({})).unwrap();
assert!(out["bytes"].as_u64().unwrap() > 1000);
let fonts = s.execute("font.list", &json!({})).unwrap();
assert!(fonts.to_string().contains("Black"), "{fonts}");
}
}
#[cfg(test)]
mod booklet_tests {
use serde_json::json;
use crate::Session;
#[test]
fn booklet_imposes_pages_in_pairs() {
use designcraft_pdf::{BookletKind, booklet_pairs};
assert_eq!(booklet_pairs(8, BookletKind::SaddleStitch), [(Some(7), Some(0)), (Some(1), Some(6)), (Some(5), Some(2)), (Some(3), Some(4))]);
assert_eq!(booklet_pairs(6, BookletKind::SaddleStitch)[0], (None, Some(0)), "padded with blanks");
assert_eq!(booklet_pairs(3, BookletKind::TwoUpConsecutive), [(Some(0), Some(1)), (Some(2), None)]);
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 4})).unwrap();
let r = s.execute("file.printBooklet", &json!({"spaceBetween": 18})).unwrap();
assert_eq!(r["sheets"], 2);
let pdf = String::from_utf8_lossy(&super::super::file::base64_decode(r["base64"].as_str().unwrap())).into_owned();
let mb = pdf.find("/MediaBox").map(|i| pdf[i..i + 40].to_string()).unwrap_or_default();
assert!(mb.contains("1242"), "two 612 pt pages and an 18 pt gap: {mb}");
assert!(s.execute("file.printBooklet", &json!({"type": "perfectBound"})).is_err());
// Page 1's black box lands on the right half of the first sheet (page 4 is on the left).
let id = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].clone();
s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [id]})).unwrap();
let r = s.execute("file.printBooklet", &json!({})).unwrap();
let bytes = super::super::file::base64_decode(r["base64"].as_str().unwrap());
let px = designcraft_render::decode_pixmap_page(&bytes, 0).expect("rasterised");
let (w, h) = (px.width() as f64, px.height() as f64);
let at = |x: f64, y: f64| px.sample((x / 1224.0 * w) as u16, (y / 792.0 * h) as u16);
let (ink, blank) = (at(612.0 + 200.0, 200.0), at(200.0, 200.0));
assert!(ink.a > 200 && ink.r < 60, "right half has page 1: {ink:?}");
assert!(blank.a < 30 || blank.r > 200, "left half (page 4) is empty: {blank:?}");
}
}