UNPKG

jats-xml

Version:

Types and utilities for working with JATS in Typescript

478 lines (477 loc) 20.5 kB
import { toText } from 'myst-common'; import { xml2js } from 'xml-js'; import { doi } from 'doi-utils'; import { validatePageFrontmatter } from 'myst-frontmatter'; import { select as unistSelect, selectAll } from 'unist-util-select'; import { Tags } from 'jats-tags'; import { findArticleId, processAffiliation, processContributor } from './utils.js'; import { tic } from 'myst-cli-utils'; import { recordJatsMessage } from './messages.js'; import { knownXmlDefectRepairMessage, repairKnownXmlDefects } from './repairKnownXmlDefects.js'; import { sanitizeXmlEntities } from './sanitizeXmlEntities.js'; import { articleMetaOrder, tableWrapOrder } from './order.js'; import { serializeJatsXml, convertToUnist, convertToXml, toDate, } from 'jats-utils'; function select(selector, node) { var _a, _b; try { return ((_a = unistSelect(selector, node)) !== null && _a !== void 0 ? _a : undefined); } catch (error) { const nodeType = (_b = node === null || node === void 0 ? void 0 : node.type) !== null && _b !== void 0 ? _b : '(undefined)'; const msg = error instanceof Error ? error.message : String(error); throw new Error(`[jats-xml/select] selector="${selector}" nodeType="${nodeType}" failed: ${msg}`); } } const DEFAULT_DOCTYPE = 'article PUBLIC "-//NLM//DTD JATS (Z39.96) Journal Archiving and Interchange DTD with MathML3 v1.3 20210610//EN" "http://jats.nlm.nih.gov/publishing/1.3/JATS-archivearticle1-3-mathml3.dtd"'; /** * Drop comments and whitespace-only text nodes introduced by xml-js when * `captureSpacesBetweenElements` is true (ignorable XML whitespace). */ function significantChildElements(elements) { return elements === null || elements === void 0 ? void 0 : elements.filter((elem) => { var _a; if (elem.type === 'comment') return false; if (elem.type === 'text') { const t = String((_a = elem.text) !== null && _a !== void 0 ? _a : ''); if (!t.trim()) return false; } return true; }); } /** * Drop top-level processing instructions (e.g. xml-stylesheet) from the prolog. * Malformed prologs such as <?version xml="1.0"?> are parsed this way by xml-js. */ function dropTopLevelInstructions(elements, onInstruction) { return elements === null || elements === void 0 ? void 0 : elements.filter((elem) => { if (elem.type === 'instruction') { onInstruction === null || onInstruction === void 0 ? void 0 : onInstruction(elem); return false; } return true; }); } export class Jats { constructor(data, opts) { var _a; this.frontmatterMessagesRecorded = false; const toc = tic(); this.log = opts === null || opts === void 0 ? void 0 : opts.log; if (opts === null || opts === void 0 ? void 0 : opts.source) this.source = opts.source; this.vfile = opts === null || opts === void 0 ? void 0 : opts.vfile; const warnProlog = (reason, note) => { recordJatsMessage(this.vfile, reason, { note }); }; const { xml: afterDefectRepairs, applied: defectRepairs } = repairKnownXmlDefects(data); defectRepairs.forEach(({ repair, count }) => { const reason = knownXmlDefectRepairMessage(repair.from, repair.to); const note = count === 1 ? undefined : `${count} occurrences`; warnProlog(reason, note); }); const { xml: parseInput, escapedBareAmpersandCount } = sanitizeXmlEntities(afterDefectRepairs); if (escapedBareAmpersandCount > 0) { const note = escapedBareAmpersandCount === 1 ? '1 bare & rewritten to &#38;' : `${escapedBareAmpersandCount} bare & rewritten to &#38;`; warnProlog('Escaped bare ampersand(s) before XML parse', note); } try { this.raw = xml2js(parseInput, { compact: false, // Preserve whitespace-only text nodes between elements. This is usually unnecessary except inside <preformat>. // convertToUnist drops these outside preformat so other content is processed independent of arbitrary xml whitespace. captureSpacesBetweenElements: true, }); } catch (error) { throw new Error('Problem parsing the JATS document, please ensure it is XML'); } const { declaration, elements } = this.raw; this.declaration = declaration === null || declaration === void 0 ? void 0 : declaration.attributes; const filteredElements = dropTopLevelInstructions(significantChildElements(elements), (instruction) => { var _a, _b; const name = (_a = instruction.name) !== null && _a !== void 0 ? _a : '(unnamed)'; const body = String((_b = instruction.instruction) !== null && _b !== void 0 ? _b : '').trim(); const note = body ? `name=${name} ${body}` : `name=${name}`; warnProlog('Removed top-level XML processing instruction from prolog', note); }); if ((filteredElements === null || filteredElements === void 0 ? void 0 : filteredElements.length) && filteredElements[0].type !== 'doctype') { warnProlog('JATS is missing DOCTYPE declaration; inserted empty doctype'); filteredElements.unshift({ type: 'doctype' }); } if (!((filteredElements === null || filteredElements === void 0 ? void 0 : filteredElements.length) === 2 && filteredElements[0].type === 'doctype' && hasSingleArticle(filteredElements[1]))) { throw new Error('JATS must be structured as <!DOCTYPE><article>...</article>'); } this.doctype = filteredElements[0].doctype; if (filteredElements[1].name === 'pmc-articleset') { warnProlog('JATS root is pmc-articleset wrapper', 'Using nested article element'); } const converted = convertToUnist(filteredElements[1]); this.tree = select('article', converted); (_a = this.log) === null || _a === void 0 ? void 0 : _a.debug(toc('Parsed and converted JATS to unist tree in %s')); } get frontmatter() { var _a, _b, _c, _d, _e, _f, _g, _h; const title = this.articleTitle; const subtitle = this.articleSubtitle; const short_title = this.articleAltTitle; const titleText = title ? toText(title).trim() : ''; const authors = ((_a = this.articleAuthors) !== null && _a !== void 0 ? _a : []).map((auth) => processContributor(auth)); const { date, datePick } = this.resolvePublicationDate(); this.recordFrontmatterMessages({ date, datePick, titleText, authorCount: authors.length, }); const affiliations = (_b = this.articleAffiliations) === null || _b === void 0 ? void 0 : _b.map((aff) => { return processAffiliation(aff); }); const keywords = (_d = (_c = this.keywords) === null || _c === void 0 ? void 0 : _c.map((k) => toText(k))) !== null && _d !== void 0 ? _d : []; const subjectScope = (_e = this.articleCategories) !== null && _e !== void 0 ? _e : this.front; const journalCollSubject = select(`${Tags.subjGroup}[subj-group-type="hwp-journal-coll"] ${Tags.subject}`, subjectScope); const articleSubject = journalCollSubject !== null && journalCollSubject !== void 0 ? journalCollSubject : select(Tags.subject, subjectScope); const journalTitle = select(Tags.journalTitle, this.front); const license = this.license; let licenseString = null; if (license === null || license === void 0 ? void 0 : license['xlink:href']) { licenseString = license['xlink:href']; } else if (license && select('[type=ali\\:license_ref]', license)) { licenseString = toText(select('[type=ali\\:license_ref]', license)); } else if (selectAll('ext-link', license).length === 1) { // this should only happen if there is only one ext-link licenseString = (_f = select('ext-link', license)['xlink:href']) !== null && _f !== void 0 ? _f : null; } else if (license) { licenseString = toText(license); } let openAccess; const licenseType = (_g = license === null || license === void 0 ? void 0 : license['license-type']) === null || _g === void 0 ? void 0 : _g.toLowerCase(); if (licenseType && ['openaccess', 'open-access'].includes(licenseType)) { openAccess = true; } else if (licenseString === null || licenseString === void 0 ? void 0 : licenseString.match(/^\s*Open Access\s*This/)) { licenseString = licenseString.replace(/^\s*Open Access\s*/, ''); openAccess = true; } else if (licenseString === null || licenseString === void 0 ? void 0 : licenseString.toLowerCase().startsWith('this is an open access article')) { openAccess = true; } const pmc = this.pmc; const identifiers = pmc ? { pmcid: `PMC${pmc}` } : undefined; const frontmatter = validatePageFrontmatter({ title: titleText || undefined, subtitle: subtitle ? toText(subtitle) : undefined, short_title: short_title ? toText(short_title) : undefined, doi: (_h = this.doi) !== null && _h !== void 0 ? _h : undefined, identifiers, date, authors: authors.length ? authors : undefined, // editors, affiliations: affiliations.length ? affiliations : undefined, keywords: keywords.length ? keywords : undefined, venue: journalTitle ? { title: toText(journalTitle) } : undefined, subject: articleSubject ? toText(articleSubject) : undefined, license: licenseString !== null && licenseString !== void 0 ? licenseString : undefined, open_access: openAccess, }, { property: 'frontmatter', messages: {} }); return frontmatter; } get front() { return select(Tags.front, this.tree); } get articleMeta() { return select(Tags.articleMeta, this.tree); } get permissions() { return select(Tags.permissions, this.front); } get doi() { var _a; return doi.normalize((_a = findArticleId(this.front, 'doi')) !== null && _a !== void 0 ? _a : ''); } get pmc() { var _a; return (_a = findArticleId(this.front, 'pmc')) === null || _a === void 0 ? void 0 : _a.replace(/^PMC:?/, ''); } get pmid() { return findArticleId(this.front, 'pmid'); } get publicationDates() { return selectAll(Tags.pubDate, this.front); } get publicationDate() { return this.publicationDates.find((d) => !!select(Tags.day, d)); } /** * JATS `<history>` can include multiple `<date date-type="...">` nodes, * often including an "accepted" date that is a better default for project/page `date`. */ get historyDates() { var _a; return selectAll('history date', (_a = this.articleMeta) !== null && _a !== void 0 ? _a : this.front); } /** * Prefer a fully-specified publication date (day/month/year). * If publication date is incomplete (e.g. year-only), fall back through history dates. */ resolvePublicationDate() { const datePick = this.pickPublicationDate(); let date; if (datePick) { const d = toDate(datePick.node); if (d) { const year = d.getUTCFullYear(); const month = (d.getUTCMonth() + 1).toString().padStart(2, '0'); const day = d.getUTCDate().toString().padStart(2, '0'); date = `${year}-${month}-${day}`; } } return { date, datePick }; } recordFrontmatterMessages(input) { var _a; if (this.frontmatterMessagesRecorded) return; this.frontmatterMessagesRecorded = true; if ((_a = input.datePick) === null || _a === void 0 ? void 0 : _a.warn) { recordJatsMessage(this.vfile, input.datePick.warn, { note: input.datePick.note }); } if (!input.date) { recordJatsMessage(this.vfile, 'No publication date found in JATS', { note: 'article-meta/pub-date or history/pub-history dates', }); } if (!input.titleText) { recordJatsMessage(this.vfile, 'No article title found in JATS', { note: 'article-meta/title-group/article-title', }); } if (input.authorCount === 0) { recordJatsMessage(this.vfile, 'No authors found in JATS', { note: 'article-meta/contrib-group/contrib[@contrib-type="author"]', }); } } pickPublicationDate() { if (this.publicationDate) { return { node: this.publicationDate }; } if (this.pubHistoryPubDate) { return { node: this.pubHistoryPubDate }; } if (this.pubHistoryAcceptedDate) { return { node: this.pubHistoryAcceptedDate }; } if (this.historyAcceptedDate) { return { node: this.historyAcceptedDate }; } if (this.pubHistoryFallbackDate) { return this.fallbackDatePick(this.pubHistoryFallbackDate, 'pub-history'); } if (this.historyFallbackDate) { return this.fallbackDatePick(this.historyFallbackDate, 'history'); } return undefined; } jatsDateType(node) { var _a; return String((_a = node['date-type']) !== null && _a !== void 0 ? _a : '') .trim() .toLowerCase(); } dateHasDay(node) { return !!select(Tags.day, node); } /** Last `<date>` with a `<day>` whose `date-type` is not in `excludeTypes`. */ lastDateWithDay(dates, excludeTypes) { const eligible = dates.filter((d) => { if (!this.dateHasDay(d)) return false; return !excludeTypes.has(this.jatsDateType(d)); }); return eligible.at(-1); } fallbackDatePick(node, source) { const dateType = this.jatsDateType(node) || 'unknown'; const xpath = source === 'pub-history' ? `article-meta/pub-history/event/date[@date-type="${dateType}"]` : `article-meta/history/date[@date-type="${dateType}"]`; return { node, warn: `Using JATS ${source} date as publication date`, note: xpath, }; } findHistoryDateByType(types) { const wanted = new Set(types.map((t) => t.toLowerCase())); const found = this.historyDates.find((d) => { return wanted.has(this.jatsDateType(d)); }); if (found && this.dateHasDay(found)) return found; return undefined; } get historyAcceptedDate() { return this.findHistoryDateByType(['accepted', 'accept']); } get pubHistoryFallbackDate() { return this.lastDateWithDay(this.pubHistoryDates, Jats.PUB_HISTORY_PRIMARY_DATE_TYPES); } get historyFallbackDate() { return this.lastDateWithDay(this.historyDates, Jats.HISTORY_PRIMARY_DATE_TYPES); } /** * Some sources store important dates under: * `<article-meta><pub-history><event><date date-type="...">...</date></event></pub-history>`. */ get pubHistoryDates() { var _a; return selectAll('pub-history event date', (_a = this.articleMeta) !== null && _a !== void 0 ? _a : this.front); } findPubHistoryDateByType(types) { const wanted = new Set(types.map((t) => t.toLowerCase())); const found = this.pubHistoryDates.find((d) => wanted.has(this.jatsDateType(d))); if (found && this.dateHasDay(found)) return found; return undefined; } get pubHistoryPubDate() { return this.findPubHistoryDateByType(['pub', 'published', 'publication']); } get pubHistoryAcceptedDate() { return this.findPubHistoryDateByType(['accepted', 'accept']); } get license() { return select(Tags.license, this.permissions); } get keywordGroup() { return select(Tags.kwdGroup, this.front); } /** The first keywords */ get keywords() { return selectAll(Tags.kwd, this.keywordGroup); } get keywordGroups() { return selectAll(Tags.kwdGroup, this.front); } get articleCategories() { return select(Tags.articleCategories, this.front); } get titleGroup() { return select(Tags.titleGroup, this.front); } get articleTitle() { return select(Tags.articleTitle, this.titleGroup); } get articleSubtitle() { return select(Tags.subtitle, this.titleGroup); } get articleAltTitle() { return select(Tags.altTitle, this.titleGroup); } get abstract() { return select(Tags.abstract, this.front); } get abstracts() { return selectAll(Tags.abstract, this.front); } get contribGroup() { return select(Tags.contribGroup, this.front); } get contribGroups() { return selectAll(Tags.contribGroup, this.front); } get articleAuthors() { const contribs = selectAll(Tags.contrib, { type: 'contribGroups', children: this.contribGroups, }); const authors = contribs.filter((contrib) => { const contribType = contrib['contrib-type']; return !contribType || contribType === 'author'; }); return authors; } get articleAffiliations() { return selectAll(`${Tags.aff}[id]`, this.front); } get body() { return select(Tags.body, this.tree); } get back() { return select(Tags.back, this.tree); } get subArticles() { return selectAll(Tags.subArticle, this.tree); } /** First `ref-list` in back matter. */ get refList() { return this.refLists[0]; } /** All `ref-list` elements under `back`, in document order. */ get refLists() { if (!this.back) return []; return selectAll(Tags.refList, this.back); } /** Every `ref` from every `ref-list` under `back`, in document order. */ get references() { return this.refLists.flatMap((list) => selectAll(Tags.ref, list)); } sort() { var _a; if (this.articleMeta) { this.articleMeta.children = (_a = this.articleMeta) === null || _a === void 0 ? void 0 : _a.children.sort((a, b) => articleMetaOrder.findIndex((x) => x === a.type) - articleMetaOrder.findIndex((x) => x === b.type)); } selectAll('table-wrap', this.tree).forEach((tw) => { tw.children = tw.children.sort((a, b) => { var _a, _b; return ((_a = tableWrapOrder[a.type]) !== null && _a !== void 0 ? _a : -1) - ((_b = tableWrapOrder[b.type]) !== null && _b !== void 0 ? _b : -1); }); }); } serialize(opts) { var _a; this.sort(); const body = convertToXml(this.tree); const element = (opts === null || opts === void 0 ? void 0 : opts.bodyOnly) ? body : { type: 'element', elements: [ { type: 'doctype', doctype: this.doctype || DEFAULT_DOCTYPE, }, body, ], declaration: { attributes: (_a = this.declaration) !== null && _a !== void 0 ? _a : { version: '1.0', encoding: 'UTF-8' } }, }; const xml = serializeJatsXml(element, opts); return xml; } } Jats.PUB_HISTORY_PRIMARY_DATE_TYPES = new Set([ 'pub', 'published', 'publication', 'accepted', 'accept', ]); Jats.HISTORY_PRIMARY_DATE_TYPES = new Set(['accepted', 'accept']); function hasSingleArticle(element) { if (element.name === 'article') { return true; } if (element.name === 'pmc-articleset') { const children = significantChildElements(element.elements); return (children === null || children === void 0 ? void 0 : children.length) === 1 && children[0].name === 'article'; } return false; }