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 <noreply@anthropic.com>
This commit is contained in:
vangel76andClaude Opus 5.5 committed 2026-11-08 12:54:30 +01:00
1 parent 032b280b04
commit 14aae7cee1
4 files changed
+70 -6

No files matched your search

+10
View File
@@ -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;
}
+19
View File
@@ -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;
+8 -3
View File
@@ -221,9 +221,14 @@ fn read_shape(obj: &El, anchor: Anchor) -> Option<Shape> {
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,
+33 -3
View File
@@ -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"><a:prstTxWarp prst="textTriangle"><a:avLst/></a:prstTxWarp></a:bodyPr>"#);
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#"<xdr:cNvSpPr txBox="1"/>"#, "<xdr:cNvSpPr/>")
.replace(
r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#,
r#"<a:prstGeom prst="ellipse"><a:avLst/></a:prstGeom><a:solidFill><a:srgbClr val="FF0000"/></a:solidFill>"#,
)
.replace(r#"anchor="t"/>"#, r#"anchor="t"><a:prstTxWarp prst="textNoShape"><a:avLst/></a:prstTxWarp></a:bodyPr>"#);
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#"<a:prstGeom prst="ellipse">"#), "{xml}");
assert!(xml.contains(r#"<a:solidFill><a:srgbClr val="FF0000"/></a:solidFill>"#), "{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"><a:prstTxWarp prst="textNoShape"><a:avLst/></a:prstTxWarp></a:bodyPr>"#);
assert_eq!(read_drawing(&text_box).sheet(0).unwrap().shapes[0].kind, ShapeKind::WordArt);
let bare = text_box
.replace(r#"<xdr:cNvSpPr txBox="1"/>"#, "<xdr:cNvSpPr/>")
.replace(r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#, r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/>"#);
assert_eq!(read_drawing(&bare).sheet(0).unwrap().shapes[0].kind, ShapeKind::WordArt);
}