From 9c3840ffa4845a53ac13a18eadbbe337f37e2f06 Mon Sep 17 00:00:00 2001 From: LloydNicholson <34131158+LloydNicholson@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:17:41 +0200 Subject: [PATCH] Text: hyperlinks are drawn in the theme's hyperlink colour, underlined (#37) Text with a:hlinkClick (to a URL or a slide) looked like the text around it. As in PowerPoint, a linked run now takes the theme's hlink colour and a single underline, unless the run itself sets a colour (PowerPoint 2019 and later honour it) or an underline (u="none" removes it). Colours from list and master styles don't count, so links in titles are blue too. This is part of run resolution, so links added with insert.hyperlink look the same, Format shows them underlined, and turning underline off writes u="none". The run's own props are left alone, so the look follows the theme. Co-authored-by: lloyd Co-authored-by: Claude Opus 5.5 --- crates/engine/src/tests.rs | 19 +++++++++++++++++++ crates/model/src/resolve.rs | 11 +++++++++++ crates/model/src/tests.rs | 27 +++++++++++++++++++++++++++ crates/render/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2218330..3d1c13d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -425,3 +425,22 @@ fn right_to_left_paragraphs() { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(para(&s).rtl, Some(true)); } + +#[test] +fn inserted_hyperlinks_look_like_links() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.insert", &json!({"text": "Read the docs"})).unwrap(); + s.execute("text.move", &json!({"to": "wordLeft", "extend": true})).unwrap(); + assert_eq!(s.execute("format.state", &json!({})).unwrap()["underline"], false); + s.execute("insert.hyperlink", &json!({"url": "https://example.org"})).unwrap(); + // Underlined (and hlink-coloured: same resolution as files) without touching the run's own props. + assert_eq!(s.execute("format.state", &json!({})).unwrap()["underline"], true); + let body = s.doc().unwrap().current_slide().unwrap().shapes[0].text.clone().unwrap(); + let run = body.paragraphs[0].runs.iter().find(|r| r.props.link.is_some()).unwrap(); + assert!(run.props.fill.is_none() && run.props.underline.is_none(), "{run:?}"); + // Underline off is the run's own u="none", as in PowerPoint. + s.execute("format.underline", &json!({})).unwrap(); + assert_eq!(s.execute("format.state", &json!({})).unwrap()["underline"], false); +} diff --git a/crates/model/src/resolve.rs b/crates/model/src/resolve.rs index 44e7a8d..8769b57 100644 --- a/crates/model/src/resolve.rs +++ b/crates/model/src/resolve.rs @@ -266,6 +266,17 @@ pub fn run(ctx: &Ctx, shape: &Shape, para: &crate::text::Paragraph, run: &RunPro let mut r = run.clone(); let lvl = level_style(ctx, shape, para.level); r.inherit(&lvl.run); + // Hyperlinked text is drawn in the theme's hyperlink colour and underlined, as in PowerPoint, + // unless the run itself sets a colour (honoured since PowerPoint 2019) or an underline + // (`u="none"` removes it). Colours and underlines from list styles don't count. + if r.link.is_some() { + if run.fill.is_none() { + r.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Hlink))); + } + if run.underline.is_none() { + r.underline = Some("sng".into()); + } + } r } diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 3c5db2e..c34fa17 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -132,3 +132,30 @@ fn four_by_three_master_scales() { let x = title.xfrm.unwrap(); assert!(x.x + x.w <= 720.0 + 1e-6); } + +#[test] +fn hyperlinked_runs_take_the_theme_hyperlink_look() { + use crate::text::{Action, Hyperlink, Paragraph, RunProps}; + let p = Presentation::default(); + let s = &p.slides[0]; + let ctx = Ctx::for_slide(&p, s).unwrap(); + let title = &s.shapes[0]; + let para = Paragraph::default(); + let link = Some(Hyperlink { action: Action::Url { url: "https://example.org".into() }, tooltip: String::new(), highlight_click: false }); + let hlink = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Hlink))); + // The title's master style colours its text tx1; a link is hlink and underlined instead. + assert_eq!(resolve::run(&ctx, title, ¶, &RunProps::default()).fill, Some(Fill::solid(ColorRef::scheme(SchemeSlot::Tx1)))); + let r = resolve::run(&ctx, title, ¶, &RunProps { link: link.clone(), ..Default::default() }); + assert_eq!((r.fill, r.underline.as_deref()), (hlink.clone(), Some("sng"))); + assert_eq!(ctx.color(&ColorRef::scheme(SchemeSlot::Hlink), None), p.masters[0].theme.colors.get(SchemeSlot::Hlink)); + // A slide link looks the same. + let slide = Some(Hyperlink { action: Action::Slide { slide: s.id }, tooltip: String::new(), highlight_click: false }); + assert_eq!(resolve::run(&ctx, title, ¶, &RunProps { link: slide, ..Default::default() }).fill, hlink); + // The run's own colour and underline win; u="none" removes the underline. + let red = Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)))); + let own = RunProps { link: link.clone(), fill: red.clone(), underline: Some("none".into()), ..Default::default() }; + let r = resolve::run(&ctx, title, ¶, &own); + assert_eq!((r.fill, r.underline.as_deref()), (red, Some("none"))); + let dbl = resolve::run(&ctx, title, ¶, &RunProps { link, underline: Some("dbl".into()), ..Default::default() }); + assert_eq!((dbl.fill, dbl.underline.as_deref()), (hlink, Some("dbl"))); +} diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 5dbe50a..eaac627 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -272,3 +272,35 @@ fn morph_text_ends_match_the_slides() { let ink = (95..140).flat_map(|y| (0..480).map(move |x| (x, y))).filter(|&(x, y)| mid.pixel(x, y)[0] < 128).count(); assert!(ink > 50, "ink {ink}"); } + +/// A text box with one run of `props` (40 pt). +fn run_deck(props: deckcraft_model::RunProps) -> Presentation { + let mut sh = text_shape(1, "Linked text", 40.0, 40.0); + if let Some(run) = sh.text.as_mut().and_then(|t| t.paragraphs[0].runs.first_mut()) { + run.props = deckcraft_model::RunProps { size: Some(40.0), ..props }; + } + deck_with(vec![sh]) +} + +#[test] +fn hyperlinks_draw_in_the_hyperlink_colour_underlined() { + use deckcraft_model::RunProps; + use deckcraft_model::text::{Action, Hyperlink}; + let o = RenderOpts { scale: 0.5, ..Default::default() }; + let px = |p: &Presentation| render_slide(p, 0, &o); + let diff = |a: &Image, b: &Image| a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.abs_diff(**y) > 64).count(); + let link = Some(Hyperlink { action: Action::Url { url: "https://example.org".into() }, tooltip: String::new(), highlight_click: false }); + let hlink = Some(Fill::solid(ColorRef::scheme(deckcraft_color::SchemeSlot::Hlink))); + let red = Some(Fill::solid(ColorRef::rgb(Rgba::rgb(200, 0, 0)))); + let _ = px(&run_deck(RunProps::default())); // faces load lazily + let linked = px(&run_deck(RunProps { link: link.clone(), ..Default::default() })); + assert!(diff(&linked, &px(&run_deck(RunProps::default()))) > 100, "a link doesn't look like plain text"); + // Exactly the theme's hyperlink colour, single underline. + let styled = px(&run_deck(RunProps { fill: hlink, underline: Some("sng".into()), ..Default::default() })); + assert_eq!(diff(&linked, &styled), 0); + // A colour on the run itself wins; u="none" drops the underline. + let own = px(&run_deck(RunProps { link: link.clone(), fill: red.clone(), underline: Some("none".into()), ..Default::default() })); + assert_eq!(diff(&own, &px(&run_deck(RunProps { fill: red.clone(), ..Default::default() }))), 0); + let own = px(&run_deck(RunProps { link, fill: red.clone(), ..Default::default() })); + assert_eq!(diff(&own, &px(&run_deck(RunProps { fill: red, underline: Some("sng".into()), ..Default::default() }))), 0); +}