diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index d70ff074..940e0aa7 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -125,7 +125,7 @@ fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, left let spec = FrameSpec { id: ItemId(0), area: Rect::new(0.0, 0.0, w.max(1.0), 1.0e6), - opts: TextFrameOptions::default(), + opts: TextFrameOptions { first_baseline: cell.first_baseline, first_baseline_min: cell.first_baseline_min, ..TextFrameOptions::default() }, vertical: false, exclusions: vec![], outline: None, @@ -835,6 +835,23 @@ mod height_tests { AnchoredObject::new(designcraft_doc::Item::new(ItemId(0), LayerId(0), designcraft_doc::Shape::Rectangle, path), position) } + #[test] + fn cell_first_baseline_option_sets_the_first_line_and_the_row_height() { + // Cell Options › Text › First Baseline: Cap Height sets the first line by the cap height, + // not the typographic ascent, so a row sized for one line of caps keeps its height. + let ascent = text_cell("Hd", 4.0); + let mut cap = ascent.clone(); + cap.first_baseline = designcraft_doc::FirstBaseline::CapHeight; + let (a, c) = (measure(&ascent, 92.0), measure(&cap, 92.0)); + let (la, lc) = (&a.text.frames[0].lines[0], &c.text.frames[0].lines[0]); + assert!(lc.baseline < la.baseline, "cap height {} should sit above ascent {}", lc.baseline, la.baseline); + assert!(c.content_h < a.content_h); + let mut fixed = ascent.clone(); + fixed.first_baseline = designcraft_doc::FirstBaseline::Fixed; + fixed.first_baseline_min = 6.0; + assert!((measure(&fixed, 92.0).text.frames[0].lines[0].baseline - 6.0).abs() < 1e-6); + } + #[test] fn cell_bottom_inset_contains_descenders_without_growing_every_row() { let cell = text_cell("gyp", 8.5); diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 72590d47..5c747fdb 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -454,6 +454,11 @@ pub struct CellStyle { pub inset_overrides: [Option; 4], #[serde(skip_serializing_if = "Option::is_none")] pub vj: Option, + /// Cell Options › Text › First Baseline, and its minimum. + #[serde(skip_serializing_if = "Option::is_none")] + pub first_baseline: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub first_baseline_min: Option, /// All four edges. #[serde(skip_serializing_if = "Option::is_none")] pub stroke: Option, @@ -529,6 +534,12 @@ impl CellStyle { if let Some(v) = self.vj { cell.vj = v; } + if let Some(v) = self.first_baseline { + cell.first_baseline = v; + } + if let Some(v) = self.first_baseline_min { + cell.first_baseline_min = v; + } if let Some(v) = &self.stroke { cell.strokes = [v.clone(), v.clone(), v.clone(), v.clone()]; cell.border_overrides = [true; 4]; @@ -698,6 +709,12 @@ pub fn resolve_cell_style(cells: &[CellStyle], name: &str) -> CellStyle { if style.vj.is_some() { out.vj = style.vj; } + if style.first_baseline.is_some() { + out.first_baseline = style.first_baseline; + } + if style.first_baseline_min.is_some() { + out.first_baseline_min = style.first_baseline_min; + } if let Some(stroke) = &style.stroke { out.strokes = std::array::from_fn(|_| CellStrokeAttrs::from_stroke(stroke)); } diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 14e02a7c..5d109d16 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -13,7 +13,7 @@ use std::sync::Arc; use serde::{Deserialize, Serialize}; use crate::ids::StoryId; -use crate::item::{StrokeType, VerticalJustification}; +use crate::item::{FirstBaseline, StrokeType, VerticalJustification}; use crate::story::{CharFormat, ParaFormat, Story, TABLE_ANCHOR}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -159,6 +159,12 @@ fn full() -> f32 { fn no_fill() -> String { designcraft_color::swatch::NONE.into() } +fn is_ascent(f: &FirstBaseline) -> bool { + *f == FirstBaseline::Ascent +} +fn is_zero_min(v: &f64) -> bool { + *v == 0.0 +} fn default_insets() -> [f64; 4] { [4.0; 4] } @@ -181,6 +187,12 @@ pub struct Cell { pub insets: [f64; 4], #[serde(default)] pub vj: VerticalJustification, + /// Cell Options › Text › First Baseline: how far the first line's baseline sits below the + /// top inset (IDML `FirstBaselineOffset`), and its minimum (`MinimumFirstBaselineOffset`). + #[serde(default, skip_serializing_if = "is_ascent")] + pub first_baseline: FirstBaseline, + #[serde(default, skip_serializing_if = "is_zero_min")] + pub first_baseline_min: f64, /// Text rotation in degrees (only 0 is composed for now). #[serde(default)] pub rotation: f64, @@ -216,6 +228,8 @@ impl Default for Cell { fill_tint: 1.0, insets: default_insets(), vj: VerticalJustification::Top, + first_baseline: FirstBaseline::Ascent, + first_baseline_min: 0.0, rotation: 0.0, strokes: Default::default(), border_overrides: [false; 4], diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index af322105..7cf7c271 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -30,7 +30,7 @@ fn style_specs() -> Vec { "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 { "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, Option)> { + 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 { const ID: &str = "table.setCell"; let g = target(s, p, ID)?; @@ -954,6 +970,7 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { 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 { 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 { .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 { 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(); diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index a773ec9e..86fef8e4 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -923,6 +923,12 @@ impl<'a> Ex<'a> { if let Some(vj) = cs.vj { el.set("VerticalJustification", names::vj_out(vj)); } + if let Some(fb) = cs.first_baseline { + el.set("FirstBaselineOffset", names::first_baseline_out(fb)); + } + if let Some(min) = cs.first_baseline_min { + el.set("MinimumFirstBaselineOffset", num(min)); + } if let Some(st) = &cs.stroke { for side in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"] { self.cell_stroke_attrs(&mut el, side, st); @@ -2770,6 +2776,8 @@ impl<'a> Ex<'a> { .attr("BottomInset", num(cell.insets[2])) .attr("RightInset", num(cell.insets[3])) .attr("VerticalJustification", vj) + .attr("FirstBaselineOffset", names::first_baseline_out(cell.first_baseline)) + .attr("MinimumFirstBaselineOffset", num(cell.first_baseline_min)) .attr("RotationAngle", num(cell.rotation)); for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() { // Do not turn absent native defaults into explicit shared-edge candidates. diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 9a898534..038eba23 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1189,6 +1189,8 @@ impl<'r> Importer<'r> { cs.inset_overrides = [None; 4]; } cs.vj = e.get("VerticalJustification").map(names::vj_in); + cs.first_baseline = e.get("FirstBaselineOffset").map(names::first_baseline_in); + cs.first_baseline_min = e.num("MinimumFirstBaselineOffset").filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 8640.0)); cs.strokes = ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].map(|side| self.cell_stroke_attrs(e, side)); if let Some(stroke) = uniform_cell_stroke(&cs.strokes) { cs.stroke = Some(stroke); @@ -2213,6 +2215,12 @@ impl<'r> Importer<'r> { if let Some(vj) = ce.get("VerticalJustification") { cell.vj = names::vj_in(vj); } + if let Some(fb) = ce.get("FirstBaselineOffset") { + cell.first_baseline = names::first_baseline_in(fb); + } + if let Some(min) = ce.num("MinimumFirstBaselineOffset").filter(|v| v.is_finite()) { + cell.first_baseline_min = min.clamp(0.0, 8640.0); + } cell.rotation = ce.num("RotationAngle").unwrap_or(cell.rotation); for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() { let attrs = self.cell_stroke_attrs(ce, side); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 2c48673c..035333da 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -4193,3 +4193,44 @@ mod liquid_page_rules { } } } + +/// Cell Options › Text › First Baseline, set by a cell style that a table style applies to its body +/// region (cells with `[None]` take it from there) and overridden on one cell. +const FIRST_BASELINE_STYLES: &str = r#" + + + + cs-cap + + + + +"#; + +const FIRST_BASELINE_STORY: &str = r#" + + + +Region +Local +
"#; + +#[test] +fn cell_first_baseline_comes_from_cell_styles_table_regions_and_cells_and_round_trips() { + use designcraft_doc::FirstBaseline; + let d = import_idml(&fixture_with_table_styles(FIRST_BASELINE_STYLES, FIRST_BASELINE_STORY)).unwrap(); + d.check().unwrap(); + let style = d.styles.cell.iter().find(|c| c.name == "Cap").unwrap(); + assert_eq!((style.first_baseline, style.first_baseline_min), (Some(FirstBaseline::CapHeight), Some(2.0))); + let table = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + let region = table.cell(0, 0).unwrap(); + assert_eq!((region.first_baseline, region.first_baseline_min), (FirstBaseline::CapHeight, 2.0), "inherited through BasedOn and the body region"); + assert_eq!(table.cell(0, 1).unwrap().first_baseline, FirstBaseline::Leading, "a cell's own attribute wins"); + let back = import_idml(&export_idml(&d)).unwrap(); + let t2 = back.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + assert_eq!(t2.cell(0, 0).unwrap().first_baseline, FirstBaseline::CapHeight); + assert_eq!(t2.cell(0, 0).unwrap().first_baseline_min, 2.0); + assert_eq!(t2.cell(0, 1).unwrap().first_baseline, FirstBaseline::Leading); + let s2 = back.styles.cell.iter().find(|c| c.name == "Cap").unwrap(); + assert_eq!(s2.first_baseline, Some(FirstBaseline::CapHeight)); +}