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

1198 lines
61 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 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), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised), 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\"|\"a2b\", compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), 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\", 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), 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!(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} (no path: {text, bytes})",
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<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"))]
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})),
}
}
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),
generic_families: generic_families(&st.doc),
};
let text = designcraft_epub::export_html(&st.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()}))
}
None => Ok(json!({"bytes": text.len(), "text": text})),
}
}
fn export_pptx(s: &mut Session, p: &Value) -> Result<Value> {
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, |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<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 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<String, String> {
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<String>) -> Vec<designcraft_epub::EmbeddedFont> {
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
}
fn export_epub(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
// Object Export Options › Rasterize: those objects go in as images.
let (raster, _) = rasterize_where(&st.doc, &s.cache, 144.0, |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<String> = 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),
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 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<Option<Vec<usize>>> {
Ok(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}"))),
})
}
pub(crate) fn options(p: &Value, page_count: usize) -> Result<PdfOptions> {
const ID: &str = "file.exportPdf";
let pages = pages_param(ID, p, page_count)?;
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),
media: p.get("media").and_then(Value::as_bool).unwrap_or(false),
..PdfOptions::default()
})
}
/// Does this object (or anything in it) need the transparency flattener?
fn transparent(it: &designcraft_doc::Item) -> bool {
it.involves_transparency()
}
/// Transparency Flattener: each object involving transparency becomes an opaque image of its
/// area as it looks over what's beneath it (so the rest stays vector); spreads whose parent items
/// involve transparency are rasterised whole. Works on a copy of the document made for export.
/// Returns the copy and the number of rasterised regions.
fn flatten(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, ppi: f64) -> (designcraft_doc::Document, usize) {
use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, Shape, SpreadRef};
let mut out = d.clone();
let mut n = 0;
let mut rr = designcraft_render::Renderer::new();
rr.threads = designcraft_render::default_threads();
let k = ppi / 72.0;
let parent_transparent = |pid: Option<designcraft_doc::SpreadId>| {
pid.and_then(|id| d.parents.iter().find(|p| p.id == id)).is_some_and(|p| p.items.iter().any(|it| transparent(it)))
};
// An opaque image item showing `img` over `r`.
let image_item =
|out: &mut designcraft_doc::Document, r: designcraft_geom::Rect, img: designcraft_render::Rendered, layer: designcraft_doc::LayerId| {
let (w, h) = (img.width, img.height);
let aid = AssetId(out.alloc());
out.assets.insert(
aid,
std::sync::Arc::new(Asset {
page: 0,
id: aid,
name: format!("flattened-{}.png", aid.0),
mime: "image/png".into(),
link: None,
data: std::sync::Arc::new(img.to_png()),
pixels: Some((w, h)),
}),
);
let mut it = Item::new(ItemId(out.alloc()), layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(r));
it.content = Content::Graphic(Graphic {
asset: aid,
size: (w as f64, h as f64),
xf: designcraft_geom::Affine::translate((r.x0, r.y0))
* designcraft_geom::Affine::scale_non_uniform(r.width() / w as f64, r.height() / h as f64),
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
fill: Default::default(),
});
it.stroke.weight = 0.0;
it
};
for (si, sp) in d.spreads.iter().enumerate() {
let whole = sp.pages.iter().any(|pg| pg.show_parent_items && parent_transparent(pg.parent));
if whole {
// Parent items involve transparency: every page of the spread as one image.
let first = d.first_page_of_spread(si);
let lid = out.default_layer();
let mut images = Vec::new();
for (pi, pg) in sp.pages.iter().enumerate() {
if let Some(img) = rr.render_page(d, cache, first + pi, k, false, &Default::default()) {
images.push((pg.bounds(), img));
}
}
let osp = std::sync::Arc::make_mut(&mut out.spreads[si]);
osp.items.clear();
for pg in &mut osp.pages {
pg.show_parent_items = false;
}
for (b, img) in images {
let it = image_item(&mut out, b, img, lid);
let _ = out.insert_item(SpreadRef::Doc(si), it, None);
n += 1;
}
continue;
}
// Region by region, bottom to top.
for (idx, it) in sp.items.iter().enumerate() {
if it.hidden || !transparent(it) {
continue;
}
let reach = it.bounds().inflate(
designcraft_render::effect_outset(it) + it.stroke.extent() + 1.0,
designcraft_render::effect_outset(it) + it.stroke.extent() + 1.0,
);
// Only the part on the spread's pages and pasteboard that prints.
let (w, h) = ((reach.width() * k).ceil().max(1.0) as u32, (reach.height() * k).ceil().max(1.0) as u32);
if w as u64 * h as u64 > 80_000_000 {
continue;
}
// The stack up to and including this object.
let mut below = d.clone();
std::sync::Arc::make_mut(&mut below.spreads[si]).items.truncate(idx + 1);
let view = designcraft_geom::Affine::scale(k) * designcraft_geom::Affine::translate((-reach.x0, -reach.y0));
let opts = designcraft_render::RenderOptions { printing_only: true, ..Default::default() };
let img = rr.render(
&below,
cache,
&[designcraft_render::Placed { spread: SpreadRef::Doc(si), xf: designcraft_geom::Affine::translate(designcraft_geom::Vec2::ZERO) }],
w,
h,
view,
&opts,
);
let new = image_item(&mut out, reach, img, it.layer);
let osp = std::sync::Arc::make_mut(&mut out.spreads[si]);
let Some(pos) = osp.items.iter().position(|x| x.id == it.id) else { continue };
if threaded(d, it) {
// Keep the frame (its story flows on) under the image.
osp.items.insert(pos + 1, std::sync::Arc::new(new));
} else {
osp.items[pos] = std::sync::Arc::new(new);
}
n += 1;
}
}
(out, n)
}
/// A text frame in a story that runs through other frames (replacing it would reflow the story).
fn threaded(d: &designcraft_doc::Document, it: &designcraft_doc::Item) -> bool {
it.text_frame().and_then(|t| d.story(t.story)).is_some_and(|st| st.frames.len() > 1)
}
/// Soft effects other than the gradient feather (which PDF export draws itself).
fn raster_effects(it: &designcraft_doc::Item) -> bool {
let e = &it.effects;
// Knockout groups too: PDF output here has no knockout transparency groups.
it.knockout
|| e.drop_shadow.on
|| e.feather > 0.0
|| e.inner_shadow.on
|| e.outer_glow.on
|| e.inner_glow.on
|| e.bevel.on
|| e.satin.on
|| e.directional_feather.on
}
/// A copy of `d` where spread-level objects with soft effects are 300 ppi images of their whole
/// appearance (transparent around them, so they composite over what's behind).
fn rasterize_effects(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache) -> (Option<designcraft_doc::Document>, usize) {
rasterize_where(d, cache, 300.0, |it| raster_effects(it) && !threaded(d, it))
}
/// A copy of `d` where the spread-level objects `pred` picks are images of their appearance at
/// `ppi` (transparent around them).
fn rasterize_where(
d: &designcraft_doc::Document,
cache: &designcraft_compose::Cache,
ppi: f64,
pred: impl Fn(&designcraft_doc::Item) -> bool,
) -> (Option<designcraft_doc::Document>, usize) {
use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, Shape, SpreadRef};
let todo: Vec<(usize, ItemId)> =
d.spreads.iter().enumerate().flat_map(|(si, sp)| sp.items.iter().filter(|it| !it.hidden && pred(it)).map(move |it| (si, it.id))).collect();
if todo.is_empty() {
return (None, 0);
}
let mut out = d.clone();
let mut rr = designcraft_render::Renderer::new();
rr.threads = designcraft_render::default_threads();
let k = ppi / 72.0;
let mut n = 0;
for (si, id) in todo {
let Some(it) = d.item(id) else { continue };
let reach = it.bounds().inflate(
designcraft_render::effect_outset(it) + it.stroke.extent() + 2.0,
designcraft_render::effect_outset(it) + it.stroke.extent() + 2.0,
);
// Just this object on its spread, no paper.
let mut solo = d.clone();
let sp = std::sync::Arc::make_mut(&mut solo.spreads[si]);
sp.items.retain(|x| x.id == id);
for pg in &mut sp.pages {
pg.show_parent_items = false;
}
let (w, h) = ((reach.width() * k).ceil().max(1.0) as u32, (reach.height() * k).ceil().max(1.0) as u32);
if w as u64 * h as u64 > 60_000_000 {
continue;
}
let view = designcraft_geom::Affine::scale(k) * designcraft_geom::Affine::translate((-reach.x0, -reach.y0));
let opts = designcraft_render::RenderOptions { paper: false, background: None, printing_only: true, ..Default::default() };
let img = rr.render(
&solo,
cache,
&[designcraft_render::Placed { spread: SpreadRef::Doc(si), xf: designcraft_geom::Affine::translate(designcraft_geom::Vec2::ZERO) }],
w,
h,
view,
&opts,
);
let aid = AssetId(out.alloc());
out.assets.insert(
aid,
std::sync::Arc::new(Asset {
page: 0,
id: aid,
name: format!("effects-{}.png", id.0),
mime: "image/png".into(),
link: None,
data: std::sync::Arc::new(img.to_png()),
pixels: Some((w, h)),
}),
);
let mut img_item = Item::new(ItemId(out.alloc()), it.layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(reach));
img_item.content = Content::Graphic(Graphic {
asset: aid,
size: (w as f64, h as f64),
xf: designcraft_geom::Affine::translate((reach.x0, reach.y0)) * designcraft_geom::Affine::scale(1.0 / k),
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
fill: Default::default(),
});
img_item.stroke.weight = 0.0;
img_item.alt_text = it.alt_text.clone();
img_item.export_options = it.export_options.clone();
// Same place in the stacking order.
let osp = std::sync::Arc::make_mut(&mut out.spreads[si]);
if let Some(pos) = osp.items.iter().position(|x| x.id == id) {
osp.items[pos] = std::sync::Arc::new(img_item);
n += 1;
}
}
(Some(out), n)
}
/// A copy of `d` where placed PDFs with Object Layer Options hidden layers are 300 ppi images.
fn rasterize_layered(d: &designcraft_doc::Document) -> (Option<designcraft_doc::Document>, usize) {
let ids: Vec<designcraft_doc::ItemId> =
d.all_items().into_iter().filter(|id| d.item(*id).is_some_and(|it| !it.pdf_hidden_layers.is_empty() && it.graphic().is_some())).collect();
if ids.is_empty() {
return (None, 0);
}
let mut out = d.clone();
let mut n = 0;
for id in ids {
let Some((g, hidden)) = out.item(id).and_then(|it| Some((it.graphic()?.clone(), it.pdf_hidden_layers.clone()))) else { continue };
let Some(a) = out.assets.get(&g.asset).cloned() else { continue };
if !designcraft_render::is_pdf(&a.data) {
continue;
}
let side = (g.size.0.max(g.size.1) * 300.0 / 72.0).clamp(64.0, 8000.0) as u32;
let Some(png) = designcraft_render::pdf_page_png(&a.data, a.page as usize, side, &hidden) else { continue };
let px = designcraft_render::image_size(&png);
let aid = designcraft_doc::AssetId(out.alloc());
out.assets.insert(
aid,
std::sync::Arc::new(designcraft_doc::Asset {
page: 0,
id: aid,
name: format!("{}.png", a.name),
mime: "image/png".into(),
link: None,
data: std::sync::Arc::new(png),
pixels: px,
}),
);
if let Some(designcraft_doc::Content::Graphic(gg)) = out.item_mut(id).map(|it| &mut it.content) {
gg.asset = aid;
}
n += 1;
}
(Some(out), n)
}
/// Object number of the first page of a PDF (its page tree's first kid).
fn first_page_ref(pdf: &[u8]) -> Option<usize> {
let s = String::from_utf8_lossy(pdf);
let i = s.find("/Kids[")? + 6;
s[i..].split_whitespace().next()?.parse().ok()
}
fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let opts = options(p, st.doc.page_count())?;
// Transparency Flattener presets: High / Medium / Low Resolution, or a ppi.
let ppi = match p.get("flatten") {
None | Some(Value::Null) | Some(Value::Bool(false)) => None,
Some(Value::String(s)) if s == "high" => Some(300.0),
Some(Value::String(s)) if s == "medium" => Some(150.0),
Some(Value::String(s)) if s == "low" => Some(72.0),
Some(Value::Bool(true)) => Some(300.0),
Some(v) => Some(
v.as_f64().filter(|v| (36.0..=1200.0).contains(v)).ok_or_else(|| bad("file.exportPdf", "`flatten`: high|medium|low or 36–1200 ppi"))?,
),
};
let flattened = ppi.map(|ppi| flatten(&st.doc, &s.cache, ppi));
// Placed PDFs with hidden layers go out as images showing just their visible layers.
let base: &designcraft_doc::Document = flattened.as_ref().map_or(&st.doc, |f| &f.0);
let (layered, layer_count) = rasterize_layered(base);
let base2: &designcraft_doc::Document = layered.as_ref().unwrap_or(base);
let (fx_doc, fx_count) = rasterize_effects(base2, &s.cache);
let doc: &designcraft_doc::Document = fx_doc.as_ref().unwrap_or(base2);
let r = designcraft_pdf::export_pdf_with_report(doc, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
let mut r = r;
// Transparency Blend Space: the page group of pages with transparency.
let spreads = designcraft_pdf::sheet_spreads(doc, &opts);
let blended: Vec<bool> = spreads.iter().map(|si| doc.spreads.get(*si).is_some_and(|sp| sp.items.iter().any(|it| transparent(it)))).collect();
if blended.iter().any(|b| *b) {
match designcraft_pdf::add_blend_space(&r.bytes, &blended, doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk) {
Some(b) => r.bytes = b,
None => r.warnings.push("the transparency blend space couldn't be set".into()),
}
}
// Interactive PDF view options.
let mut cat = String::new();
if p.get("fullScreen").and_then(Value::as_bool) == Some(true) {
cat.push_str("/PageMode/FullScreen");
} else if p.get("bookmarksPanel").and_then(Value::as_bool) == Some(true) {
cat.push_str("/PageMode/UseOutlines");
}
match str_param(p, "pageLayout") {
Some("single") => cat.push_str("/PageLayout/SinglePage"),
Some("continuous") => cat.push_str("/PageLayout/OneColumn"),
Some("twoUp") => cat.push_str("/PageLayout/TwoPageLeft"),
Some("twoUpCover") => cat.push_str("/PageLayout/TwoPageRight"),
Some("twoUpContinuous") => cat.push_str("/PageLayout/TwoColumnRight"),
Some(o) => return Err(bad("file.exportPdf", format!("unknown pageLayout `{o}`"))),
None => {}
}
if let Some(v) = str_param(p, "view") {
// The first page opens with this view (pages are objects in order, so refer by index 0).
let dest = match v {
"fitPage" => "/Fit",
"fitWidth" => "/FitH null",
"actual" => "/XYZ null null 1",
o => return Err(bad("file.exportPdf", format!("unknown view `{o}` (fitPage, fitWidth, actual)"))),
};
if let Some(first) = first_page_ref(&r.bytes) {
cat.push_str(&format!("/OpenAction[{first} 0 R{dest}]"));
}
}
if !cat.is_empty() {
match designcraft_pdf::add_catalog_entries(&r.bytes, &cat) {
Some(b) => r.bytes = b,
None => r.warnings.push("the PDF view options couldn't be set".into()),
}
}
// Flip pages every N seconds (presentation).
if let Some(secs) = p.get("advanceSeconds").and_then(Value::as_f64).filter(|s| *s > 0.0) {
let n = designcraft_pdf::sheet_spreads(doc, &opts).len();
let dur: Vec<Option<String>> = (0..n).map(|_| Some(format!("/Dur {secs:.2}"))).collect();
match designcraft_pdf::add_page_entries(&r.bytes, &dur) {
Some(b) => r.bytes = b,
None => r.warnings.push("page timings couldn't be set".into()),
}
}
// Page transitions (each spread's first page holds them).
let trans: Vec<Option<designcraft_doc::PageTransition>> =
spreads.into_iter().map(|si| doc.spreads.get(si).and_then(|sp| sp.pages.first()).and_then(|pg| pg.transition)).collect();
if trans.iter().any(Option::is_some) {
match designcraft_pdf::add_transitions(&r.bytes, &trans) {
Some(b) => r.bytes = b,
None => r.warnings.push("page transitions couldn't be added to this PDF".into()),
}
}
if fx_count > 0 {
r.warnings.push(format!("{fx_count} object(s) with soft effects (shadows, glows, feathers, bevels) or knockout groups exported as images"));
}
if layer_count > 0 {
r.warnings.push(format!("{layer_count} placed PDF(s) with hidden layers exported as images"));
}
if let Some((_, n)) = &flattened
&& *n > 0
{
r.warnings.push(format!("transparency flattened: {n} region(s) rasterised"));
}
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<()> {
crate::io::write_file(path, bytes).map_err(EngineError::Other)
}
#[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;
fn unzip(b64: &str) -> zip::ZipArchive<std::io::Cursor<Vec<u8>>> {
zip::ZipArchive::new(std::io::Cursor::new(super::super::file::base64_decode(b64))).unwrap()
}
fn read(z: &mut zip::ZipArchive<std::io::Cursor<Vec<u8>>>, 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 object_export_options() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
// A plain rectangle rasterised for EPUB, centred, after a page break.
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();
s.execute(
"object.exportOptions",
&json!({"ids": [r["id"]], "rasterize": true, "align": "center", "pageBreakBefore": true, "altText": "A dot"}),
)
.unwrap();
let e = s.execute("file.exportEpub", &json!({})).unwrap();
let mut z = unzip(e["base64"].as_str().unwrap());
let x = read(&mut z, "OEBPS/content.xhtml");
assert!(
x.contains("<figure style=\"text-align:center;page-break-before:always;break-before:page;\"><img") && x.contains("alt=\"A dot\""),
"{x}"
);
// Tagged PDF: an artifact object adds no figure.
let pdf = |s: &mut Session| {
String::from_utf8_lossy(&super::super::file::base64_decode(
s.execute("file.exportPdf", &json!({"tagged": true})).unwrap()["base64"].as_str().unwrap(),
))
.matches("/S/Figure")
.count()
};
let figures = pdf(&mut s);
s.execute("object.exportOptions", &json!({"ids": [r["id"]], "artifact": true})).unwrap();
assert!(pdf(&mut s) < figures);
}
#[test]
fn epub_cover_and_fixed_layout() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2})).unwrap();
s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Page one words", "caret": false})).unwrap();
let r = s.execute("file.exportEpub", &json!({"cover": true})).unwrap();
assert_eq!(r["warnings"], json!([]), "no images, nothing to report");
let mut z = unzip(r["base64"].as_str().unwrap());
let opf = read(&mut z, "OEBPS/content.opf");
assert!(opf.contains("cover-image") && opf.contains("<itemref idref=\"cover\"/>"));
assert!(z.by_name("OEBPS/images/cover.png").is_ok());
let r = s.execute("file.exportFixedEpub", &json!({})).unwrap();
assert_eq!(r["pages"], 2);
let mut z = unzip(r["base64"].as_str().unwrap());
let opf = read(&mut z, "OEBPS/content.opf");
assert!(opf.contains("rendition:layout\">pre-paginated") && opf.contains("page2.xhtml"));
let p1 = read(&mut z, "OEBPS/page1.xhtml");
assert!(p1.contains("width=612, height=792") && p1.contains("Page one words"), "{p1}");
}
/// #775: the fallback follows the font's class, and embedFonts packages the stylesheet's fonts
/// (and reports those it can't).
#[test]
fn epub_font_fallbacks_and_embedding() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Sans", "chars": {"fontFamily": "Source Sans 3", "fontStyle": "Regular"}})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Gone", "chars": {"fontFamily": "No Such Font Family", "fontStyle": "Regular"}}))
.unwrap();
s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Words", "caret": false})).unwrap();
let r = s.execute("file.exportEpub", &json!({})).unwrap();
let mut z = unzip(r["base64"].as_str().unwrap());
let css = read(&mut z, "OEBPS/style.css");
assert!(css.contains("font-family: \"Source Sans 3\", sans-serif;"), "{css}");
assert!(!css.contains("@font-face") && r["warnings"] == json!([]), "nothing embedded unless asked");
let r = s.execute("file.exportEpub", &json!({"embedFonts": true})).unwrap();
let mut z = unzip(r["base64"].as_str().unwrap());
let css = read(&mut z, "OEBPS/style.css");
assert!(css.contains("@font-face { font-family: \"Source Sans 3\"; src: url(\"fonts/"), "{css}");
let opf = read(&mut z, "OEBPS/content.opf");
let font = opf.split("href=\"fonts/").nth(1).and_then(|r| r.split('"').next()).map(|n| format!("OEBPS/fonts/{n}"));
assert!(font.as_ref().is_some_and(|f| z.by_name(f).is_ok_and(|e| e.size() > 1000)), "{opf}");
let warnings = r["warnings"].as_array().unwrap();
assert!(warnings.iter().any(|w| w.as_str().unwrap().contains("No Such Font Family")), "{warnings:?}");
let html = s.execute("file.exportHtml", &json!({})).unwrap()["text"].as_str().unwrap().to_string();
assert!(html.contains("\"Source Sans 3\", sans-serif;"));
}
/// A font whose licence allows embedding bitmaps only (fsType bit 9) is not packaged whole,
/// and a Semibold style asks for the Semibold face's weight, not Bold's.
#[test]
fn epub_embedding_follows_bitmap_only_licences_and_weights() {
use designcraft_fonts::testing::{font_with, with_fs_type};
const BITMAP: &str = "DocFont Epub Bitmap Only";
for fs_type in [0x0200, 0x0208] {
designcraft_fonts::FontDb::global()
.add_font(with_fs_type(font_with(&format!("{BITMAP} {fs_type:04x}"), &['W']).unwrap(), fs_type).unwrap());
}
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1})).unwrap();
for fs_type in [0x0200, 0x0208] {
let family = format!("{BITMAP} {fs_type:04x}");
s.execute("style.paragraph.create", &json!({"name": family, "chars": {"fontFamily": family, "fontStyle": "Regular"}})).unwrap();
}
s.execute("style.paragraph.create", &json!({"name": "Semi", "chars": {"fontFamily": "Source Sans 3", "fontStyle": "Semibold"}})).unwrap();
s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Words", "caret": false})).unwrap();
let r = s.execute("file.exportEpub", &json!({"embedFonts": true})).unwrap();
let mut z = unzip(r["base64"].as_str().unwrap());
let css = read(&mut z, "OEBPS/style.css");
assert!(!css.contains(&format!("@font-face {{ font-family: \"{BITMAP}")), "{css}");
let warnings = r["warnings"].to_string();
for fs_type in [0x0200, 0x0208] {
assert!(warnings.contains(&format!("{BITMAP} {fs_type:04x} Regular`: its licence doesn't allow embedding")), "{warnings}");
}
let semi = css.lines().find(|l| l.contains(".semi {")).unwrap_or_default();
assert!(semi.contains("font-weight: 600;"), "{css}");
}
/// #771, #773: a cover image file and an identifier through the command; the package's
/// modified time is the export's.
#[test]
fn epub_cover_image_file_and_identifier() {
let dir = std::env::temp_dir().join(format!("dc-epub-cover-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let jpg = dir.join("cover.jpg");
std::fs::write(&jpg, [0xFF, 0xD8, 0xFF, 0xE0, 0, 16]).unwrap();
let bad = dir.join("cover.txt");
std::fs::write(&bad, b"not an image").unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1})).unwrap();
s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Words", "caret": false})).unwrap();
let r = s.execute("file.exportEpub", &json!({"coverImage": jpg.to_str().unwrap(), "identifier": "urn:isbn:9780000000002"})).unwrap();
let mut z = unzip(r["base64"].as_str().unwrap());
let opf = read(&mut z, "OEBPS/content.opf");
assert!(opf.contains("<dc:identifier id=\"bookid\">urn:isbn:9780000000002</dc:identifier>"), "{opf}");
assert!(opf.contains("href=\"images/cover.jpg\" media-type=\"image/jpeg\""), "{opf}");
assert!(!opf.contains("2026-01-01T00:00:00Z"), "the export time, not a fixed date: {opf}");
assert!(z.by_name("OEBPS/images/cover.jpg").is_ok());
assert!(s.execute("file.exportEpub", &json!({"coverImage": bad.to_str().unwrap()})).is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn flattener_rasterises_spreads_with_transparency() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 3})).unwrap();
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();
// Opaque: nothing to flatten.
let r = s.execute("file.exportPdf", &json!({"flatten": "low"})).unwrap();
assert!(r["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap().contains("flattened")));
s.execute("object.opacity", &json!({"ids": [id], "opacity": 0.5})).unwrap();
// Text away from the transparency stays live text.
s.execute("frame.create", &json!({"rect": [72, 400, 400, 450], "content": "text", "text": "Still vector", "caret": false})).unwrap();
let r = s.execute("file.exportPdf", &json!({"flatten": "low"})).unwrap();
let pdf = super::super::file::base64_decode(r["base64"].as_str().unwrap());
assert!(String::from_utf8_lossy(&pdf).contains("/FontFile"), "the text is still text");
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("flattened: 1 ")), "{r}");
assert_eq!(r["pages"], 3);
let bytes = super::super::file::base64_decode(r["base64"].as_str().unwrap());
assert_eq!(designcraft_render::pdf_page_count(&bytes), Some(3));
// The document itself is untouched.
assert!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(id.as_u64().unwrap())).is_some());
assert!(s.execute("file.exportPdf", &json!({"flatten": 5})).is_err());
// Interactive view options land in the catalog and on the pages.
let r = s.execute("file.exportPdf", &json!({"fullScreen": true, "pageLayout": "single", "view": "fitPage", "advanceSeconds": 5})).unwrap();
let t = String::from_utf8_lossy(&super::super::file::base64_decode(r["base64"].as_str().unwrap())).to_string();
assert!(
t.contains("/PageMode/FullScreen") && t.contains("/PageLayout/SinglePage") && t.contains("/OpenAction[") && t.contains("/Dur 5.00"),
"view options"
);
assert!(s.execute("file.exportPdf", &json!({"pageLayout": "sideways"})).is_err());
// Unflattened, the page blends in the document's blend space (CMYK for print).
let r = s.execute("file.exportPdf", &json!({})).unwrap();
let text = String::from_utf8_lossy(&super::super::file::base64_decode(r["base64"].as_str().unwrap())).to_string();
assert_eq!(text.matches("/S/Transparency/CS/DeviceCMYK").count(), 1, "only the page with transparency");
}
#[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 tagged = s.execute("file.exportText", &json!({"frame": r["id"], "format": "tagged"})).unwrap();
assert!(tagged["text"].as_str().unwrap().contains("<ParaStyle:"));
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:?}");
}
}
#[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<u8> {
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<u8> {
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 [
"<</Type/Catalog/Pages 2 0 R/Version /1.7>>".to_owned(),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_owned(),
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]/Contents 4 0 R/Resources<<>>>>".to_owned(),
format!("<</Length {}>>\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<</Size 5/Root 1 0 R>>\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<u8> {
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
}
/// The placed PDF's transparency group blended in DeviceRGB in a print document
/// (`/Group<</Type/Group/S/Transparency/I true/CS/DeviceRGB>>`).
#[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!({"compress": false})).unwrap();
String::from_utf8_lossy(&base64_decode(r["base64"].as_str().unwrap())).into_owned()
};
let t = text(&mut s);
assert!(t.contains("/S/Transparency/I true/CS/DeviceCMYK>>"), "CMYK 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<std::io::Cursor<Vec<u8>>> {
zip::ZipArchive::new(std::io::Cursor::new(super::super::file::base64_decode(b64))).unwrap()
}
fn read(z: &mut zip::ZipArchive<std::io::Cursor<Vec<u8>>>, 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("<a:tbl>"));
assert!(read(&mut z, "ppt/slides/slide1.xml").contains("<p:pic>"));
let text: String = read(&mut z, "ppt/slides/slide2.xml").split("<a:t>").skip(1).filter_map(|t| t.split("</a:t>").next()).collect();
assert!(text.contains("Notes on the Grid"), "{text}");
assert!(read(&mut z, "ppt/presentation.xml").contains("<p:embeddedFontLst>"));
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("<p:pic>") && !x.contains("prst=\"ellipse\""), "{x}");
}
}