@file-type/xml
Version:
XML detection plugin for file-type
244 lines (243 loc) • 7.77 kB
JavaScript
import sax from 'sax';
import { parseDoctype } from "./xmlDocTypeParser.js";
function startsWith(array, prefix) {
if (prefix.length > array.length) {
return false;
}
for (let i = 0; i < prefix.length; i++) {
if (array[i] !== prefix[i]) {
return false;
}
}
return true;
}
function hasXmlTag(xmlString) {
return /^<\s*\w+(?=\s+[^<>]*=|>)/.test(xmlString);
}
function hasArrayXmlTag(array, encoding) {
const textDecoder = new TextDecoder(encoding);
return hasXmlTag(textDecoder.decode(array));
}
export function isXml(array) {
if (startsWith(array, [60, 63, 120, 109, 108, 32])) {
return { xml: true, encoding: 'utf-8', offset: 0 };
}
if (startsWith(array, [0xEF, 0xBB, 0xBF])) { // UTF-8 BOM
const encoding = 'utf-8';
if (startsWith(array.subarray(3), [60, 63, 120, 109, 108, 32]) || hasArrayXmlTag(array, encoding)) {
return { xml: true, encoding, offset: 3 };
}
}
if (startsWith(array, [0xFE, 0xFF])) {
const encoding = 'utf-16be';
if (startsWith(array.subarray(2), [0, 60, 0, 63, 0, 120, 0, 109, 0, 108, 0, 32]) || hasArrayXmlTag(array, encoding)) {
return { xml: true, encoding, offset: 2 };
}
}
if (startsWith(array, [0xFF, 0xFE])) {
const encoding = 'utf-16le';
if (startsWith(array.subarray(2), [60, 0, 63, 0, 120, 0, 109, 0, 108, 0, 32, 0]) || hasArrayXmlTag(array, encoding)) {
return { xml: true, encoding, offset: 2 };
}
}
if (startsWith(array, [0, 60, 0, 63, 0, 120, 0, 109, 0, 108, 0, 32])) {
return { xml: true, encoding: 'utf-16be', offset: 0 };
}
if (startsWith(array, [60, 0, 63, 0, 120, 0, 109, 0, 108, 0, 32, 0])) {
return { xml: true, encoding: 'utf-16le', offset: 0 };
}
if (hasArrayXmlTag(array, 'utf-8')) {
return { xml: true, encoding: 'utf-8', offset: 0 };
}
return { xml: false };
}
export const fileType = {
docBook: {
ext: 'dbk',
mime: 'application/docbook+xml'
},
gpx: {
ext: 'gpx',
mime: 'application/gpx+xml'
},
musicXml: {
ext: 'musicxml',
mime: 'application/vnd.recordare.musicxml+xml'
},
plist: {
ext: 'plist',
mime: 'application/x-plist'
},
smil: {
ext: 'smil',
mime: 'application/smil+xml'
},
svg: {
ext: 'svg',
mime: 'image/svg+xml'
},
x3d: {
ext: 'x3d',
mime: 'model/x3d+xml'
}
};
/**
* Maps the root element namespace to the corresponding file-type
*/
const namespaceMapping = {
'http://www.w3.org/2000/svg': fileType.svg,
'http://www.w3.org/1999/xhtml': {
ext: 'xhtml',
mime: 'application/xhtml+xml'
},
'http://www.opengis.net/kml/2.2': {
ext: 'kml',
mime: 'application/vnd.google-earth.kml+xml'
},
'http://www.opengis.net/gml': {
ext: 'gml',
mime: 'application/gml+xml'
},
'http://www.w3.org/ns/ttml': {
ext: 'ttml',
mime: 'application/ttml+xml'
},
'http://www.w3.org/2001/SMIL20/Language': fileType.smil,
'http://www.w3.org/2005/Atom': {
ext: 'atom',
mime: 'application/atom+xml'
},
'urn:oasis:names:tc:xliff:document:2.0': {
ext: 'xlf',
mime: 'application/xliff+xml'
},
'http://docbook.org/ns/docbook': fileType.docBook,
'http://www.tei-c.org/ns/1.0': {
ext: 'tei',
mime: 'application/tei+xml'
},
'http://www.w3.org/1998/Math/MathML': {
ext: 'mml',
mime: 'application/mathml+xml'
},
'http://www.topografix.com/GPX/1/0': fileType.gpx,
'http://www.topografix.com/GPX/1/1': fileType.gpx,
};
/**
* Maps the root element name to the corresponding file-type.
* Used for Non-namespaced XML
*/
const rootNameMapping = {
opml: {
ext: 'opml',
mime: 'text/x-opml'
},
plist: fileType.plist,
rss: {
ext: 'rss',
mime: 'application/rss+xml'
},
'score-partwise': fileType.musicXml,
smil: fileType.smil,
svg: fileType.svg
};
/**
* Maps DOCTYPE public identifier to the corresponding file-type
*/
const docTypeMapping = {
'-//OASIS//DTD DocBook XML V4.0//EN': fileType.docBook,
'-//OASIS//DTD DocBook XML V4.1//EN': fileType.docBook,
'-//OASIS//DTD DocBook XML V4.2//EN': fileType.docBook,
'-//OASIS//DTD DocBook XML V4.3//EN': fileType.docBook,
'-//OASIS//DTD DocBook XML V4.4//EN': fileType.docBook,
'-//OASIS//DTD DocBook XML V4.5//EN': fileType.docBook,
'-//Recordare//DTD MusicXML 4.0 Partwise//EN': fileType.musicXml,
'-//Apple//DTD PLIST 1.0//EN': fileType.plist,
'ISO//Web3D//DTD X3D 3.3//EN': fileType.x3d,
};
export class XmlTextDetector {
constructor(options) {
this.options = options ?? {};
this.firstTag = true;
this.onEnd = false;
this.parser = sax.parser(true, { xmlns: true });
this.nesting = 0;
this.parser.onerror = e => {
if (e.message.startsWith('Invalid character entity')) { // Allow entity reference
return;
}
this.fileType = undefined;
this.onEnd = true;
};
this.parser.onopentag = node => {
++this.nesting;
if (!this.firstTag || this.onEnd) {
return;
}
this.firstTag = false;
if (node.uri) {
// Resolve file-type boot root element namespace
this.fileType = namespaceMapping[node.uri];
}
else if (node.name) {
// Fall back on element name if there is no namespace
this.fileType = rootNameMapping[node.name.toLowerCase()];
}
if (this.fileType && !this.options.fullScan) {
this.onEnd = true;
}
};
this.parser.ondoctype = rawDocType => {
if (this.fileType || this.onEnd) {
return;
}
const docType = parseDoctype(rawDocType);
if (docType.kind === 'PUBLIC' && docType.publicId) {
this.fileType = docTypeMapping[docType.publicId];
if (this.fileType && !this.options.fullScan) {
this.onEnd = true;
}
}
};
this.parser.onclosetag = () => {
--this.nesting;
};
}
write(text) {
this.parser.write(text);
}
close() {
this.parser.close();
this.onEnd = true;
}
isValid() {
return this.nesting === 0;
}
}
export const detectXml = {
id: 'xml',
detect: async (tokenizer) => {
const buffer = new Uint8Array(512);
// Increase the sample size from 12 to 128.
await tokenizer.peekBuffer(buffer, { length: 128, mayBeLess: true });
const xmlDetection = isXml(buffer);
if (xmlDetection.xml) {
await tokenizer.ignore(xmlDetection.offset);
const xmlTextDetector = new XmlTextDetector();
const textDecoder = new TextDecoder(xmlDetection.encoding);
do {
const len = await tokenizer.readBuffer(buffer, { mayBeLess: true });
const portion = buffer.subarray(0, len);
const text = textDecoder.decode(portion);
xmlTextDetector.write(text);
if (len < buffer.length) {
xmlTextDetector.close();
}
} while (!xmlTextDetector.onEnd);
return xmlTextDetector.fileType ?? {
ext: 'xml',
mime: 'application/xml'
};
}
}
};