//! File › Place of text files (plain text, Word, RTF): into the text insertion point, the //! selected frame, or a new frame on the page; with autoflow, pages and threaded frames are added //! until the whole story fits (InDesign's Shift-click with a loaded text cursor). use designcraft_doc::{CharacterStyle, Content, Document, ParaFormat, ParagraphStyle, SpreadRef, Story, StoryId, TextSel}; use designcraft_geom::Rect; use serde_json::{Value, json}; use super::bad; use crate::{Result, Session}; /// Add the imported styles the document doesn't have yet (existing styles win, as in InDesign's /// default style-conflict option). /// How imported styles that share a name with the document's are handled (Word Import Options). #[derive(Clone, Copy, PartialEq, Eq)] enum Conflict { /// Use the document's definition. UseExisting, /// The imported definition replaces the document's. Redefine, /// Import under a new name (`Name_wrd_1`). AutoRename, } /// Map imported style names (`styleMap`) and resolve name clashes in the import before it lands. fn map_styles(d: &Document, imp: &mut designcraft_textimport::Imported, map: &std::collections::HashMap, conflict: Conflict) { let mut rename: std::collections::HashMap = map.clone(); if conflict == Conflict::AutoRename { let st = &d.styles; for s in imp.para_styles.iter().filter(|s| !map.contains_key(&s.name) && st.para(&s.name).is_some()) { if let Some(n) = (1..=st.paragraph.len() + 1).map(|i| format!("{}_wrd_{i}", s.name)).find(|n| st.para(n).is_none()) { rename.insert(s.name.clone(), n); } } for s in imp.char_styles.iter().filter(|s| !map.contains_key(&s.name) && st.char_style(&s.name).is_some()) { if let Some(n) = (1..=st.character.len() + 1).map(|i| format!("{}_wrd_{i}", s.name)).find(|n| st.char_style(n).is_none()) { rename.insert(s.name.clone(), n); } } } if rename.is_empty() { return; } // Mapped styles aren't imported; renamed ones come in under their new name. imp.para_styles.retain(|s| !map.contains_key(&s.name)); imp.char_styles.retain(|s| !map.contains_key(&s.name)); for s in imp.para_styles.iter_mut().chain(imp.char_styles.iter_mut()) { if let Some(n) = rename.get(&s.name) { s.name = n.clone(); } if let Some(b) = s.based_on.as_ref().and_then(|b| rename.get(b)) { s.based_on = Some(b.clone()); } } let n = imp.story.len(); imp.story.format_paras(0..n, |p| { if let Some(m) = rename.get(&p.style) { p.style = m.clone(); } }); imp.story.format_chars(0..n, |f| { if let Some(m) = rename.get(&f.style) { f.style = m.clone(); } }); } fn add_styles(d: &mut Document, imp: &designcraft_textimport::Imported, conflict: Conflict) { let st = d.styles_mut(); if conflict == Conflict::Redefine { for s in &imp.para_styles { if let Some(ps) = st.paragraph.iter_mut().find(|p| p.name == s.name) { (ps.para, ps.chars) = (s.para.clone(), s.chars.clone()); } } for s in &imp.char_styles { if let Some(cs) = st.character.iter_mut().find(|c| c.name == s.name) { cs.chars = s.chars.clone(); } } } for s in &imp.para_styles { if st.para(&s.name).is_none() { st.paragraph.push(ParagraphStyle { name: s.name.clone(), based_on: s.based_on.clone().or_else(|| Some(designcraft_doc::story::BASIC_PARAGRAPH.into())), next_style: None, para: s.para.clone(), chars: s.chars.clone(), shortcut: String::new(), }); } } for s in &imp.char_styles { if !st.character.iter().any(|c| c.name == s.name) { st.character.push(CharacterStyle { name: s.name.clone(), based_on: s.based_on.clone(), chars: s.chars.clone(), shortcut: String::new() }); } } } /// "Remove styles and formatting": every paragraph [Basic Paragraph], no local formatting. fn strip(story: &mut Story) { let n = story.len(); story.format_paras(0..n, |p| *p = ParaFormat { table: p.table, ..Default::default() }); story.format_chars(0..n, |f| *f = Default::default()); } /// Is the story overset in its frames, and how much text do its full frames hold (bytes, frames)? /// A frame a column, frame or page break ends early is left out: it says nothing about how much /// a frame holds. fn fit(d: &Document, sid: StoryId) -> (Option, usize, usize) { let cs = designcraft_compose::compose_story(d, sid, &Default::default()); let text = d.story(sid).map_or("", |s| s.text.as_str()); let is_break = |c: char| matches!(c, designcraft_doc::COLUMN_BREAK | designcraft_doc::FRAME_BREAK | designcraft_doc::PAGE_BREAK); let (mut held, mut full) = (0, 0); for f in &cs.frames { let Some(shown) = text.get(f.range.clone()) else { continue }; if !shown.is_empty() && !shown.chars().any(is_break) { held += shown.len(); full += 1; } } (cs.overset_at, held, full) } /// Thread new frames on new pages (margin rectangles) until the story fits. Returns pages added. /// A story on a parent spread doesn't autoflow: its frames thread only on that parent. Errors /// name `cmd`, the command that autoflows. pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &str) -> Result { let last = d.story(sid).and_then(|s| s.frames.last().copied()).ok_or_else(|| bad(cmd, "the story has no frame"))?; if !matches!(d.find(last).map(|l| l.spread), Some(SpreadRef::Doc(_))) { return Err(bad(cmd, "autoflow works only from a frame on a document page")); } let mut added = 0; loop { let (overset, held, full) = fit(d, sid); let Some(at) = overset else { return Ok(added) }; if added >= max_pages { return Err(bad(cmd, format!("autoflow stopped after {max_pages} pages"))); } let len = d.story(sid).map_or(0, |s| s.len()); // Frames still needed at the rate the full ones are filled (at least one); with no full // frame to go by yet, one more page. let want = held.checked_div(full).map_or(1, |per_frame| len.saturating_sub(at).div_ceil(per_frame.max(1))).clamp(1, max_pages - added); let last = *d.story(sid).and_then(|s| s.frames.last()).ok_or_else(|| bad(cmd, "the story has no frame"))?; let mut page = d.page_of_item(last).unwrap_or(d.page_count().saturating_sub(1)); let parent = d.page_loc(page).and_then(|(si, pi)| d.spreads[si].pages[pi].parent); d.insert_pages(Some(page), want, parent)?; let layer = d.default_layer(); let mut prev = last; for _ in 0..want { page += 1; let (si, pi) = d.page_loc(page).ok_or_else(|| bad(cmd, "page insert failed"))?; let r = d.spreads[si].pages[pi].margin_rect(); let (fid, _) = d.add_text_frame(SpreadRef::Doc(si), r, layer, "", ParaFormat::default())?; d.thread(prev, fid)?; prev = fid; } added += want; } } pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -> Result { let mut imp = designcraft_textimport::import(name, bytes).map_err(|e| bad("file.place", e.to_string()))?; let remove = p.get("removeStyles").and_then(Value::as_bool).unwrap_or(false); if remove { strip(&mut imp.story); imp.para_styles.clear(); imp.char_styles.clear(); } let conflict = match p.get("styleConflicts").and_then(Value::as_str).unwrap_or("useExisting") { "useExisting" => Conflict::UseExisting, "redefine" => Conflict::Redefine, "autoRename" => Conflict::AutoRename, c => return Err(bad("file.place", format!("unknown styleConflicts `{c}` (useExisting, redefine, autoRename)"))), }; let map: std::collections::HashMap = p .get("styleMap") .and_then(Value::as_object) .map(|o| o.iter().filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_string()))).collect()) .unwrap_or_default(); map_styles(&s.doc()?.doc, &mut imp, &map, conflict); let autoflow_on = p.get("autoflow").and_then(Value::as_bool).unwrap_or(false); let st = s.doc()?; let caret = st.selection.text.filter(|t| t.cell.is_none()); let frame = super::id_param(p, "frame").or_else(|| { st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Text(_)))) }); let page = p.get("page").and_then(Value::as_u64).map(|v| (v as usize).saturating_sub(1)); let rect = super::rect_param(p, "rect"); let warnings = imp.warnings.clone(); s.edit(|d, sel| { add_styles(d, &imp, conflict); let sid = if let Some(t) = caret { // Into the text at the insertion point (replacing the selection). let story = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?; let r = t.range(); story.delete(r.clone()); let end = story.insert_story(r.start, &imp.story); sel.text = Some(TextSel { anchor: end, focus: end, ..t }); t.story } else { let fid = match frame { Some(f) => f, None => { let pg = page.unwrap_or(0).min(d.page_count().saturating_sub(1)); let (si, pi) = d.page_loc(pg).ok_or_else(|| bad("file.place", "no such page"))?; let r: Rect = rect.unwrap_or_else(|| d.spreads[si].pages[pi].margin_rect()); let layer = d.default_layer(); d.add_text_frame(SpreadRef::Doc(si), r, layer, "", ParaFormat::default())?.0 } }; // Empty frames become text frames. let sid = match d.item(fid).map(|i| i.content.clone()) { Some(Content::Text(t)) => t.story, Some(_) => { let sid = StoryId(d.alloc()); let mut ns = Story::new(sid); ns.direction = d.new_story_direction(); ns.frames = vec![fid]; d.stories.insert(sid, std::sync::Arc::new(ns)); if let Some(it) = d.item_mut(fid) { it.content = Content::Text(designcraft_doc::TextFrame { story: sid, options: Default::default() }); } sid } None => return Err(bad("file.place", "no such frame")), }; let story = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; let n = story.len(); story.delete(0..n); story.insert_story(0, &imp.story); if let Some(first) = imp.story.paras.first() { story.paras[0] = ParaFormat { table: story.paras[0].table, ..first.clone() }; } *sel = designcraft_doc::Selection::items(vec![fid]); sid }; let pages = if autoflow_on { autoflow(d, sid, 2000, "file.place")? } else { 0 }; let overset = fit(d, sid).0.is_some(); Ok(json!({"story": sid.0, "pagesAdded": pages, "overset": overset, "warnings": warnings})) }) } #[cfg(test)] mod tests { use super::*; #[test] fn place_text_file_with_autoflow() { let dir = std::env::temp_dir().join(format!("dc-place-text-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("long.txt"); let para = "The quick brown fox jumps over the lazy dog, again and again, to fill the page. ".repeat(12); std::fs::write(&path, vec![para.as_str(); 60].join("\n")).unwrap(); let mut s = Session::new(); s.execute("file.new", &json!({"pages": 1})).unwrap(); let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "autoflow": true})).unwrap(); assert!(r["pagesAdded"].as_u64().unwrap() >= 2, "{r}"); assert_eq!(r["overset"], false); let d = &s.doc().unwrap().doc; let sid = StoryId(r["story"].as_u64().unwrap()); assert_eq!(d.story(sid).unwrap().frames.len(), d.page_count()); assert_eq!(d.story(sid).unwrap().paras.len(), 60); // Into the insertion point of another frame. let fr = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Start end"})).unwrap(); let sid2 = fr["story"].as_u64().unwrap(); s.execute("text.select", &json!({"story": sid2, "anchor": 6, "focus": 6})).unwrap(); let small = dir.join("small.rtf"); std::fs::write(&small, br"{\rtf1 {\b middle}}").unwrap(); s.execute("file.place", &json!({"path": small.to_string_lossy()})).unwrap(); let st = s.doc().unwrap().doc.story(StoryId(sid2)).unwrap().clone(); assert_eq!(st.text, "Start middle end"); assert_eq!(st.format_after(6).over.font_style.as_deref(), Some("Bold")); let _ = std::fs::remove_dir_all(&dir); } #[test] fn autoflow_adds_only_the_pages_breaks_ask_for() { // Regression (#262): a frame a break ends early made autoflow take it for a frame that // holds a few characters, and add a page per few characters left (33 for this text). let dir = std::env::temp_dir().join(format!("dc-place-breaks-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); for (name, rtf, pages) in [ ("lead.rtf", r"{\rtf1 \page\par A Short Heading\par First short paragraph.\par Second short paragraph.\par}", 2), ("each.rtf", r"{\rtf1 One\par\page\par Two\par\page\par Three\par\page\par Four\par}", 4), ] { let path = dir.join(name); std::fs::write(&path, rtf).unwrap(); let mut s = Session::new(); s.execute("file.new", &json!({"pages": 1})).unwrap(); let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "autoflow": true})).unwrap(); assert_eq!(r["overset"], false, "{name}: {r}"); let d = &s.doc().unwrap().doc; assert_eq!(d.page_count(), pages, "{name}: {r}"); assert_eq!(d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames.len(), pages, "{name}"); } let _ = std::fs::remove_dir_all(&dir); } } #[cfg(test)] mod place_pdf_tests { use serde_json::json; use crate::Session; #[test] fn place_pdf_renders_and_exports_as_vector() { let dir = std::env::temp_dir().join(format!("dc-place-pdf-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let src = dir.join("ad.pdf"); // A PDF made by our own exporter: a black square on a page. let mut s = Session::new(); s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); let id = s.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap()["id"].as_u64().unwrap(); s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap(); s.execute("file.exportPdf", &json!({"path": src.to_string_lossy()})).unwrap(); // Place it in a new document. s.execute("file.new", &json!({})).unwrap(); let r = s.execute("file.place", &json!({"path": src.to_string_lossy(), "x": 100, "y": 100})).unwrap(); let d = s.doc().unwrap().doc.clone(); let it = d.spreads[0].items.last().unwrap(); let b = it.bounds(); assert!((b.width() - 200.0).abs() < 1.0 && (b.height() - 100.0).abs() < 1.0, "sized by the PDF page: {b:?} {r}"); let asset = d.assets.values().next().unwrap(); assert_eq!(asset.mime, "application/pdf"); // On screen: the square is black, the rest of the placed page white. let mut rr = designcraft_render::Renderer::new(); let img = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(); assert!(img.pixel(150, 150)[0] < 90, "{:?}", img.pixel(150, 150)); assert!(img.pixel(250, 150)[0] > 200, "{:?}", img.pixel(250, 150)); // Exported: still a valid PDF, larger than a page without the placed PDF would be. let out = dir.join("out.pdf"); s.execute("file.exportPdf", &json!({"path": out.to_string_lossy()})).unwrap(); let bytes = std::fs::read(&out).unwrap(); let pdf = hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.clone())).expect("valid pdf"); assert_eq!(pdf.pages().len(), 1); let text = String::from_utf8_lossy(&bytes); assert!(text.contains("/Subtype /Form") || text.contains("/Subtype/Form"), "embedded as a form XObject"); assert!(!text.contains("/Subtype /Image") && !text.contains("/Subtype/Image"), "not rasterized"); let _ = std::fs::remove_dir_all(&dir); } #[test] fn place_svg_on_screen_and_as_vectors_in_pdf() { let dir = std::env::temp_dir().join(format!("dc-place-svg-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let src = dir.join("logo.svg"); std::fs::write( &src, r##""##, ) .unwrap(); let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); s.execute("file.place", &json!({"path": src.to_string_lossy(), "x": 100, "y": 100})).unwrap(); let d = s.doc().unwrap().doc.clone(); let b = d.spreads[0].items.last().unwrap().bounds(); assert!((b.width() - 120.0).abs() < 1.0 && (b.height() - 60.0).abs() < 1.0, "160×80 px = 120×60 pt: {b:?}"); assert_eq!(d.assets.values().next().unwrap().mime, "image/svg+xml"); let mut rr = designcraft_render::Renderer::new(); let img = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(); let p = img.pixel(130, 130); assert!(p[2] > 200 && p[0] < 60, "blue square on screen: {p:?}"); let out = dir.join("out.pdf"); s.execute("file.exportPdf", &json!({"path": out.to_string_lossy()})).unwrap(); let bytes = std::fs::read(&out).unwrap(); hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.clone())).expect("valid pdf"); let text = String::from_utf8_lossy(&bytes); assert!(!text.contains("/Subtype /Image") && !text.contains("/Subtype/Image"), "not rasterized"); let _ = std::fs::remove_dir_all(&dir); } } #[cfg(test)] mod reflow_tests { use serde_json::json; use crate::Session; #[test] fn smart_text_reflow_adds_and_removes_pages() { let mut s = Session::new(); s.execute("file.new", &json!({"pages": 1, "primaryTextFrame": true})).unwrap(); let sid = s.doc().unwrap().doc.settings.primary_story.expect("primary story").0; assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().frames.len(), 1); s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); let long = "Words keep coming and the page fills up quickly with them. ".repeat(250); s.execute("text.insert", &json!({"text": long})).unwrap(); let pages = s.doc().unwrap().doc.page_count(); assert!(pages >= 3, "pages added: {pages}"); let d = s.doc().unwrap().doc.clone(); assert_eq!(d.story(designcraft_doc::StoryId(sid)).unwrap().frames.len(), pages); // One undo takes the typing and its pages back. s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc.page_count(), 1); s.execute("edit.redo", &json!({})).unwrap(); // Delete the text: the empty pages at the end go. let n = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().len(); s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": n})).unwrap(); s.execute("text.delete", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc.page_count(), 1); s.doc().unwrap().doc.check().unwrap(); // Off: no pages added. s.execute("prefs.set", &json!({"smartTextReflow": false})).unwrap(); s.execute("text.insert", &json!({"text": long})).unwrap(); assert_eq!(s.doc().unwrap().doc.page_count(), 1); } } #[cfg(test)] mod pdf_page_tests { use serde_json::json; use crate::Session; #[test] fn place_a_chosen_pdf_page() { let dir = std::env::temp_dir().join(format!("dc-pdf-page-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let src = dir.join("two.pdf"); // Page 1 white, page 2 a different size with a black square. let mut s = Session::new(); s.execute("file.new", &json!({"pages": 2, "facingPages": false, "width": 200, "height": 100})).unwrap(); s.execute("layout.pageSize", &json!({"pages": [2], "width": 300, "height": 150})).unwrap(); let id = s.execute("frame.create", &json!({"spread": 1, "rect": [0, 0, 300, 150]})).unwrap()["id"].as_u64().unwrap(); s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap(); s.execute("file.exportPdf", &json!({"path": src.to_string_lossy()})).unwrap(); s.execute("file.new", &json!({})).unwrap(); assert!(s.execute("file.place", &json!({"path": src.to_string_lossy(), "pdfPage": 3})).is_err()); s.execute("file.place", &json!({"path": src.to_string_lossy(), "pdfPage": 2, "x": 100, "y": 100})).unwrap(); let d = s.doc().unwrap().doc.clone(); let it = d.spreads[0].items.last().unwrap(); let b = it.bounds(); assert!((b.width() - 300.0).abs() < 1.0 && (b.height() - 150.0).abs() < 1.0, "page 2's size: {b:?}"); assert_eq!(d.assets.values().next().unwrap().page, 1); let mut rr = designcraft_render::Renderer::new(); rr.threads = 0; let img = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(); assert!(img.pixel(250, 170)[0] < 60, "page 2 (black) shows: {:?}", img.pixel(250, 170)); let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap(); assert!(String::from_utf8_lossy(&pdf).contains("/Subtype /Form") || String::from_utf8_lossy(&pdf).contains("/Subtype/Form")); let _ = std::fs::remove_dir_all(&dir); } fn word_file() -> Vec { use std::io::Write; const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#; let styles = format!( r#" "# ); let doc = format!( r#"Head Text"# ); let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); let o = zip::write::SimpleFileOptions::default(); for (n, c) in [("word/document.xml", doc), ("word/styles.xml", styles)] { w.start_file(n, o).unwrap(); w.write_all(c.as_bytes()).unwrap(); } w.finish().unwrap().into_inner() } #[test] fn word_import_options_map_and_rename_styles() { let b64 = crate::cmd::base64_encode(&word_file()); let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); s.execute("style.paragraph.create", &json!({"name": "Body", "chars": {"size": 9}})).unwrap(); s.execute("style.paragraph.create", &json!({"name": "Headline", "chars": {"size": 30}})).unwrap(); let info = s.execute("place.styles", &json!({"base64": b64, "name": "a.docx"})).unwrap(); assert_eq!(info["conflicts"], json!(["Body"])); // Title → the document's Headline; Body clashes → imported as Body_wrd_1. let r = s .execute("file.place", &json!({"base64": b64, "name": "a.docx", "styleMap": {"Title": "Headline"}, "styleConflicts": "autoRename"})) .unwrap(); let d = &s.doc().unwrap().doc; let st = d.story(designcraft_doc::StoryId(r["story"].as_u64().unwrap())).unwrap(); assert_eq!(st.paras.iter().map(|p| p.style.as_str()).collect::>(), ["Headline", "Body_wrd_1"]); assert!(d.styles.para("Title").is_none(), "mapped styles aren't imported"); assert_eq!(d.styles.para("Body").unwrap().chars.size, Some(9.0), "the document's Body is untouched"); // Redefine: the imported definition replaces the document's. s.execute("file.place", &json!({"base64": b64, "name": "a.docx", "styleConflicts": "redefine", "rect": [72, 400, 300, 500]})).unwrap(); assert_eq!(s.doc().unwrap().doc.styles.para("Body").unwrap().chars.size, Some(10.0)); } }