From d9fb8cfbfaf11976192e866f09ec1b06e3fa3229 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:47 +0000 Subject: [PATCH] refactor(engine): move the text_tests module of cmd/export.rs to cmd/export/text_tests.rs --- crates/engine/src/cmd/export.rs | 392 +-------------------- crates/engine/src/cmd/export/text_tests.rs | 378 ++++++++++++++++++++ 2 files changed, 379 insertions(+), 391 deletions(-) create mode 100644 crates/engine/src/cmd/export/text_tests.rs diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 2dad2d820..f308f45a3 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -1216,397 +1216,7 @@ fn write_file(path: &str, _bytes: &[u8]) -> Result<()> { } #[cfg(test)] -mod text_tests { - use serde_json::json; - - use crate::Session; - - fn unzip(b64: &str) -> zip::ZipArchive>> { - zip::ZipArchive::new(std::io::Cursor::new(super::super::file::base64_decode(b64))).unwrap() - } - - fn read(z: &mut zip::ZipArchive>>, name: &str) -> String { - let mut s = String::new(); - std::io::Read::read_to_string(&mut z.by_name(name).unwrap(), &mut s).unwrap(); - s - } - - /// 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("
")); - 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("urn:isbn:9780000000002"), "{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 { - 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::>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image")) - .filter_map(|s| match s.dict().get::>(b"ColorSpace")? { - Object::Name(n) => Some(n.as_str().to_string()), - Object::Array(a) => { - let mut it = a.iter::>(); - let family = it.next()?.into_name()?.as_str().to_string(); - let names = match it.next() { - Some(Object::Array(n)) => n.iter::>().map(|n| n.as_str().to_string()).collect::>().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::>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image") || d.get::(b"BitsPerComponent") != Some(8) { - continue; - } - let Some(cs) = d.get::>(b"ColorSpace") else { continue }; - let mut it = cs.iter::>(); - 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 = names.iter::>().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::::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

")); - let tagged = s.execute("file.exportText", &json!({"frame": r["id"], "format": "tagged"})).unwrap(); - assert!(tagged["text"].as_str().unwrap().contains(" zip::ZipArchive>> { + zip::ZipArchive::new(std::io::Cursor::new(super::super::file::base64_decode(b64))).unwrap() +} + +fn read(z: &mut zip::ZipArchive>>, name: &str) -> String { + let mut s = String::new(); + std::io::Read::read_to_string(&mut z.by_name(name).unwrap(), &mut s).unwrap(); + s +} + +/// 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("
")); + 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("urn:isbn:9780000000002"), "{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 { + 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::>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image")) + .filter_map(|s| match s.dict().get::>(b"ColorSpace")? { + Object::Name(n) => Some(n.as_str().to_string()), + Object::Array(a) => { + let mut it = a.iter::>(); + let family = it.next()?.into_name()?.as_str().to_string(); + let names = match it.next() { + Some(Object::Array(n)) => n.iter::>().map(|n| n.as_str().to_string()).collect::>().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::>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image") || d.get::(b"BitsPerComponent") != Some(8) { + continue; + } + let Some(cs) = d.get::>(b"ColorSpace") else { continue }; + let mut it = cs.iter::>(); + 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 = names.iter::>().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::::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

")); + let tagged = s.execute("file.exportText", &json!({"frame": r["id"], "format": "tagged"})).unwrap(); + assert!(tagged["text"].as_str().unwrap().contains("