1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
//! The document's natural language (ISO 32000-1 §14.9.2): the catalog's
//! `/Lang`, the default for every piece of text a structure element or a
//! marked-content sequence does not tag with a language of its own.
use crate::object::{decode_text_string, Dict};
use crate::source::AsyncObjectSource;
use crate::tree::resolved_dict;
/// The language the catalog declares for the document's text (`/Lang`),
/// decoded as a text string, or `None` when the catalog declares none or
/// the entry is not a string. A structure element's or a marked-content
/// sequence's own `/Lang` overrides it for their text (`Placement::lang`,
/// and `lang` on the text crate's spans).
///
/// Covers ISO 32000-1 §14.9.2.
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()?))
}
/// A language identifier as `/Lang` entries spell it (ISO 32000-1
/// §14.9.2.2, the RFC 3066 form): a primary language subtag, then subtags
/// separated by hyphens, the second one a country code when it has two
/// letters. Case carries no meaning in a tag; the language comes out in
/// lower case and the country in upper case, as they are usually written,
/// and the other subtags as written.
///
/// Covers ISO 32000-1 §14.9.2.2.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct LanguageTag {
/// The primary subtag: an ISO 639 language code, or `i` for a registered
/// and `x` for a private-use tag.
pub language: String,
/// The ISO 3166 country code, when the second subtag has two letters.
pub country: Option<String>,
/// The subtags after the language and the country, as written.
pub subtags: Vec<String>,
}
impl LanguageTag {
/// Longest subtag RFC 3066 allows.
const MAX_SUBTAG: usize = 8;
/// Parses `tag`, surrounding whitespace ignored; `None` when it is not a
/// well-formed tag: an empty subtag, a subtag longer than eight
/// characters, a character outside ASCII letters (and digits after the
/// first subtag), or a separator other than the hyphen.
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(),
})
}
/// One to eight ASCII letters, digits allowed too after the first subtag.
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()
}
// Covers ISO 32000-1 §14.9.2.
#[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);
}
// Covers ISO 32000-1 §14.9.2.2.
#[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());
// Case is not significant; the usual spelling comes out.
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));
// A second subtag of another length is not a country; later subtags
// stay as written.
let tag = LanguageTag::parse("zh-Hant-TW").unwrap();
assert_eq!(tag.language, "zh");
assert_eq!(tag.country, None);
assert_eq!(tag.subtags, ["Hant", "TW"]);
// The registered and private-use prefixes are primary subtags too.
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());
}
}