//! Liquid Layout (Layout › Liquid Layout, the Page tool): page rules that decide how objects //! follow when a page changes size, liquid guides, object pins, and alternate layouts. use std::sync::Arc; use designcraft_doc::{Content, Document, Item, LiquidRule, ObjectLiquid, Orientation, SpreadRef}; use designcraft_geom::{Affine, Rect}; use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, has_selection, str_param, targets}; use crate::Result; /// One axis of an object-based rule: the new (lo, hi) for an object at (lo, hi) on a page that /// went from (o0, o1) to (n0, n1). fn axis(lo: f64, hi: f64, o: (f64, f64), n: (f64, f64), pin_lo: bool, pin_hi: bool, resize: bool) -> (f64, f64) { let k = (n.1 - n.0) / (o.1 - o.0).max(1e-9); let w = hi - lo; let nw = if resize { w * k } else { w }; match (pin_lo, pin_hi) { (true, true) if resize => (n.0 + (lo - o.0), n.1 - (o.1 - hi)), (true, true) => { let c = (n.0 + (lo - o.0) + n.1 - (o.1 - hi)) / 2.0; (c - w / 2.0, c + w / 2.0) } (true, false) => (n.0 + (lo - o.0), n.0 + (lo - o.0) + nw), (false, true) => (n.1 - (o.1 - hi) - nw, n.1 - (o.1 - hi)), (false, false) => { let c = n.0 + ((lo + hi) / 2.0 - o.0) * k; (c - nw / 2.0, c + nw / 2.0) } } } /// The new spread-space bounds of an object at `b` under `rule`. fn target(rule: LiquidRule, b: Rect, old: Rect, new: Rect, liquid: Option, vguides: &[f64], hguides: &[f64]) -> Rect { let (ox, oy, nx, ny) = ((old.x0, old.x1), (old.y0, old.y1), (new.x0, new.x1), (new.y0, new.y1)); match rule { LiquidRule::Off => b, LiquidRule::ReCenter => b + (new.center() - old.center()), LiquidRule::Scale => { let k = (new.width() / old.width()).min(new.height() / old.height()); let c = new.center() + (b.center() - old.center()) * k; Rect::from_center_size(c, (b.width() * k, b.height() * k)) } LiquidRule::GuideBased => { let sx = vguides.iter().any(|g| *g > b.x0 && *g < b.x1); let sy = hguides.iter().any(|g| *g > b.y0 && *g < b.y1); let (x0, x1) = axis(b.x0, b.x1, ox, nx, false, false, sx); let (y0, y1) = axis(b.y0, b.y1, oy, ny, false, false, sy); Rect::new(x0, y0, x1, y1) } LiquidRule::ObjectBased => { let l = liquid.unwrap_or(ObjectLiquid { resize_width: true, resize_height: true, ..Default::default() }); let (x0, x1) = axis(b.x0, b.x1, ox, nx, l.pin_left, l.pin_right, l.resize_width); let (y0, y1) = axis(b.y0, b.y1, oy, ny, l.pin_top, l.pin_bottom, l.resize_height); Rect::new(x0, y0, x1, y1) } } } /// Re-flow page `pi` of spread `sr` after it went from `old` to its current bounds (objects are /// still placed relative to `old`). Nothing happens with the rule off. pub(crate) fn apply(d: &mut Document, sr: SpreadRef, pi: usize, old: Rect) { let Some(sp) = d.spread(sr) else { return }; let Some(pg) = sp.pages.get(pi) else { return }; let (rule, new) = (pg.liquid, pg.bounds()); if rule == LiquidRule::Off || old == new || old.width() < 1e-6 || old.height() < 1e-6 { return; } let liquid_guides = |o: Orientation| pg.guides.iter().filter(|g| g.liquid && g.orientation == o).map(|g| g.position).collect::>(); let (vg, hg) = (liquid_guides(Orientation::Vertical), liquid_guides(Orientation::Horizontal)); // The page's objects: centres within its old horizontal extent (outer pages take the pasteboard). let last = sp.pages.len() - 1; let (lo, hi) = (if pi == 0 { f64::NEG_INFINITY } else { old.x0 }, if pi == last { f64::INFINITY } else { old.x1 }); let strokes = false; let Some(sp) = d.spread_mut(sr) else { return }; for it in &mut sp.items { let b = it.bounds(); let c = b.center().x; if c < lo || c >= hi || b.width() < 1e-9 || b.height() < 1e-9 { continue; } let to = target(rule, b, old, new, it.liquid, &vg, &hg); if to == b { continue; } let m = Affine::translate((to.x0, to.y0)) * Affine::scale_non_uniform(to.width() / b.width(), to.height() / b.height()) * Affine::translate((-b.x0, -b.y0)); let it: &mut Item = Arc::make_mut(it); if rule == LiquidRule::Scale { super::object::scale_item(it, m, (to.width() / b.width()).abs(), strokes); } else if matches!(it.content, Content::Group { .. }) { it.xf = m * it.xf; } else { // The frame stretches; content keeps its size unless the frame auto-fits it. let inner = it.xf.inverse() * m * it.xf; super::object::bake(it, inner); let ib = it.inner_bounds(); if let Content::Graphic(g) = &mut it.content { let o = inner * designcraft_geom::Point::ZERO; g.xf = Affine::translate(o.to_vec2()) * g.xf; if let Some(xf) = g.fitted(ib, g.auto_fit) { g.xf = xf; } } } } } fn rule_param(p: &Value, id: &str) -> Result { serde_json::from_value(p.get("rule").cloned().unwrap_or(json!("off"))).map_err(|_| bad(id, "`rule`: off|scale|reCenter|guideBased|objectBased")) } pub fn specs() -> Vec { vec![ cmd!( "liquid.pageRule", "Liquid Page Rule", ["Layout", "Liquid Layout"], None, "{rule: off|scale|reCenter|guideBased|objectBased, pages?: [1-based] (default: all)}", has_doc, |s, p| { let rule = rule_param(p, "liquid.pageRule")?; let pages: Option> = p.get("pages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|n| n as usize).collect()); s.edit(|d, _| { let n = d.page_count(); let list = pages.clone().unwrap_or_else(|| (1..=n).collect()); for page in &list { let Some((si, pi)) = page.checked_sub(1).and_then(|i| d.page_loc(i)) else { continue }; Arc::make_mut(&mut d.spreads[si]).pages[pi].liquid = rule; } Ok(json!({"pages": list.len()})) }) } ), cmd!( "liquid.object", "Object Liquid Settings", [], None, "{ids?, resizeWidth?, resizeHeight?, pinTop?, pinBottom?, pinLeft?, pinRight?} — for object-based pages", has_selection, |s, p| { let ids = targets(s, p)?; s.edit(|d, _| { for id in &ids { let Some(it) = d.item_mut(*id) else { continue }; let mut l = it.liquid.unwrap_or(ObjectLiquid { resize_width: true, resize_height: true, ..Default::default() }); for (k, f) in [ ("resizeWidth", &mut l.resize_width), ("resizeHeight", &mut l.resize_height), ("pinTop", &mut l.pin_top), ("pinBottom", &mut l.pin_bottom), ("pinLeft", &mut l.pin_left), ("pinRight", &mut l.pin_right), ] { if let Some(v) = p.get(k).and_then(Value::as_bool) { *f = v; } } it.liquid = Some(l); } Ok(json!({"objects": ids.len()})) }) } ), cmd!("guide.liquid", "Convert to Liquid Guide", [], None, "{spread?, page, index, on?: bool (default true)}", has_doc, |s, p| { let sr = super::spread_param(p, "guide.liquid")?; let pi = p.get("page").and_then(Value::as_u64).unwrap_or(0) as usize; let gi = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("guide.liquid", "`index` required"))? as usize; let on = p.get("on").and_then(Value::as_bool).unwrap_or(true); s.edit(|d, _| { let sp = d.spread_mut(sr).ok_or_else(|| bad("guide.liquid", "no such spread"))?; let g = sp.pages.get_mut(pi).and_then(|pg| pg.guides.get_mut(gi)).ok_or_else(|| bad("guide.liquid", "no such guide"))?; g.liquid = on; Ok(Value::Null) }) }), cmd!( "layout.createAlternate", "Create Alternate Layout…", ["Layout"], None, "{name, width, height, rule?: page rule for the copies (default: keep each page's)} — every page copied after the last at the new size (a new section marked `name`), objects following the liquid rules", has_doc, create_alternate ), ] } fn create_alternate(s: &mut crate::Session, p: &Value) -> Result { const ID: &str = "layout.createAlternate"; let name = str_param(p, "name").unwrap_or("Alternate").to_string(); let w = p.get("width").and_then(Value::as_f64).ok_or_else(|| bad(ID, "`width` required"))?.max(1.0); let h = p.get("height").and_then(Value::as_f64).ok_or_else(|| bad(ID, "`height` required"))?.max(1.0); let rule = p.get("rule").map(|_| rule_param(p, ID)).transpose()?; let n = s.doc()?.doc.page_count(); s.edit(|d, _| { let src = d.clone(); let parent = d.page_loc(n - 1).and_then(|(si, pi)| d.spreads[si].pages[pi].parent); d.insert_pages(Some(n - 1), n, parent)?; for i in 0..n { let (Some((si, pi)), Some((ti, tj))) = (src.page_loc(i), d.page_loc(n + i)) else { continue }; let from = &src.spreads[si]; let pb = from.pages[pi].bounds(); let last = from.pages.len() - 1; let ids: Vec<_> = from .items .iter() .filter(|it| { let c = it.bounds().center().x; (pi == 0 || c >= pb.x0) && (pi == last || c < pb.x1) }) .map(|it| it.id) .collect(); // The copy keeps the page's liquid rule (or takes the one given). let to = d.spreads[ti].pages[tj].bounds(); Arc::make_mut(&mut d.spreads[ti]).pages[tj].liquid = rule.unwrap_or(from.pages[pi].liquid); Arc::make_mut(&mut d.spreads[ti]).pages[tj].guides = from.pages[pi] .guides .iter() .cloned() .map(|mut g| { if g.orientation == Orientation::Vertical { g.position += to.x0 - pb.x0; } g }) .collect(); let new = super::object::duplicate_from(d, &src, &ids, SpreadRef::Doc(ti), designcraft_geom::Vec2::new(to.x0 - pb.x0, 0.0))?; // The copies' stories stay linked to the originals (Links panel: update when they change). for (old, new) in ids.iter().zip(&new) { let (Some(os), Some(ns)) = (src.item(*old).and_then(|i| i.text_frame()).map(|t| t.story), d.item(*new).and_then(|i| i.text_frame()).map(|t| t.story)) else { continue; }; let rev = src.story(os).map_or(0, |s| s.rev); if let Some(st) = d.story_mut(ns) { st.link = Some((os, rev)); } } } let pages: Vec = (n + 1..=2 * n).collect(); super::layout::resize_pages(d, &pages, Some(w), Some(h)); d.sections.retain(|x| x.start != n); d.sections.push(designcraft_doc::Section { start: n, start_number: Some(1), style: Default::default(), prefix: String::new(), marker: name.clone(), include_prefix: false, }); d.sections.sort_by_key(|x| x.start); Ok(json!({"firstPage": n + 1, "pages": n})) }) } #[cfg(test)] mod tests { use serde_json::json; use crate::Session; fn bounds(s: &Session, id: u64) -> designcraft_geom::Rect { s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds() } #[test] fn liquid_rules_follow_a_page_resize() { let mut s = Session::new(); s.execute("file.new", &json!({"pages": 1, "width": 600, "height": 800})).unwrap(); let a = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); // Re-center: same size, shifted by half the growth. s.execute("liquid.pageRule", &json!({"rule": "reCenter"})).unwrap(); s.execute("layout.pageSize", &json!({"pages": [1], "width": 800})).unwrap(); let b = bounds(&s, a); assert!((b.x0 - 200.0).abs() < 1e-6 && (b.width() - 100.0).abs() < 1e-6, "{b:?}"); // Object-based: pinned left and right, resizable → stretches with the page. s.execute("liquid.pageRule", &json!({"rule": "objectBased"})).unwrap(); s.execute("liquid.object", &json!({"ids": [a], "pinLeft": true, "pinRight": true, "resizeWidth": true, "resizeHeight": false})).unwrap(); s.execute("layout.pageSize", &json!({"pages": [1], "width": 900})).unwrap(); let b = bounds(&s, a); assert!((b.x0 - 200.0).abs() < 1e-6 && (b.x1 - 400.0).abs() < 1e-6, "{b:?}"); // Scale: uniform, centred. s.execute("liquid.pageRule", &json!({"rule": "scale"})).unwrap(); s.execute("layout.pageSize", &json!({"pages": [1], "width": 450, "height": 400})).unwrap(); let b = bounds(&s, a); assert!((b.width() - 100.0).abs() < 1e-6 && (b.height() - 50.0).abs() < 1e-6, "{b:?}"); } #[test] fn guide_based_and_alternate_layout() { let mut s = Session::new(); s.execute("file.new", &json!({"pages": 1, "width": 600, "height": 800})).unwrap(); let a = s.execute("frame.create", &json!({"rect": [100, 100, 300, 200]})).unwrap()["id"].as_u64().unwrap(); let b = s.execute("frame.create", &json!({"rect": [400, 100, 500, 200]})).unwrap()["id"].as_u64().unwrap(); s.execute("guide.add", &json!({"orientation": "vertical", "position": 200, "page": 0})).unwrap(); s.execute("guide.liquid", &json!({"page": 0, "index": 0})).unwrap(); s.execute("liquid.pageRule", &json!({"rule": "guideBased"})).unwrap(); let r = s.execute("layout.createAlternate", &json!({"name": "Wide", "width": 1200, "height": 800})).unwrap(); assert_eq!(r["firstPage"], 2); let d = s.doc().unwrap().doc.clone(); assert_eq!(d.page_count(), 2); let (si, pi) = d.page_loc(1).unwrap(); let page = d.spreads[si].pages[pi].bounds(); assert!((page.width() - 1200.0).abs() < 1e-6); let copies: Vec<_> = d.spreads[si].items.iter().map(|it| it.bounds()).collect(); assert_eq!(copies.len(), 2); // The frame the liquid guide crosses doubles in width; the other keeps its size. let mut widths: Vec = copies.iter().map(|r| r.width()).collect(); widths.sort_by(f64::total_cmp); assert!((widths[0] - 100.0).abs() < 1e-6 && (widths[1] - 400.0).abs() < 1e-6, "{widths:?}"); // The originals are untouched, and it is one undo step. assert!((bounds(&s, a).width() - 200.0).abs() < 1e-6 && (bounds(&s, b).width() - 100.0).abs() < 1e-6); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc.page_count(), 1); // Text in an alternate layout stays linked to the original story. s.execute("edit.redo", &json!({})).unwrap(); let t = s.execute("frame.create", &json!({"rect": [100, 300, 300, 400], "content": "text", "text": "Original", "caret": false})).unwrap(); s.execute("layout.createAlternate", &json!({"name": "Tall", "width": 600, "height": 1200})).unwrap(); let links = s.execute("story.links", &json!({})).unwrap(); assert!(links.as_array().unwrap().iter().any(|l| l["parent"] == t["story"]), "{links}"); } }