diff --git a/ROADMAP.md b/ROADMAP.md index aa4c17396..8c49fcebf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,6 +23,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`). - Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid), snippets, object styles, Numbering & Section Options. - Footnotes: Type › Insert Footnote (caret moves into the note), Document Footnote Options (numbering style incl. symbols, start/restart per page/spread/section, prefix/suffix, reference position/character style, paragraph style, separator, spacing, first baseline, rule above); notes composed at the bottom of the referencing column with the body text making room; edited in place on the canvas; rendered, exported to PDF (real text) and IDML (InDesign's structure, verified opening in InDesign 2026) and imported from IDML; `footnote.*` commands. +- IDML1: embedded text frames and groups retain forward story references in body text, footnotes and table cells; anonymous composed stories retain anchored artwork for screen/PDF rendering. Filled compound outlines retain open contours; object-only inline lines use geometric extents, preflight distinguishes unplaced stories and locates embedded overflow at its page frame, and cyclic frame references are rejected. - Cross-references and text anchors: Insert Cross-Reference (paragraph by style, or text anchor), 11 formats with InDesign's building blocks (full/partial paragraph, paragraph text/number, page number, anchor name, chapter, file name) plus user formats; resolved live at composition — never out of date, no Update step; unresolved destinations show `??`; PDF link annotations to the destination page; IDML export/import as InDesign cross-reference sources, hyperlinks, text destinations and formats (verified in InDesign 2026); `xref.*` / `anchor.*` commands. - Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands. - Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus. diff --git a/crates/compose/src/cache.rs b/crates/compose/src/cache.rs index 6d6b1b980..ca2fcd5e2 100644 --- a/crates/compose/src/cache.rs +++ b/crates/compose/src/cache.rs @@ -249,7 +249,14 @@ fn signature(doc: &Document, story: &Arc) -> (Vec, Vec>) let mut spreads = Vec::new(); for f in &story.frames { let Some(loc) = doc.find(*f) else { - sig.push(0); + // Embedded frames live in another story (possibly in a note/cell), + // so changing their geometry does not change this story's pointer. + use std::hash::{Hash, Hasher}; + let mut hash = std::collections::hash_map::DefaultHasher::new(); + if let Some(item) = doc.anchored_item(*f) { + format!("{item:?}").hash(&mut hash); + } + sig.push(hash.finish() as usize); continue; }; if let Some(sp) = doc.spread(loc.spread) { diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 5a3056d8f..ce66656fe 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -212,6 +212,8 @@ pub struct FrameText { pub struct PlacedObject { /// Index in the story's [`Story::objects`]. pub index: usize, + /// Source item retained for anonymous footnote and cell stories. + pub object: Arc, /// Top-left of the object in frame inner space. pub origin: Point, pub size: (f64, f64), @@ -328,13 +330,14 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { let Some(st) = doc.story(sid) else { return vec![] }; let mut out = Vec::with_capacity(st.frames.len()); for &fid in &st.frames { - let Some(loc) = doc.find(fid) else { continue }; - let Some(item) = doc.item_at(&loc) else { continue }; + let loc = doc.find(fid); + let Some(item) = loc.as_ref().and_then(|l| doc.item_at(l)).or_else(|| doc.anchored_item(fid)) else { continue }; let Some(tf) = item.text_frame() else { continue }; - let xf = doc.parent_xf(&loc) * item.xf; + let xf = loc.as_ref().map(|l| doc.parent_xf(l)).unwrap_or(designcraft_geom::Affine::IDENTITY) * item.xf; let inv = xf.inverse(); let mut exclusions = Vec::new(); - let spread = doc.spread(loc.spread); + let spread_ref = loc.as_ref().map(|l| l.spread); + let spread = spread_ref.and_then(|r| doc.spread(r)); if !tf.options.ignore_wrap && let Some(sp) = spread { @@ -352,14 +355,14 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { exclusions.push(Exclusion { rect: inner, mode: other.wrap.mode }); } } - let (page_name, page, left_page) = match (loc.spread, spread) { - (designcraft_doc::SpreadRef::Doc(si), Some(sp)) => { + let (page_name, page, left_page) = match (spread_ref, spread) { + (Some(designcraft_doc::SpreadRef::Doc(si)), Some(sp)) => { let c = item.bounds().center(); let pi = sp.page_at_x(c.x).unwrap_or(0); let abs = doc.first_page_of_spread(si) + pi; (Some(doc.page_name(abs)), Some(abs), sp.pages.get(pi).is_some_and(|p| p.side == designcraft_doc::PageSide::Left)) } - (designcraft_doc::SpreadRef::Parent(pi), Some(sp)) => { + (Some(designcraft_doc::SpreadRef::Parent(pi)), Some(sp)) => { let prefix = sp.parent.as_ref().map(|p| p.prefix.clone()).unwrap_or_else(|| "A".into()); let _ = pi; (Some(prefix), None, false) @@ -367,7 +370,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { _ => (None, None, false), }; // The page under the frame and its margins, in inner space (bounding boxes). - let page_rect = match (loc.spread, spread) { + let page_rect = match (spread_ref, spread) { (_, Some(sp)) => sp .page_at_x(item.bounds().center().x) .and_then(|pi| sp.pages.get(pi)) @@ -882,8 +885,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let text_ascent = line_glyphs.iter().filter(|g| g.ch != designcraft_doc::OBJECT_MARK).map(|g| g.ascent).fold(0.0, f64::max); let text_ascent = if text_ascent > 0.0 { text_ascent } else { base_chars.size * 0.75 }; for g in ft.lines[li].glyphs.iter().filter(|g| g.len > 0) { - if let Some(o) = sub_objects.get(&g.byte) { - ft.objects.push(PlacedObject { index: o.index, origin: Point::ZERO, size: (o.w, o.h), line: li, x: g.x, text_ascent }); + if let Some(o) = sub_objects.get(&g.byte) + && let Some(source) = story.objects.get(o.index) + { + ft.objects.push(PlacedObject { + index: o.index, + object: Arc::clone(source), + origin: Point::ZERO, + size: (o.w, o.h), + line: li, + x: g.x, + text_ascent, + }); } } } diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 41583b0a9..7e1d4fb9d 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -587,8 +587,10 @@ fn shape_run_raw( // Sits on the baseline like a (big) character. Some(y) => { g.adv = o.w; - g.ascent = g.ascent.max(o.h + y); - g.descent = g.descent.max(-y); + // The replacement character has no font ink. Its box alone + // supplies extents; adjacent text supplies its own font metrics. + g.ascent = (o.h + y).max(0.0); + g.descent = (-y).max(0.0); } // Pushes its line down by its height and spacing. None => g.ascent += o.h + o.space, diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 549e7bccf..6127a8ff6 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1877,3 +1877,58 @@ fn vertical_lines_fit_the_em_box() { assert!((l.baseline - col.y0 - ascent).abs() < 1e-9, "{family}: {} {}", l.baseline, col.y0); } } + +#[test] +fn embedded_text_frames_compose_and_footnotes_retain_object_sources() { + use designcraft_doc::{AnchorPosition, AnchoredObject}; + let (mut d, body, _) = doc_with("Body", Rect::new(20.0, 20.0, 400.0, 600.0), ParaAttrs::default()); + let (fid, child) = + d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 80.0, 40.0), d.default_layer(), "Embedded", ParaFormat::default()).unwrap(); + let item = d.item(fid).unwrap().clone(); + d.spread_mut(SpreadRef::Doc(0)).unwrap().items.retain(|i| i.id != fid); + let story = d.story_mut(body).unwrap(); + story.insert_note(4, "Note", ParaFormat::default()); + Arc::make_mut(&mut story.notes[0]).text.insert_object(0, AnchoredObject::new(item, AnchorPosition::Inline { y_offset: 0.0 })); + d.check().unwrap(); + let child_cs = compose_story(&d, child, &ComposeOptions::default()); + assert!(!child_cs.is_overset()); + assert!(!child_cs.frame(fid).unwrap().lines.is_empty()); + let cs = compose_story(&d, body, &ComposeOptions::default()); + assert!(!cs.is_overset()); + let note = &cs.frames[0].notes[0]; + assert_eq!(note.text.story, StoryId(0)); + assert_eq!(note.text.frames[0].objects[0].object.item.id, fid); + + let cache = Cache::new(); + assert!(!cache.get(&d, child, None).is_overset()); + let note = Arc::make_mut(&mut d.story_mut(body).unwrap().notes[0]); + let anchor = Arc::make_mut(&mut note.text.objects[0]); + let original_path = anchor.item.path.clone(); + anchor.item.path = designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 1.0)); + assert!(cache.get(&d, child, None).is_overset(), "embedded frame geometry must invalidate cached composition"); + Arc::make_mut(&mut Arc::make_mut(&mut d.story_mut(body).unwrap().notes[0]).text.objects[0]).item.path = original_path; + + // A reference back into the owning story must be rejected before rendering. + let mut cycle = d.anchored_item(fid).unwrap().clone(); + cycle.id = ItemId(d.alloc()); + cycle.text_frame_mut().unwrap().story = body; + d.story_mut(body).unwrap().frames.push(cycle.id); + d.story_mut(child).unwrap().insert_object(0, AnchoredObject::new(cycle, AnchorPosition::Inline { y_offset: 0.0 })); + assert!(d.check().unwrap_err().to_string().contains("cyclic")); +} + +#[test] +fn inline_object_only_line_does_not_reserve_phantom_font_descent() { + use designcraft_doc::{AnchorPosition, AnchoredObject, Item, Shape}; + let (mut d, sid, _) = doc_with("", Rect::new(0.0, 0.0, 10.0, 15.0), ParaAttrs::default()); + let item = Item::new(ItemId(d.alloc()), d.default_layer(), Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 7.0, 9.0))); + d.story_mut(sid).unwrap().insert_object(0, AnchoredObject::new(item, AnchorPosition::Inline { y_offset: 5.0 })); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + assert_eq!(cs.frames[0].objects.len(), 1); + assert_eq!(cs.frames[0].lines[0].descent, 0.0); + // Genuine geometric overflow must still be reported. + d.story_mut(sid).unwrap().objects[0] = + Arc::new(AnchoredObject::new(cs.frames[0].objects[0].object.item.clone(), AnchorPosition::Inline { y_offset: 7.0 })); + assert!(compose_story(&d, sid, &ComposeOptions::default()).is_overset()); +} diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 188f514d2..97728c875 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -89,6 +89,28 @@ impl Document { self.item_at(&self.find(id)?) } + /// Items embedded in stories (including footnotes and table cells), with their + /// owning top-level story. Does not follow text-frame references between stories. + pub fn anchored_items(&self) -> Vec<(StoryId, &Item)> { + let mut out = Vec::new(); + let mut stories: Vec<_> = self.stories.iter().map(|(id, story)| (*id, story.as_ref())).collect(); + while let Some((owner, story)) = stories.pop() { + stories.extend(story.notes.iter().chain(&story.endnotes).map(|n| (owner, &n.text))); + stories.extend(story.tables.values().flat_map(|t| t.cells.iter().map(|c| (owner, &c.text)))); + let mut items: Vec<&Item> = story.objects.iter().map(|o| &o.item).collect(); + while let Some(item) = items.pop() { + out.push((owner, item)); + items.extend(item.children().iter().map(AsRef::as_ref)); + } + } + out + } + + /// An embedded item is not a spread selection location (`find`). + pub fn anchored_item(&self, id: ItemId) -> Option<&Item> { + self.anchored_items().into_iter().find_map(|(_, item)| (item.id == id).then_some(item)) + } + pub fn item_mut_at(&mut self, loc: &ItemLoc) -> Option<&mut Item> { let sp = self.spread_mut(loc.spread)?; let mut it: &mut Item = Arc::make_mut(sp.items.get_mut(*loc.path.first()?)?); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c4b69e86d..4ac3b9a5d 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -729,8 +729,8 @@ impl Document { return Err(DocError::Invalid(format!("story key {sid} != id {}", st.id))); } for f in &st.frames { - let item = self.find(*f).ok_or(DocError::NoItem(*f))?; - match self.item_at(&item).and_then(|i| i.text_frame().map(|t| t.story)) { + let item = self.item(*f).or_else(|| self.anchored_item(*f)).ok_or(DocError::NoItem(*f))?; + match item.text_frame().map(|t| t.story) { Some(s) if s == *sid => {} other => return Err(DocError::Invalid(format!("frame {f} in story {sid} points at {other:?}"))), } @@ -752,6 +752,50 @@ impl Document { } } } + let embedded = self.anchored_items(); + let mut edges: std::collections::HashMap> = std::collections::HashMap::new(); + for (owner, item) in embedded { + if !ids.insert(item.id.0) { + return Err(DocError::Invalid(format!("duplicate item id {}", item.id))); + } + if let Some(tf) = item.text_frame() { + if !self.stories.get(&tf.story).is_some_and(|s| s.frames.contains(&item.id)) { + return Err(DocError::Invalid(format!("embedded text frame {} not in its story's thread", item.id))); + } + edges.entry(owner).or_default().push(tf.story); + } + } + // Reject cycles and excessive nesting before renderers follow embedded frames. + fn visit( + id: StoryId, + edges: &std::collections::HashMap>, + path: &mut Vec, + done: &mut std::collections::HashMap, + ) -> Result { + if path.contains(&id) || path.len() >= 64 { + return Err(DocError::Invalid("cyclic or excessively nested anchored text frames".into())); + } + if let Some(&height) = done.get(&id) { + return Ok(height); + } + path.push(id); + let mut height = 1; + if let Some(children) = edges.get(&id) { + for &child in children { + height = height.max(1 + visit(child, edges, path, done)?); + } + } + path.pop(); + if height > 64 { + return Err(DocError::Invalid("excessively nested anchored text frames".into())); + } + done.insert(id, height); + Ok(height) + } + let mut done = std::collections::HashMap::new(); + for &id in edges.keys() { + visit(id, &edges, &mut Vec::new(), &mut done)?; + } if self.next_id < ids.iter().copied().max().unwrap_or(0) { return Err(DocError::Invalid("next_id behind existing ids".into())); } diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index 3fc061d7d..f615da300 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -71,6 +71,45 @@ fn missing_glyphs(cs: &ComposedStory, text: &str, depth: usize, out: &mut Vec<(F } } +/// Locate an embedded frame at the outer page frame that actually renders it. +fn overset_location(s: &Session, target: designcraft_doc::ItemId) -> Option<(designcraft_doc::ItemId, Option)> { + let d = &s.active()?.doc; + if let Some(loc) = d.find(target) { + return Some((target, d.item(target).and_then(|it| page_of(d, loc.spread, it)))); + } + fn contains(root: &designcraft_compose::FrameText, target: designcraft_doc::ItemId) -> bool { + let mut frames = vec![root]; + while let Some(frame) = frames.pop() { + for object in &frame.objects { + let mut found = false; + object.object.item.walk(&mut |it| { + found |= it.id == target; + }); + if found { + return true; + } + } + frames.extend(frame.notes.iter().flat_map(|n| n.text.frames.iter())); + frames.extend(frame.tables.iter().flat_map(|t| t.cells.iter().flat_map(|c| c.text.frames.iter()))); + } + false + } + let mut current = target; + let mut seen = std::collections::HashSet::new(); + let embedded = d.anchored_items(); + while seen.insert(current) { + let owner = embedded.iter().find_map(|(owner, it)| (it.id == current).then_some(*owner))?; + let cs = s.cache.get(d, owner, None); + // Search each host frame separately, including objects in its notes and cells. + let frame = cs.frames.iter().find(|f| contains(f, current))?; + current = frame.frame; + if let Some(loc) = d.find(current) { + return Some((current, d.item(current).and_then(|it| page_of(d, loc.spread, it)))); + } + } + None +} + pub fn check(s: &Session, min_ppi: f64) -> Vec { let Some(st) = s.active() else { return vec![] }; let d = &st.doc; @@ -85,9 +124,20 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { missing_glyphs(&cs, &story.text, 0, &mut missing); if cs.is_overset() { let last = story.frames.last().copied(); - let page = last.and_then(|f| d.find(f).zip(d.item(f))).and_then(|(loc, it)| page_of(d, loc.spread, it)); - let n = story.text[cs.overset_at.unwrap_or(0).min(story.len())..].chars().count(); - out.push(Issue { severity: "error", kind: "overset", message: format!("Overset text: {n} characters"), item: last.map(|i| i.0), page }); + let location = last.and_then(|f| overset_location(s, f)); + let n = story.text.get(cs.overset_at.unwrap_or(0)..).unwrap_or("").chars().count(); + let unplaced = story.frames.is_empty(); + out.push(Issue { + severity: if unplaced { "warning" } else { "error" }, + kind: if unplaced { "unplacedStory" } else { "overset" }, + message: if unplaced { + format!("Unplaced story {}: {n} characters have no text frame", story.id) + } else { + format!("Overset text: {n} characters") + }, + item: location.map(|(item, _)| item.0).or(last.map(|i| i.0)), + page: location.and_then(|(_, page)| page), + }); } let ranges = story.para_ranges(); for (pi, pf) in story.paras.iter().enumerate() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8f2f39b0f..4baaa3e4f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -537,3 +537,31 @@ fn commands_take_non_object_params_without_panicking() { assert!(r.is_ok(), "{p}: {r:?}"); } } + +#[test] +fn preflight_distinguishes_unplaced_stories_and_locates_footnote_overset() { + use designcraft_doc::{AnchorPosition, AnchoredObject, ParaFormat, SpreadRef, Story, StoryId}; + use designcraft_geom::Rect; + let mut s = session(); + let doc = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let layer = doc.default_layer(); + let (host, body) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(20.0, 20.0, 400.0, 600.0), layer, "Body", ParaFormat::default()).unwrap(); + let (frame, _) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 5.0, 2.0), layer, "Overflow", ParaFormat::default()).unwrap(); + let item = doc.item(frame).unwrap().clone(); + doc.spread_mut(SpreadRef::Doc(0)).unwrap().items.retain(|i| i.id != frame); + let story = doc.story_mut(body).unwrap(); + story.insert_note(4, "Note", ParaFormat::default()); + Arc::make_mut(&mut story.notes[0]).text.insert_object(0, AnchoredObject::new(item, AnchorPosition::Inline { y_offset: 0.0 })); + let unplaced = StoryId(doc.alloc()); + doc.stories.insert(unplaced, Arc::new(Story::with_text(unplaced, "Unplaced", ParaFormat::default()))); + doc.check().unwrap(); + let issues = s.execute("preflight.run", &json!({})).unwrap(); + let issues = issues["issues"].as_array().unwrap(); + let overset: Vec<_> = issues.iter().filter(|i| i["kind"] == "overset").collect(); + assert_eq!(overset.len(), 1, "{issues:?}"); + assert_eq!(overset[0]["item"], host.0); + assert_eq!(overset[0]["page"], 0); + let unplaced = issues.iter().find(|i| i["kind"] == "unplacedStory").unwrap(); + assert_eq!(unplaced["severity"], "warning"); + assert!(unplaced.get("page").is_none()); +} diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 3e1c17e28..3799588a4 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -376,11 +376,15 @@ impl<'r> Importer<'r> { self.index_topics_of(ix, &[]); } // Stories (ids first so frames can reference them). + let mut pending_stories = Vec::new(); for e in top.iter().filter(|e| e.local() == "Story") { let id = StoryId(self.alloc()); if let Some(s) = e.get("Self") { self.story_ids.insert(s.to_string(), id); } + pending_stories.push((id, e)); + } + for (id, e) in pending_stories { let story = self.story(id, e); self.stories.insert(id, story); } @@ -1668,11 +1672,9 @@ impl<'r> Importer<'r> { rcf.over.position = None; b.push(&designcraft_doc::FOOTNOTE_REF.to_string(), &rcf); } - // Not supported yet: skip their content entirely. - "Rectangle" | "Oval" | "Polygon" | "GraphicLine" | "Group" => { - // An anchored object (text frames inside stories aren't supported yet). - let has_text = c.find_all("TextFrame").next().is_some(); - if !has_text && let Some(item) = self.item(c, designcraft_geom::Affine::IDENTITY) { + // Anchored page items, including groups containing text frames. + "Rectangle" | "Oval" | "Polygon" | "GraphicLine" | "Group" | "TextFrame" => { + if let Some(item) = self.item(c, designcraft_geom::Affine::IDENTITY) { use designcraft_doc::anchored::AnchorAlign as A; let set = c.find("AnchoredObjectSetting"); let get = |k: &str| set.and_then(|s| s.num(k)).unwrap_or(0.0); @@ -1714,7 +1716,7 @@ impl<'r> Importer<'r> { b.push(&designcraft_doc::OBJECT_MARK.to_string(), cf); } } - "Properties" | "Note" | "TextFrame" | "StoryPreference" | "InCopyExportOption" | "TextVariableInstance" => {} + "Properties" | "Note" | "StoryPreference" | "InCopyExportOption" | "TextVariableInstance" => {} _ => self.walk_story(c, b, pf, pchars, cf, brk), }, Node::Pi(t, v) if t == "ACE" => { @@ -2147,8 +2149,8 @@ impl<'r> Importer<'r> { let threads = std::mem::take(&mut self.ctx.threads); let by_self: HashMap = self.item_ids.clone(); let mut per_story: BTreeMap> = BTreeMap::new(); - for (i, (id, _, _, _)) in threads.iter().enumerate() { - let sid = self.frame_story(*id); + for (i, (id, story, _, _)) in threads.iter().enumerate() { + let sid = self.story_ids.get(story).copied().or_else(|| self.frame_story(*id)); if let Some(sid) = sid { per_story.entry(sid).or_default().push(i); } diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index b636a2ac1..a4767ee2b 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -857,3 +857,46 @@ fn arabic_story_and_table_directions_survive_idml_export() { assert_eq!(st.direction, designcraft_doc::TextDirection::RightToLeft); assert_eq!(st.tables.values().next().unwrap().options.direction, designcraft_doc::TextDirection::RightToLeft); } + +/// Synthetic forward references: both a body anchor and a footnote anchor own +/// a group containing a text frame whose story is serialized later. +#[test] +fn round_trips_anchored_text_frames_in_body_and_footnotes() { + use designcraft_doc::{AnchorPosition, AnchoredObject, Content, Item, ItemId}; + use std::sync::Arc; + let mut d = Document::new(&NewDocument::default()); + let layer = d.default_layer(); + let (_, body) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(20.0, 20.0, 400.0, 600.0), layer, "Body", ParaFormat::default()).unwrap(); + d.story_mut(body).unwrap().insert_note(4, "Note", ParaFormat::default()); + for in_note in [false, true] { + let (frame, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 60.0, 30.0), layer, "Symbol", ParaFormat::default()).unwrap(); + let child = d.item(frame).unwrap().clone(); + d.spread_mut(SpreadRef::Doc(0)).unwrap().items.retain(|i| i.id != frame); + let mut group = Item::new(ItemId(d.alloc()), layer, Shape::Group, Default::default()); + group.content = Content::Group { items: vec![Arc::new(child)] }; + let anchor = AnchoredObject::new(group, AnchorPosition::Inline { y_offset: 2.0 }); + let story = d.story_mut(body).unwrap(); + if in_note { + Arc::make_mut(&mut story.notes[0]).text.insert_object(0, anchor); + } else { + story.insert_object(0, anchor); + } + } + d.check().unwrap(); + for _ in 0..2 { + d = import_idml(&export_idml(&d)).unwrap(); + d.check().unwrap(); + assert_eq!(d.stories.len(), 3); + let body = d.stories.values().find(|s| !s.notes.is_empty()).unwrap(); + assert_eq!(body.notes.len(), 1); + assert_eq!(body.objects.len(), 1); + assert_eq!(body.notes[0].text.objects.len(), 1); + let children: Vec<_> = d.anchored_items().into_iter().filter(|(_, i)| i.text_frame().is_some()).collect(); + assert_eq!(children.len(), 2); + for (_, item) in children { + let story = d.story(item.text_frame().unwrap().story).unwrap(); + assert_eq!(story.text, "Symbol"); + assert_eq!(story.frames, vec![item.id]); + } + } +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 0c77e69b9..f6ede8851 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -141,6 +141,7 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) pdf.set_metadata(meta); let mut ex = Exporter { + drawing_items: Vec::new(), doc, cache, opts, @@ -313,6 +314,7 @@ pub fn export_booklet(doc: &Document, cache: &Cache, opts: &BookletOptions) -> R } pdf.set_metadata(meta); let mut ex = Exporter { + drawing_items: Vec::new(), doc, cache, opts: &pdf_opts, @@ -408,6 +410,7 @@ pub(crate) struct Exporter<'a> { /// Current sheet's visible area (spread space) for culling. pub clip: Rect, pub warnings: Vec, + drawing_items: Vec, images: HashMap>, /// Placed PDFs (embedded as vector pages). pdfs: HashMap>, @@ -711,6 +714,16 @@ impl Exporter<'_> { } fn item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page_name: Option<&str>) { + if self.drawing_items.len() >= 64 || self.drawing_items.contains(&it.id) { + self.warnings.push(format!("Cyclic or excessively nested item {} was not rendered", it.id)); + return; + } + self.drawing_items.push(it.id); + self.item_guarded(s, it, parent, page_name); + self.drawing_items.pop(); + } + + fn item_guarded(&mut self, s: &mut Surface, it: &Item, parent: Affine, page_name: Option<&str>) { let gf = &it.effects.gradient_feather; if !gf.on || it.hidden || it.nonprinting { return self.item_inner(s, it, parent, page_name); @@ -854,7 +867,6 @@ impl Exporter<'_> { s.push_transform(&tf(xf)); // Fill. if !it.fill.is_none() - && it.path.is_closed() && let Some(p) = &path && let Some(paint) = self.fill_paint(&it.fill, bp.bounding_box()) { @@ -921,16 +933,30 @@ impl Exporter<'_> { for _ in 0..pushes { s.pop(); } - // Anchored objects in the frame's text. - if let Content::Text(tfr) = &it.content - && let Some(st) = doc.story(tfr.story).filter(|st| !st.objects.is_empty()) - { + if let Content::Text(tfr) = &it.content { let cs = self.cache.get(doc, tfr.story, page_name); if let Some(ft) = cs.frame(it.id) { - for o in &ft.objects { - if let Some(obj) = st.objects.get(o.index) { - self.item(s, &obj.item, xf * Affine::translate(o.origin.to_vec2()), page_name); - } + self.frame_objects(s, ft, xf * doc.text_local(it), page_name); + } + } + } + + /// Draw embedded page items after restoring the surface, retaining spread-space + /// transforms for page culling (also for anonymous note and cell stories). + fn frame_objects(&mut self, s: &mut Surface, ft: &designcraft_compose::FrameText, xf: Affine, page_name: Option<&str>) { + let mut pending = vec![(ft, xf)]; + while let Some((frame, transform)) = pending.pop() { + for object in &frame.objects { + self.item(s, &object.object.item, transform * Affine::translate(object.origin.to_vec2()), page_name); + } + for note in &frame.notes { + if let Some(frame) = note.text.frames.first() { + pending.push((frame, transform * Affine::translate(note.origin.to_vec2()))); + } + } + for cell in frame.tables.iter().flat_map(|t| &t.cells) { + if let Some(frame) = cell.text.frames.first() { + pending.push((frame, transform * Affine::translate(cell.origin.to_vec2()))); } } } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 1771c4e4f..3908c0bc3 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -337,3 +337,32 @@ fn missing_glyphs_print_as_boxes() { assert_eq!(glyphs.iter().map(|g| g.0.as_str()).collect::>(), ["a", "b"], "{glyphs:?}"); assert_eq!(boxes(&paths), 1, "{paths:?}"); } + +#[test] +fn exports_embedded_text_from_footnotes() { + use designcraft_doc::{AnchorPosition, AnchoredObject}; + use std::sync::Arc; + let mut d = doc_with_text("Body"); + let body = *d.stories.keys().next().unwrap(); + let (fid, _) = + d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 100.0, 40.0), d.default_layer(), "Embedded", ParaFormat::default()).unwrap(); + let item = d.item(fid).unwrap().clone(); + d.spread_mut(SpreadRef::Doc(0)).unwrap().items.retain(|i| i.id != fid); + let story = d.story_mut(body).unwrap(); + story.insert_note(4, "Note", ParaFormat::default()); + Arc::make_mut(&mut story.notes[0]).text.insert_object(0, AnchoredObject::new(item, AnchorPosition::Inline { y_offset: 0.0 })); + d.check().unwrap(); + let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap(); + let text = extract_text(&bytes).join(""); + assert!(text.contains("Embedded"), "{text}"); +} + +#[test] +fn fills_open_contours_without_changing_strokes() { + let mut d = Document::new(&NewDocument::default()); + add_box(&mut d, Rect::new(40.0, 50.0, 62.0, 67.0), designcraft_color::swatch::BLACK); + let id = d.spreads[0].items[0].id; + d.item_mut(id).unwrap().path.subpaths[0].closed = false; + let (_, paths) = drawn(&export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap()); + assert!(paths.iter().any(|r| (r.width() - 22.0).abs() < 0.01 && (r.height() - 17.0).abs() < 0.01), "{paths:?}"); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c1b288def..86dba8dac 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -217,6 +217,7 @@ pub struct Renderer { glyphs: text::GlyphCache, /// Composed stories looked up during the current render. stories: HashMap<(StoryId, Option), Arc>, + drawing_items: Vec, pub threads: u16, pub stats: FrameStats, } @@ -262,6 +263,7 @@ impl Renderer { resources: Resources::new(), glyphs: text::GlyphCache::default(), stories: HashMap::new(), + drawing_items: Vec::new(), threads: default_threads(), stats: FrameStats::default(), } @@ -432,6 +434,15 @@ impl Renderer { } fn draw_item(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, parent: Affine, page_name: Option<&str>) { + if self.drawing_items.len() >= 64 || self.drawing_items.contains(&it.id) { + return; + } + self.drawing_items.push(it.id); + self.draw_item_inner(ctx, f, it, parent, page_name); + self.drawing_items.pop(); + } + + fn draw_item_inner(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, parent: Affine, page_name: Option<&str>) { if it.hidden || f.opts.hidden.contains(&it.id) || f.opts.printing_only && it.nonprinting { return; } @@ -551,7 +562,7 @@ impl Renderer { if op { ctx.push_layer(None, Some(BlendMode::new(Mix::Multiply, Compose::SrcOver)), None, None, None); } - if set_fill_paint(ctx, doc, &it.fill, bp.bounding_box()) && it.path.is_closed() { + if set_fill_paint(ctx, doc, &it.fill, bp.bounding_box()) { ctx.fill_path(bp); } ctx.reset_paint_transform(); @@ -1169,6 +1180,31 @@ mod tests { use designcraft_doc::build::NewDocument; use designcraft_doc::{Fill, ParaFormat}; + #[test] + fn footnote_vector_objects_are_painted() { + use designcraft_doc::{AnchorPosition, AnchoredObject, ItemId, Shape, SpreadRef}; + let mut d = Document::new(&NewDocument::default()); + let (_, sid) = + d.add_text_frame(SpreadRef::Doc(0), Rect::new(20.0, 20.0, 300.0, 300.0), d.default_layer(), "Body", ParaFormat::default()).unwrap(); + let mut item = + Item::new(ItemId(d.alloc()), d.default_layer(), Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 12.0, 12.0))); + item.fill = Fill::swatch(designcraft_color::swatch::BLACK); + // Open contours with a fill are implicitly closed for painting. A single + // open contour must not hide the whole compound glyph. + item.path.subpaths[0].closed = false; + let story = d.story_mut(sid).unwrap(); + story.insert_note(4, "", ParaFormat::default()); + Arc::make_mut(&mut story.notes[0]).text.insert_object(0, AnchoredObject::new(item, AnchorPosition::Inline { y_offset: 0.0 })); + let cache = Cache::new(); + let cs = cache.get(&d, sid, None); + let note = &cs.frames[0].notes[0]; + let object = ¬e.text.frames[0].objects[0]; + let centre = note.origin + object.origin.to_vec2() + designcraft_geom::Vec2::new(6.0, 6.0); + let image = Renderer::new().render_page(&d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap(); + let pixel = image.pixel(centre.x as u32, centre.y as u32); + assert!(pixel[0] < 80 && pixel[1] < 80 && pixel[2] < 80, "{pixel:?}"); + } + /// `Rendered` has public fields: a buffer that doesn't match the stated size used to panic /// in `to_png`. /// A canvas 65535 pixels wide (any render wider than that was clamped to it) panicked inside diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 165366eb7..98710e659 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -392,14 +392,8 @@ impl Renderer { } } // Anchored objects ride on their lines. - if !ft.objects.is_empty() - && let Some(st) = doc.story(cs.story) - { - for o in &ft.objects { - if let Some(obj) = st.objects.get(o.index) { - self.draw_item(ctx, f, &obj.item, xf * Affine::translate(o.origin.to_vec2()), None); - } - } + for o in &ft.objects { + self.draw_item(ctx, f, &o.object.item, xf * Affine::translate(o.origin.to_vec2()), None); } } }