Merge pull request #1237 from aykutfirat/fix/cell-first-baseline

Tables: cell First Baseline offset (Cell Options › Text › First Baseline)
This commit is contained in:
Brandon Thomas
2026-10-19 02:49:33 -04:00
committed by GitHub
7 changed files with 164 additions and 5 deletions
+18 -1
View File
@@ -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);
+17
View File
@@ -454,6 +454,11 @@ pub struct CellStyle {
pub inset_overrides: [Option<f64>; 4],
#[serde(skip_serializing_if = "Option::is_none")]
pub vj: Option<crate::item::VerticalJustification>,
/// Cell Options › Text › First Baseline, and its minimum.
#[serde(skip_serializing_if = "Option::is_none")]
pub first_baseline: Option<crate::item::FirstBaseline>,
#[serde(skip_serializing_if = "Option::is_none")]
pub first_baseline_min: Option<f64>,
/// All four edges.
#[serde(skip_serializing_if = "Option::is_none")]
pub stroke: Option<crate::table::CellStroke>,
@@ -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));
}
+15 -1
View File
@@ -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],
+57 -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();
+8
View File
@@ -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.
+8
View File
@@ -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);
+41
View File
@@ -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#"<idPkg:Styles xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
<RootCellStyleGroup>
<CellStyle Self="CellStyle/$ID/[None]" Name="$ID/[None]"/>
<CellStyle Self="cs-cap" Name="Cap" FirstBaselineOffset="CapHeight" MinimumFirstBaselineOffset="2" VerticalJustification="CenterAlign"/>
<CellStyle Self="cs-cap-child" Name="CapChild" FillTint="50"><Properties><BasedOn type="object">cs-cap</BasedOn></Properties></CellStyle>
</RootCellStyleGroup>
<RootParagraphStyleGroup/>
<RootTableStyleGroup>
<TableStyle Self="ts" Name="Tbl" BodyRegionCellStyle="cs-cap-child"/>
</RootTableStyleGroup></idPkg:Styles>"#;
const FIRST_BASELINE_STORY: &str = r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
<Story Self="s1"><ParagraphStyleRange><CharacterStyleRange><Table AppliedTableStyle="ts" HeaderRowCount="0">
<Row Name="0" MinimumHeight="20"/>
<Column Name="0" SingleColumnWidth="60"/><Column Name="1" SingleColumnWidth="60"/>
<Cell Name="0:0" AppliedCellStyle="CellStyle/$ID/[None]"><ParagraphStyleRange><CharacterStyleRange><Content>Region</Content></CharacterStyleRange></ParagraphStyleRange></Cell>
<Cell Name="1:0" AppliedCellStyle="CellStyle/$ID/[None]" FirstBaselineOffset="LeadingOffset"><ParagraphStyleRange><CharacterStyleRange><Content>Local</Content></CharacterStyleRange></ParagraphStyleRange></Cell>
</Table></CharacterStyleRange></ParagraphStyleRange></Story></idPkg:Story>"#;
#[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));
}