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 <lloyd@stackworx.io> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5c13f6ec72
commit
9c3840ffa4
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue