Files
designcraft/crates/doc/src/edit.rs
T
aiongg fe16e00e37 PDF/A keeps placed images; right-to-left tables and columns (#128)
* PDF: PDF/A exports keep placed PNG, JPEG, GIF and WebP images by writing them without interpolation (#71)

* Tables: new tables take their paragraph's direction, and the Table panel sets a table's direction (#100)

* Text: stories made in a right-to-left-bound document fill their columns from the right (#100)
2026-10-10 01:13:48 -04:00

421 lines
17 KiB
Rust

//! Locating and editing items, threading, and story management.
use std::sync::Arc;
use designcraft_geom::{Affine, Point, Rect, shapes};
use serde::{Deserialize, Serialize};
use crate::ids::{ItemId, LayerId, StoryId};
use crate::item::{Content, Item, Shape, TextFrame, TextFrameOptions};
use crate::page::Spread;
use crate::story::{CharRun, ParaFormat, Story};
use crate::{DocError, Document, Result};
/// Which spread list an item lives in.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind", content = "index")]
pub enum SpreadRef {
Doc(usize),
Parent(usize),
}
/// Indices from `spread.items` down through nested groups.
pub type ItemPath = Vec<usize>;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ItemLoc {
pub spread: SpreadRef,
pub path: ItemPath,
}
impl ItemLoc {
/// Top-level index in the spread.
pub fn top(&self) -> usize {
self.path[0]
}
}
fn find_in(items: &[Arc<Item>], id: ItemId, path: &mut ItemPath) -> bool {
for (i, it) in items.iter().enumerate() {
path.push(i);
if it.id == id || find_in(it.children(), id, path) {
return true;
}
path.pop();
}
false
}
impl Document {
pub fn spread(&self, r: SpreadRef) -> Option<&Spread> {
match r {
SpreadRef::Doc(i) => self.spreads.get(i).map(|s| &**s),
SpreadRef::Parent(i) => self.parents.get(i).map(|s| &**s),
}
}
pub fn spread_mut(&mut self, r: SpreadRef) -> Option<&mut Spread> {
match r {
SpreadRef::Doc(i) => self.spreads.get_mut(i).map(Arc::make_mut),
SpreadRef::Parent(i) => self.parents.get_mut(i).map(Arc::make_mut),
}
}
/// All spread refs: document spreads then parents.
pub fn spread_refs(&self) -> impl Iterator<Item = SpreadRef> {
(0..self.spreads.len()).map(SpreadRef::Doc).chain((0..self.parents.len()).map(SpreadRef::Parent))
}
pub fn find(&self, id: ItemId) -> Option<ItemLoc> {
for r in self.spread_refs() {
let mut path = Vec::new();
if find_in(&self.spread(r)?.items, id, &mut path) {
return Some(ItemLoc { spread: r, path });
}
}
None
}
pub fn item_at(&self, loc: &ItemLoc) -> Option<&Item> {
let sp = self.spread(loc.spread)?;
let mut it: &Item = sp.items.get(*loc.path.first()?)?;
for &i in &loc.path[1..] {
it = it.children().get(i)?;
}
Some(it)
}
pub fn item(&self, id: ItemId) -> Option<&Item> {
self.item_at(&self.find(id)?)
}
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()?)?);
for &i in &loc.path[1..] {
it = Arc::make_mut(it.children_mut()?.get_mut(i)?);
}
Some(it)
}
pub fn item_mut(&mut self, id: ItemId) -> Option<&mut Item> {
let loc = self.find(id)?;
self.item_mut_at(&loc)
}
/// The top-level item (group root) containing `id`.
pub fn top_level_of(&self, id: ItemId) -> Option<ItemId> {
let loc = self.find(id)?;
Some(self.spread(loc.spread)?.items[loc.top()].id)
}
/// Spread-space transform of the parent group chain of the item at `loc` (identity at top level).
pub fn parent_xf(&self, loc: &ItemLoc) -> designcraft_geom::Affine {
let mut xf = designcraft_geom::Affine::IDENTITY;
if let Some(sp) = self.spread(loc.spread) {
let mut items = &sp.items;
for &i in &loc.path[..loc.path.len().saturating_sub(1)] {
let it = &items[i];
xf *= it.xf;
items = match &it.content {
Content::Group { items } => items,
_ => break,
};
}
}
xf
}
/// Insert an item into a spread at `index` (None = front).
pub fn insert_item(&mut self, r: SpreadRef, item: Item, index: Option<usize>) -> Result<ItemId> {
let id = item.id;
let sp = self.spread_mut(r).ok_or_else(|| DocError::Invalid(format!("no spread {r:?}")))?;
let i = index.unwrap_or(sp.items.len()).min(sp.items.len());
sp.items.insert(i, Arc::new(item));
Ok(id)
}
/// Remove an item (and its descendants) from the document. Text frames leave their threads;
/// stories that lose their last frame are deleted.
pub fn remove_item(&mut self, id: ItemId) -> Result<Arc<Item>> {
let loc = self.find(id).ok_or(DocError::NoItem(id))?;
let removed = {
let sp = self.spread_mut(loc.spread).ok_or(DocError::NoItem(id))?;
if loc.path.len() == 1 {
sp.items.remove(loc.path[0])
} else {
let mut it: &mut Item = Arc::make_mut(&mut sp.items[loc.path[0]]);
for &i in &loc.path[1..loc.path.len() - 1] {
it = Arc::make_mut(&mut it.children_mut().ok_or(DocError::NoItem(id))?[i]);
}
let last = *loc.path.last().ok_or(DocError::NoItem(id))?;
it.children_mut().ok_or(DocError::NoItem(id))?.remove(last)
}
};
let mut frames = Vec::new();
removed.walk(&mut |i| {
if let Some(t) = i.text_frame() {
frames.push((i.id, t.story));
}
});
for (fid, sid) in frames {
let empty = {
let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?;
st.frames.retain(|f| *f != fid);
st.frames.is_empty()
};
if empty {
self.stories.remove(&sid);
}
}
Ok(removed)
}
/// The column direction a new story starts with: right to left in a document whose binding is
/// right to left (Document Setup), else left to right. Stories already made keep theirs.
pub fn new_story_direction(&self) -> crate::TextDirection {
if self.settings.right_to_left_binding { crate::TextDirection::RightToLeft } else { crate::TextDirection::LeftToRight }
}
/// Create a text frame with a new (or given) story at `rect` on spread `r`.
pub fn add_text_frame(&mut self, r: SpreadRef, rect: Rect, layer: LayerId, text: &str, para: ParaFormat) -> Result<(ItemId, StoryId)> {
let id = ItemId(self.alloc());
let sid = StoryId(self.alloc());
let mut story = Story::with_text(sid, text, para);
story.direction = self.new_story_direction();
story.frames.push(id);
self.stories.insert(sid, Arc::new(story));
let mut item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(rect));
item.object_style = self.styles.default_text_frame.clone();
if let Some(os) = self.styles.object_style(&self.styles.default_text_frame).cloned() {
if let Some(f) = os.fill {
item.fill = f;
}
if let Some(s) = os.stroke {
item.stroke = s;
}
}
item.content = Content::Text(TextFrame { story: sid, options: TextFrameOptions::default() });
self.insert_item(r, item, None)?;
Ok((id, sid))
}
/// Thread `from` → `to`: `to` joins `from`'s story right after `from`. If `to` held its own text,
/// that text is appended to the story (as a new paragraph) and its old story is removed.
pub fn thread(&mut self, from: ItemId, to: ItemId) -> Result<()> {
if from == to {
return Err(DocError::Invalid("cannot thread a frame to itself".into()));
}
let fs = self.item(from).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(from))?;
// Convert unassigned/graphic-less frames into text frames.
let ts = match self.item(to).ok_or(DocError::NoItem(to))?.content.clone() {
Content::Text(t) => Some(t.story),
Content::Unassigned => None,
_ => return Err(DocError::Invalid("can only thread to text or empty frames".into())),
};
if ts == Some(fs) {
return Err(DocError::Invalid("frames are already in the same story".into()));
}
if let Some(ts) = ts {
let other = self.stories.remove(&ts).ok_or(DocError::NoStory(ts))?;
let other = Arc::unwrap_or_clone(other);
// Frames of the other story after `to` follow along; frames before it are left as empty new stories.
let pos_to = other.frames.iter().position(|f| *f == to).unwrap_or(0);
let before: Vec<ItemId> = other.frames[..pos_to].to_vec();
let after: Vec<ItemId> = other.frames[pos_to..].to_vec();
{
let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?;
if !other.text.is_empty() {
st.text.push('\n');
st.text.push_str(&other.text);
st.paras.extend(other.paras.iter().cloned());
// Char runs: '\n' takes the last run's format, then the other story's runs.
if let Some(last) = st.chars.last_mut() {
last.len += 1;
}
st.chars.extend(other.chars.iter().filter(|r| r.len > 0).cloned());
st.tables.extend(other.tables.iter().map(|(k, v)| (*k, v.clone())));
for n in &other.notes {
let id = st.next_note_id();
st.notes.push(Arc::new(crate::Footnote { id, text: n.text.clone() }));
}
st.anchors.extend(other.anchors.iter().cloned());
st.xrefs.extend(other.xrefs.iter().cloned());
st.index_refs.extend(other.index_refs.iter().cloned());
st.objects.extend(other.objects.iter().cloned());
st.rev += 1;
}
let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1);
for (k, f) in after.iter().enumerate() {
st.frames.insert(at + k, *f);
}
}
for f in &after {
if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) {
t.story = fs;
}
}
if !before.is_empty() {
let nid = StoryId(self.alloc());
let mut ns = Story::new(nid);
ns.frames = before.clone();
ns.vertical = other.vertical;
ns.direction = other.direction;
for f in &before {
if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) {
t.story = nid;
}
}
self.stories.insert(nid, Arc::new(ns));
}
} else {
let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?;
let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1);
st.frames.insert(at, to);
let it = self.item_mut(to).ok_or(DocError::NoItem(to))?;
it.content = Content::Text(TextFrame { story: fs, options: TextFrameOptions::default() });
}
if let Some(st) = self.story_mut(fs) {
st.rev += 1;
}
Ok(())
}
/// Break the thread after `frame`: the following frames become a new empty story (the text
/// stays with the first part and may become overset).
pub fn unthread_after(&mut self, frame: ItemId) -> Result<()> {
let sid = self.item(frame).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(frame))?;
let (tail, vertical, direction) = {
let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?;
let p = st.frames.iter().position(|f| *f == frame).ok_or(DocError::NoItem(frame))?;
let tail = st.frames.split_off(p + 1);
st.rev += 1;
(tail, st.vertical, st.direction)
};
if tail.is_empty() {
return Ok(());
}
let nid = StoryId(self.alloc());
let mut ns = Story::new(nid);
ns.frames = tail.clone();
ns.vertical = vertical;
ns.direction = direction;
self.stories.insert(nid, Arc::new(ns));
for f in tail {
if let Some(t) = self.item_mut(f).and_then(Item::text_frame_mut) {
t.story = nid;
}
}
Ok(())
}
/// Next / previous frame in a thread.
pub fn next_frame(&self, frame: ItemId) -> Option<ItemId> {
let st = self.story(self.item(frame)?.text_frame()?.story)?;
let p = st.frames.iter().position(|f| *f == frame)?;
st.frames.get(p + 1).copied()
}
pub fn prev_frame(&self, frame: ItemId) -> Option<ItemId> {
let st = self.story(self.item(frame)?.text_frame()?.story)?;
let p = st.frames.iter().position(|f| *f == frame)?;
p.checked_sub(1).map(|i| st.frames[i])
}
/// Does this text frame set its text vertically (its story is vertical; type on a path stays
/// along the path)?
pub fn frame_vertical(&self, item: &Item) -> bool {
item.text_frame().is_some_and(|t| t.options.path.is_none() && self.story(t.story).is_some_and(|s| s.vertical))
}
/// Text space → item inner space for a frame's composed text (identity except for vertical
/// frames).
pub fn text_local(&self, item: &Item) -> Affine {
if self.frame_vertical(item) { crate::item::vertical_text_xf(item.text_area()) } else { Affine::IDENTITY }
}
/// Text space → spread-parent space for a frame's composed text (the item transform, with the
/// quarter turn of a vertical frame).
pub fn text_xf(&self, item: &Item) -> Affine {
item.xf * self.text_local(item)
}
/// Hit test: the frontmost visible, unlocked-layer item on spread `si` containing `p` (spread space).
/// Frames hit by area if they have a fill or are text/graphic frames; otherwise near the outline.
pub fn hit_item(&self, si: usize, p: Point, tol: f64) -> Option<ItemId> {
let sp = self.spreads.get(si)?;
let mut items: Vec<&Arc<Item>> = sp.items.iter().collect();
items.sort_by_key(|i| self.layer_rank(i.layer));
for it in items.into_iter().rev() {
let layer = self.layer(it.layer);
if it.hidden || layer.is_some_and(|l| !l.visible || l.locked) {
continue;
}
if item_hit(it, p, tol) {
return Some(it.id);
}
}
None
}
/// Every item id (pre-order) on document spreads.
pub fn all_items(&self) -> Vec<ItemId> {
let mut v = Vec::new();
for sp in &self.spreads {
for it in &sp.items {
it.walk(&mut |i| v.push(i.id));
}
}
v
}
/// Plain story text with frame/story stats (for agents).
pub fn story_summary(&self, sid: StoryId) -> Option<serde_json::Value> {
let st = self.story(sid)?;
Some(serde_json::json!({
"id": sid.0,
"length": st.text.len(),
"direction": st.direction,
"paragraphs": st.paras.len(),
"frames": st.frames.iter().map(|f| f.0).collect::<Vec<_>>(),
"vertical": st.vertical,
"text": st.text,
}))
}
/// Replace a story's text and formatting wholesale (agents / import).
pub fn set_story_text(&mut self, sid: StoryId, text: &str) -> Result<()> {
let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?;
let para = st.paras.first().cloned().map(|p| ParaFormat { table: None, ..p }).unwrap_or_default();
let fmt = st.chars.first().map(|r| r.format.clone()).unwrap_or_default();
let n = text.matches('\n').count() + 1;
st.text = text.to_string();
st.paras = vec![para; n];
st.chars = vec![CharRun { len: text.len(), format: fmt }];
st.tables.clear();
st.notes.clear();
st.fix_notes();
st.anchors.clear();
st.xrefs.clear();
st.index_refs.clear();
st.objects.clear();
st.fix_marks();
st.rev += 1;
Ok(())
}
}
/// Does `p` (in the item's parent space) hit `it`?
pub fn item_hit(it: &Item, p: Point, tol: f64) -> bool {
if let Content::Group { items } = &it.content {
let inner = it.xf.inverse() * p;
return items.iter().any(|c| item_hit(c, inner, tol));
}
let inner = it.xf.inverse() * p;
let bp = it.path.to_bezpath();
let area = !it.fill.is_none() || matches!(it.content, Content::Text(_) | Content::Graphic(_)) || it.path.is_closed() && it.shape != Shape::Path;
if area && it.path.is_closed() && designcraft_geom::hit::fill_contains(&bp, designcraft_geom::FillRule::NonZero, inner) {
return true;
}
designcraft_geom::hit::stroke_contains(&bp, it.stroke.weight.max(1.0), tol, inner)
}