A language tag can't end in a singleton

A singleton (one letter or digit) starts an extension, or with x private
use, and BCP 47 needs a subtag after it. en-x and en-a were written as the
document's language. They and a singleton straight after another are now
left out; one-character subtags inside private use stay allowed.
This commit is contained in:
oracola91 committed 2026-11-04 23:00:11 +02:00
1 parent 0cae1c518b
commit 561cc0d707
2 files changed
+29 -6

No files matched your search

+23 -6
View File
@@ -14,16 +14,33 @@ const LONGEST_TAG: usize = 255;
/// `lang` when it is a well formed language tag (BCP 47, as a PDF's `/Lang` holds one): a primary
/// language of two or three letters, then subtags of one to eight letters or digits, joined by
/// hyphens, [`LONGEST_TAG`] characters at most. Anything else names no language.
/// hyphens, [`LONGEST_TAG`] characters at most. A singleton (one letter or digit) starts an
/// extension, or with `x` private use, and needs a subtag after it. Anything else names no
/// language.
pub(crate) fn language_tag(lang: Option<&str>) -> Option<&str> {
let tag = lang?.trim();
let mut subtags = tag.split('-');
let primary = subtags.next()?;
let well_formed = tag.len() <= LONGEST_TAG
&& (2..=3).contains(&primary.len())
&& primary.bytes().all(|b| b.is_ascii_alphabetic())
&& subtags.all(|s| (1..=8).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_alphanumeric()));
well_formed.then_some(tag)
if tag.len() > LONGEST_TAG || !(2..=3).contains(&primary.len()) || !primary.bytes().all(|b| b.is_ascii_alphabetic()) {
return None;
}
// In private use every subtag is data, one character long or not.
let (mut private, mut singleton) = (false, false);
for s in subtags {
if !(1..=8).contains(&s.len()) || !s.bytes().all(|b| b.is_ascii_alphanumeric()) {
return None;
}
if s.len() == 1 && !private {
if singleton {
return None;
}
private = s.eq_ignore_ascii_case("x");
singleton = true;
} else {
singleton = false;
}
}
(!singleton).then_some(tag)
}
pub(crate) fn esc(s: &str) -> String {
+6
View File
@@ -265,6 +265,8 @@ fn word_and_html_take_the_documents_language() {
assert!(styles(Some(long)).contains(&format!("<w:lang w:val=\"{long}\"/>")), "{long}");
assert!(html_with_language(&pages, "Report", Some(long)).contains(&format!("<html lang=\"{long}\">")), "{long}");
let too_long = format!("en{}", "-abcdefgh".repeat(29));
// Private use takes one-character subtags.
assert!(html_with_language(&pages, "Report", Some("en-x-a-b")).contains("<html lang=\"en-x-a-b\">"));
for lang in [
None,
Some(""),
@@ -274,6 +276,10 @@ fn word_and_html_take_the_documents_language() {
Some("bulg-BG"),
Some("bg--BG"),
Some("bg-"),
Some("en-x"),
Some("en-a"),
Some("en-a-x-private"),
Some("en-US-u-ca-x"),
Some("en-US-abcdefghi"),
Some(too_long.as_str()),
Some("en-\"x\""),