use std::ops::Deref;
use std::str::FromStr;
use comemo::Tracked;
use ecow::{EcoString, eco_format};
use rustc_hash::FxHashMap;
use typst_syntax::{Span, VirtualPath};
use typst_utils::PicoStr;
use crate::diag::{At, SourceDiagnostic, SourceResult, StrResult, bail, warning};
use crate::engine::Engine;
use crate::foundations::{
Args, Construct, Content, Label, NativeElement, Packed, Repr, Selector, ShowSet,
Smart, StyleChain, Styles, cast, elem,
};
use crate::introspection::{
Counter, CounterKey, History, Introspect, Introspector, Locatable, Location,
PagedPosition, PathIntrospection, QueryFirstIntrospection, QueryLabelIntrospection,
Tagged,
};
use crate::layout::PageElem;
use crate::model::{NumberingPattern, Refable};
use crate::text::{LocalName, TextElem};
#[elem(Locatable)]
pub struct LinkElem {
#[required]
#[parse(
let dest = args.expect::<LinkTarget>("destination")?;
dest.clone()
)]
pub dest: LinkTarget,
#[required]
#[parse(match &dest {
LinkTarget::Dest(Destination::Url(url)) => match args.eat()? {
Some(body) => body,
None => body_from_url(url),
},
_ => args.expect("body")?,
})]
pub body: Content,
#[internal]
#[ghost]
pub current: Option<Destination>,
}
impl LinkElem {
pub fn from_url(url: Url) -> Self {
let body = body_from_url(&url);
Self::new(LinkTarget::Dest(Destination::Url(url)), body)
}
pub fn find_destinations(
introspector: &dyn Introspector,
) -> impl Iterator<Item = Location> {
introspector
.query(&Self::ELEM.select())
.into_iter()
.map(|elem| elem.into_packed::<Self>().unwrap())
.filter_map(|elem| match elem.dest.resolve_late(introspector) {
Ok(Destination::Location(loc)) => Some(loc),
_ => None,
})
}
}
impl ShowSet for Packed<LinkElem> {
fn show_set(&self, _: StyleChain) -> Styles {
let mut out = Styles::new();
out.set(TextElem::hyphenate, Smart::Custom(false));
out
}
}
pub(crate) fn body_from_url(url: &Url) -> Content {
let stripped = url.strip_contact_scheme().map(|(_, s)| s.into());
TextElem::packed(stripped.unwrap_or_else(|| url.clone().into_inner()))
}
#[derive(Debug, Clone, PartialEq, Hash)]
pub enum LinkTarget {
Dest(Destination),
Label(Label),
}
impl LinkTarget {
pub fn resolve_early(
&self,
engine: &mut Engine,
span: Span,
) -> SourceResult<Destination> {
Ok(match self {
LinkTarget::Dest(dest) => dest.clone(),
LinkTarget::Label(label) => {
let elem =
engine.introspect(QueryLabelIntrospection(*label, span)).at(span)?;
Destination::Location(elem.location().unwrap())
}
})
}
pub fn resolve_late(
&self,
introspector: &dyn Introspector,
) -> StrResult<Destination> {
Ok(match self {
LinkTarget::Dest(dest) => dest.clone(),
LinkTarget::Label(label) => {
let elem = introspector.query_label(*label)?;
Destination::Location(elem.location().unwrap())
}
})
}
}
cast! {
LinkTarget,
self => match self {
Self::Dest(v) => v.into_value(),
Self::Label(v) => v.into_value(),
},
v: Destination => Self::Dest(v),
v: Label => Self::Label(v),
}
impl From<Destination> for LinkTarget {
fn from(dest: Destination) -> Self {
Self::Dest(dest)
}
}
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub enum Destination {
Url(Url),
Position(PagedPosition),
Location(Location),
}
impl Destination {
pub fn alt_text(
&self,
engine: &mut Engine,
styles: StyleChain,
span: Span,
) -> SourceResult<EcoString> {
match self {
Destination::Url(url) => {
let contact = url.strip_contact_scheme().map(|(scheme, stripped)| {
eco_format!("{} {stripped}", scheme.local_name_in(styles))
});
Ok(contact.unwrap_or_else(|| url.clone().into_inner()))
}
Destination::Position(pos) => {
let page_nr = eco_format!("{}", pos.page.get());
let page_str = PageElem::local_name_in(styles);
Ok(eco_format!("{page_str} {page_nr}"))
}
&Destination::Location(loc) => {
let fallback = |engine: &mut Engine| {
let numbering =
loc.page_numbering(engine, span).unwrap_or_else(|| {
NumberingPattern::from_str("1").unwrap().into()
});
let page_nr = Counter::new(CounterKey::Page)
.display_at(engine, loc, styles, &numbering, span)?
.plain_text();
let page_str = PageElem::local_name_in(styles);
Ok(eco_format!("{page_str} {page_nr}"))
};
if let Some(elem) = engine
.introspect(QueryFirstIntrospection(Selector::Location(loc), span))
&& let Some(refable) = elem.with::<dyn Refable>()
{
let counter = refable.counter();
let supplement = refable.supplement().plain_text();
if let Some(numbering) = refable.numbering() {
let numbers = counter.display_at(
engine,
loc,
styles,
&numbering.clone().trimmed(),
span,
)?;
return Ok(eco_format!("{supplement} {}", numbers.plain_text()));
} else {
let page_ref = fallback(engine)?;
return Ok(eco_format!("{supplement}, {page_ref}"));
}
}
fallback(engine)
}
}
}
}
impl Repr for Destination {
fn repr(&self) -> EcoString {
eco_format!("{self:?}")
}
}
cast! {
Destination,
self => match self {
Self::Url(v) => v.into_value(),
Self::Position(v) => v.into_value(),
Self::Location(v) => v.into_value(),
},
v: Url => Self::Url(v),
v: PagedPosition => Self::Position(v),
v: Location => Self::Location(v),
}
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub struct Url(EcoString);
impl Url {
pub fn new(url: impl Into<EcoString>) -> StrResult<Self> {
let url = url.into();
if url.len() > 8000 {
bail!("URL is too long")
} else if url.is_empty() {
bail!("URL must not be empty")
}
Ok(Self(url))
}
pub fn into_inner(self) -> EcoString {
self.0
}
pub fn strip_contact_scheme(&self) -> Option<(UrlContactScheme, &str)> {
[UrlContactScheme::Mailto, UrlContactScheme::Tel]
.into_iter()
.find_map(|scheme| {
let stripped = self.strip_prefix(scheme.as_str())?;
Some((scheme, stripped))
})
}
}
impl Deref for Url {
type Target = EcoString;
fn deref(&self) -> &Self::Target {
&self.0
}
}
cast! {
Url,
self => self.0.into_value(),
v: EcoString => Self::new(v)?,
}
#[elem(Construct)]
pub struct DirectLinkElem {
#[required]
#[internal]
pub loc: Location,
#[required]
#[internal]
pub body: Content,
#[required]
#[internal]
pub alt: Option<EcoString>,
}
impl Construct for DirectLinkElem {
fn construct(_: &mut Engine, args: &mut Args) -> SourceResult<Content> {
bail!(args.span, "cannot be constructed manually");
}
}
#[elem(Tagged, Construct)]
pub struct LinkMarker {
#[internal]
#[required]
pub body: Content,
#[internal]
#[required]
pub alt: Option<EcoString>,
}
impl Construct for LinkMarker {
fn construct(_: &mut Engine, args: &mut Args) -> SourceResult<Content> {
bail!(args.span, "cannot be constructed manually");
}
}
#[derive(Copy, Clone)]
pub enum UrlContactScheme {
Mailto,
Tel,
}
impl UrlContactScheme {
pub fn as_str(self) -> &'static str {
match self {
Self::Mailto => "mailto:",
Self::Tel => "tel:",
}
}
pub fn local_name_in(self, styles: StyleChain) -> &'static str {
match self {
UrlContactScheme::Mailto => Email::local_name_in(styles),
UrlContactScheme::Tel => Telephone::local_name_in(styles),
}
}
}
#[derive(Copy, Clone)]
pub struct Email;
impl LocalName for Email {
const KEY: &'static str = "email";
}
#[derive(Copy, Clone)]
pub struct Telephone;
impl LocalName for Telephone {
const KEY: &'static str = "telephone";
}
pub struct AnchorGenerator<'a> {
introspector: &'a dyn Introspector,
loc_counter: usize,
label_counter: FxHashMap<Label, usize>,
}
impl<'a> AnchorGenerator<'a> {
pub fn new(introspector: &'a dyn Introspector) -> Self {
Self {
introspector,
loc_counter: 0,
label_counter: FxHashMap::default(),
}
}
pub fn introspector(&self) -> &'a dyn Introspector {
self.introspector
}
pub fn identify(&mut self, label: Option<Label>) -> EcoString {
if let Some(label) = label {
let resolved = label.resolve();
let text = resolved.as_str();
if can_use_label_as_id(text) {
if self.introspector.label_count(label) == 1 {
return text.into();
}
let counter = self.label_counter.entry(label).or_insert(0);
*counter += 1;
return disambiguate(self.introspector, text, counter);
}
}
self.loc_counter += 1;
disambiguate(self.introspector, "loc", &mut self.loc_counter)
}
}
fn can_use_label_as_id(label: &str) -> bool {
!label.is_empty()
&& label.chars().all(|c| c.is_alphanumeric() || matches!(c, '-' | '_'))
&& !label.starts_with(|c: char| c.is_numeric() || c == '-')
}
fn disambiguate(
introspector: &dyn Introspector,
text: &str,
counter: &mut usize,
) -> EcoString {
loop {
let disambiguated = eco_format!("{text}-{counter}");
if PicoStr::get(&disambiguated)
.and_then(Label::new)
.is_some_and(|label| introspector.label_count(label) > 0)
{
*counter += 1;
} else {
break disambiguated;
}
}
}
pub struct EarlyLinkResolver {
base: Location,
span: Span,
}
impl EarlyLinkResolver {
pub fn new(base: Location, span: Span) -> Self {
Self { base, span }
}
pub fn resolve(
&self,
engine: &mut Engine,
location: Location,
) -> StrResult<ResolvedLink> {
let from = engine.introspect(PathIntrospection(self.base, self.span));
let to = engine.introspect(PathIntrospection(location, self.span));
let anchor = engine
.introspect(LinkAnchorIntrospection(location, self.span))
.ok_or("failed to determine link anchor")?;
Ok(match (from, to) {
(None, None) => ResolvedLink::Local { anchor },
(Some(from), Some(to)) => {
if from == to {
ResolvedLink::Local { anchor }
} else {
ResolvedLink::Cross { from, to, anchor }
}
}
(Some(_), None) => {
bail!("link destination is not within a document")
}
(None, Some(_)) => bail!("failed to resolve cross-link"),
})
}
}
pub struct LateLinkResolver<'a> {
base: Option<&'a VirtualPath>,
introspector: &'a dyn Introspector,
}
impl<'a> LateLinkResolver<'a> {
pub fn new(
base: Option<&'a VirtualPath>,
introspector: &'a dyn Introspector,
) -> Self {
Self { base, introspector }
}
}
#[comemo::track]
impl<'a> LateLinkResolver<'a> {
pub fn resolve(&self, location: Location) -> Option<ResolvedLink> {
let from = self.base;
let to = self.introspector.path(location);
let anchor = self.introspector.anchor(location)?.clone();
Some(match (from, to) {
(None, None) => ResolvedLink::Local { anchor },
(Some(from), Some(to)) => {
if from == to {
ResolvedLink::Local { anchor }
} else {
ResolvedLink::Cross { from: from.clone(), to: to.clone(), anchor }
}
}
(Some(_), None) => return None,
(None, Some(_)) => return None,
})
}
}
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub enum ResolvedLink {
Local {
anchor: EcoString,
},
Cross {
from: VirtualPath,
to: VirtualPath,
anchor: EcoString,
},
}
impl ResolvedLink {
pub fn into_relative_uri(self) -> StrResult<EcoString> {
Ok(match self {
Self::Local { anchor } => eco_format!("#{anchor}"),
Self::Cross { from, to, anchor } => {
let Some(parent) = from.parent() else {
bail!("containing document has invalid path");
};
let relative_path = to.relative_from(&parent);
let encoded = percent_encode_path(&relative_path);
if anchor.is_empty() {
encoded
} else {
eco_format!("{encoded}#{anchor}")
}
}
})
}
}
fn percent_encode_path(relative_path: &str) -> EcoString {
static NOT_PATH_SAFE: percent_encoding::AsciiSet = percent_encoding::NON_ALPHANUMERIC
.remove(b'-')
.remove(b'.')
.remove(b'_')
.remove(b'~')
.remove(b'/');
let encoded_parts =
percent_encoding::percent_encode(relative_path.as_bytes(), &NOT_PATH_SAFE);
let mut encoded = EcoString::new();
for item in encoded_parts {
encoded.push_str(item);
}
encoded
}
#[derive(Debug, Clone, PartialEq, Hash)]
struct LinkAnchorIntrospection(Location, Span);
impl Introspect for LinkAnchorIntrospection {
type Output = Option<EcoString>;
fn introspect(
&self,
_: &mut Engine,
introspector: Tracked<dyn Introspector + '_>,
) -> Self::Output {
introspector.anchor(self.0).cloned()
}
fn diagnose(&self, history: &History<Self::Output>) -> SourceDiagnostic {
let introspector = history.final_introspector();
let what = match introspector.query_first(&Selector::Location(self.0)) {
Some(content) => content.elem().name(),
None => "element",
};
warning!(
self.1,
"link anchor assigned to the destination {what} did not stabilize",
)
.with_hint(history.hint("anchors", |id| match id {
Some(id) => id.clone(),
None => "(no anchor)".into(),
}))
}
}