mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-01 22:26:49 -06:00
Merge storytold/main into fix/340-package-relative-links
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user