//! File › Export PDF (Print).
use designcraft_pdf::{Marks, PdfOptions, Standard};
use serde_json::{Value, json};
use std::{borrow::Cow, sync::Arc};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use crate::{EngineError, Result, Session};
pub fn specs() -> Vec {
vec![
cmd!(noundo "file.exportPdf", "Export PDF…", ["File"], None,
"{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised; with standard x1a, the resolution complex transparent regions are rasterized at: high 300, medium 150, low 72, default 300), spreads?: bool, fullScreen?: bool, bookmarksPanel?: bool, pageLayout?: single|continuous|twoUp|twoUpCover|twoUpContinuous, view?: fitPage|fitWidth|actual, advanceSeconds?: number (interactive PDF), bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"x1a\"|\"a2b\" (x1a: PDF/X-1a:2003, the PDF/X-4 file flattened in its inks by craft-prepress: no transparency, only CMYK, gray and spot color), compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), title?, author?, created?: int (creation date in Unix seconds; default now, for reproducible files)} → {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\", identifier?: str (dc:identifier; a stable urn:uuid by default), coverImage?: path (a PNG or JPEG as the cover), tocTitle?: str (the table of contents' title; from the language by default), cover?: bool (the first page as the cover image), embedFonts?: bool (package the stylesheet's fonts whose licence allows it, whole files), split?: \"off\"|\"exportTags\"|{style: name} (Split Document for this export, not stored; the document's `document.epubSplit` by default), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes, warnings} (no path: {base64, bytes, warnings}); warnings name images without alt text, exported as decorative, and fonts that could not be embedded",
has_doc, export_epub),
cmd!(
"document.epubSplit",
"EPUB Split Document",
[],
None,
"{split?: \"off\"|\"exportTags\"|{style: name}} — EPUB Export Options › Split Document, stored with the document: one content file (off, the default), a file before each paragraph whose style has Export Tagging › Split Document on (exportTags), or before each paragraph of one style → {split}",
has_doc,
|s, p| {
const ID: &str = "document.epubSplit";
let cur = s.doc()?.doc.settings.epub_split.clone();
let Some(v) = p.get("split") else { return Ok(json!({"split": cur})) };
let split = epub_split_param(ID, &s.doc()?.doc, v)?;
if split != cur {
let stored = split.clone();
s.edit(move |d, _| {
d.settings.epub_split = stored;
Ok(Value::Null)
})?;
}
Ok(json!({"split": split}))
}
),
cmd!(noundo "file.exportFixedEpub", "Export EPUB (Fixed Layout)…", ["File"], None,
"{path?, title?, author?, language?} — pre-paginated EPUB → {path, bytes} (no path: {base64, bytes})",
has_doc, |s, p| {
export_epub(s, &super::with_param(p, "fixedLayout", serde_json::json!(true)))
}),
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, warnings} (no path: {text, bytes, warnings})",
has_doc, export_html),
cmd!(noundo "file.exportPptx", "Export PowerPoint…", ["File"], None,
"{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), lineBreaks?: keep|reflow (keep: DesignCraft's lines, spacing and leading; reflow: one box per frame, PowerPoint sets the lines), rasterize?: bool (default true: objects PowerPoint can't show — placed PDF/EPS, type on a path, some effects — go in as 300 ppi images), embedFonts?: bool (default true: the TrueType fonts whose licences allow it), title?, author?} — every page as a slide of editable shapes, pictures, text boxes and tables → {path, bytes, slides, warnings} (no path: {base64, …})",
has_doc, export_pptx),
cmd!(noundo "file.exportText", "Export Text…", ["File"], None,
"{path?, format?: \"txt\"|\"rtf\"|\"tagged\" (Tagged Text; 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 {
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(&super::interchange::with_pdf_boxes(&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"))]
crate::io::write_file(path, &r.bytes).map_err(EngineError::Other)?;
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})),
}
}
/// HTML and reflowable EPUB embed the EPS preview; keep the session's source bytes intact.
fn eps_previews(d: &designcraft_doc::Document) -> (Cow<'_, designcraft_doc::Document>, Vec) {
// Match the exporter's reading order (including Article exclusions), not every asset
// in the document: unused or hidden graphics must not prevent an export.
let mut used = std::collections::BTreeSet::new();
for block in designcraft_epub::reading_order(d) {
let designcraft_epub::Block::Image(id) = block else {
continue;
};
let Some(it) = d.item(id) else { continue };
let designcraft_doc::Content::Graphic(g) = &it.content else {
continue;
};
used.insert(g.asset);
if d.assets.get(&g.asset).is_some_and(|a| designcraft_doc::media_kind(&a.mime).is_some())
&& let Some(poster) = it.media.as_ref().and_then(|m| m.poster)
{
used.insert(poster);
}
}
let mut out = Cow::Borrowed(d);
let mut warnings = Vec::new();
for id in used {
let Some(a) = d.assets.get(&id).filter(|a| designcraft_images::is_eps(&a.data)) else {
continue;
};
let png = designcraft_images::eps_png(&a.data);
if png.is_none() {
warnings.push(format!("{}: unusable EPS preview; graphic omitted", a.name));
}
if let Some(a) = out.to_mut().assets.get_mut(&id) {
let a = Arc::make_mut(a);
a.mime = "image/png".into();
a.data = Arc::new(png.unwrap_or_default());
}
}
(out, warnings)
}
fn export_html(s: &mut Session, p: &Value) -> Result {
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),
generic_families: generic_families(&st.doc),
};
let (doc, warnings) = eps_previews(&st.doc);
let text = designcraft_epub::export_html(&doc, &opts);
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
crate::io::write_file(path, text.as_bytes()).map_err(EngineError::Other)?;
Ok(json!({"path": path, "bytes": text.len(), "warnings": warnings}))
}
None => Ok(json!({"bytes": text.len(), "text": text, "warnings": warnings})),
}
}
fn export_pptx(s: &mut Session, p: &Value) -> Result {
const ID: &str = "file.exportPptx";
let st = s.doc()?;
let line_breaks = match str_param(p, "lineBreaks") {
None | Some("keep") => designcraft_pptx::LineBreaks::Keep,
Some("reflow") => designcraft_pptx::LineBreaks::Reflow,
Some(x) => return Err(bad(ID, format!("unknown lineBreaks `{x}` (keep, reflow)"))),
};
let opts = designcraft_pptx::PptxOptions {
pages: pages_param(ID, p, st.doc.page_count())?,
line_breaks,
title: str_param(p, "title").map(str::to_string),
author: str_param(p, "author").map(str::to_string),
created: None,
embed_fonts: p.get("embedFonts").and_then(Value::as_bool).unwrap_or(true),
};
// Object Export Options › Rasterize, and what PowerPoint has no objects for (a threaded frame
// stays text: a picture of it would drop the rest of its story).
let auto = p.get("rasterize").and_then(Value::as_bool).unwrap_or(true);
let (raster, rasterized) = rasterize_where(&st.doc, &s.cache, 300.0, true, |it| {
!threaded(&st.doc, it) && (it.export_options.rasterize || (auto && designcraft_pptx::needs_raster(&st.doc, it)))
});
let doc: &designcraft_doc::Document = raster.as_ref().unwrap_or(&st.doc);
let mut r = designcraft_pptx::export_pptx(doc, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
if rasterized > 0 {
r.warnings.push(format!("{rasterized} object(s) went in as images"));
}
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
crate::io::write_file(path, &r.bytes).map_err(EngineError::Other)?;
Ok(json!({"path": path, "bytes": r.bytes.len(), "slides": r.slides, "warnings": r.warnings}))
}
None => Ok(json!({"base64": super::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "slides": r.slides, "warnings": r.warnings})),
}
}
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 {
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 format = match str_param(p, "format") {
Some(f @ ("rtf" | "tagged")) => f,
Some("txt") | Some("text") => "txt",
Some(f) => return Err(bad(ID, format!("unknown format `{f}` (txt, rtf, tagged)"))),
None if path.is_some_and(|x| x.to_ascii_lowercase().ends_with(".rtf")) => "rtf",
None => "txt",
};
let text = match format {
"rtf" => designcraft_textimport::export::rtf(doc, story),
"tagged" => designcraft_textimport::tagged::tagged_text(doc, story),
_ => designcraft_textimport::export::plain_text(story),
};
match path {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
crate::io::write_file(path, text.as_bytes()).map_err(EngineError::Other)?;
Ok(json!({"path": path, "bytes": text.len()}))
}
None => Ok(json!({"bytes": text.len(), "text": text})),
}
}
/// Text of the frames on page `abs`, in line order (for fixed-layout pages).
fn page_text(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, abs: usize) -> String {
let mut out = String::new();
for st in d.stories.values() {
let cs = cache.get(d, st.id, None);
for ft in &cs.frames {
if d.page_of_item(ft.frame) != Some(abs) {
continue;
}
for l in &ft.lines {
let r = l.range.start.min(st.text.len())..l.range.end.min(st.text.len());
let t: String = st.text[r].chars().filter(|c| !('\u{E000}'..='\u{F8FF}').contains(c)).collect();
if !t.trim().is_empty() {
out.push_str(t.trim_end_matches('\n'));
out.push('\n');
}
}
}
}
out
}
/// The CSS generic family of each font family the EPUB/HTML stylesheet names, from the fonts'
/// classification (PANOSE, OS/2 class, name), for the `font-family` fallbacks.
fn generic_families(d: &designcraft_doc::Document) -> std::collections::BTreeMap {
use designcraft_fonts::FontClass;
let db = designcraft_fonts::FontDb::global().scoped(d.font_scope);
let mut out = std::collections::BTreeMap::new();
for (family, _) in designcraft_epub::stylesheet_fonts(d) {
if out.contains_key(&family) {
continue;
}
let generic = match db.family_kind(&family).map(|(k, _)| k.class) {
Some(FontClass::Serif | FontClass::SlabSerif) => "serif",
Some(FontClass::SansSerif) => "sans-serif",
Some(FontClass::Monospaced) => "monospace",
Some(FontClass::Script) => "cursive",
Some(FontClass::Decorative) => "fantasy",
Some(FontClass::Symbol | FontClass::Other) | None => continue,
};
out.insert(family, generic.to_string());
}
out
}
/// The faces the EPUB stylesheet names, to package with it; `warnings` gets the ones left out
/// (not available, a licence that forbids embedding, or part of a font collection).
fn embedded_fonts(d: &designcraft_doc::Document, warnings: &mut Vec) -> Vec {
let db = designcraft_fonts::FontDb::global().scoped(d.font_scope);
let mut out = Vec::new();
for (family, style) in designcraft_epub::stylesheet_fonts(d) {
let face = db.face(&family, &style);
if !face.family.eq_ignore_ascii_case(&family) {
warnings.push(format!("font `{family} {style}` is not available; not embedded"));
} else if face.restricted_licence() || face.bitmap_only() {
warnings.push(format!("font `{family} {style}`: its licence doesn't allow embedding; not embedded"));
} else if face.data().starts_with(b"ttcf") {
warnings.push(format!("font `{family} {style}` is part of a font collection; not embedded"));
} else {
out.push(designcraft_epub::EmbeddedFont { family, style: face.style.clone(), data: face.data().to_vec() });
}
}
out
}
/// A Split Document setting (`"off"`, `"exportTags"` or `{"style": name}`), naming a paragraph
/// style the document has.
pub(crate) fn epub_split_param(id: &str, doc: &designcraft_doc::Document, v: &Value) -> Result {
let split: designcraft_doc::EpubSplit =
serde_json::from_value(v.clone()).map_err(|_| bad(id, "`split`: \"off\", \"exportTags\" or {\"style\": name}"))?;
if let designcraft_doc::EpubSplit::Style(name) = &split
&& doc.styles.para(name).is_none()
{
return Err(bad(id, format!("no paragraph style `{name}`")));
}
Ok(split)
}
fn export_epub(s: &mut Session, p: &Value) -> Result {
let st = s.doc()?;
let split = p.get("split").map(|v| epub_split_param("file.exportEpub", &st.doc, v)).transpose()?;
// Object Export Options › Rasterize: those objects go in as images.
let (raster, _) = rasterize_where(&st.doc, &s.cache, 144.0, false, |it| it.export_options.rasterize && !threaded(&st.doc, it));
let epub_doc: &designcraft_doc::Document = raster.as_ref().unwrap_or(&st.doc);
let fixed = p.get("fixedLayout").and_then(Value::as_bool).unwrap_or(false);
let want_cover = p.get("cover").and_then(Value::as_bool).unwrap_or(false);
let mut rr = designcraft_render::Renderer::new();
rr.threads = designcraft_render::default_threads();
let ropts = designcraft_render::RenderOptions { printing_only: true, ..Default::default() };
let mut warnings: Vec = Vec::new();
// A cover image file (PNG or JPEG), else the first page rendered when `cover` asks for it.
let cover = match p.get("coverImage").and_then(Value::as_str).filter(|_| !fixed) {
Some(path) => Some(super::datamerge::read_path_capped(path, super::datamerge::MAX_IMAGE_FILE_BYTES).map_err(crate::EngineError::Other)?),
None => (want_cover && !fixed).then(|| rr.render_page(&st.doc, &s.cache, 0, 2.0, false, &ropts).map(|img| img.to_png())).flatten(),
};
if let Some(c) = &cover
&& !(c.starts_with(b"\x89PNG") || c.starts_with(&[0xFF, 0xD8, 0xFF]))
{
return Err(crate::EngineError::Other("the cover image must be a PNG or JPEG file".into()));
}
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: p.get("identifier").and_then(Value::as_str).map(str::to_string),
cover,
toc_title: p.get("tocTitle").and_then(Value::as_str).map(str::to_string),
split,
generic_families: generic_families(&st.doc),
fonts: if p.get("embedFonts").and_then(Value::as_bool).unwrap_or(false) && !fixed {
embedded_fonts(&st.doc, &mut warnings)
} else {
Vec::new()
},
// The export time; on the web the document's own save time stands in.
#[cfg(not(target_arch = "wasm32"))]
modified: std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok().and_then(|d| i64::try_from(d.as_secs()).ok()),
#[cfg(target_arch = "wasm32")]
modified: None,
};
if fixed {
let d = &st.doc;
let mut pages = Vec::new();
for i in 0..d.page_count() {
let Some((si, pi)) = d.page_loc(i) else {
continue;
};
let b = d.spreads[si].pages[pi].bounds();
let Some(img) = rr.render_page(d, &s.cache, i, 2.0, false, &ropts) else {
continue;
};
pages.push(designcraft_epub::FixedPage { png: img.to_png(), width: b.width(), height: b.height(), text: page_text(d, &s.cache, i) });
}
let bytes = designcraft_epub::export_fixed_epub(d, &pages, &opts).map_err(|e| crate::EngineError::Other(e.to_string()))?;
return match p.get("path").and_then(Value::as_str) {
Some(path) => {
write_file(path, &bytes)?;
Ok(serde_json::json!({"path": path, "bytes": bytes.len(), "pages": pages.len()}))
}
None => Ok(serde_json::json!({"base64": super::base64_encode(&bytes), "bytes": bytes.len(), "pages": pages.len()})),
};
}
let (epub_doc, eps_warnings) = eps_previews(epub_doc);
warnings.extend(eps_warnings);
let bytes = designcraft_epub::export_epub(&epub_doc, &opts).map_err(|e| crate::EngineError::Other(e.to_string()))?;
warnings.extend(
designcraft_epub::images_without_alt(&epub_doc)
.into_iter()
.map(|name| format!("{name}: no alt text (Object Export Options); exported as decorative (alt=\"\")")),
);
match p.get("path").and_then(Value::as_str) {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
crate::io::write_file(path, &bytes).map_err(crate::EngineError::Other)?;
Ok(serde_json::json!({"path": path, "bytes": bytes.len(), "warnings": warnings}))
}
None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len(), "warnings": warnings})),
}
}
/// The `pages` parameter: a range string ("1-3,5"), a list or a number of 1-based positions.
fn pages_param(id: &str, p: &Value, page_count: usize) -> Result
"));
let tagged = s.execute("file.exportText", &json!({"frame": r["id"], "format": "tagged"})).unwrap();
assert!(tagged["text"].as_str().unwrap().contains(" 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:?}");
}
}
#[cfg(test)]
mod placed_pdf_print_tests {
use serde_json::json;
use crate::Session;
use crate::cmd::{base64_decode, base64_encode};
/// A one-page PDF 1.7 with a black box, as VectorCraft writes a logo.
fn logo() -> Vec {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap();
s.execute("frame.create", &json!({"rect": [10, 10, 90, 40]})).unwrap();
s.execute("object.fill", &json!({"swatch": "[Black]"})).unwrap();
let r = s.execute("file.exportPdf", &json!({})).unwrap();
base64_decode(r["base64"].as_str().unwrap())
}
fn page_with(pdf: &[u8]) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("file.place", &json!({"base64": base64_encode(pdf), "name": "logo.pdf", "x": 72, "y": 72})).unwrap();
s
}
/// A placed PDF 1.7 failed the PDF/X-4 (PDF 1.6) export with
/// "PDF writer error: Pdf(PdfDocument(PdfDocumentRepr { .. }), VersionMismatch(Pdf17), None)".
#[test]
fn pdf_17_placed_in_pdfx4() {
let logo = logo();
assert!(logo.starts_with(b"%PDF-1.7"));
let mut s = page_with(&logo);
let r = s.execute("file.exportPdf", &json!({"standard": "x4"})).unwrap();
assert!(r["warnings"].to_string().contains("logo.pdf: a PDF 1.7 placed in a PDF 1.6 file"), "{}", r["warnings"]);
assert!(base64_decode(r["base64"].as_str().unwrap()).starts_with(b"%PDF-1.6"));
// PDF 2.0 can't simply be relabelled: the export fails and names the file.
let mut v20 = logo.clone();
v20[5..8].copy_from_slice(b"2.0");
let mut s = page_with(&v20);
let e = s.execute("file.exportPdf", &json!({"standard": "x4"})).unwrap_err().to_string();
assert!(e.contains("logo.pdf is PDF 2.0") && e.contains("PDF 1.6"), "{e}");
}
/// A one-page PDF with a black box whose catalog says `/Version /1.7`, as TCPDF writes it.
fn catalog_v17(header: &str) -> Vec {
let content = "0 0 0 1 k 0 0 200 200 re f";
let mut pdf = format!("{header}\n").into_bytes();
let mut offsets = Vec::new();
for body in [
"<>".to_owned(),
"<>".to_owned(),
"<>>>".to_owned(),
format!("<>\nstream\n{content}\nendstream", content.len()),
] {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", offsets.len()).as_bytes());
}
let xref = pdf.len();
pdf.extend_from_slice(b"xref\n0 5\n0000000000 65535 f\r\n");
for offset in offsets {
pdf.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
pdf
}
/// A placed PDF whose catalog `/Version /1.7` overrides its header failed the PDF/X-4 export
/// with "logo.pdf is PDF 1.7", though a 1.7 header alone is relabelled (#907).
#[test]
fn catalog_version_17_placed_in_pdfx4() {
for header in ["%PDF-1.7", "%PDF-1.4"] {
let pdf = catalog_v17(header);
assert_eq!(hayro_syntax::Pdf::new(pdf.clone()).unwrap().version(), hayro_syntax::PdfVersion::Pdf17);
let mut s = page_with(&pdf);
let r = s.execute("file.exportPdf", &json!({"standard": "x4"})).unwrap_or_else(|e| panic!("{header}: {e}"));
assert!(r["warnings"].to_string().contains("logo.pdf: a PDF 1.7 placed in a PDF 1.6 file"), "{}", r["warnings"]);
let out = base64_decode(r["base64"].as_str().unwrap());
assert!(out.starts_with(b"%PDF-1.6"));
assert_eq!(hayro_syntax::Pdf::new(out).unwrap().version(), hayro_syntax::PdfVersion::Pdf16);
}
}
}
#[cfg(test)]
mod print_group_tests {
use serde_json::json;
use crate::Session;
use crate::cmd::{base64_decode, base64_encode};
/// A one-page PDF 1.7 with a black box, as VectorCraft writes a logo.
fn logo() -> Vec {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap();
s.execute("frame.create", &json!({"rect": [10, 10, 90, 40]})).unwrap();
s.execute("object.fill", &json!({"swatch": "[Black]"})).unwrap();
let r = s.execute("file.exportPdf", &json!({})).unwrap();
base64_decode(r["base64"].as_str().unwrap())
}
fn page_with(pdf: &[u8]) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("file.place", &json!({"base64": base64_encode(pdf), "name": "logo.pdf", "x": 72, "y": 72})).unwrap();
s
}
/// A placed PDF's transparency group blends in CMYK in a print document and in RGB in a
/// screen one.
#[test]
fn print_transparency_groups_blend_in_cmyk() {
let mut s = page_with(&logo());
let text = |s: &mut Session| {
let r = s.execute("file.exportPdf", &json!({"compressImages": false})).unwrap();
String::from_utf8_lossy(&base64_decode(r["base64"].as_str().unwrap())).into_owned()
};
let t = text(&mut s);
// The placed page's group inherits the page group, which blends in CMYK.
assert!(t.contains("/Group<>"), "placed page group without a blending space");
assert!(t.contains("/Group<>"), "CMYK page group");
assert!(!t.contains("DeviceRGB"), "no RGB left");
assert!(!designcraft_pdf::has_rgb_groups(t.as_bytes()));
// The file still reads, with the same page.
let r = s.execute("file.exportPdf", &json!({})).unwrap();
let bytes = base64_decode(r["base64"].as_str().unwrap());
assert_eq!(hayro_syntax::Pdf::new(bytes).unwrap().pages().len(), 1);
// An RGB blend space keeps RGB groups.
s.execute("edit.transparencyBlendSpace", &json!({"space": "rgb"})).unwrap();
let t = text(&mut s);
assert!(t.contains("/CS/DeviceRGB") && designcraft_pdf::has_rgb_groups(t.as_bytes()));
}
}
#[cfg(test)]
mod pptx_tests {
use serde_json::json;
use crate::Session;
fn unzip(b64: &str) -> zip::ZipArchive>> {
zip::ZipArchive::new(std::io::Cursor::new(super::super::file::base64_decode(b64))).unwrap()
}
fn read(z: &mut zip::ZipArchive>>, name: &str) -> String {
let mut s = String::new();
std::io::Read::read_to_string(&mut z.by_name(name).unwrap(), &mut s).unwrap();
s
}
#[test]
fn sample_exports_editable_slides() {
let mut s = Session::new();
s.execute("file.newSample", &json!({})).unwrap();
let r = s.execute("file.exportPptx", &json!({})).unwrap();
assert_eq!(r["slides"], 4);
let mut z = unzip(r["base64"].as_str().unwrap());
// The swatch table is a PowerPoint table; the cover picture is embedded; text is text.
assert!(read(&mut z, "ppt/slides/slide4.xml").contains(""));
assert!(read(&mut z, "ppt/slides/slide1.xml").contains(""));
let text: String = read(&mut z, "ppt/slides/slide2.xml").split("").skip(1).filter_map(|t| t.split("").next()).collect();
assert!(text.contains("Notes on the Grid"), "{text}");
assert!(read(&mut z, "ppt/presentation.xml").contains(""));
let r = s.execute("file.exportPptx", &json!({"pages": "2-3", "embedFonts": false, "lineBreaks": "reflow"})).unwrap();
assert_eq!(r["slides"], 2);
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("fonts are not embedded")));
assert!(s.execute("file.exportPptx", &json!({"lineBreaks": "wrap"})).is_err());
assert!(s.execute("file.exportPptx", &json!({"pages": [9]})).is_err());
}
#[test]
fn rasterize_option_places_a_picture() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 200, 200], "shape": "ellipse"})).unwrap();
s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [r["id"]]})).unwrap();
let shapes = |s: &mut Session| {
let r = s.execute("file.exportPptx", &json!({})).unwrap();
read(&mut unzip(r["base64"].as_str().unwrap()), "ppt/slides/slide1.xml")
};
assert!(shapes(&mut s).contains("prst=\"ellipse\""));
s.execute("object.exportOptions", &json!({"ids": [r["id"]], "rasterize": true})).unwrap();
let x = shapes(&mut s);
assert!(x.contains("") && !x.contains("prst=\"ellipse\""), "{x}");
}
}
#[cfg(test)]
mod page_list_tests {
use serde_json::{Value, json};
use crate::Session;
fn doc(pages: u64) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200, "pages": pages, "facingPages": false, "margins": 0})).unwrap();
s
}
/// The same selection written two ways exported different files: `"2,2"` is one page, because
/// `parse_page_range` keeps the first of a repeat, while `[2, 2]` was two copies of page 2.
/// The list says which pages to export, not how many times.
#[test]
fn a_page_named_twice_is_exported_once_either_way() {
let mut s = doc(4);
for (pages, want) in [
(json!("2,2"), 1),
(json!([2, 2]), 1),
(json!([2, 2, 2]), 1),
(json!("2-2,2"), 1),
(json!([1, 2, 1]), 2),
(json!("1,2,1"), 2),
(json!([4, 1]), 2),
(json!("4,1"), 2),
(json!("all"), 4),
] {
let r = s.execute("file.exportPdf", &json!({"pages": pages.clone()})).unwrap();
assert_eq!(r["pages"].as_u64(), Some(want), "pdf {pages}");
let r = s.execute("file.exportPptx", &json!({"pages": pages.clone()})).unwrap();
assert_eq!(r["slides"].as_u64(), Some(want), "pptx {pages}");
}
// The two forms now agree byte for byte, and the pages come out in the document's order
// whichever order the list names them in (which is how the export already behaved).
let mut file = |pages: Value| {
let r = s.execute("file.exportPdf", &json!({"pages": pages})).unwrap();
r["base64"].as_str().unwrap_or_default().to_string()
};
assert_eq!(file(json!([4, 1])), file(json!("4,1")), "the array and the string agree");
assert_eq!(file(json!([4, 1])), file(json!([1, 4])), "pages 1 and 4, in the document's order");
assert_eq!(file(json!([2, 2])), file(json!("2")), "a repeat is the single page");
// A page that does not exist is still refused, and an empty list still exports nothing.
assert!(s.execute("file.exportPdf", &json!({"pages": [5]})).is_err());
assert!(s.execute("file.exportPdf", &json!({"pages": []})).is_err());
}
}