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); +}