Merge storytold/main into fix/340-package-relative-links

This commit is contained in:
Brandon Thomas
2026-10-19 02:51:22 -04:00
46 changed files with 1213 additions and 146 deletions
+89 -10
View File
@@ -14,7 +14,9 @@
use std::path::Path;
use std::sync::Arc;
use designcraft_doc::{AssetId, Content, Document, Fingerprint, ItemId, Selection, Story, StoryId, TextFileLink};
use designcraft_compose::Cache;
use designcraft_doc::{AssetId, Content, Document, Fingerprint, Item, ItemId, Selection, Story, StoryId, TextFileLink};
use designcraft_geom::Affine;
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
@@ -25,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(query "links.list", "Links", [], None, "{} → [{asset, name, path, status: ok|modified|missing|embedded, refused?: why a missing link isn't followed (a path on another computer, a device), pixels, pdfCrop (placed PDFs: the box shown), uses: [{id, page, ppi}]}], then linked text files: [{story, name, path, status (also tooLarge: unchanged size, too big to check), edited (the story was changed in the layout), uses: [{id, page}]}]", has_doc, |s, _| {
// Taken out for the call so the document needn't be cloned on every poll.
let mut checks = std::mem::take(&mut s.text_link_checks);
let links = s.doc().map(|d| list(&d.doc, d.path.as_deref().map(Path::new), &mut checks));
let links = s.doc().map(|d| list(&d.doc, &s.cache, d.path.as_deref().map(Path::new), &mut checks));
s.text_link_checks = checks;
Ok(Value::Array(links?))
}),
@@ -262,7 +264,60 @@ fn file_name(path: &str) -> String {
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
}
fn list(d: &Document, document: Option<&Path>, checks: &mut TextChecks) -> Vec<Value> {
/// A graphic showing an asset: the item, its page, and the transform from its parent's space to
/// the spread.
struct Shown<'a> {
item: &'a Item,
page: Option<usize>,
parent_xf: Affine,
}
/// Where an asset shows: frames on spreads (see [`uses`]) and anchored or inline objects, whose
/// page and position come from the frame their story lays them out in.
fn shown<'a>(d: &'a Document, cache: &Cache, aid: AssetId) -> Vec<Shown<'a>> {
fn walk<'a>(it: &'a Item, xf: Affine, aid: AssetId, page: Option<usize>, out: &mut Vec<Shown<'a>>) {
if let Content::Graphic(g) = &it.content
&& g.asset == aid
{
out.push(Shown { item: it, page, parent_xf: xf });
}
for c in it.children() {
walk(c, xf * it.xf, aid, page, out);
}
}
let mut out: Vec<Shown> = uses(d, aid)
.into_iter()
.filter_map(|id| {
let loc = d.find(id)?;
Some(Shown { item: d.item_at(&loc)?, page: d.page_of_item(id), parent_xf: d.parent_xf(&loc) })
})
.collect();
for st in d.stories.values() {
let mut composed = None;
for (k, o) in st.objects.iter().enumerate() {
let mut found = false;
o.item.walk(&mut |i| found |= matches!(&i.content, Content::Graphic(g) if g.asset == aid));
if !found {
continue;
}
let cs = composed.get_or_insert_with(|| cache.get(d, st.id, None));
let placed = cs.frames.iter().find_map(|ft| ft.objects.iter().find(|p| p.index == k).map(|p| (ft.frame, p.origin)));
// Overset: no page; the resolution of the object as it would show.
let (page, xf) = match placed {
Some((fid, origin)) => {
let frame_xf =
d.find(fid).map_or(Affine::IDENTITY, |loc| d.parent_xf(&loc)) * d.item(fid).map_or(Affine::IDENTITY, |i| d.text_xf(i));
(d.page_of_item(fid), frame_xf * Affine::translate(origin.to_vec2()))
}
None => (None, Affine::IDENTITY),
};
walk(&o.item, xf, aid, page, &mut out);
}
}
out
}
fn list(d: &Document, cache: &Cache, document: Option<&Path>, checks: &mut TextChecks) -> Vec<Value> {
let texts = d.stories.values().filter_map(|st| {
let l = st.file_link.as_ref()?;
let uses: Vec<Value> = st.frames.iter().map(|id| json!({"id": id.0, "page": d.page_of_item(*id).map(|p| d.page_name(p))})).collect();
@@ -273,25 +328,24 @@ fn list(d: &Document, document: Option<&Path>, checks: &mut TextChecks) -> Vec<V
.values()
.filter(|a| used.contains(&a.id))
.map(|a| {
let uses: Vec<Value> = uses(d, a.id)
let uses: Vec<Value> = shown(d, cache, a.id)
.into_iter()
.map(|id| {
.map(|u| {
let it = u.item;
// Effective resolution: image pixels over the size it is shown at.
let ppi = (|| {
let loc = d.find(id)?;
let it = d.item_at(&loc)?;
let Content::Graphic(g) = &it.content else { return None };
// Vector (placed PDF or SVG): no effective resolution.
if a.mime == "application/pdf" || a.mime == "image/svg+xml" {
return None;
}
let (pw, _) = a.pixels?;
let xf = d.parent_xf(&loc) * it.xf * g.xf;
let xf = u.parent_xf * it.xf * g.xf;
let shown_w = xf.transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1)).width();
(shown_w > 0.0).then(|| (pw as f64 / (shown_w / 72.0)).round())
})();
let page = d.page_of_item(id).map(|p| d.page_name(p));
json!({"id": id.0, "page": page, "ppi": ppi})
let page = u.page.map(|p| d.page_name(p));
json!({"id": it.id.0, "page": page, "ppi": ppi})
})
.collect();
let pdf_crop = (a.mime == "application/pdf").then(|| a.pdf_crop.name());
@@ -527,6 +581,31 @@ mod tests {
super::super::file::base64_decode(result["base64"].as_str().unwrap())
}
#[test]
fn anchored_image_lists_its_page_and_resolution() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap();
let r = s
.execute("frame.create", &json!({"spread": 1, "rect": [72, 72, 400, 400], "content": "text", "text": "Before after and more words."}))
.unwrap();
let sid = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
let placed = s
.execute(
"file.place",
&json!({"base64": super::super::file::base64_encode(&png(10, 10)), "name": "dot.png", "x": 450, "y": 450, "width": 36}),
)
.unwrap();
let id = placed["id"].as_u64().unwrap();
let before = s.execute("links.list", &json!({})).unwrap();
assert_eq!(before[0]["uses"][0]["ppi"], 20.0);
s.execute("anchored.insert", &json!({"ids": [id], "story": sid.0, "pos": 7})).unwrap();
let list = s.execute("links.list", &json!({})).unwrap();
let uses = list[0]["uses"].as_array().unwrap();
assert_eq!(uses.len(), 1, "anchored image must have one use: {list}");
assert_eq!(uses[0]["page"], "2");
assert_eq!(uses[0]["ppi"], 20.0);
}
#[test]
fn eps_web_exports_convert_tiff_and_skip_bad_used_assets() {
use std::io::Read;
+45 -1
View File
@@ -61,7 +61,9 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(id) = super::id_param(p, "id") {
return s.edit(|d, _| {
let old = d.item(id).ok_or_else(|| bad("math.insert", "no such object"))?;
let at = old.bounds();
// The path and graphic live in the item's inner space, which `xf` maps to the spread:
// build the new ones at the old inner corner and keep `xf`.
let at = old.path.bounds().ok_or_else(|| bad("math.insert", "equation has no geometry"))?;
let it = math_item(d, &latex, size, lid, (at.x0, at.y0));
let target = d.item_mut(id).ok_or_else(|| bad("math.insert", "no such object"))?;
target.path = it.path;
@@ -129,4 +131,46 @@ mod tests {
let st = &s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(f["story"].as_u64().unwrap())];
assert_eq!(st.objects.len(), 1);
}
/// The item's top-left corner in spread space (inner path corner through its transform).
fn origin(s: &Session, id: designcraft_doc::ItemId) -> designcraft_geom::Point {
let d = &s.doc().unwrap().doc;
let it = d.item(id).unwrap();
let r = it.path.bounds().unwrap();
it.xf * designcraft_geom::Point::new(r.x0, r.y0)
}
#[test]
fn editing_a_transformed_equation_keeps_its_place() {
for step in ["move", "rotate", "scale"] {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("math.insert", &json!({"latex": r"\frac{a}{b}", "x": 100, "y": 100})).unwrap();
let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap());
match step {
"move" => s.execute("transform.move", &json!({"ids": [id.0], "dx": 50, "dy": 40})),
"rotate" => s.execute("transform.rotate", &json!({"ids": [id.0], "angle": 30})),
_ => s.execute("transform.scale", &json!({"ids": [id.0], "sx": 2.0, "sy": 1.5})),
}
.unwrap();
let before = origin(&s, id);
s.execute("math.insert", &json!({"id": id.0, "latex": r"\frac{a}{b}"})).unwrap();
let after = origin(&s, id);
assert!((before.x - after.x).abs() < 1e-6 && (before.y - after.y).abs() < 1e-6, "{step}: {before:?} -> {after:?}");
}
}
#[test]
fn editing_a_moved_equation_keeps_its_bounds() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("math.insert", &json!({"latex": "x", "x": 100, "y": 100})).unwrap();
let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap());
s.execute("transform.move", &json!({"ids": [id.0], "dx": 50, "dy": 40})).unwrap();
let before = s.doc().unwrap().doc.item(id).unwrap().bounds();
s.execute("math.insert", &json!({"id": id.0, "latex": "x"})).unwrap();
let after = s.doc().unwrap().doc.item(id).unwrap().bounds();
assert_eq!((before.x0, before.y0), (after.x0, after.y0));
assert_eq!((after.x0, after.y0), (150.0, 140.0));
}
}
+7 -2
View File
@@ -259,9 +259,14 @@ pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {
}
/// Plain text from a parameter, with CR LF and lone CR (classic Mac and InDesign line ends) as
/// `\n`, the paragraph separator, as edit.paste and text import already do. Kept as they were,
/// the CRs joined the paragraphs into one.
/// the CRs joined the paragraphs into one. A vertical tab (Word's and PowerPoint's Shift+Enter,
/// Chr(11)) is a forced line break: kept, it was drawn as a missing-glyph box.
pub(crate) fn text_param(p: &Value, key: &str) -> String {
str_param(p, key).unwrap_or("").replace("\r\n", "\n").replace('\r', "\n")
str_param(p, key)
.unwrap_or("")
.replace("\r\n", "\n")
.replace('\r', "\n")
.replace('\u{B}', designcraft_doc::story::FORCED_LINE_BREAK.encode_utf8(&mut [0; 4]))
}
pub(crate) fn id_param(p: &Value, key: &str) -> Option<ItemId> {
p.get(key).and_then(Value::as_u64).map(ItemId)
+32 -7
View File
@@ -297,7 +297,7 @@ pub fn specs() -> Vec<CommandSpec> {
st.swatch = v.into();
}
if let Some(v) = p2.get("tint").and_then(Value::as_f64) {
st.tint = v as f32;
st.tint = v.clamp(0.0, 1.0) as f32;
}
if let Some(v) = p2.get("weight").and_then(Value::as_f64) {
st.weight = v.max(0.0);
@@ -309,7 +309,7 @@ pub fn specs() -> Vec<CommandSpec> {
st.gap_swatch = v.into();
}
if let Some(v) = p2.get("gapTint").and_then(Value::as_f64) {
st.gap_tint = v as f32;
st.gap_tint = v.clamp(0.0, 1.0) as f32;
}
if let Some(v) = p2.get("gapOverprint").and_then(Value::as_bool) {
st.gap_overprint = v;
@@ -1201,11 +1201,10 @@ fn transform_resize(s: &mut Session, p: &Value) -> Result<Value> {
let to = rect_param(p, "to").ok_or_else(|| bad("transform.resize", "missing to"))?;
let scale_content = bool_or(p, "content", false);
let distribute = bool_or(p, "distribute", false);
if from.width().abs() < 1e-9 || from.height().abs() < 1e-9 {
return Err(bad("transform.resize", "degenerate source rect"));
}
let sx = to.width() / from.width();
let sy = to.height() / from.height();
// A flat source (a horizontal or vertical line) has no size to scale on its flat axis: that axis only moves.
let ratio = |new: f64, old: f64| if old.abs() < 1e-9 { 1.0 } else { new / old };
let sx = ratio(to.width(), from.width());
let sy = ratio(to.height(), from.height());
let sx = if sx.abs() < 1e-3 { 1e-3f64.copysign(sx) } else { sx };
let sy = if sy.abs() < 1e-3 { 1e-3f64.copysign(sy) } else { sy };
let m = Affine::translate((to.x0, to.y0)) * Affine::scale_non_uniform(sx, sy) * Affine::translate((-from.x0, -from.y0));
@@ -4189,6 +4188,16 @@ mod fitting_tests {
assert!(String::from_utf8_lossy(&bytes).contains("/Subtype/Image"));
}
#[test]
fn a_flat_line_resizes_along_its_length() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s.execute("line.create", &json!({"a": [100, 100], "b": [200, 100]})).unwrap()["id"].as_u64().unwrap();
s.execute("transform.resize", &json!({"ids": [id], "from": [100, 100, 200, 100], "to": [100, 100, 300, 100]})).unwrap();
let r = s.doc().unwrap().doc.item(ItemId(id)).unwrap().bounds();
assert!((r.x0 - 100.0).abs() < 1e-9 && (r.x1 - 300.0).abs() < 1e-9 && (r.y0 - 100.0).abs() < 1e-9 && r.height().abs() < 1e-9, "{r:?}");
}
#[test]
fn live_distribute_spreads_objects_without_resizing() {
let mut s = Session::new();
@@ -4369,6 +4378,22 @@ mod line_end_tests {
s.execute("note.edit", &json!({"story": n["story"], "id": n["id"], "text": "Three\r\nFour"})).unwrap();
assert_eq!(note(&mut s), "Three\nFour");
}
/// "One\u{B}Two" kept the vertical tab, drawn as a missing-glyph box, where Word and
/// PowerPoint text (Chr(11)) means a forced line break.
#[test]
fn vertical_tabs_are_forced_line_breaks() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "One\u{B}Two"})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
let story = |s: &Session| s.doc().unwrap().doc.story(sid).unwrap().clone();
assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("One\u{2028}Two", 1));
s.execute("text.insert", &json!({"text": "\u{B}Three", "raw": true})).unwrap();
assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("One\u{2028}Two\u{2028}Three", 1));
s.execute("story.setText", &json!({"story": sid.0, "text": "A\u{B}B\rC"})).unwrap();
assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("A\u{2028}B\nC", 2));
}
}
#[cfg(test)]
+73 -4
View File
@@ -236,7 +236,8 @@ fn same_rect(a: Rect, b: Rect) -> bool {
/// on its page (when it sits in one), then one frame per column on each page it adds. With
/// `primary` (Smart Text Reflow of the primary text frame), each added page gets one frame on the
/// live area with the page's columns instead. Empty frames it made at the end of the thread are
/// removed again, except the first on a page. Returns pages added.
/// removed again, except the first on a page it didn't add; a page it added that holds only such
/// a frame goes with it. Returns pages added.
/// A story on a parent spread doesn't autoflow: its frames thread only on that parent. Errors
/// name `cmd`, the command that autoflows.
pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &str, primary: bool) -> Result<usize> {
@@ -249,6 +250,7 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
let vertical = story_vertical || primary;
let mut added = 0;
let mut made = Vec::new();
let mut new_pages = Vec::new();
let mut first = true;
loop {
let (overset, held, full) = fit(d, sid);
@@ -278,7 +280,7 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
let per_page = if vertical { 1 } else { d.page(page).map_or(1, |p| p.columns.count.max(1) as usize) };
let pages = want.div_ceil(per_page).clamp(1, max_pages - added);
let parent = d.page_loc(page).and_then(|(si, pi)| d.spreads[si].pages[pi].parent);
d.insert_pages(Some(page), pages, parent)?;
new_pages.extend(d.insert_pages(Some(page), pages, parent)?);
for pg in page + 1..=page + pages {
let (si, pi) = d.page_loc(pg).ok_or_else(|| bad(cmd, "page insert failed"))?;
let columns = d.spreads[si].pages[pi].columns.clone();
@@ -297,10 +299,24 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
while let Some(&f) = made.last() {
let frames = d.story(sid).map_or(&[][..], |s| s.frames.as_slice());
let [.., before, tail] = frames else { break };
if *tail != f || cs.frame(f).is_some_and(|ft| !ft.range.is_empty()) || d.page_of_item(*before) != d.page_of_item(f) {
if *tail != f || cs.frame(f).is_some_and(|ft| !ft.range.is_empty()) {
break;
}
d.remove_item(f)?;
let page = d.page_of_item(f);
if d.page_of_item(*before) != page {
// The first frame on its page: the estimate added more pages than the text needs.
// Drop the page too when autoflow added it and the frame is all it holds.
let Some(pg) = page.filter(|pg| d.page(*pg).is_some_and(|p| new_pages.contains(&p.id))) else { break };
let alone = d.spreads.iter().flat_map(|sp| sp.items.iter()).all(|it| it.id == f || d.page_of_item(it.id) != Some(pg));
if !alone {
break;
}
d.remove_item(f)?;
d.delete_pages(&[pg])?;
added = added.saturating_sub(1);
} else {
d.remove_item(f)?;
}
made.pop();
}
Ok(added)
@@ -366,7 +382,14 @@ pub(super) fn place_text(
let story = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let r = t.range();
story.delete(r.clone());
// An insertion point in an empty paragraph takes the first imported paragraph's style
// (insert_story would keep the empty paragraph's own, losing the styleMap result).
let p0 = story.para_at(r.start);
let blank = story.para_ranges().get(p0).is_some_and(|x| x.is_empty());
let end = story.insert_story(r.start, &imp.story);
if blank && let (Some(first), Some(p)) = (imp.story.paras.first(), story.paras.get_mut(p0)) {
*p = ParaFormat { table: p.table, ..first.clone() };
}
sel.text = Some(TextSel { anchor: end, focus: end, ..t });
if link.is_some() {
warnings.push("not linked: a linked text file fills a whole story, not an insertion point".into());
@@ -686,6 +709,38 @@ mod reflow_tests {
s.execute("text.delete", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.page_count(), 1);
}
/// Placing text into the primary frame adds only the pages the text needs, also when a wrap
/// leaves page 1 little room or the text ends at a page boundary (#1165).
#[test]
fn smart_text_reflow_place_adds_no_empty_pages() {
let dir = std::env::temp_dir().join(format!("dc-reflow-place-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for (n, wrap, want) in [(280, true, 5), (250, false, 3)] {
let mut s = Session::new();
s.execute("file.new", &json!({"preset": "Letter", "pages": 1, "primaryTextFrame": true})).unwrap();
if wrap {
let id = s.execute("frame.create", &json!({"rect": [36, 36, 576, 600]})).unwrap()["id"].as_u64().unwrap();
s.execute("object.textWrap", &json!({"ids": [id], "mode": "boundingBox"})).unwrap();
}
let sid = s.doc().unwrap().doc.settings.primary_story.expect("primary story");
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap();
let path = dir.join(format!("words-{n}.txt"));
std::fs::write(&path, "Words keep coming and the page fills up quickly with them. ".repeat(n)).unwrap();
s.execute("file.place", &json!({"path": path.to_string_lossy()})).unwrap();
let d = s.doc().unwrap().doc.clone();
assert_eq!(d.page_count(), want, "{n} lines");
let frames = &d.story(sid).unwrap().frames;
assert_eq!(frames.len(), d.page_count(), "{n} lines");
let cs = designcraft_compose::compose_story(&d, sid, &Default::default());
assert!(cs.overset_at.is_none(), "{n} lines");
for f in frames {
assert!(cs.frame(*f).is_some_and(|ft| !ft.range.is_empty()), "{n} lines: empty frame {f:?}");
}
d.check().unwrap();
}
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
@@ -767,6 +822,20 @@ mod pdf_page_tests {
assert_eq!(s.doc().unwrap().doc.styles.para("Body").unwrap().chars.size, Some(10.0));
}
#[test]
fn word_first_paragraph_into_empty_caret_keeps_mapped_style() {
// Regression (#1208): placing into a new text frame (which leaves its caret selected) left the first paragraph unstyled.
let b64 = crate::cmd::base64_encode(&word_file());
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Headline"})).unwrap();
let fr = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text"})).unwrap();
let sid = fr["story"].as_u64().unwrap();
s.execute("file.place", &json!({"base64": b64, "name": "a.docx", "frame": fr["id"], "styleMap": {"Title": "Headline"}})).unwrap();
let st = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().clone();
assert_eq!(st.paras.iter().map(|p| p.style.as_str()).collect::<Vec<_>>(), ["Headline", "Body"]);
}
#[test]
fn word_normal_comes_in_as_its_own_style() {
// Regression (#722): Normal's formatting (over Word's document defaults) was dropped.
+82 -3
View File
@@ -30,7 +30,7 @@ fn style_specs() -> Vec<CommandSpec> {
"New Cell Style…",
[],
None,
"{name, fromSelection?: bool (the target cell's look), fill?, tint?, insets?: n|[t,l,b,r], vj?: top|center|bottom|justify, stroke?: {weight?, color?, tint?, type?, gapColor?, gapTint?} (all four edges; values as table.setCell's stroke), paragraphStyle?, basedOn?: cell style name, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} → {name}",
"{name, fromSelection?: bool (the target cell's look), fill?, tint?, insets?: n|[t,l,b,r], vj?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, stroke?: {weight?, color?, tint?, type?, gapColor?, gapTint?} (all four edges; values as table.setCell's stroke), paragraphStyle?, basedOn?: cell style name, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} → {name}",
has_doc,
|s, p| cell_style_create(s, p)
),
@@ -297,7 +297,7 @@ fn table_specs() -> Vec<CommandSpec> {
"Cell Options",
[],
None,
"{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns",
"{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns",
in_table,
set_cell
),
@@ -932,6 +932,22 @@ fn drawn_side(t: &Table, owners: &[(usize, usize)], (r, c): (usize, usize), side
drawn.or_else(|| t.cell(r, c).and_then(|cell| cell.strokes.get(side).cloned()))
}
/// Cell Options › Text › First Baseline: `firstBaseline` (offset method) and `firstBaselineMin` (pt).
fn first_baseline_params(p: &Value, id: &str) -> Result<(Option<designcraft_doc::FirstBaseline>, Option<f64>)> {
let fb = match p.get("firstBaseline").filter(|v| !v.is_null()) {
Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(id, format!("firstBaseline: {e}")))?),
None => None,
};
let min = match p.get("firstBaselineMin").filter(|v| !v.is_null()) {
Some(v) => match v.as_f64().filter(|m| m.is_finite() && (0.0..=8640.0).contains(m)) {
Some(m) => Some(m),
None => return Err(bad(id, "firstBaselineMin: a number of points from 0 to 8640")),
},
None => None,
};
Ok((fb, min))
}
fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "table.setCell";
let g = target(s, p, ID)?;
@@ -954,6 +970,7 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
Some(o) => return Err(bad(ID, format!("unknown vj `{o}`"))),
None => None,
};
let (first_baseline, first_baseline_min) = first_baseline_params(p, ID)?;
let text = str_param(p, "text").map(|_| super::text_param(p, "text"));
let stroke = match p.get("stroke").filter(|v| !v.is_null()) {
Some(sv) => Some((StrokeEdit::parse(doc, sv, ID)?, EdgeSel::parse(sv.get("edges"), ID)?)),
@@ -998,6 +1015,12 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = vj {
cell.vj = v;
}
if let Some(v) = first_baseline {
cell.first_baseline = v;
}
if let Some(v) = first_baseline_min {
cell.first_baseline_min = v;
}
if let Some(tx) = &text {
let len = cell.text.len();
cell.text.replace(0..len, tx);
@@ -1525,7 +1548,7 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
.filter_map(|(r, c)| {
let cell = t.cell(r, c)?;
Some(json!({"row": r, "col": c, "rowSpan": cell.row_span, "colSpan": cell.col_span, "text": cell.text.text, "fill": cell.fill,
"insets": cell.insets, "vj": cell.vj, "style": cell.style}))
"insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "style": cell.style}))
})
.collect();
Ok(json!({
@@ -1572,6 +1595,13 @@ fn cell_style_fields(cs: &mut designcraft_doc::CellStyle, p: &Value, stroke: Opt
if let Some(v) = p.get("vj") {
cs.vj = Some(serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("vj: {e}")))?);
}
let (first_baseline, first_baseline_min) = first_baseline_params(p, cmd)?;
if first_baseline.is_some() {
cs.first_baseline = first_baseline;
}
if first_baseline_min.is_some() {
cs.first_baseline_min = first_baseline_min;
}
if let Some(st) = stroke {
cs.stroke = Some(st);
cs.strokes = Default::default();
@@ -1600,6 +1630,8 @@ fn cell_style_create(s: &mut Session, p: &Value) -> Result<Value> {
cs.fill_tint = Some(cell.fill_tint);
cs.insets = Some(cell.insets);
cs.vj = Some(cell.vj);
cs.first_baseline = Some(cell.first_baseline);
cs.first_baseline_min = Some(cell.first_baseline_min);
cs.stroke = Some(cell.strokes[0].clone());
cs.paragraph_style = cell.text.paras.first().map(|p| p.style.clone());
}
@@ -1787,6 +1819,28 @@ mod style_tests {
use crate::Session;
#[test]
fn cell_first_baseline_is_a_cell_option_and_a_cell_style_attribute() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap();
let sid = r["story"].as_u64().unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap();
s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap();
s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaseline": "capHeight", "firstBaselineMin": 3})).unwrap();
let got = s.execute("table.get", &json!({})).unwrap();
let cell = got["cells"].as_array().unwrap().iter().find(|c| c["row"] == 0 && c["col"] == 0).unwrap().clone();
assert_eq!((cell["firstBaseline"].as_str(), cell["firstBaselineMin"].as_f64()), (Some("capHeight"), Some(3.0)));
assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaseline": "middle"})).is_err());
assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaselineMin": -1})).is_err());
s.execute("style.cell.create", &json!({"name": "Leading", "firstBaseline": "leading"})).unwrap();
s.execute("style.cell.apply", &json!({"name": "Leading", "row": 1, "col": 1})).unwrap();
let d = &s.doc().unwrap().doc;
let t = d.story(designcraft_doc::StoryId(sid)).unwrap().tables.values().next().unwrap();
assert_eq!(t.cell(1, 1).unwrap().first_baseline, designcraft_doc::FirstBaseline::Leading);
assert_eq!(t.cell(1, 0).unwrap().first_baseline, designcraft_doc::FirstBaseline::Ascent);
}
#[test]
fn cell_and_table_styles_apply_and_follow_edits() {
let mut s = Session::new();
@@ -2191,6 +2245,31 @@ mod sort_tests {
assert_eq!((cell(&s, 2, 0), cell(&s, 2, 1)), ("x".to_string(), "a".to_string()));
}
#[test]
fn move_row_cannot_move_a_header_row_out_of_place() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap();
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1})).unwrap();
assert!(s.execute("table.moveRow", &json!({"from": 0, "to": 2})).is_err());
s.doc().unwrap().doc.check().unwrap();
// Dragging the selected header row onto the last body row is refused too.
let d = s.doc().unwrap().doc.clone();
let (sid, tid) = d.stories.values().find_map(|st| st.tables.keys().next().map(|k| (st.id, *k))).unwrap();
let fid = d.stories[&sid].frames[0];
let cs = s.cache.get(&d, sid, None);
let cells = &cs.frames[0].tables[0].cells;
let centre = |r: usize| {
let c = cells.iter().find(|c| c.row == r && c.col == 0).unwrap().rect.center();
d.text_xf(d.item(fid).unwrap()) * c
};
let (from, to) = (centre(0), centre(2));
s.execute("table.select", &json!({"story": sid.0, "table": tid, "rows": [0, 0], "what": "row"})).unwrap();
assert!(s.execute("table.dropCells", &json!({"frame": fid.0, "from": [from.x, from.y], "to": [to.x, to.y]})).is_err());
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn table_options_limits_header_and_footer_rows() {
let mut s = Session::new();
+18 -5
View File
@@ -125,12 +125,20 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
|s, p| {
let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.replaceRange", "missing story"))?);
let a = p.get("start").and_then(Value::as_u64).unwrap_or(0) as usize;
let b = p.get("end").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(a);
let offset = |v: u64| usize::try_from(v).map_err(|_| bad("story.replaceRange", format!("offset {v} is out of range")));
let a = offset(p.get("start").and_then(Value::as_u64).unwrap_or(0))?;
let b = match p.get("end").and_then(Value::as_u64) {
Some(v) => offset(v)?,
None => a,
};
let text = super::text_param(p, "text");
s.edit(|d, sel| {
let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let (a, b) = (a.min(st.len()), b.min(st.len()).max(a.min(st.len())));
let n = st.len();
if a > b || b > n || !st.text.is_char_boundary(a) || !st.text.is_char_boundary(b) {
let msg = format!("range {a}..{b} must be UTF-8 byte offsets on character boundaries, start <= end; the story is {n} bytes");
return Err(bad("story.replaceRange", msg));
}
st.replace(a..b, &text);
let len = st.len();
if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid) {
@@ -2292,9 +2300,14 @@ mod open_type_tests {
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [0, 0, 200, 100], "content": "text", "text": "é漢🙂x"})).unwrap();
let sid = r["story"].clone();
// Offsets inside 漢 (2..5) and 🙂 (5..9) snap back to the character starts: 漢 is replaced.
s.execute("story.replaceRange", &json!({"story": sid, "start": 3, "end": 6, "text": "字"})).unwrap();
let story = designcraft_doc::StoryId(sid.as_u64().unwrap());
// Offsets inside 漢 (2..5) and 🙂 (5..9), past the end, or reversed are refused and change nothing.
for (start, end) in [(3, 6), (2, 4), (0, 99), (5, 2)] {
let r = s.execute("story.replaceRange", &json!({"story": sid, "start": start, "end": end, "text": "字"}));
assert!(r.is_err_and(|e| e.to_string().contains("10 bytes")), "{start}..{end}");
assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "é漢🙂x");
}
s.execute("story.replaceRange", &json!({"story": sid, "start": 2, "end": 5, "text": "字"})).unwrap();
assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "é字🙂x");
// A caret before 🙂 (byte 5) would land inside it once é (2 bytes) is gone.
s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap();