mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 12:16:53 -06:00
1927 lines
98 KiB
Rust
1927 lines
98 KiB
Rust
//! 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<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; 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<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(&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<String>) {
|
||
// 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<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 (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<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, 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<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
|
||
}
|
||
|
||
/// 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<designcraft_doc::EpubSplit> {
|
||
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<Value> {
|
||
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<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),
|
||
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<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()
|
||
.and_then(|n| usize::try_from(n).ok())
|
||
.filter(|n| *n >= 1 && *n <= page_count)
|
||
.ok_or_else(|| bad(id, format!("bad page {x}")))?;
|
||
// A page named twice is one page, as `"2,2"` already is: the list says which
|
||
// pages to export, not how many times. `[2, 2]` exported two copies of page 2
|
||
// while `"2,2"` exported one.
|
||
if !v.contains(&(n - 1)) {
|
||
v.push(n - 1);
|
||
}
|
||
}
|
||
Some(v)
|
||
}
|
||
Some(Value::Number(n)) => {
|
||
let n = n
|
||
.as_u64()
|
||
.and_then(|n| usize::try_from(n).ok())
|
||
.filter(|n| *n >= 1 && *n <= page_count)
|
||
.ok_or_else(|| bad(id, format!("bad page {n}")))?;
|
||
Some(vec![n - 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, x1a, a2b)")))?,
|
||
None => Standard::None,
|
||
};
|
||
let created = match p.get("created") {
|
||
None | Some(Value::Null) => None,
|
||
Some(v) => Some(v.as_i64().ok_or_else(|| bad(ID, "`created`: Unix seconds, an integer"))?),
|
||
};
|
||
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),
|
||
created,
|
||
..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.parent_index(id))
|
||
.is_some_and(|pi| d.parent_chain(pi).iter().filter_map(|&i| d.parents.get(i)).any(|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 {
|
||
id: aid,
|
||
name: format!("flattened-{}.png", aid.0),
|
||
mime: "image/png".into(),
|
||
data: std::sync::Arc::new(img.to_png()),
|
||
pixels: Some((w, h)),
|
||
..Default::default()
|
||
}),
|
||
);
|
||
let mut it = Item::new(ItemId(out.alloc()), layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(r));
|
||
it.content = Content::Graphic(Graphic {
|
||
layer_options: None,
|
||
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),
|
||
clip: None,
|
||
auto_fit: Default::default(),
|
||
fit_align: 4,
|
||
crop: [0.0; 4],
|
||
wrap: Default::default(),
|
||
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;
|
||
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). Objects in `skip`
|
||
/// stay as they are.
|
||
fn rasterize_effects(
|
||
d: &designcraft_doc::Document,
|
||
cache: &designcraft_compose::Cache,
|
||
skip: &std::collections::HashMap<designcraft_doc::ItemId, designcraft_pdf::ShadowMask>,
|
||
) -> (Option<designcraft_doc::Document>, usize) {
|
||
rasterize_where(d, cache, 300.0, true, |it| raster_effects(it) && !threaded(d, it) && !skip.contains_key(&it.id))
|
||
}
|
||
|
||
/// A drop shadow is the object's only soft effect, and the PDF writer draws its shadow itself
|
||
/// (a leaf object, not a group or a frame with items pasted into it).
|
||
fn shadow_only(it: &designcraft_doc::Item) -> bool {
|
||
let e = &it.effects;
|
||
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
|
||
|| e.gradient_feather.on)
|
||
&& it.children().is_empty()
|
||
&& !it.has_nested_items()
|
||
}
|
||
|
||
/// For PDF/X-1a: the coverage of each spread object's soft drop shadow at `ppi`, so the PDF draws
|
||
/// the shadow in its own swatch's inks through a mask (which craft-prepress flattens per ink)
|
||
/// instead of an RGB image of the object. Objects whose only soft effect is the drop shadow.
|
||
///
|
||
/// The coverage comes from the object drawn alone, at full opacity and Normal blending, with its
|
||
/// shadow and without it ([`shadow_coverage`]): the shadow's own alpha, its opacity included. The
|
||
/// object's own opacity and blend mode stay on the object's group in the PDF.
|
||
fn shadow_masks(
|
||
d: &designcraft_doc::Document,
|
||
cache: &designcraft_compose::Cache,
|
||
ppi: f64,
|
||
) -> std::collections::HashMap<designcraft_doc::ItemId, designcraft_pdf::ShadowMask> {
|
||
use designcraft_doc::SpreadRef;
|
||
let mut out = std::collections::HashMap::new();
|
||
let todo: Vec<(usize, designcraft_doc::ItemId)> = d
|
||
.spreads
|
||
.iter()
|
||
.enumerate()
|
||
.flat_map(|(si, sp)| sp.items.iter().filter(|it| !it.hidden && shadow_only(it)).map(move |it| (si, it.id)))
|
||
.collect();
|
||
if todo.is_empty() {
|
||
return out;
|
||
}
|
||
let mut rr = designcraft_render::Renderer::new();
|
||
rr.threads = designcraft_render::default_threads();
|
||
let k = ppi.clamp(36.0, 1200.0) / 72.0;
|
||
for (si, id) in todo {
|
||
let Some(it) = d.item(id) else { continue };
|
||
let grow = designcraft_render::effect_outset(it) + it.stroke.extent() + 2.0;
|
||
let reach = it.bounds().inflate(grow, grow);
|
||
let (w, h) = ((reach.width() * k).ceil().max(1.0), (reach.height() * k).ceil().max(1.0));
|
||
if !(w.is_finite() && h.is_finite()) || w * h > 60_000_000.0 {
|
||
continue;
|
||
}
|
||
let (w, h) = (w as u32, h as u32);
|
||
let mut alpha = |shadow: bool| {
|
||
let mut solo = d.clone();
|
||
let Some(sp) = solo.spreads.get_mut(si) else { return Vec::new() };
|
||
let sp = std::sync::Arc::make_mut(sp);
|
||
sp.items.retain(|x| x.id == id);
|
||
for pg in &mut sp.pages {
|
||
pg.show_parent_items = false;
|
||
}
|
||
if let Some(x) = sp.items.first_mut() {
|
||
let x = std::sync::Arc::make_mut(x);
|
||
x.opacity = 1.0;
|
||
x.blend = Default::default();
|
||
x.effects.drop_shadow.on = shadow;
|
||
}
|
||
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 placed = [designcraft_render::Placed { spread: SpreadRef::Doc(si), xf: designcraft_geom::Affine::IDENTITY }];
|
||
rr.render(&solo, cache, &placed, w, h, view, &opts).pixels.as_chunks::<4>().0.iter().map(|px| px[3]).collect::<Vec<u8>>()
|
||
};
|
||
let (with, without) = (alpha(true), alpha(false));
|
||
let n = w as usize * h as usize;
|
||
if with.len() != n || without.len() != n {
|
||
continue;
|
||
}
|
||
let coverage: Vec<u8> = with.iter().zip(&without).map(|(a, b)| shadow_coverage(*a, *b)).collect();
|
||
out.insert(id, designcraft_pdf::ShadowMask { rect: reach, width: w, height: h, coverage: std::sync::Arc::new(coverage) });
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The shadow's alpha `s` at a pixel where the object alone has alpha `without` and the object
|
||
/// over its shadow `with`: `with = a + s(1 − a)` for the object's alpha `a`, so the PDF, which
|
||
/// draws the object over the shadow, gets the same pixel back. 0 where the object is opaque.
|
||
fn shadow_coverage(with: u8, without: u8) -> u8 {
|
||
let rest = 255 - u32::from(without);
|
||
if rest == 0 {
|
||
return 0;
|
||
}
|
||
let shown = u32::from(with.saturating_sub(without));
|
||
u8::try_from(((shown * 255 + rest / 2) / rest).min(255)).unwrap_or(u8::MAX)
|
||
}
|
||
|
||
/// A copy of `d` where the spread-level objects `pred` picks are images of their appearance at
|
||
/// `ppi` (transparent around them). With `keep_wrap`, each object also stays behind as a
|
||
/// nonprinting [`wrap_stand_in`], so text wraps around it as before.
|
||
fn rasterize_where(
|
||
d: &designcraft_doc::Document,
|
||
cache: &designcraft_compose::Cache,
|
||
ppi: f64,
|
||
keep_wrap: bool,
|
||
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 {
|
||
id: aid,
|
||
name: format!("effects-{}.png", id.0),
|
||
mime: "image/png".into(),
|
||
data: std::sync::Arc::new(img.to_png()),
|
||
pixels: Some((w, h)),
|
||
..Default::default()
|
||
}),
|
||
);
|
||
let mut img_item = Item::new(ItemId(out.alloc()), it.layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(reach));
|
||
img_item.content = Content::Graphic(Graphic {
|
||
layer_options: None,
|
||
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),
|
||
clip: None,
|
||
auto_fit: Default::default(),
|
||
fit_align: 4,
|
||
crop: [0.0; 4],
|
||
wrap: Default::default(),
|
||
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);
|
||
if keep_wrap && has_wrap(it, 0) {
|
||
osp.items.insert(pos, std::sync::Arc::new(wrap_stand_in(it, 0)));
|
||
}
|
||
n += 1;
|
||
}
|
||
}
|
||
(Some(out), n)
|
||
}
|
||
|
||
/// Whether `it`, its placed graphic or an item in it wraps text.
|
||
fn has_wrap(it: &designcraft_doc::Item, depth: usize) -> bool {
|
||
use designcraft_doc::{Content, WrapMode};
|
||
if depth > MAX_STAND_IN_DEPTH {
|
||
return false;
|
||
}
|
||
it.wrap.mode != WrapMode::None
|
||
|| match &it.content {
|
||
Content::Graphic(g) => g.wrap.mode != WrapMode::None,
|
||
Content::Group { items } => items.iter().any(|c| has_wrap(c, depth + 1)),
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
/// Nesting depth past which groups inside a rasterised object keep no wrap stand-ins.
|
||
const MAX_STAND_IN_DEPTH: usize = 64;
|
||
|
||
/// A nonprinting copy of `it` that keeps its text wrap (and its graphic's and its items') and
|
||
/// nothing else that reaches the PDF: no text, form field, button or media. The image of the
|
||
/// object stands in for all of that, as it does without the copy.
|
||
fn wrap_stand_in(it: &designcraft_doc::Item, depth: usize) -> designcraft_doc::Item {
|
||
use designcraft_doc::Content;
|
||
let mut c = it.clone();
|
||
c.nonprinting = true;
|
||
c.form_field = None;
|
||
c.button = None;
|
||
c.media = None;
|
||
c.content = match &it.content {
|
||
Content::Text(_) => Content::Unassigned,
|
||
Content::Group { items } if depth < MAX_STAND_IN_DEPTH => {
|
||
Content::Group { items: items.iter().map(|i| std::sync::Arc::new(wrap_stand_in(i, depth + 1))).collect() }
|
||
}
|
||
Content::Group { .. } => Content::Group { items: Vec::new() },
|
||
other => other.clone(),
|
||
};
|
||
c
|
||
}
|
||
|
||
/// A copy of `d` where placed PDFs with Object Layer Options hidden layers are 300 ppi images.
|
||
///
|
||
/// Parent-page and anchored placements count too, including anchors inside table-cell,
|
||
/// footnote and endnote text. Only the export copy is rewritten.
|
||
fn rasterize_layered(d: &designcraft_doc::Document) -> Result<(Option<designcraft_doc::Document>, usize)> {
|
||
let choices: Vec<_> = d
|
||
.placed_items()
|
||
.into_iter()
|
||
.filter(|it| it.has_pdf_layer_override())
|
||
.filter_map(|it| {
|
||
let g = it.graphic()?;
|
||
let a = d.assets.get(&g.asset).filter(|a| designcraft_render::is_pdf(&a.data))?;
|
||
Some((it.id, g.clone(), a.clone(), it.pdf_hidden_layers.clone()))
|
||
})
|
||
.collect();
|
||
if choices.is_empty() {
|
||
return Ok((None, 0));
|
||
}
|
||
let mut out = d.clone();
|
||
let mut images = std::collections::HashMap::new();
|
||
let mut n = 0;
|
||
for (id, g, a, hidden) in choices {
|
||
// The whole page at 300 ppi (the graphic may show only a box of it).
|
||
let shown = designcraft_render::shown_box(&a);
|
||
let [l, t, r, b] = shown.unwrap_or([0.0, 0.0, 1.0, 1.0]);
|
||
let page_side = (g.size.0 / (r - l)).max(g.size.1 / (b - t));
|
||
let side = (page_side * 300.0 / 72.0).clamp(64.0, 8000.0) as u32;
|
||
let png = designcraft_render::pdf_page_png_options_checked(&a.data, a.page as usize, side, Some(&hidden), shown)
|
||
.map_err(|e| EngineError::Other(format!("{}: {e}", a.name)))?;
|
||
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 {
|
||
id: aid,
|
||
name: format!("{}.png", a.name),
|
||
mime: "image/png".into(),
|
||
data: std::sync::Arc::new(png),
|
||
pixels: px,
|
||
..Default::default()
|
||
}),
|
||
);
|
||
images.insert(id, aid);
|
||
n += 1;
|
||
}
|
||
out.update_placed_items(
|
||
|it| images.contains_key(&it.id),
|
||
|it| {
|
||
if let designcraft_doc::Content::Graphic(gg) = &mut it.content
|
||
&& let Some(aid) = images.get(&it.id)
|
||
{
|
||
gg.asset = *aid;
|
||
}
|
||
},
|
||
);
|
||
Ok((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 r = pdf_export(s, p)?;
|
||
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})),
|
||
}
|
||
}
|
||
|
||
/// Transparency Flattener presets (`flatten`): High / Medium / Low Resolution, or a ppi.
|
||
pub(crate) fn flatten_ppi(id: &str, p: &Value) -> Result<Option<f64>> {
|
||
Ok(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(id, "`flatten`: high|medium|low or 36–1200 ppi"))?),
|
||
})
|
||
}
|
||
|
||
/// The print PDF of `opts`' pages: the transparency flattener at `ppi` (if any), placed PDFs
|
||
/// with hidden layers and soft effects as images, the PDF boxes of placed graphics, the PDF
|
||
/// itself and its transparency blend space. Export PDF and File › Print add their view options
|
||
/// to it ([`pdf_export`]); Separations Preview separates it as it is.
|
||
pub(crate) fn print_pdf(
|
||
d: &designcraft_doc::Document,
|
||
cache: &designcraft_compose::Cache,
|
||
opts: &PdfOptions,
|
||
ppi: Option<f64>,
|
||
) -> Result<designcraft_pdf::ExportReport> {
|
||
// PDF/X-1a: craft-prepress flattens the PDF in its inks; `ppi` sets its resolution.
|
||
let x1a = opts.standard == Standard::PdfX1a;
|
||
let mut opts = opts.clone();
|
||
if x1a {
|
||
opts.flatten_dpi = ppi.unwrap_or(300.0) as f32;
|
||
}
|
||
let flattened = if x1a { None } else { ppi.map(|ppi| flatten(d, cache, ppi)) };
|
||
let base: &designcraft_doc::Document = flattened.as_ref().map_or(d, |f| &f.0);
|
||
let (layered, layer_count) = if opts.standard == Standard::PdfA2b { rasterize_layered(base)? } else { (None, 0) };
|
||
let base2: &designcraft_doc::Document = layered.as_ref().unwrap_or(base);
|
||
let shadows = if x1a { shadow_masks(base2, cache, f64::from(opts.flatten_dpi)) } else { Default::default() };
|
||
let (fx_doc, fx_count) = rasterize_effects(base2, cache, &shadows);
|
||
opts.shadows = std::sync::Arc::new(shadows);
|
||
let opts = &opts;
|
||
let doc: &designcraft_doc::Document = fx_doc.as_ref().unwrap_or(base2);
|
||
let boxed = super::interchange::with_pdf_boxes(doc);
|
||
let doc: &designcraft_doc::Document = &boxed;
|
||
let mut r = designcraft_pdf::export_pdf_with_report(doc, cache, opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||
// Flattening and effect rasterization replace placements, so inspect the source too.
|
||
if d.has_graphic_layer_visibility_overrides() && !r.warnings.iter().any(|w| w == designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING) {
|
||
r.warnings.push(designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING.into());
|
||
}
|
||
// Transparency Blend Space: the page group of pages with transparency. CMYK print output
|
||
// gets its CMYK page groups from the writer.
|
||
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 !designcraft_pdf::print_color(doc, opts) && 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()),
|
||
}
|
||
}
|
||
if fx_count > 0 {
|
||
r.warnings.push(format!("{fx_count} object(s) with soft effects (shadows, glows, feathers, bevels) 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"));
|
||
}
|
||
Ok(r)
|
||
}
|
||
|
||
/// The PDF that File › Export PDF writes for `p`, with its warnings: the print PDF
|
||
/// ([`print_pdf`]) with its view options, timings and transitions. File › Print prints it.
|
||
pub(crate) fn pdf_export(s: &Session, p: &Value) -> Result<designcraft_pdf::ExportReport> {
|
||
let st = s.doc()?;
|
||
let opts = options(p, st.doc.page_count())?;
|
||
let ppi = flatten_ppi("file.exportPdf", p)?;
|
||
let mut r = print_pdf(&st.doc, &s.cache, &opts, ppi)?;
|
||
let doc: &designcraft_doc::Document = &st.doc;
|
||
let spreads = designcraft_pdf::sheet_spreads(doc, &opts);
|
||
// 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()),
|
||
}
|
||
}
|
||
Ok(r)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod placed_layer_tests {
|
||
use std::sync::Arc;
|
||
|
||
use designcraft_doc::{AnchoredObject, Document, Item, ItemId, SpreadRef, StoryId, Table};
|
||
use designcraft_geom::Rect;
|
||
use serde_json::json;
|
||
|
||
use crate::Session;
|
||
|
||
fn two_layer_pdf() -> Vec<u8> {
|
||
let content = "/OC /a BDC 0 0 1 rg 0 0 50 100 re f EMC /OC /b BDC 1 0 0 rg 50 0 50 100 re f EMC";
|
||
let objs = [
|
||
"<</Type/Catalog/Lang(en-GB)/Pages 2 0 R/OCProperties<</OCGs[5 0 R 6 0 R]/D<</Order[5 0 R 6 0 R]/Name(Default)/Locked[5 0 R]>>>>>>"
|
||
.to_string(),
|
||
"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_string(),
|
||
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<</Properties<</a 5 0 R/b 6 0 R>>>>/Contents 4 0 R>>".to_string(),
|
||
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
|
||
"<</Type/OCG/Name(Blue)>>".to_string(),
|
||
"<</Type/OCG/Name(Red)>>".to_string(),
|
||
];
|
||
let mut out = b"%PDF-1.7\n".to_vec();
|
||
let mut offs = Vec::new();
|
||
for (i, o) in objs.iter().enumerate() {
|
||
offs.push(out.len());
|
||
out.extend_from_slice(format!("{} 0 obj\n{}\nendobj\n", i + 1, o).as_bytes());
|
||
}
|
||
let x = out.len();
|
||
let mut t = format!("xref\n0 {}\n0000000000 65535 f\r\n", objs.len() + 1);
|
||
for o in offs {
|
||
t.push_str(&format!("{o:010} 00000 n\r\n"));
|
||
}
|
||
t.push_str(&format!("trailer\n<</Size {}/Root 1 0 R>>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1));
|
||
out.extend_from_slice(t.as_bytes());
|
||
out
|
||
}
|
||
|
||
fn placement<'a>(d: &'a Document, location: &str, id: ItemId, sid: StoryId) -> &'a Item {
|
||
match location {
|
||
"cell" => &d.story(sid).unwrap().tables.values().next().unwrap().cells[0].text.objects[0].item,
|
||
"footnote" => &d.story(sid).unwrap().notes[0].text.objects[0].item,
|
||
"endnote" => &d.story(sid).unwrap().endnotes[0].text.objects[0].item,
|
||
"anchor" => &d.story(sid).unwrap().objects[0].item,
|
||
_ => d.item(id).unwrap(),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn stale_layer_fields_on_non_pdf_placements_do_not_block_pdf_a() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let result = s
|
||
.execute(
|
||
"file.place",
|
||
&json!({
|
||
"base64": super::super::file::base64_encode(&two_layer_pdf()), "name": "layers.pdf",
|
||
}),
|
||
)
|
||
.unwrap();
|
||
let id = ItemId(result["id"].as_u64().unwrap());
|
||
s.execute("object.layerOptions", &json!({"ids": [id.0], "hidden": ["Blue"]})).unwrap();
|
||
// Relinking to an ordinary image can leave legacy fields on the placement.
|
||
// They are not an actionable placed-PDF override and must not fail closed.
|
||
let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||
let aid = d.item(id).unwrap().graphic().unwrap().asset;
|
||
let asset = Arc::make_mut(d.assets.get_mut(&aid).unwrap());
|
||
asset.mime = "image/png".into();
|
||
asset.data = Arc::new(designcraft_render::Rendered { width: 1, height: 1, pixels: vec![0, 0, 0, 0] }.to_png());
|
||
let (copy, count) = super::rasterize_layered(d).unwrap();
|
||
assert_eq!(count, 0);
|
||
assert!(copy.is_none());
|
||
s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap();
|
||
}
|
||
|
||
fn check_placement(location: &str) {
|
||
for all_on in [false, true] {
|
||
check_choice(location, all_on);
|
||
}
|
||
}
|
||
|
||
fn check_choice(location: &str, all_on: bool) {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap();
|
||
let placed = s
|
||
.execute(
|
||
"file.place",
|
||
&json!({
|
||
"base64": super::super::file::base64_encode(&two_layer_pdf()), "name": "layers.pdf",
|
||
"x": 0, "y": 0, "width": 100,
|
||
"spread": {"kind": if location == "parent" { "parent" } else { "doc" }, "index": 0}
|
||
}),
|
||
)
|
||
.unwrap();
|
||
let id = ItemId(placed["id"].as_u64().unwrap());
|
||
let hidden = if all_on { vec![] } else { vec!["Blue"] };
|
||
s.execute("object.layerOptions", &json!({"ids": [id.0], "hidden": hidden, "useDefaults": false})).unwrap();
|
||
let sid = {
|
||
let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||
let layer = d.default_layer();
|
||
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 80.0, 80.0), layer, "Body", Default::default()).unwrap();
|
||
if !matches!(location, "doc" | "parent") {
|
||
let item = (**d.spreads[0].items.iter().find(|it| it.id == id).unwrap()).clone();
|
||
Arc::make_mut(&mut d.spreads[0]).items.retain(|it| it.id != id);
|
||
let object = AnchoredObject { item, position: Default::default() };
|
||
let story = d.story_mut(sid).unwrap();
|
||
match location {
|
||
"cell" => {
|
||
let mut table = Table::new(1, 1, 1, 0, 0, 20.0);
|
||
table.cells[0].text.insert_object(0, object);
|
||
story.insert_table(0, table);
|
||
}
|
||
"footnote" => {
|
||
story.insert_note(0, "Note", Default::default()).unwrap();
|
||
Arc::make_mut(&mut story.notes[0]).text.insert_object(0, object);
|
||
}
|
||
"endnote" => {
|
||
story.insert_endnote(0, "Endnote", Default::default()).unwrap();
|
||
Arc::make_mut(&mut story.endnotes[0]).text.insert_object(0, object);
|
||
}
|
||
"anchor" => story.insert_object(0, object),
|
||
_ => assert!(matches!(location, "doc" | "parent")),
|
||
}
|
||
}
|
||
sid
|
||
};
|
||
// Exercise the real export command for each location, not just the traversal helper.
|
||
let result = s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap();
|
||
assert!(
|
||
result["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("1 placed PDF(s)")),
|
||
"{location}: {}",
|
||
result["warnings"]
|
||
);
|
||
// Check the rewritten placement, including its pixels. Counting a placement without
|
||
// updating the nested object would still export the file's default layer visibility.
|
||
let source = &s.doc().unwrap().doc;
|
||
let (copy, count) = super::rasterize_layered(source).unwrap();
|
||
assert_eq!(count, 1, "{location}");
|
||
let copy = copy.unwrap();
|
||
let item = placement(©, location, id, sid);
|
||
let asset = ©.assets[&item.graphic().unwrap().asset];
|
||
assert_eq!(asset.mime, "image/png", "{location}");
|
||
let image = designcraft_render::images::decoded(&asset.data, 0);
|
||
let image = image.unwrap();
|
||
let pixel = image.data()[usize::from(image.height()) / 2 * usize::from(image.width()) + usize::from(image.width()) / 4];
|
||
assert_eq!(pixel.a, if all_on { 255 } else { 0 }, "Blue visibility at {location}, all_on={all_on}");
|
||
// Export preparation must not change the open document's placement or source asset.
|
||
let original = placement(source, location, id, sid);
|
||
assert_eq!(original.pdf_hidden_layers, hidden);
|
||
assert!(original.pdf_layer_override);
|
||
assert_eq!(source.assets[&original.graphic().unwrap().asset].mime, "application/pdf");
|
||
}
|
||
|
||
#[test]
|
||
fn layered_pdf_a_document_placement() {
|
||
check_placement("doc");
|
||
}
|
||
#[test]
|
||
fn layered_pdf_a_parent_placement() {
|
||
check_placement("parent");
|
||
}
|
||
#[test]
|
||
fn layered_pdf_a_story_anchor() {
|
||
check_placement("anchor");
|
||
}
|
||
#[test]
|
||
fn layered_pdf_a_table_cell_anchor() {
|
||
check_placement("cell");
|
||
}
|
||
#[test]
|
||
fn layered_pdf_a_footnote_anchor() {
|
||
check_placement("footnote");
|
||
}
|
||
#[test]
|
||
fn layered_pdf_a_endnote_anchor() {
|
||
check_placement("endnote");
|
||
}
|
||
}
|
||
|
||
#[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
|
||
}
|
||
|
||
/// Split Document is stored with the document as one undo step, follows a renamed style, and
|
||
/// an export can override it without storing its own.
|
||
#[test]
|
||
fn epub_split_document_setting() {
|
||
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\nText\nTwo\nMore"})).unwrap();
|
||
let sid = r["story"].as_u64().unwrap();
|
||
s.execute("style.paragraph.create", &json!({"name": "Chapter"})).unwrap();
|
||
for at in [0, 9] {
|
||
s.execute("text.select", &json!({"story": sid, "anchor": at, "focus": at})).unwrap();
|
||
s.execute("style.paragraph.apply", &json!({"name": "Chapter"})).unwrap();
|
||
}
|
||
let files = |s: &mut Session, p: serde_json::Value| {
|
||
let mut z = unzip(s.execute("file.exportEpub", &p).unwrap()["base64"].as_str().unwrap());
|
||
(1..).take_while(|n| z.by_name(&if *n == 1 { "OEBPS/content.xhtml".into() } else { format!("OEBPS/content{n}.xhtml") }).is_ok()).count()
|
||
};
|
||
assert_eq!(s.execute("document.epubSplit", &json!({})).unwrap()["split"], "off");
|
||
assert_eq!(files(&mut s, json!({})), 1, "one content file by default");
|
||
assert!(s.execute("document.epubSplit", &json!({"split": {"style": "Nope"}})).is_err());
|
||
assert!(s.execute("document.epubSplit", &json!({"split": "h1"})).is_err());
|
||
assert!(s.execute("file.exportEpub", &json!({"split": {"style": "Nope"}})).is_err());
|
||
assert_eq!(s.execute("document.epubSplit", &json!({"split": {"style": "Chapter"}})).unwrap()["split"], json!({"style": "Chapter"}));
|
||
assert_eq!(files(&mut s, json!({})), 2);
|
||
assert_eq!(files(&mut s, json!({"split": "off"})), 1);
|
||
assert_eq!(s.doc().unwrap().doc.settings.epub_split, designcraft_doc::EpubSplit::Style("Chapter".into()), "the override isn't stored");
|
||
s.execute("style.paragraph.edit", &json!({"name": "Chapter", "rename": "Part"})).unwrap();
|
||
assert_eq!(s.execute("document.epubSplit", &json!({})).unwrap()["split"], json!({"style": "Part"}));
|
||
assert_eq!(files(&mut s, json!({})), 2);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(s.execute("document.epubSplit", &json!({})).unwrap()["split"], "off");
|
||
}
|
||
|
||
#[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();
|
||
}
|
||
|
||
/// The color spaces of every image XObject in `pdf`, as the families and colorant names.
|
||
fn image_spaces(pdf: &[u8]) -> Vec<String> {
|
||
use hayro_syntax::object::{Name, Object};
|
||
let file = hayro_syntax::Pdf::new(pdf.to_vec()).unwrap();
|
||
file.objects()
|
||
.into_iter()
|
||
.filter_map(|o| o.into_stream())
|
||
.filter(|s| s.dict().get::<Name<'_>>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image"))
|
||
.filter_map(|s| match s.dict().get::<Object<'_>>(b"ColorSpace")? {
|
||
Object::Name(n) => Some(n.as_str().to_string()),
|
||
Object::Array(a) => {
|
||
let mut it = a.iter::<Object<'_>>();
|
||
let family = it.next()?.into_name()?.as_str().to_string();
|
||
let names = match it.next() {
|
||
Some(Object::Array(n)) => n.iter::<Name<'_>>().map(|n| n.as_str().to_string()).collect::<Vec<_>>().join(" "),
|
||
Some(Object::Name(n)) => n.as_str().to_string(),
|
||
_ => String::new(),
|
||
};
|
||
Some(format!("{family} {names}"))
|
||
}
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// The (Black, Gold) samples of every flattened DeviceN image that has both colorants.
|
||
fn black_and_gold(pdf: &[u8]) -> Vec<(u8, u8)> {
|
||
use hayro_syntax::object::{Array, Name, Object};
|
||
let file = hayro_syntax::Pdf::new(pdf.to_vec()).unwrap();
|
||
let mut out = Vec::new();
|
||
for st in file.objects().into_iter().filter_map(|o| o.into_stream()) {
|
||
let d = st.dict();
|
||
if !d.get::<Name<'_>>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image") || d.get::<i32>(b"BitsPerComponent") != Some(8) {
|
||
continue;
|
||
}
|
||
let Some(cs) = d.get::<Array<'_>>(b"ColorSpace") else { continue };
|
||
let mut it = cs.iter::<Object<'_>>();
|
||
if !it.next().and_then(|o| o.into_name()).is_some_and(|n| n.as_ref() == b"DeviceN") {
|
||
continue;
|
||
}
|
||
let Some(Object::Array(names)) = it.next() else { continue };
|
||
let names: Vec<String> = names.iter::<Name<'_>>().map(|n| n.as_str().to_string()).collect();
|
||
let (Some(k), Some(g)) = (names.iter().position(|n| n == "Black"), names.iter().position(|n| n == "Gold")) else { continue };
|
||
let data = st.decoded().unwrap();
|
||
out.extend(data.chunks(names.len()).filter(|px| px.len() == names.len()).map(|px| (px[k], px[g])));
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The shadow coverage gives the rendered pixel back when the PDF draws the object over the
|
||
/// shadow, also under a partly transparent object (an anti-aliased edge, a soft-alpha image).
|
||
#[test]
|
||
fn shadow_coverage_composites_back() {
|
||
for a in (0..=255u32).step_by(15) {
|
||
for s in (0..=255u32).step_by(15) {
|
||
let over = |c: u32| a + (c * (255 - a) + 127) / 255;
|
||
let with = over(s) as u8;
|
||
let c = u32::from(super::shadow_coverage(with, a as u8));
|
||
assert!(over(c).abs_diff(u32::from(with)) <= 1, "object {a}, shadow {s}: coverage {c}");
|
||
}
|
||
}
|
||
assert_eq!(super::shadow_coverage(255, 255), 0);
|
||
assert_eq!(super::shadow_coverage(10, 20), 0);
|
||
}
|
||
|
||
/// PDF/X-1a through craft-prepress: a soft drop shadow over a spot, a 50% object and a
|
||
/// knockout group leave no transparency and no RGB; the shadow is flattened in the page's
|
||
/// inks (Black over the spot), not exported as an RGB image of the object; text stays text and
|
||
/// overprint stays `/op`; `flatten` sets the resolution.
|
||
#[test]
|
||
fn x1a_export_flattens_in_inks() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"pages": 1})).unwrap();
|
||
s.execute("swatch.create", &json!({"name": "Gold", "color": {"c": 0, "m": 30, "y": 90, "k": 10}, "spot": true})).unwrap();
|
||
let bg = s.execute("frame.create", &json!({"rect": [36, 36, 576, 756]})).unwrap()["id"].clone();
|
||
s.execute("object.fill", &json!({"ids": [bg], "swatch": "Gold"})).unwrap();
|
||
let shadowed = s.execute("frame.create", &json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].clone();
|
||
s.execute("object.fill", &json!({"ids": [shadowed], "swatch": "C=0 M=100 Y=0 K=0"})).unwrap();
|
||
s.execute("object.dropShadow", &json!({"ids": [shadowed], "on": true, "distance": 8, "size": 6})).unwrap();
|
||
let half = s.execute("frame.create", &json!({"rect": [300, 72, 400, 172]})).unwrap()["id"].clone();
|
||
s.execute("object.fill", &json!({"ids": [half], "swatch": "C=100 M=0 Y=0 K=0"})).unwrap();
|
||
s.execute("object.opacity", &json!({"ids": [half], "opacity": 0.5})).unwrap();
|
||
let a = s.execute("frame.create", &json!({"rect": [72, 300, 172, 400]})).unwrap()["id"].clone();
|
||
let b = s.execute("frame.create", &json!({"rect": [122, 350, 222, 450]})).unwrap()["id"].clone();
|
||
s.execute("object.fill", &json!({"ids": [a, b], "swatch": "C=0 M=0 Y=100 K=0"})).unwrap();
|
||
s.execute("object.opacity", &json!({"ids": [a, b], "opacity": 0.5})).unwrap();
|
||
let g = s.execute("object.group", &json!({"ids": [a, b]})).unwrap()["id"].clone();
|
||
s.execute("object.transparencyGroup", &json!({"ids": [g], "knockout": true})).unwrap();
|
||
let op = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400]})).unwrap()["id"].clone();
|
||
s.execute("object.fill", &json!({"ids": [op], "swatch": "C=0 M=100 Y=0 K=0"})).unwrap();
|
||
s.execute("object.attributes", &json!({"ids": [op], "overprintFill": true})).unwrap();
|
||
s.execute("frame.create", &json!({"rect": [72, 500, 400, 560], "content": "text", "text": "Live text", "caret": false})).unwrap();
|
||
|
||
let r = s.execute("file.exportPdf", &json!({"standard": "x1a", "flatten": "low"})).unwrap();
|
||
let pdf = super::super::file::base64_decode(r["base64"].as_str().unwrap());
|
||
let text = String::from_utf8_lossy(&pdf).to_string();
|
||
assert_eq!(designcraft_pdf::check_pdfx1a(&pdf), Vec::<String>::new());
|
||
assert!(pdf.starts_with(b"%PDF-1.4") && text.contains("/GTS_PDFXVersion(PDF/X-1:2003)") && text.contains("/S/GTS_PDFX"));
|
||
assert!(text.contains("/GTS_PDFXConformance(PDF/X-1a:2003)"), "the conformance level");
|
||
let warnings = r["warnings"].to_string();
|
||
assert!(!warnings.contains("soft effects"), "the shadow isn't an RGB image of the object: {warnings}");
|
||
assert!(warnings.contains("at 72 ppi"), "{warnings}");
|
||
let spaces = image_spaces(&pdf);
|
||
assert!(spaces.iter().all(|cs| !cs.contains("RGB") && !cs.contains("ICC")), "{spaces:?}");
|
||
assert!(
|
||
spaces.iter().any(|cs| cs.starts_with("DeviceN") && cs.contains("Black") && cs.contains("Gold")),
|
||
"the shadow over the spot: {spaces:?}"
|
||
);
|
||
// The shadow multiplies: the spot stays at 100% where the shadow's black is.
|
||
let shadowed: Vec<(u8, u8)> = black_and_gold(&pdf).into_iter().filter(|(k, _)| *k > 100).collect();
|
||
assert!(!shadowed.is_empty(), "no shadow pixels");
|
||
assert!(shadowed.iter().all(|(_, gold)| *gold >= 250), "the spot under the shadow: {:?}", &shadowed[..shadowed.len().min(8)]);
|
||
assert!(text.contains("/op true") && text.contains("/OPM 1"), "overprint");
|
||
assert!(text.contains("/FontFile"), "the text is still text");
|
||
// The default resolution.
|
||
let r = s.execute("file.exportPdf", &json!({"standard": "x1a"})).unwrap();
|
||
assert!(r["warnings"].to_string().contains("at 300 ppi"), "{}", r["warnings"]);
|
||
}
|
||
|
||
#[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 objects_with_soft_effects_keep_their_text_wrap_in_pdf() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let text = "Words wrap around the objects on this page. ".repeat(60);
|
||
let t = s.execute("frame.create", &json!({"rect": [36, 36, 576, 756], "content": "text", "text": text, "caret": false})).unwrap()["id"]
|
||
.as_u64()
|
||
.unwrap();
|
||
// A feathered frame with a contour wrap of the frame itself.
|
||
let a = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].clone();
|
||
s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [a]})).unwrap();
|
||
s.execute("object.textWrap", &json!({"ids": [a], "mode": "contour", "contour": "sameAsClipping", "offset": 6})).unwrap();
|
||
// A feathered frame whose placed graphic has a bounding-box wrap.
|
||
let png = designcraft_render::Rendered { width: 40, height: 40, pixels: vec![90; 40 * 40 * 4] }.to_png();
|
||
let b = s.execute("frame.create", &json!({"rect": [300, 400, 400, 500]})).unwrap()["id"].as_u64().unwrap();
|
||
s.execute("file.place", &json!({"base64": super::super::file::base64_encode(&png), "name": "a.png", "frame": b})).unwrap();
|
||
s.execute("object.textWrap", &json!({"ids": [b], "content": true, "mode": "boundingBox", "offset": 6})).unwrap();
|
||
s.execute("object.feather", &json!({"ids": [a, b], "width": 14})).unwrap();
|
||
|
||
let d = &s.doc().unwrap().doc;
|
||
let sid = d.item(designcraft_doc::ItemId(t)).and_then(|it| it.text_frame()).unwrap().story;
|
||
let before = designcraft_compose::frame_specs(d, sid)[0].exclusions.clone();
|
||
assert_eq!(before.len(), 2);
|
||
let (fx, n) = super::rasterize_effects(d, &s.cache, &Default::default());
|
||
assert_eq!(n, 2);
|
||
let fx = fx.unwrap();
|
||
assert_eq!(designcraft_compose::frame_specs(&fx, sid)[0].exclusions, before);
|
||
// The objects that keep the wrap don't print; their images do.
|
||
let sp = &fx.spreads[0];
|
||
assert_eq!(sp.items.iter().filter(|it| it.nonprinting).count(), 2);
|
||
assert_eq!(sp.items.iter().filter(|it| !it.nonprinting && it.graphic().is_some()).count(), 2);
|
||
}
|
||
|
||
#[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
|
||
}
|
||
|
||
/// 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<</Type/Group/S/Transparency/I true>>"), "placed page group without a blending space");
|
||
assert!(t.contains("/Group<</Type/Group/S/Transparency/CS/DeviceCMYK>>"), "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<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}");
|
||
}
|
||
}
|
||
|
||
#[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());
|
||
}
|
||
}
|