mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-25 18:56:50 -06:00
Text frames: column rules draw between columns on screen and in PDF
A frame with Column Rule on gets a bar centred in each gutter, inside the insets, broken around paragraphs that span columns; right-to-left and vertical frames included. object.textFrameOptions takes columnRuleWeight and columnRuleColor (unknown swatches are an error) and an inset array whose null sides stay; document.inspect reports the rule.
This commit is contained in:
@@ -1036,6 +1036,13 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
|
||||
last_end = ft.range.end;
|
||||
}
|
||||
}
|
||||
// Column rules, once vertical justification has put the lines in place.
|
||||
for (ft, f) in out.frames.iter_mut().zip(frames) {
|
||||
if f.opts.column_rule {
|
||||
let rects = column_rule_rects(&ft.columns, &ft.lines, f.opts.column_rule_weight);
|
||||
ft.decos.extend(rects.into_iter().map(|rect| Deco { rect, color: f.opts.column_rule_color.clone(), tint: 1.0 }));
|
||||
}
|
||||
}
|
||||
out.styles = styles_tab;
|
||||
mark_keep_violations(doc, story, &mut out);
|
||||
out
|
||||
@@ -1132,6 +1139,63 @@ fn para_box_decos(out: &mut ComposedStory, pi: usize, pp: &ParaProps) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Thickest column rule drawn (points).
|
||||
const MAX_COLUMN_RULE_WEIGHT: f64 = 1000.0;
|
||||
|
||||
/// Text Frame Options › Column Rules: one bar per gutter, `weight` wide and centred between the
|
||||
/// facing edges of the two columns, from the columns' top to their bottom (the text area, inside
|
||||
/// the insets). Works in the composed space, so right-to-left and vertical frames (whose columns
|
||||
/// are laid out in the turned box) get their rules between the columns too.
|
||||
///
|
||||
/// A paragraph that spans columns interrupts the rule: the bar stops at the top of its first line
|
||||
/// and resumes below its last line. Nothing is drawn for a single column or a weight that is not a
|
||||
/// positive finite number.
|
||||
pub fn column_rule_rects(columns: &[Rect], lines: &[Line], weight: f64) -> Vec<Rect> {
|
||||
if !weight.is_finite() || weight <= 0.0 || columns.len() < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
let half = weight.min(MAX_COLUMN_RULE_WEIGHT) / 2.0;
|
||||
let mut cols: Vec<Rect> =
|
||||
columns.iter().copied().filter(|c| c.x0.is_finite() && c.x1.is_finite() && c.y0.is_finite() && c.y1.is_finite()).collect();
|
||||
cols.sort_by(|a, b| a.x0.total_cmp(&b.x0));
|
||||
let mut out = Vec::new();
|
||||
for pair in cols.windows(2) {
|
||||
let [a, b] = [pair[0], pair[1]];
|
||||
let cx = (a.x1 + b.x0) / 2.0;
|
||||
let (top, bottom) = (a.y0.max(b.y0), a.y1.min(b.y1));
|
||||
if bottom <= top {
|
||||
continue;
|
||||
}
|
||||
// Vertical bands of the paragraphs whose lines cross this gutter.
|
||||
let mut spans: Vec<(usize, f64, f64)> = Vec::new();
|
||||
for l in lines.iter().filter(|l| l.x0 < cx && l.x1 > cx) {
|
||||
let (t, z) = (l.baseline - l.ascent, l.baseline + l.descent);
|
||||
match spans.iter_mut().find(|s| s.0 == l.para) {
|
||||
Some(s) => {
|
||||
s.1 = s.1.min(t);
|
||||
s.2 = s.2.max(z);
|
||||
}
|
||||
None => spans.push((l.para, t, z)),
|
||||
}
|
||||
}
|
||||
spans.sort_by(|p, q| p.1.total_cmp(&q.1));
|
||||
let mut y = top;
|
||||
for (_, t, z) in spans {
|
||||
if t > y {
|
||||
out.push(Rect::new(cx - half, y, cx + half, t.min(bottom)));
|
||||
}
|
||||
y = y.max(z);
|
||||
if y >= bottom {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if y < bottom {
|
||||
out.push(Rect::new(cx - half, y, cx + half, bottom));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Breaker parameters from the paragraph's settings.
|
||||
fn spacing_for(pp: &ParaProps, base_size: f64) -> Spacing {
|
||||
Spacing {
|
||||
|
||||
@@ -1877,3 +1877,92 @@ fn vertical_lines_fit_the_em_box() {
|
||||
assert!((l.baseline - col.y0 - ascent).abs() < 1e-9, "{family}: {} {}", l.baseline, col.y0);
|
||||
}
|
||||
}
|
||||
|
||||
fn ruled(text: &str, rect: Rect, columns: u32, edit: impl FnOnce(&mut Document, StoryId)) -> (Document, ItemId, Vec<Rect>) {
|
||||
let (mut d, sid, fid) = doc_with(text, rect, ParaAttrs::default());
|
||||
{
|
||||
let o = &mut d.item_mut(fid).unwrap().text_frame_mut().unwrap().options;
|
||||
o.columns = columns;
|
||||
o.gutter = 12.0;
|
||||
o.inset = [10.0, 6.0, 20.0, 6.0];
|
||||
o.column_rule = true;
|
||||
o.column_rule_weight = 2.0;
|
||||
o.column_rule_color = "[Black]".into();
|
||||
}
|
||||
edit(&mut d, sid);
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let rules = cs.frames[0].decos.iter().filter(|dc| dc.color == "[Black]").map(|dc| dc.rect).collect();
|
||||
(d, fid, rules)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_rules_sit_in_the_gutters_inside_the_insets() {
|
||||
// Two columns in 0..300 with 6 pt side insets: the gutter is centred on 150.
|
||||
let (_, _, rules) = ruled("Text", Rect::new(0.0, 0.0, 300.0, 200.0), 2, |_, _| {});
|
||||
assert_eq!(rules.len(), 1, "{rules:?}");
|
||||
let r = rules[0];
|
||||
assert!((r.center().x - 150.0).abs() < 1e-9 && (r.width() - 2.0).abs() < 1e-9, "{r:?}");
|
||||
assert!((r.y0 - 10.0).abs() < 1e-9 && (r.y1 - 180.0).abs() < 1e-9, "{r:?}");
|
||||
// Three columns: one rule per gutter, each centred between its two columns.
|
||||
let (_, _, rules) = ruled("Text", Rect::new(0.0, 0.0, 300.0, 200.0), 3, |_, _| {});
|
||||
assert_eq!(rules.len(), 2, "{rules:?}");
|
||||
let w = (288.0 - 24.0) / 3.0;
|
||||
for (i, r) in rules.iter().enumerate() {
|
||||
let edge = 6.0 + w * (i + 1) as f64 + 12.0 * i as f64;
|
||||
assert!((r.center().x - (edge + 6.0)).abs() < 1e-9, "{i}: {r:?}");
|
||||
}
|
||||
// Off, or a single column: no rule.
|
||||
let (_, _, rules) = ruled("Text", Rect::new(0.0, 0.0, 300.0, 200.0), 1, |_, _| {});
|
||||
assert!(rules.is_empty());
|
||||
let (_, _, rules) = ruled("Text", Rect::new(0.0, 0.0, 300.0, 200.0), 2, |d, sid| {
|
||||
let fid = d.story(sid).unwrap().frames[0];
|
||||
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.column_rule = false;
|
||||
});
|
||||
assert!(rules.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_rules_centre_on_unequal_gutters_and_ignore_bad_weights() {
|
||||
let cols = [Rect::new(200.0, 0.0, 300.0, 50.0), Rect::new(0.0, 0.0, 100.0, 50.0), Rect::new(110.0, 0.0, 180.0, 50.0)];
|
||||
let rules = column_rule_rects(&cols, &[], 1.0);
|
||||
let centres: Vec<f64> = rules.iter().map(|r| r.center().x).collect();
|
||||
assert_eq!(centres, [105.0, 190.0]);
|
||||
assert!(column_rule_rects(&cols, &[], f64::NAN).is_empty());
|
||||
assert!(column_rule_rects(&cols, &[], -1.0).is_empty());
|
||||
assert!((column_rule_rects(&cols, &[], 1e12)[0].width() - 1000.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_rules_in_right_to_left_and_vertical_frames() {
|
||||
let (_, _, rules) = ruled("نص", Rect::new(0.0, 0.0, 300.0, 200.0), 2, |d, sid| {
|
||||
d.story_mut(sid).unwrap().direction = designcraft_doc::TextDirection::RightToLeft;
|
||||
});
|
||||
assert_eq!(rules.len(), 1);
|
||||
assert!((rules[0].center().x - 150.0).abs() < 1e-9, "{rules:?}");
|
||||
// Vertical type: the columns stack top to bottom, so the rule runs across the frame.
|
||||
let (d, fid, rules) = ruled("縦書き", Rect::new(100.0, 100.0, 400.0, 300.0), 2, |d, sid| d.story_mut(sid).unwrap().vertical = true);
|
||||
assert_eq!(rules.len(), 1);
|
||||
let r = d.text_xf(d.item(fid).unwrap()).transform_rect_bbox(rules[0]);
|
||||
// Text area: x 106..394, y 110..280; the gutter is centred on y 195.
|
||||
assert!((r.x0 - 106.0).abs() < 1e-6 && (r.x1 - 394.0).abs() < 1e-6, "{r:?}");
|
||||
assert!((r.center().y - 195.0).abs() < 1e-6 && (r.height() - 2.0).abs() < 1e-6, "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_rules_break_around_paragraphs_that_span_columns() {
|
||||
let text = format!("A heading that spans both columns\n{LOREM} {LOREM}");
|
||||
let (d, fid, rules) = ruled(&text, Rect::new(0.0, 0.0, 300.0, 400.0), 2, |d, sid| {
|
||||
d.story_mut(sid).unwrap().paras[0].para.span_columns = Some(designcraft_doc::SpanColumns::Span(0));
|
||||
});
|
||||
let sid = d.item(fid).unwrap().text_frame().unwrap().story;
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let span: Vec<&Line> = cs.frames[0].lines.iter().filter(|l| l.para == 0).collect();
|
||||
assert!(!span.is_empty() && span.iter().all(|l| l.x0 < 150.0 && l.x1 > 150.0), "the heading spans");
|
||||
let (top, bottom) = (span[0].baseline - span[0].ascent, span[span.len() - 1].baseline + span[span.len() - 1].descent);
|
||||
assert!(!rules.is_empty());
|
||||
for r in &rules {
|
||||
assert!(r.y1 <= top + 1e-9 || r.y0 >= bottom - 1e-9, "rule {r:?} cuts the span {top}..{bottom}");
|
||||
}
|
||||
// The rule resumes below the span and runs to the bottom of the columns.
|
||||
assert!(rules.iter().any(|r| (r.y0 - bottom).abs() < 1e-9 && (r.y1 - 380.0).abs() < 1e-9), "{rules:?}");
|
||||
}
|
||||
|
||||
@@ -41,6 +41,11 @@ fn item_json(it: &Item) -> Value {
|
||||
Content::Text(t) => {
|
||||
v["story"] = json!(t.story.0);
|
||||
v["columns"] = json!(t.options.columns);
|
||||
v["columnRule"] = json!(t.options.column_rule);
|
||||
if t.options.column_rule {
|
||||
v["columnRuleWeight"] = json!(t.options.column_rule_weight);
|
||||
v["columnRuleColor"] = json!(t.options.column_rule_color);
|
||||
}
|
||||
}
|
||||
Content::Graphic(g) => v["asset"] = json!(g.asset.0),
|
||||
Content::Group { items } => v["children"] = Value::Array(items.iter().map(|c| item_json(c)).collect()),
|
||||
|
||||
@@ -479,7 +479,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Text Frame Options…",
|
||||
["Object"],
|
||||
Some("Cmd+B"),
|
||||
"{columns?, gutter?, inset?: number|[t,l,b,r], verticalJustification?: top|center|bottom|justify, firstBaseline?, autoSize?, ignoreWrap?, balanceColumns?, vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}",
|
||||
"{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, firstBaseline?, autoSize?, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}",
|
||||
has_selection,
|
||||
text_frame_options
|
||||
),
|
||||
@@ -1410,6 +1410,7 @@ fn content_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "object.textFrameOptions";
|
||||
let st = s.doc()?;
|
||||
let mut ids = targets(s, p)?;
|
||||
if ids.is_empty()
|
||||
@@ -1417,6 +1418,18 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
ids = st.doc.story(t.story).map(|s| s.frames.clone()).unwrap_or_default();
|
||||
}
|
||||
let rule_weight = match p.get("columnRuleWeight") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => match v.as_f64() {
|
||||
Some(w) if w.is_finite() && (0.0..=1000.0).contains(&w) => Some(w),
|
||||
_ => return Err(bad(ID, format!("columnRuleWeight must be a number of points from 0 to 1000, not {v}"))),
|
||||
},
|
||||
};
|
||||
let rule_color = match p.get("columnRuleColor") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(c)) if st.doc.swatch(c).is_some() => Some(c.clone()),
|
||||
Some(v) => return Err(bad(ID, format!("no swatch {v}"))),
|
||||
};
|
||||
let p = p.clone();
|
||||
s.edit(|d, _| {
|
||||
for id in &ids {
|
||||
@@ -1431,8 +1444,12 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match p.get("inset") {
|
||||
Some(Value::Number(n)) => o.inset = [n.as_f64().unwrap_or(0.0); 4],
|
||||
Some(v @ Value::Array(_)) => {
|
||||
if let Ok(a) = serde_json::from_value(v.clone()) {
|
||||
o.inset = a;
|
||||
if let Ok(a) = serde_json::from_value::<[Option<f64>; 4]>(v.clone()) {
|
||||
for (side, v) in o.inset.iter_mut().zip(a) {
|
||||
if let Some(v) = v {
|
||||
*side = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -1456,6 +1473,12 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = p.get("columnRule").and_then(Value::as_bool) {
|
||||
o.column_rule = v;
|
||||
}
|
||||
if let Some(w) = rule_weight {
|
||||
o.column_rule_weight = w;
|
||||
}
|
||||
if let Some(c) = &rule_color {
|
||||
o.column_rule_color = c.clone();
|
||||
}
|
||||
}
|
||||
// Story direction belongs to the story: every frame of the thread turns.
|
||||
if let Some(v) = p.get("vertical").and_then(Value::as_bool) {
|
||||
@@ -2421,3 +2444,46 @@ mod named_target_tests {
|
||||
assert_eq!(s.execute("conveyor.collect", &json!({"id": r["id"]})).unwrap()["count"], 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod column_rule_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn column_rules_set_validate_undo_and_inspect() {
|
||||
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": "Hello"})).unwrap();
|
||||
let id = ItemId(r["id"].as_u64().unwrap());
|
||||
let opts = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.clone();
|
||||
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "columns": 3, "inset": [1, 2, 3, 4]})).unwrap();
|
||||
let before = opts(&s);
|
||||
// An unknown swatch or a bad weight is an error and changes nothing.
|
||||
let e = s.execute("object.textFrameOptions", &json!({"ids": [id.0], "columnRule": true, "columnRuleColor": "Mauve"})).unwrap_err();
|
||||
assert!(e.to_string().contains("Mauve"), "{e}");
|
||||
assert!(s.execute("object.textFrameOptions", &json!({"ids": [id.0], "columnRuleWeight": -1})).is_err());
|
||||
assert_eq!(opts(&s), before);
|
||||
s.execute(
|
||||
"object.textFrameOptions",
|
||||
&json!({"ids": [id.0], "columnRule": true, "columnRuleWeight": 0.5, "columnRuleColor": "C=100 M=0 Y=0 K=0"}),
|
||||
)
|
||||
.unwrap();
|
||||
let o = opts(&s);
|
||||
assert!(o.column_rule && o.column_rule_weight == 0.5 && o.column_rule_color == "C=100 M=0 Y=0 K=0");
|
||||
assert_eq!(o.inset, [1.0, 2.0, 3.0, 4.0], "untouched options stay");
|
||||
let item = s.execute("document.inspect", &json!({})).unwrap()["spreads"][0]["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|i| i["id"] == json!(id.0))
|
||||
.cloned()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(&item["columnRule"], &item["columnRuleWeight"], &item["columnRuleColor"]),
|
||||
(&json!(true), &json!(0.5), &json!("C=100 M=0 Y=0 K=0"))
|
||||
);
|
||||
// One undo step.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(opts(&s), before);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,3 +337,24 @@ fn missing_glyphs_print_as_boxes() {
|
||||
assert_eq!(glyphs.iter().map(|g| g.0.as_str()).collect::<Vec<_>>(), ["a", "b"], "{glyphs:?}");
|
||||
assert_eq!(boxes(&paths), 1, "{paths:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_rules_are_drawn() {
|
||||
let rules = |on: bool| {
|
||||
let mut d = doc_with_text("Column text");
|
||||
let fid = d.stories.values().next().unwrap().frames[0];
|
||||
let o = &mut d.item_mut(fid).unwrap().text_frame_mut().unwrap().options;
|
||||
o.columns = 3;
|
||||
o.gutter = 12.0;
|
||||
o.inset = [0.0; 4];
|
||||
o.column_rule = on;
|
||||
o.column_rule_weight = 2.0;
|
||||
let (_, paths) = drawn(&export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap());
|
||||
paths.into_iter().filter(|r| (r.width() - 2.0).abs() < 0.01 && (r.height() - 264.0).abs() < 0.01).map(|r| r.center().x).collect::<Vec<_>>()
|
||||
};
|
||||
// 540 pt wide in three columns with 12 pt gutters: columns of 172 pt from x 36.
|
||||
let xs = rules(true);
|
||||
assert_eq!(xs.len(), 2, "{xs:?}");
|
||||
assert!((xs[0] - 214.0).abs() < 0.01 && (xs[1] - 398.0).abs() < 0.01, "{xs:?}");
|
||||
assert!(rules(false).is_empty());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user