diff --git a/ROADMAP.md b/ROADMAP.md index 80e5cdf..34fcf12 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-11 | Text boxes keep their formatting: run size, bold, italic, typeface, text colour and outline, alignment, vertical anchor and "no outline" (#377) | | 2026-10-11 | Desktop printing opens the print PDF in the system viewer; Save As offers CSV, web page and PDF; macOS File › Properties opens File › Info (#370, #343) | | 2026-10-11 | Windows: DirectX 12 shaders compile with FXC, so a missing or stray `dxcompiler.dll` no longer stops the app at start (#339, #354) | | 2026-10-11 | Merged cells draw their whole outline: right and bottom edges come from the last column's and last row's cells (#376) | diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index 7bf0ed3..af3e65e 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -94,6 +94,7 @@ fn stroke(s: &mut Session, p: &Json) -> Result { fill: Color::from_hex(&format!("#{:02X}0000", width.round().clamp(0.0, 255.0) as u8)).unwrap_or_default(), line: Color::from_hex(&color).unwrap_or(Color::rgb(0x1F, 0x5F, 0xC9)), text, + format: Default::default(), }; edit(s, |cx| { cx.sheet_mut(si)?.shapes.push(shape); diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 6a9137a..16859c3 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -983,6 +983,7 @@ fn insert_shape(s: &mut Session, p: &Json) -> Result { fill, line, text: str_param(p, "text").unwrap_or("").into(), + format: Default::default(), }; let sheet = d.wb.active_sheet; edit(s, |cx| { @@ -1415,6 +1416,7 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result { fill: color, line: Color::Auto, text: name, + format: Default::default(), }; let sheet = d.wb.active_sheet; edit(s, |cx| { diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 0290ca5..986351e 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -1267,7 +1267,7 @@ fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block) } let (x, y, w, h) = place(r); let fill = rgb_of(shp.fill, wb); - let line = rgb_of(shp.line, wb).map(|c| (c, (0.75 * b.k).max(0.25))); + let line = rgb_of(shp.line, wb).filter(|_| !shp.format.no_line).map(|c| (c, (0.75 * b.k).max(0.25))); match shp.kind { ShapeKind::Ellipse => { let pts: Vec<[f32; 2]> = (0..72) @@ -1294,13 +1294,29 @@ fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block) } } if !shp.text.is_empty() { - let size = 11.0 * b.scale; - let lines = wrap_lines(&shp.text, Font::Helvetica, size, (w - 4.0 * b.k).max(size)); - let mut yy = y + 2.0 * b.k + size * 0.8; + use gridcraft_model::{ShapeTextAlign, ShapeTextAnchor}; + let fm = &shp.format; + let text_box = shp.kind == ShapeKind::TextBox; + let size = fm.font_size.unwrap_or(11.0).clamp(1.0, 4000.0) * b.scale; + let font = Font::styled(Font::family_for(fm.font.as_deref().unwrap_or("")), fm.bold, fm.italic); + let color = fm.text_color.and_then(|c| rgb_of(c, wb)).unwrap_or([0, 0, 0]); + let align = fm.align.unwrap_or(if text_box { ShapeTextAlign::Left } else { ShapeTextAlign::Center }); + let anchor = fm.anchor.unwrap_or(if text_box { ShapeTextAnchor::Top } else { ShapeTextAnchor::Middle }); + let lines = wrap_lines(&shp.text, font, size, (w - 4.0 * b.k).max(size)); + let block_h = lines.len().min(500) as f32 * size * 1.2; + let mut yy = match anchor { + ShapeTextAnchor::Top => y + 2.0 * b.k, + ShapeTextAnchor::Middle => y + (h - block_h) / 2.0, + ShapeTextAnchor::Bottom => y + h - 2.0 * b.k - block_h, + } + size * 0.8; for l in lines.iter().take(500) { - let lw = gridcraft_pdf::text_width(Font::Helvetica, size, l); - let lx = if shp.kind == ShapeKind::TextBox { x + 2.0 * b.k } else { x + (w - lw) / 2.0 }; - pg.text(lx, yy, size, Font::Helvetica, [0, 0, 0], l); + let lw = gridcraft_pdf::text_width(font, size, l); + let lx = match align { + ShapeTextAlign::Left => x + 2.0 * b.k, + ShapeTextAlign::Center => x + (w - lw) / 2.0, + ShapeTextAlign::Right => x + w - 2.0 * b.k - lw, + }; + pg.text(lx, yy, size, font, color, l); yy += size * 1.2; } } diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index 30395c8..28dc52d 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -448,6 +448,50 @@ pub struct Shape { pub fill: Color, pub line: Color, pub text: String, + /// Outline visibility and text formatting (DrawingML `a:ln`, `a:bodyPr`, `a:pPr`, `a:rPr`). + #[serde(default)] + pub format: ShapeFormat, +} + +/// Horizontal alignment of a shape's text (`a:pPr algn`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ShapeTextAlign { + #[default] + Left, + Center, + Right, +} + +/// Vertical anchor of a shape's text in its box (`a:bodyPr anchor`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ShapeTextAnchor { + #[default] + Top, + Middle, + Bottom, +} + +/// How a shape's outline and text look. The text properties apply to the whole text (the first +/// run's properties when a file has several). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct ShapeFormat { + /// No outline (`a:ln/a:noFill`), whatever `Shape::line` says. + pub no_line: bool, + /// Text size in points (`a:rPr sz` / 100); `None` is the default size. + pub font_size: Option, + pub bold: bool, + pub italic: bool, + /// Typeface (`a:latin typeface`); `None` is the default font. + pub font: Option, + /// Text fill colour; `None` is the default (black in text boxes). + pub text_color: Option, + /// Text outline (`a:rPr/a:ln`): colour and width in points. + pub text_outline: Option<(Color, f32)>, + /// `None`: the shape's default (left for text boxes, centred for other shapes). + pub align: Option, + /// `None`: the shape's default (top for text boxes, middle for other shapes). + pub anchor: Option, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/ui-egui/src/chartview.rs b/crates/ui-egui/src/chartview.rs index e773ebf..180f15c 100644 --- a/crates/ui-egui/src/chartview.rs +++ b/crates/ui-egui/src/chartview.rs @@ -195,6 +195,54 @@ fn area_mesh(v: &[Pos2], col: Color32) -> egui::Mesh { mesh } +/// A shape's text with its formatting: size, bold/italic, typeface, colour, outline, horizontal +/// alignment and vertical anchor. Shapes other than text boxes default to white text centred in +/// the shape; text boxes to black text at the top left. +fn paint_shape_text(p: &Painter, wb: &Workbook, sp: &gridcraft_engine::model::Shape, r: Rect, z: f32) { + use gridcraft_engine::model::{ShapeTextAlign, ShapeTextAnchor}; + let f = &sp.format; + let text_box = sp.kind == ShapeKind::TextBox; + let align = f.align.unwrap_or(if text_box { ShapeTextAlign::Left } else { ShapeTextAlign::Center }); + let anchor = f.anchor.unwrap_or(if text_box { ShapeTextAnchor::Top } else { ShapeTextAnchor::Middle }); + let default_color = if text_box { Color32::BLACK } else { Color32::WHITE }; + let color = f.text_color.and_then(|c| c.resolve(&wb.theme)).map(theme::color32).unwrap_or(default_color); + // Default 14 px (as before); a set size is in points. + let size = f.font_size.map_or(14.0, |pt| pt * 96.0 / 72.0).clamp(1.0, 2000.0) * z; + let family = theme::cell_family(f.font.as_deref().unwrap_or(""), f.bold, f.italic); + let inset = 4.0; + let width = (r.width() - 2.0 * inset).max(1.0); + let mut job = egui::text::LayoutJob::simple(sp.text.clone(), FontId::new(size, family), color, width); + job.halign = match align { + ShapeTextAlign::Left => egui::Align::LEFT, + ShapeTextAlign::Center => egui::Align::Center, + ShapeTextAlign::Right => egui::Align::RIGHT, + }; + let g = p.layout_job(job); + let x = match align { + ShapeTextAlign::Left => r.left() + inset, + ShapeTextAlign::Center => r.center().x, + ShapeTextAlign::Right => r.right() - inset, + }; + let y = match anchor { + ShapeTextAnchor::Top => r.top() + inset, + ShapeTextAnchor::Middle => r.center().y - g.size().y / 2.0, + ShapeTextAnchor::Bottom => r.bottom() - inset - g.size().y, + }; + let pos = pos2(x, y); + let clip = p.with_clip_rect(r.intersect(p.clip_rect())); + // egui has no stroked text: the outline is the text drawn in the outline colour, offset in + // eight directions by the outline width, under the filled text. + if let Some((c, w)) = f.text_outline + && let Some(oc) = c.resolve(&wb.theme).map(theme::color32) + { + let d = (w * 96.0 / 72.0 * z / 2.0).clamp(0.5, 20.0); + for (dx, dy) in [(-1.0, -1.0), (0.0, -1.0), (1.0, -1.0), (-1.0, 0.0), (1.0, 0.0), (-1.0, 1.0), (0.0, 1.0), (1.0, 1.0)] { + clip.galley_with_override_text_color(pos + vec2(dx * d, dy * d), g.clone(), oc); + } + } + clip.galley(pos, g, color); +} + pub(crate) fn anchor_rect(geo: &Geo, sh: &Sheet, a: &Anchor) -> Rect { let x = geo.x(sh, a.cell.col) + a.dx * geo.z; let y = geo.y(sh, a.cell.row) + a.dy * geo.z; @@ -221,7 +269,8 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo let start = p.add(egui::Shape::Noop); let mut label = true; let fill = sp.fill.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::TRANSPARENT); - let line = sp.line.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::DARK_GRAY); + let line = + if sp.format.no_line { Color32::TRANSPARENT } else { sp.line.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::DARK_GRAY) }; match sp.kind { ShapeKind::Rectangle | ShapeKind::TextBox => { p.rect_filled(r, 0.0, fill); @@ -270,15 +319,7 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo } } if label && !sp.text.is_empty() && !app.editing_text_box(sp.id) { - let job = egui::text::LayoutJob::simple( - sp.text.clone(), - FontId::new(14.0 * geo.z, egui::FontFamily::Name(theme::CELL.into())), - if sp.kind == ShapeKind::TextBox { Color32::BLACK } else { Color32::WHITE }, - r.width() - 8.0, - ); - let g = p.layout_job(job); - let pos = if sp.kind == ShapeKind::TextBox { r.min + vec2(4.0, 4.0) } else { r.center() - g.size() / 2.0 }; - p.galley(pos, g, Color32::BLACK); + paint_shape_text(p, wb, sp, r, geo.z); } if label && sp.kind == ShapeKind::TextBox && app.selected_chart == Some(sp.id) { selection_frame(p, r); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 202fcc4..9ef0d50 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -300,6 +300,8 @@ impl SheetApp { names.extend(d.wb.styles.iter().map(|(_, s)| s.font.name.clone())); names.push(d.wb.theme.major_font.clone()); names.push(d.wb.theme.minor_font.clone()); + // Text boxes and shapes name their own typefaces. + names.extend(d.wb.sheets.iter().flat_map(|sh| sh.shapes.iter().filter_map(|s| s.format.font.clone()))); } names } diff --git a/crates/xlsx/src/drawing.rs b/crates/xlsx/src/drawing.rs index d3b2061..076148a 100644 --- a/crates/xlsx/src/drawing.rs +++ b/crates/xlsx/src/drawing.rs @@ -4,7 +4,7 @@ use std::fmt::Write as _; use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS}; -use gridcraft_model::{Anchor, AnchorMode, Color, Image, Shape, ShapeKind, Sheet, Theme}; +use gridcraft_model::{Anchor, AnchorMode, Color, Image, Shape, ShapeFormat, ShapeKind, ShapeTextAlign, ShapeTextAnchor, Sheet, Theme}; use crate::IoError; use crate::read::Ctx; @@ -217,7 +217,44 @@ fn read_shape(obj: &El, anchor: Anchor) -> Option { tb.kids("p").map(|p| p.kids("r").filter_map(|r| r.child("t")).map(|t| t.text.as_str()).collect::()).collect::>().join("\n") }) .unwrap_or_default(); - Some(Shape { id: 0, kind, anchor, fill, line, text }) + let mut format = read_text_format(obj.child("txBody")); + format.no_line = ln.is_some_and(|l| l.child("noFill").is_some()); + Some(Shape { id: 0, kind, anchor, fill, line, text, format }) +} + +/// A shape's text formatting: vertical anchor (`a:bodyPr anchor`), the first paragraph's +/// alignment (`a:pPr algn`) and the first run's properties (`a:rPr`: size, bold, italic, fill, +/// outline, `a:latin` typeface). +fn read_text_format(body: Option<&El>) -> ShapeFormat { + let mut f = ShapeFormat::default(); + let Some(body) = body else { return f }; + f.anchor = body.child("bodyPr").and_then(|b| b.attr("anchor")).map(|a| match a { + "ctr" => ShapeTextAnchor::Middle, + "b" => ShapeTextAnchor::Bottom, + _ => ShapeTextAnchor::Top, + }); + let first_p = body.kids("p").next(); + f.align = first_p.and_then(|p| p.child("pPr")).and_then(|p| p.attr("algn")).map(|a| match a { + "ctr" => ShapeTextAlign::Center, + "r" => ShapeTextAlign::Right, + _ => ShapeTextAlign::Left, + }); + let rpr = body.kids("p").flat_map(|p| p.kids("r")).find_map(|r| r.child("rPr")); + if let Some(r) = rpr { + // `sz` is in hundredths of a point (100..=400000 in the schema). + f.font_size = r.attr_f64("sz").filter(|v| v.is_finite()).map(|v| (v / 100.0).clamp(1.0, 4000.0) as f32); + f.bold = r.flag("b", false); + f.italic = r.flag("i", false); + f.font = r.child("latin").and_then(|l| l.attr("typeface")).filter(|t| !t.is_empty() && !t.starts_with('+')).map(str::to_string); + f.text_color = r.child("solidFill").and_then(dml_color); + f.text_outline = r.child("ln").and_then(|l| { + let c = l.child("solidFill").and_then(dml_color)?; + // Width in EMU (12700 per point); Excel's default hairline is 0.75 pt. + let w = l.attr_f64("w").filter(|w| w.is_finite()).map_or(0.75, |w| (w / 12700.0).clamp(0.0, 100.0)) as f32; + Some((c, w)) + }); + } + f } // ---------------------------------------------------------------- writing @@ -341,7 +378,9 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String { let _ = write!(s, "{c}"); } let line_c = srgb(&sh.line, theme); - if line_c.is_some() || sh.kind == ShapeKind::Arrow { + if sh.format.no_line { + s.push_str(""); + } else if line_c.is_some() || sh.kind == ShapeKind::Arrow { s.push_str(""); match line_c { Some(c) => { @@ -356,12 +395,30 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String { } s.push_str(""); if !sh.text.is_empty() { - s.push_str(""); + let f = &sh.format; + let anchor = match f.anchor { + Some(ShapeTextAnchor::Top) => " anchor=\"t\"", + Some(ShapeTextAnchor::Middle) => " anchor=\"ctr\"", + Some(ShapeTextAnchor::Bottom) => " anchor=\"b\"", + None if sh.kind == ShapeKind::TextBox => " anchor=\"t\"", + None => "", + }; + let _ = write!(s, ""); + let ppr = match f.align { + None => "", + Some(ShapeTextAlign::Left) => "", + Some(ShapeTextAlign::Center) => "", + Some(ShapeTextAlign::Right) => "", + }; + let (rpr_attrs, rpr_body) = run_props(f, theme); for line in sh.text.split('\n') { if line.is_empty() { - s.push_str(""); + let _ = write!(s, "{ppr}"); + } else if rpr_body.is_empty() { + let _ = write!(s, "{ppr}{}", esc(line)); } else { - let _ = write!(s, "{}", esc(line)); + let _ = + write!(s, "{ppr}{rpr_body}{}", esc(line)); } } s.push_str(""); @@ -376,6 +433,35 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String { s } +/// A text run's `a:rPr` attributes (size, bold, italic) and children in schema order (outline, +/// fill, typeface). +fn run_props(f: &ShapeFormat, theme: &Theme) -> (String, String) { + let mut attrs = String::new(); + if let Some(sz) = f.font_size.filter(|v| v.is_finite()) { + let _ = write!(attrs, " sz=\"{}\"", (sz.clamp(1.0, 4000.0) * 100.0).round() as u32); + } + if f.bold { + attrs.push_str(" b=\"1\""); + } + if f.italic { + attrs.push_str(" i=\"1\""); + } + let mut body = String::new(); + if let Some((c, w)) = &f.text_outline + && let Some(c) = srgb(c, theme) + { + let emu = (w.clamp(0.0, 100.0) * 12700.0).round() as u32; + let _ = write!(body, "{c}"); + } + if let Some(c) = f.text_color.as_ref().and_then(|c| srgb(c, theme)) { + let _ = write!(body, "{c}"); + } + if let Some(font) = &f.font { + let _ = write!(body, "", esc(font)); + } + (attrs, body) +} + /// File extension for an image MIME type. pub fn image_ext(mime: &str, data: &[u8]) -> &'static str { match mime.to_ascii_lowercase().as_str() { diff --git a/crates/xlsx/src/tests/fixtures.rs b/crates/xlsx/src/tests/fixtures.rs index 9cd09a3..47dde5a 100644 --- a/crates/xlsx/src/tests/fixtures.rs +++ b/crates/xlsx/src/tests/fixtures.rs @@ -454,3 +454,44 @@ fn chartex_parts_as_excel_expects_them() { let s = &back.sheet(0).unwrap().charts[0].series[0]; assert_eq!((s.name.as_deref(), s.categories.as_deref(), s.values.as_str()), (Some("Data!$B$1"), Some("Data!$A$2:$A$5"), "Data!$B$2:$B$5")); } + +/// #377: a WordArt-style text box keeps its run size, bold, typeface, text fill and outline, its +/// centring (horizontal and vertical) and its "no outline" shape line, through a round trip. +#[test] +fn text_box_formatting_is_read_and_written() { + let drawing = r#" + 10207080 + + + + DRAFT"#; + let srels = r#""#; + let bytes = minimal( + r#""#, + &[("xl/worksheets/_rels/sheet1.xml.rels", srels), ("xl/drawings/drawing1.xml", drawing)], + "", + "", + ); + let (wb, _) = read_xlsx(&bytes).unwrap(); + let sp = &wb.sheet(0).unwrap().shapes[0]; + assert_eq!(sp.kind, gridcraft_model::ShapeKind::TextBox); + assert_eq!(sp.text, "DRAFT"); + let f = &sp.format; + assert!(f.no_line, "a:ln/a:noFill: no border"); + assert_eq!(f.font_size, Some(54.0)); + assert!(f.bold && !f.italic); + assert_eq!(f.font.as_deref(), Some("Arial Black")); + assert_eq!(f.text_color, Some(Color::Theme(0, 0))); + assert_eq!(f.text_outline, Some((Color::Theme(1, 0), 1.5))); + assert_eq!(f.align, Some(gridcraft_model::ShapeTextAlign::Center)); + assert_eq!(f.anchor, Some(gridcraft_model::ShapeTextAnchor::Middle)); + + let (back, _) = read_xlsx(&crate::write_xlsx(&wb).unwrap()).unwrap(); + let g = &back.sheet(0).unwrap().shapes[0].format; + let rgb = |c: Option| c.and_then(|c| c.resolve(&wb.theme)); + assert_eq!((g.no_line, g.font_size, g.bold, g.font.as_deref()), (true, Some(54.0), true, Some("Arial Black"))); + assert_eq!((g.align, g.anchor), (f.align, f.anchor)); + assert_eq!(rgb(g.text_color), rgb(f.text_color)); + assert_eq!(rgb(g.text_outline.map(|o| o.0)), rgb(f.text_outline.map(|o| o.0))); + assert_eq!(g.text_outline.map(|o| o.1), Some(1.5)); +} diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index b72c787..d2ecec7 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -317,6 +317,7 @@ fn sample() -> Workbook { fill: Color::Rgb(0x4472C4), line: Color::Rgb(0x000000), text: "Hi\nthere".into(), + format: Default::default(), }); s.shapes.push(Shape { id: 12, @@ -325,6 +326,7 @@ fn sample() -> Workbook { fill: Color::Auto, line: Color::Rgb(0xFF0000), text: String::new(), + format: Default::default(), }); s.shapes.push(Shape { id: 13, @@ -333,6 +335,17 @@ fn sample() -> Workbook { fill: Color::Auto, line: Color::Auto, text: "box".into(), + format: ShapeFormat { + no_line: true, + font_size: Some(20.0), + bold: true, + italic: true, + font: Some("Arial".into()), + text_color: Some(Color::Rgb(0x112233)), + text_outline: Some((Color::Rgb(0xFF0000), 1.5)), + align: Some(ShapeTextAlign::Right), + anchor: Some(ShapeTextAnchor::Bottom), + }, }); let base_chart = Chart { id: 20, @@ -498,7 +511,7 @@ fn full_roundtrip() { assert_eq!(b.shapes[1].anchor.mode, AnchorMode::MoveAndSize, "default anchor mode"); assert_eq!(b.shapes.len(), 3); for (x, y) in a.shapes.iter().zip(&b.shapes) { - assert_eq!((x.kind, &x.text, x.fill, x.line), (y.kind, &y.text, y.fill, y.line)); + assert_eq!((x.kind, &x.text, x.fill, x.line, &x.format), (y.kind, &y.text, y.fill, y.line, &y.format)); } assert_eq!(b.charts.len(), a.charts.len()); for (x, y) in a.charts.iter().zip(&b.charts) {