use crate::object::{decode_text_string, Dict};
use crate::source::AsyncObjectSource;
use crate::tree::resolved_dict;
pub async fn language_with<S: AsyncObjectSource>(src: &S, trailer: &Dict) -> Option<String> {
let catalog = resolved_dict(src, trailer.get("Root")?).await?;
let value = src.resolve(catalog.get("Lang")?).await.ok()?;
Some(decode_text_string(value.as_str_bytes()?))
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct LanguageTag {
pub language: String,
pub country: Option<String>,
pub subtags: Vec<String>,
}
impl LanguageTag {
const MAX_SUBTAG: usize = 8;
pub fn parse(tag: &str) -> Option<LanguageTag> {
let mut subtags = tag.trim().split('-');
let language = subtags.next()?;
if !Self::is_subtag(language, false) {
return None;
}
let mut rest: Vec<&str> = subtags.collect();
if !rest.iter().all(|subtag| Self::is_subtag(subtag, true)) {
return None;
}
let country = match rest.first() {
Some(second)
if second.len() == 2 && second.bytes().all(|b| b.is_ascii_alphabetic()) =>
{
Some(rest.remove(0).to_ascii_uppercase())
}
_ => None,
};
Some(LanguageTag {
language: language.to_ascii_lowercase(),
country,
subtags: rest.into_iter().map(str::to_string).collect(),
})
}
fn is_subtag(subtag: &str, digits: bool) -> bool {
!subtag.is_empty()
&& subtag.len() <= Self::MAX_SUBTAG
&& subtag
.bytes()
.all(|b| b.is_ascii_alphabetic() || (digits && b.is_ascii_digit()))
}
}
#[cfg(test)]
mod tests {
use super::LanguageTag;
use crate::Document;
use pdfboss_testkit::PdfBuilder;
fn doc(catalog_extra: &str, objects: &[(u32, &str)]) -> Document {
let mut b = PdfBuilder::new();
b.object(
1,
&format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} >>"),
);
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>");
for (num, body) in objects {
b.object(*num, body);
}
Document::load(b.build(1)).unwrap()
}
#[test]
fn the_catalog_lang_is_the_document_language() {
assert_eq!(
doc("/Lang (en-US)", &[]).language().as_deref(),
Some("en-US")
);
assert_eq!(
doc("/Lang <FEFF00640065>", &[]).language().as_deref(),
Some("de")
);
assert_eq!(
doc("/Lang 4 0 R", &[(4, "(fr)")]).language().as_deref(),
Some("fr")
);
assert_eq!(doc("", &[]).language(), None);
assert_eq!(doc("/Lang 7", &[]).language(), None);
}
#[test]
fn language_tags_split_into_language_and_country() {
let tag = LanguageTag::parse("en-US").unwrap();
assert_eq!(tag.language, "en");
assert_eq!(tag.country.as_deref(), Some("US"));
assert!(tag.subtags.is_empty());
let tag = LanguageTag::parse(" EN-us ").unwrap();
assert_eq!(
(tag.language.as_str(), tag.country.as_deref()),
("en", Some("US"))
);
let tag = LanguageTag::parse("de").unwrap();
assert_eq!((tag.language.as_str(), tag.country), ("de", None));
let tag = LanguageTag::parse("zh-Hant-TW").unwrap();
assert_eq!(tag.language, "zh");
assert_eq!(tag.country, None);
assert_eq!(tag.subtags, ["Hant", "TW"]);
let tag = LanguageTag::parse("x-pdfboss").unwrap();
assert_eq!(
(tag.language.as_str(), tag.subtags.as_slice()),
("x", ["pdfboss".to_string()].as_slice())
);
assert_eq!(LanguageTag::parse("i-klingon").unwrap().language, "i");
for bad in [
"",
"-en",
"en-",
"en--US",
"en_US",
"toolongsubtag",
"en-1234567890",
"en US",
"éé",
] {
assert!(LanguageTag::parse(bad).is_none(), "{bad:?}");
}
assert_eq!(
doc("/Lang (en-GB)", &[])
.language_tag()
.unwrap()
.country
.as_deref(),
Some("GB")
);
assert!(doc("/Lang (not a tag)", &[]).language_tag().is_none());
}
}