From 14aae7cee1c7a513cca65996713bb65b78229f92 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 12:52:29 +0100 Subject: [PATCH] WordArt: keep ordinary shapes with textNoShape; print the text outline textNoShape is the standard no-warp value, so it alone no longer makes a shape WordArt: only a rectangle that is a text box (as the writer saves WordArt) or has no fill and no outline reads as WordArt. A filled ellipse with an explicit textNoShape now reads and saves as the same ellipse. The PDF/print path drew WordArt text without its outline, so Mint Outline (white text, green outline) printed as a blank page. The outline is now drawn like on screen: the text in the outline colour, offset in eight directions, under the filled text. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/print/objects.rs | 10 +++++++ crates/engine/src/tests.rs | 19 +++++++++++++ crates/xlsx/src/drawing.rs | 11 +++++--- crates/xlsx/src/tests/shape_text_rtl.rs | 36 ++++++++++++++++++++++--- 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/print/objects.rs b/crates/engine/src/cmd/print/objects.rs index c6e296600..0f62b8b93 100644 --- a/crates/engine/src/cmd/print/objects.rs +++ b/crates/engine/src/cmd/print/objects.rs @@ -208,6 +208,16 @@ fn draw_shape(pg: &mut Page, wb: &Workbook, shp: &gridcraft_model::Shape, b: &Bl let off = (size * 0.06).max(0.5); pg.text(lx + off, yy + off, size, font, sc, l); } + // WordArt's text outline, as on screen: the line in the outline colour, offset in eight + // directions by half the outline width, under the filled text. + if let Some((c, ow)) = fm.text_outline + && let Some(oc) = rgb_of(c, wb) + { + let d = (ow * b.scale / 2.0).clamp(0.25, 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)] { + pg.text(lx + dx * d, yy + dy * d, size, font, oc, l); + } + } pg.text(lx, yy, size, font, color, l); yy += size * 1.2; } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a2c8e028a..18ffdefe9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -587,6 +587,25 @@ fn word_art_survives_xlsx_roundtrip() { assert!(after.format.no_line); } +/// White text with a green outline (Mint Outline) prints its outline, so it is not invisible on a +/// white page. +#[test] +fn word_art_outline_prints() { + let mut s = s(); + s.execute("insert.wordArt", json!({"style": "Mint Outline", "text": "VISIBLE", "at": "A1"})).unwrap(); + let pdf = s.execute("file.exportPdf", json!({"range": "A1:G10"})).unwrap(); + let data = crate::io::base64_decode(pdf["base64"].as_str().unwrap()).unwrap(); + let text = String::from_utf8_lossy(&data); + let outline = text.matches("0.18 0.608 0.416 rg").count(); + assert!(outline >= 1, "the outline colour (2E9B6A) is drawn"); + assert_eq!(text.matches("(VISIBLE) Tj").count(), 9, "eight outline copies under the white text"); + assert!(text.contains("1 1 1 rg"), "the white fill is drawn on top"); + // The last copy drawn is the filled text, in white; every outline copy comes before it. + let before_fill = &text[..text.rfind("(VISIBLE) Tj").unwrap()]; + assert!(before_fill.rfind("1 1 1 rg") > before_fill.rfind("0.18 0.608 0.416 rg"), "the white fill is drawn last"); + assert_eq!(before_fill.matches("(VISIBLE) Tj").count(), 8); +} + #[test] fn no_border_clears_only_shared_edges_and_undo_restores_them() { use gridcraft_core::RangeRef; diff --git a/crates/xlsx/src/drawing.rs b/crates/xlsx/src/drawing.rs index ebda1e8f3..cf033db08 100644 --- a/crates/xlsx/src/drawing.rs +++ b/crates/xlsx/src/drawing.rs @@ -221,9 +221,14 @@ fn read_shape(obj: &El, anchor: Anchor) -> Option { let tx_box = obj.path(&["nvSpPr", "cNvSpPr"]).is_some_and(|c| c.flag("txBox", false)); let ln = sp.child("ln"); let arrow = ln.is_some_and(|l| l.child("tailEnd").or_else(|| l.child("headEnd")).is_some_and(|t| t.attr("type").is_some_and(|v| v != "none"))); - // WordArt: our marker, a text body with the non-warping text warp `textNoShape`. Any other warp - // (Excel puts one on ordinary filled shapes too) leaves the shape what its geometry says. - let word_art = obj.path(&["txBody", "bodyPr", "prstTxWarp"]).is_some_and(|w| w.attr("prst") == Some("textNoShape")); + // WordArt: our marker, a text body with the non-warping text warp `textNoShape`, on a rectangle + // that is a text box (as the writer saves it) or has no fill and no outline of its own (as + // WordArt usually comes). `textNoShape` is the standard "no warp" value, so on an ordinary + // shape (an ellipse, a filled rectangle) it, like any other warp, leaves the shape what its + // geometry says. + let word_art = prst == "rect" + && (tx_box || (sp.child("noFill").is_some() && !ln.is_some_and(|l| l.child("solidFill").is_some()))) + && obj.path(&["txBody", "bodyPr", "prstTxWarp"]).is_some_and(|w| w.attr("prst") == Some("textNoShape")); let kind = match prst { _ if word_art => ShapeKind::WordArt, _ if tx_box => ShapeKind::TextBox, diff --git a/crates/xlsx/src/tests/shape_text_rtl.rs b/crates/xlsx/src/tests/shape_text_rtl.rs index 0ec5944bb..c750a199f 100644 --- a/crates/xlsx/src/tests/shape_text_rtl.rs +++ b/crates/xlsx/src/tests/shape_text_rtl.rs @@ -117,7 +117,37 @@ fn a_text_warp_on_a_filled_ellipse_does_not_make_it_word_art() { .replace(r#"anchor="t"/>"#, r#"anchor="t">"#); let wb = read_drawing(&drawing); assert_eq!(wb.sheet(0).unwrap().shapes[0].kind, ShapeKind::Ellipse); - // The marker the writer uses still reads back as WordArt. - let marked = drawing.replace("textTriangle", "textNoShape"); - assert_eq!(read_drawing(&marked).sheet(0).unwrap().shapes[0].kind, ShapeKind::WordArt); +} + +/// `textNoShape` is the standard "no text warp" value, so on its own it does not make an ordinary +/// shape WordArt: a filled ellipse that carries it reads and saves as the same filled ellipse. +#[test] +fn an_explicit_no_warp_on_a_filled_ellipse_stays_an_ellipse() { + let drawing = ARABIC_TEXT_BOX + .replace(r#""#, "") + .replace( + r#""#, + r#""#, + ) + .replace(r#"anchor="t"/>"#, r#"anchor="t">"#); + let wb = read_drawing(&drawing); + let shape = &wb.sheet(0).unwrap().shapes[0]; + assert_eq!(shape.kind, ShapeKind::Ellipse); + assert_eq!(shape.fill, Color::Rgb(0xFF0000)); + let xml = written_drawing(&wb); + assert!(xml.contains(r#""#), "{xml}"); + assert!(xml.contains(r#""#), "{xml}"); + assert!(!xml.contains(r#"txBox="1""#), "{xml}"); + let (reopened, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert_eq!(reopened.sheet(0).unwrap().shapes[0].kind, ShapeKind::Ellipse); + // The same warp on a filled rectangle that is not a text box keeps it a rectangle too. + let rect = drawing.replace(r#"prst="ellipse""#, r#"prst="rect""#); + assert_eq!(read_drawing(&rect).sheet(0).unwrap().shapes[0].kind, ShapeKind::Rectangle); + // WordArt as written (a text box) or as a fill-less, line-less rectangle still reads as WordArt. + let text_box = ARABIC_TEXT_BOX.replace(r#"anchor="t"/>"#, r#"anchor="t">"#); + assert_eq!(read_drawing(&text_box).sheet(0).unwrap().shapes[0].kind, ShapeKind::WordArt); + let bare = text_box + .replace(r#""#, "") + .replace(r#""#, r#""#); + assert_eq!(read_drawing(&bare).sheet(0).unwrap().shapes[0].kind, ShapeKind::WordArt); }