Merge pull request #401 from vangel76/fix/text-box-formatting
Drawing: text box formatting — run size/bold/font/outline, alignment, anchor, no outline (#377)
This commit is contained in:
commit
a5137872c1
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ fn stroke(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -983,6 +983,7 @@ fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
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<Json> {
|
|||
fill: color,
|
||||
line: Color::Auto,
|
||||
text: name,
|
||||
format: Default::default(),
|
||||
};
|
||||
let sheet = d.wb.active_sheet;
|
||||
edit(s, |cx| {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<f32>,
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
/// Typeface (`a:latin typeface`); `None` is the default font.
|
||||
pub font: Option<String>,
|
||||
/// Text fill colour; `None` is the default (black in text boxes).
|
||||
pub text_color: Option<Color>,
|
||||
/// 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<ShapeTextAlign>,
|
||||
/// `None`: the shape's default (top for text boxes, middle for other shapes).
|
||||
pub anchor: Option<ShapeTextAnchor>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Shape> {
|
|||
tb.kids("p").map(|p| p.kids("r").filter_map(|r| r.child("t")).map(|t| t.text.as_str()).collect::<String>()).collect::<Vec<_>>().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, "<a:solidFill>{c}</a:solidFill>");
|
||||
}
|
||||
let line_c = srgb(&sh.line, theme);
|
||||
if line_c.is_some() || sh.kind == ShapeKind::Arrow {
|
||||
if sh.format.no_line {
|
||||
s.push_str("<a:ln><a:noFill/></a:ln>");
|
||||
} else if line_c.is_some() || sh.kind == ShapeKind::Arrow {
|
||||
s.push_str("<a:ln w=\"9525\">");
|
||||
match line_c {
|
||||
Some(c) => {
|
||||
|
|
@ -356,12 +395,30 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String {
|
|||
}
|
||||
s.push_str("</xdr:spPr>");
|
||||
if !sh.text.is_empty() {
|
||||
s.push_str("<xdr:txBody><a:bodyPr wrap=\"square\" rtlCol=\"0\" anchor=\"t\"/><a:lstStyle/>");
|
||||
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, "<xdr:txBody><a:bodyPr wrap=\"square\" rtlCol=\"0\"{anchor}/><a:lstStyle/>");
|
||||
let ppr = match f.align {
|
||||
None => "",
|
||||
Some(ShapeTextAlign::Left) => "<a:pPr algn=\"l\"/>",
|
||||
Some(ShapeTextAlign::Center) => "<a:pPr algn=\"ctr\"/>",
|
||||
Some(ShapeTextAlign::Right) => "<a:pPr algn=\"r\"/>",
|
||||
};
|
||||
let (rpr_attrs, rpr_body) = run_props(f, theme);
|
||||
for line in sh.text.split('\n') {
|
||||
if line.is_empty() {
|
||||
s.push_str("<a:p><a:endParaRPr lang=\"en-US\"/></a:p>");
|
||||
let _ = write!(s, "<a:p>{ppr}<a:endParaRPr lang=\"en-US\"{rpr_attrs}/></a:p>");
|
||||
} else if rpr_body.is_empty() {
|
||||
let _ = write!(s, "<a:p>{ppr}<a:r><a:rPr lang=\"en-US\"{rpr_attrs}/><a:t>{}</a:t></a:r></a:p>", esc(line));
|
||||
} else {
|
||||
let _ = write!(s, "<a:p><a:r><a:rPr lang=\"en-US\"/><a:t>{}</a:t></a:r></a:p>", esc(line));
|
||||
let _ =
|
||||
write!(s, "<a:p>{ppr}<a:r><a:rPr lang=\"en-US\"{rpr_attrs}>{rpr_body}</a:rPr><a:t>{}</a:t></a:r></a:p>", esc(line));
|
||||
}
|
||||
}
|
||||
s.push_str("</xdr:txBody>");
|
||||
|
|
@ -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, "<a:ln w=\"{emu}\"><a:solidFill>{c}</a:solidFill></a:ln>");
|
||||
}
|
||||
if let Some(c) = f.text_color.as_ref().and_then(|c| srgb(c, theme)) {
|
||||
let _ = write!(body, "<a:solidFill>{c}</a:solidFill>");
|
||||
}
|
||||
if let Some(font) = &f.font {
|
||||
let _ = write!(body, "<a:latin typeface=\"{}\"/>", 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() {
|
||||
|
|
|
|||
|
|
@ -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#"<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><xdr:twoCellAnchor>
|
||||
<xdr:from><xdr:col>1</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>2</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from><xdr:to><xdr:col>7</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>8</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>
|
||||
<xdr:sp macro="" textlink=""><xdr:nvSpPr><xdr:cNvPr id="2" name="TextBox 1"/><xdr:cNvSpPr txBox="1"/></xdr:nvSpPr>
|
||||
<xdr:spPr><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:solidFill><a:schemeClr val="bg1"/></a:solidFill><a:ln w="9525" cmpd="sng"><a:noFill/></a:ln></xdr:spPr>
|
||||
<xdr:txBody><a:bodyPr vertOverflow="clip" horzOverflow="clip" wrap="square" rtlCol="0" anchor="ctr"/><a:lstStyle/>
|
||||
<a:p><a:pPr algn="ctr"/><a:r><a:rPr lang="en-US" sz="5400" b="1"><a:ln w="19050"><a:solidFill><a:schemeClr val="tx1"/></a:solidFill></a:ln><a:solidFill><a:schemeClr val="bg1"/></a:solidFill><a:latin typeface="Arial Black"/></a:rPr><a:t>DRAFT</a:t></a:r></a:p></xdr:txBody></xdr:sp><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>"#;
|
||||
let srels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing1.xml"/></Relationships>"#;
|
||||
let bytes = minimal(
|
||||
r#"<sheetData/><drawing r:id="rId1"/>"#,
|
||||
&[("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<Color>| 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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue