streaming_utils_BCP47Utils.js

/**
 * The copyright in this software is being made available under the BSD License,
 * included below. This software may be subject to other third party and contributor
 * rights, including patent rights, and no such rights are granted under this license.
 *
 * Copyright (c) 2013, Dash Industry Forum.
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without modification,
 * are permitted provided that the following conditions are met:
 *  * Redistributions of source code must retain the above copyright notice, this
 *  list of conditions and the following disclaimer.
 *  * Redistributions in binary form must reproduce the above copyright notice,
 *  this list of conditions and the following disclaimer in the documentation and/or
 *  other materials provided with the distribution.
 *  * Neither the name of Dash Industry Forum nor the names of its
 *  contributors may be used to endorse or promote products derived from this software
 *  without specific prior written permission.
 *
 *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY
 *  EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
 *  WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
 *  IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
 *  INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
 *  NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 *  PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
 *  WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 *  POSSIBILITY OF SUCH DAMAGE.
 */

/**
 * Lightweight BCP-47 tag normalization.
 *
 * Produces the RFC 5646 section 4.5 canonical tag in "extlang form":
 * 1. Grandfathered and redundant tags replaced by their IANA Preferred-Value
 * 2. Deprecated language and region subtags replaced by their IANA Preferred-Value
 * 3. ISO 639-2/B and 639-2/T (3-letter) to ISO 639-1 (2-letter) conversion (RFC 5646 section 2.2.1)
 * 4. Extended language subtags prefixed with their macrolanguage (e.g. cmn -> zh-cmn)
 * 5. Extension sequences ordered by singleton, private use kept last
 * 6. Case normalization per RFC 5646 section 2.1.1
 *
 * CLDR canonicalization (likely-subtag minimization such as nl-NL -> nl, macrolanguage
 * folding such as zh-cmn -> zh, und -> en) is not part of BCP 47 and deliberately not applied.
 *
 * Replaces the heavy bcp-47-normalize package (~280 KB) which carried
 * a full IANA subtag registry (8039 entries) that dash.js never needed.
 */

/**
 * ISO 639-2 (3-letter) to ISO 639-1 (2-letter) mappings for languages
 * commonly found in broadcast/streaming content (DASH, HLS, DVB).
 * Covers both 639-2/B (bibliographic) and 639-2/T (terminological) codes.
 */
export const ISO_639_2_TO_1 = Object.create(null, Object.getOwnPropertyDescriptors({
    aar: 'aa', abk: 'ab', afr: 'af', aka: 'ak', alb: 'sq', amh: 'am',
    ara: 'ar', arg: 'an', arm: 'hy', asm: 'as', ava: 'av', ave: 'ae',
    aym: 'ay', aze: 'az', bak: 'ba', bam: 'bm', baq: 'eu', bel: 'be',
    ben: 'bn', bis: 'bi', bod: 'bo', bos: 'bs', bre: 'br', bul: 'bg',
    bur: 'my', cat: 'ca', ces: 'cs', cha: 'ch', che: 'ce', chi: 'zh',
    chu: 'cu', chv: 'cv', cor: 'kw', cos: 'co', cre: 'cr', cym: 'cy',
    cze: 'cs', dan: 'da', deu: 'de', div: 'dv', dut: 'nl', dzo: 'dz',
    ell: 'el', eng: 'en', epo: 'eo', est: 'et', eus: 'eu', ewe: 'ee',
    fao: 'fo', fas: 'fa', fij: 'fj', fin: 'fi', fra: 'fr', fre: 'fr',
    fry: 'fy', ful: 'ff', geo: 'ka', ger: 'de', gla: 'gd', gle: 'ga',
    glg: 'gl', glv: 'gv', gre: 'el', grn: 'gn', guj: 'gu', hat: 'ht',
    hau: 'ha', heb: 'he', her: 'hz', hin: 'hi', hmo: 'ho', hrv: 'hr',
    hun: 'hu', hye: 'hy', ibo: 'ig', ice: 'is', ido: 'io', iii: 'ii',
    iku: 'iu', ile: 'ie', ina: 'ia', ind: 'id', ipk: 'ik', isl: 'is',
    ita: 'it', jav: 'jv', jpn: 'ja', kal: 'kl', kan: 'kn', kas: 'ks',
    kat: 'ka', kau: 'kr', kaz: 'kk', khm: 'km', kik: 'ki', kin: 'rw',
    kir: 'ky', kom: 'kv', kon: 'kg', kor: 'ko', kua: 'kj', kur: 'ku',
    lao: 'lo', lat: 'la', lav: 'lv', lim: 'li', lin: 'ln', lit: 'lt',
    ltz: 'lb', lub: 'lu', lug: 'lg', mac: 'mk', mah: 'mh', mal: 'ml',
    mao: 'mi', mar: 'mr', may: 'ms', mkd: 'mk', mlg: 'mg', mlt: 'mt',
    mon: 'mn', mri: 'mi', msa: 'ms', mya: 'my', nau: 'na', nav: 'nv',
    nbl: 'nr', nde: 'nd', ndo: 'ng', nep: 'ne', nld: 'nl', nno: 'nn',
    nob: 'nb', nor: 'no', nya: 'ny', oci: 'oc', oji: 'oj', ori: 'or',
    orm: 'om', oss: 'os', pan: 'pa', per: 'fa', pli: 'pi', pol: 'pl',
    por: 'pt', pus: 'ps', que: 'qu', roh: 'rm', ron: 'ro', rum: 'ro',
    run: 'rn', rus: 'ru', sag: 'sg', san: 'sa', sin: 'si', slk: 'sk',
    slo: 'sk', slv: 'sl', sme: 'se', smo: 'sm', sna: 'sn', snd: 'sd',
    som: 'so', sot: 'st', spa: 'es', sqi: 'sq', srd: 'sc', srp: 'sr',
    ssw: 'ss', sun: 'su', swa: 'sw', swe: 'sv', tah: 'ty', tam: 'ta',
    tat: 'tt', tel: 'te', tgk: 'tg', tgl: 'tl', tha: 'th', tir: 'ti',
    ton: 'to', tsn: 'tn', tso: 'ts', tuk: 'tk', tur: 'tr', twi: 'tw',
    uig: 'ug', ukr: 'uk', urd: 'ur', uzb: 'uz', ven: 've', vie: 'vi',
    vol: 'vo', wel: 'cy', wln: 'wa', wol: 'wo', xho: 'xh', yid: 'yi',
    yor: 'yo', zha: 'za', zho: 'zh', zul: 'zu'
}));

/**
 * IANA Language Subtag Registry: grandfathered and redundant tags that have a Preferred-Value.
 * Grandfathered tags without a Preferred-Value (e.g. i-default, zh-min) are kept as they are.
 */
const TAG_PREFERRED_VALUES = Object.create(null, Object.getOwnPropertyDescriptors({
    'art-lojban': 'jbo', 'en-gb-oed': 'en-GB-oxendict', 'i-ami': 'ami', 'i-bnn': 'bnn', 'i-hak': 'hak',
    'i-klingon': 'tlh', 'i-lux': 'lb', 'i-navajo': 'nv', 'i-pwn': 'pwn', 'i-tao': 'tao', 'i-tay': 'tay',
    'i-tsu': 'tsu', 'no-bok': 'nb', 'no-nyn': 'nn', 'sgn-be-fr': 'sfb', 'sgn-be-nl': 'vgt', 'sgn-ch-de': 'sgg',
    'zh-guoyu': 'cmn', 'zh-hakka': 'hak', 'zh-min-nan': 'nan', 'zh-xiang': 'hsn',
    'sgn-br': 'bzs', 'sgn-co': 'csn', 'sgn-de': 'gsg', 'sgn-dk': 'dsl', 'sgn-es': 'ssp', 'sgn-fr': 'fsl',
    'sgn-gb': 'bfi', 'sgn-gr': 'gss', 'sgn-ie': 'isg', 'sgn-it': 'ise', 'sgn-jp': 'jsl', 'sgn-mx': 'mfs',
    'sgn-ni': 'ncs', 'sgn-nl': 'dse', 'sgn-no': 'nsl', 'sgn-pt': 'psr', 'sgn-se': 'swl', 'sgn-us': 'ase',
    'sgn-za': 'sfs'
}));

/**
 * IANA Language Subtag Registry: deprecated language subtags with a Preferred-Value.
 * ponytail: only the 2-letter ones; add deprecated 3-letter codes if they show up in content
 */
const DEPRECATED_LANGUAGES = Object.create(null, Object.getOwnPropertyDescriptors({
    in: 'id', iw: 'he', ji: 'yi', jw: 'jv', mo: 'ro'
}));

/**
 * IANA Language Subtag Registry: deprecated region subtags with a Preferred-Value.
 */
const DEPRECATED_REGIONS = Object.create(null, Object.getOwnPropertyDescriptors({
    BU: 'MM', DD: 'DE', FX: 'FR', TP: 'TL', YD: 'YE', ZR: 'CD'
}));

/**
 * IANA Language Subtag Registry: extlang subtags and their Prefix, used to produce the extlang form.
 * ponytail: Chinese, Arabic, Malay, Swahili and sign language families only; add other extlangs from the registry if needed
 */
const EXTLANG_PREFIXES = Object.create(null);
[
    ['zh', 'cdo cjy cmn cpx czh czo gan hak hsn lzh mnp nan wuu yue'],
    ['ar', 'acm afb aeb ajp apc apd arb arq ars ary arz ayl'],
    ['ms', 'min zlm zsm'],
    ['sw', 'swc swh'],
    ['sgn', 'ase bfi bzs csn dse dsl fsl gsg gss isg ise jsl mfs ncs nsl psr sfb sfs sgg ssp swl vgt']
].forEach(([prefix, extlangs]) => extlangs.split(' ').forEach(extlang => EXTLANG_PREFIXES[extlang] = prefix));

// Grandfathered and redundant tags, longest match first (zh-min-nan before zh-min)
function _replacePreferredTag(parts) {
    for (let n = Math.min(3, parts.length); n > 1; n--) {
        const preferredValue = TAG_PREFERRED_VALUES[parts.slice(0, n).join('-')];
        if (preferredValue) {
            return preferredValue.toLowerCase().split('-').concat(parts.slice(n));
        }
    }
    return parts;
}

export function normalizeBcp47(tag) {
    if (!tag || typeof tag !== 'string') {
        return tag;
    }
    let parts = _replacePreferredTag(tag.toLowerCase().split('-'));

    // Private use (x-...) and irregular grandfathered tags without Preferred-Value (i-default) stay lowercase
    if (parts[0].length === 1) {
        return parts.join('-');
    }

    // Language: deprecated subtags and ISO 639-2 to 639-1
    parts[0] = DEPRECATED_LANGUAGES[parts[0]] || ISO_639_2_TO_1[parts[0]] || parts[0];

    // Deprecated regions, up to the first singleton
    for (let i = 1; i < parts.length && parts[i].length !== 1; i++) {
        const region = DEPRECATED_REGIONS[parts[i].toUpperCase()];
        if (parts[i].length === 2 && region) {
            parts[i] = region.toLowerCase();
        }
    }

    // The replacements above can produce a redundant tag (sgn-DD -> sgn-DE -> gsg), resolve it in the same call
    parts = _replacePreferredTag(parts);

    // Extlang form (cmn -> zh-cmn)
    if (EXTLANG_PREFIXES[parts[0]]) {
        parts.unshift(EXTLANG_PREFIXES[parts[0]]);
    }

    // Script and region casing applies only up to the first singleton, extension subtags stay lowercase
    let i = 1;
    for (; i < parts.length && parts[i].length !== 1; i++) {
        if (parts[i].length === 4) {
            // Script: titlecase (e.g. latn -> Latn)
            parts[i] = parts[i].charAt(0).toUpperCase() + parts[i].slice(1);
        } else if (parts[i].length === 2 && /^[a-z]+$/.test(parts[i])) {
            // Region (2 alpha): uppercase (e.g. us -> US)
            parts[i] = parts[i].toUpperCase();
        }
    }

    // Extension sequences ordered by singleton, private use sequence kept last
    const privateUseIndex = parts.indexOf('x', i);
    const end = privateUseIndex === -1 ? parts.length : privateUseIndex;
    const extensions = [];
    for (let j = i; j < end; j++) {
        if (parts[j].length === 1) {
            extensions.push([parts[j]]);
        } else {
            extensions[extensions.length - 1].push(parts[j]);
        }
    }
    extensions.sort((a, b) => a[0] < b[0] ? -1 : 1);

    return parts.slice(0, i).concat(...extensions, parts.slice(end)).join('-');
}