mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-02 14:47:05 -06:00
IDML1: preserve anchored text frames and footnote artwork
Resolve forward story references before importing embedded frames and groups. Compose embedded stories, retain anonymous-story objects for screen and PDF rendering, and invalidate layout caches when embedded frame geometry changes. Measure inline objects by geometry, fill open contours, reject cyclic frame references, and distinguish unplaced stories from located overset text. Add synthetic round-trip, composition, preflight, pixel-rendering, and PDF regressions. Update the roadmap with the supported import and rendering paths.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -249,7 +249,14 @@ fn signature(doc: &Document, story: &Arc<Story>) -> (Vec<usize>, Vec<Arc<Item>>)
|
||||
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) {
|
||||
|
||||
+23
-10
@@ -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<designcraft_doc::AnchoredObject>,
|
||||
/// 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<FrameSpec> {
|
||||
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<FrameSpec> {
|
||||
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<FrameSpec> {
|
||||
_ => (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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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()?)?);
|
||||
|
||||
+46
-2
@@ -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<StoryId, Vec<StoryId>> = 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<StoryId, Vec<StoryId>>,
|
||||
path: &mut Vec<StoryId>,
|
||||
done: &mut std::collections::HashMap<StoryId, usize>,
|
||||
) -> Result<usize> {
|
||||
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()));
|
||||
}
|
||||
|
||||
@@ -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<usize>)> {
|
||||
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<Issue> {
|
||||
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<Issue> {
|
||||
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() {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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<String, ItemId> = self.item_ids.clone();
|
||||
let mut per_story: BTreeMap<StoryId, Vec<usize>> = 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);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
drawing_items: Vec<designcraft_doc::ItemId>,
|
||||
images: HashMap<AssetId, Option<Image>>,
|
||||
/// Placed PDFs (embedded as vector pages).
|
||||
pdfs: HashMap<AssetId, Option<krilla::pdf::PdfDocument>>,
|
||||
@@ -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())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,3 +337,32 @@ fn missing_glyphs_print_as_boxes() {
|
||||
assert_eq!(glyphs.iter().map(|g| g.0.as_str()).collect::<Vec<_>>(), ["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:?}");
|
||||
}
|
||||
|
||||
@@ -217,6 +217,7 @@ pub struct Renderer {
|
||||
glyphs: text::GlyphCache,
|
||||
/// Composed stories looked up during the current render.
|
||||
stories: HashMap<(StoryId, Option<String>), Arc<ComposedStory>>,
|
||||
drawing_items: Vec<designcraft_doc::ItemId>,
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user