diff --git a/crates/export/src/as_html.rs b/crates/export/src/as_html.rs
index 45f6ecafca..8c250ca484 100644
--- a/crates/export/src/as_html.rs
+++ b/crates/export/src/as_html.rs
@@ -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 {
diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs
index ba5f95f66d..5820fc57bc 100644
--- a/crates/export/src/tests.rs
+++ b/crates/export/src/tests.rs
@@ -265,6 +265,8 @@ fn word_and_html_take_the_documents_language() {
assert!(styles(Some(long)).contains(&format!("")), "{long}");
assert!(html_with_language(&pages, "Report", Some(long)).contains(&format!("")), "{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(""));
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\""),