From ed0c4c447ea1f28b6d43359abbace1862b4046df Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Tue, 2 Sep 2025 10:41:41 +0100 Subject: [PATCH] Merge pull request #24468 from overleaf/mj-client-side-references [web] Perform ARS on client-side GitOrigin-RevId: 19703c82758cae450fe52463ad9612d3a2383ba0 --- .../src/Features/Project/ProjectController.js | 1 + services/web/config/settings.defaults.js | 1 + .../web/cypress/support/webpack.cypress.ts | 6 + .../ide-react/context/react-context-root.tsx | 28 +- .../ide-react/context/references-context.tsx | 94 +- .../references/basic-reference-index.ts | 24 + .../features/ide-react/references/bib2json.js | 1966 +++++++++++++++++ .../ide-react/references/reference-index.ts | 43 + .../ide-react/references/reference-indexer.ts | 137 ++ .../ide-react/references/references.worker.ts | 47 + .../js/features/ide-react/references/types.ts | 23 + .../pdf-preview/components/visual-preview.tsx | 3 + .../source-editor/extensions/language.ts | 4 + .../hooks/use-codemirror-scope.ts | 11 +- .../js/infrastructure/project-snapshot.ts | 84 +- .../references/basic-reference-index.test.ts | 104 + .../unit/references/reference-index.test.ts | 83 + .../unit/references/reference-indexer.spec.ts | 326 +++ .../codemirror-editor-autocomplete.spec.tsx | 5 + .../frontend/helpers/editor-providers.tsx | 23 + .../infrastructure/project-snapshot.test.ts | 53 + 21 files changed, 3040 insertions(+), 26 deletions(-) create mode 100644 services/web/frontend/js/features/ide-react/references/basic-reference-index.ts create mode 100644 services/web/frontend/js/features/ide-react/references/bib2json.js create mode 100644 services/web/frontend/js/features/ide-react/references/reference-index.ts create mode 100644 services/web/frontend/js/features/ide-react/references/reference-indexer.ts create mode 100644 services/web/frontend/js/features/ide-react/references/references.worker.ts create mode 100644 services/web/frontend/js/features/ide-react/references/types.ts create mode 100644 services/web/test/frontend/features/ide-react/unit/references/basic-reference-index.test.ts create mode 100644 services/web/test/frontend/features/ide-react/unit/references/reference-index.test.ts create mode 100644 services/web/test/frontend/features/ide-react/unit/references/reference-indexer.spec.ts diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index 0f0d9731d9..b7f4ac61a7 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -380,6 +380,7 @@ const _ProjectController = { 'word-count-client', 'editor-popup-ux-survey', 'new-editor-error-logs-redesign', + 'client-side-references', ].filter(Boolean) const getUserValues = async userId => diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index 2422de2a2e..5b8d4a1e04 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -1032,6 +1032,7 @@ module.exports = { integrationPanelComponents: [], referenceSearchSetting: [], errorLogsComponents: [], + referenceIndices: [], }, moduleImportSequence: [ diff --git a/services/web/cypress/support/webpack.cypress.ts b/services/web/cypress/support/webpack.cypress.ts index e70d303b21..77ed1105e1 100644 --- a/services/web/cypress/support/webpack.cypress.ts +++ b/services/web/cypress/support/webpack.cypress.ts @@ -46,6 +46,12 @@ const buildConfig = () => { '../../frontend/js/features/source-editor/hunspell/hunspell.worker' ) + // add entrypoint under '/' for references worker + addWorker( + 'references-worker', + '../../frontend/js/features/ide-react/references/references.worker.ts' + ) + // add entrypoints under '/' for pdfjs workers addWorker('pdfjs-dist', 'pdfjs-dist/build/pdf.worker.mjs') diff --git a/services/web/frontend/js/features/ide-react/context/react-context-root.tsx b/services/web/frontend/js/features/ide-react/context/react-context-root.tsx index 6236f2bfe2..d57d0eada4 100644 --- a/services/web/frontend/js/features/ide-react/context/react-context-root.tsx +++ b/services/web/frontend/js/features/ide-react/context/react-context-root.tsx @@ -87,13 +87,13 @@ export const ReactContextRoot: FC< - - - - - - - + + + + + + + @@ -113,13 +113,13 @@ export const ReactContextRoot: FC< - - - - - - - + + + + + + + diff --git a/services/web/frontend/js/features/ide-react/context/references-context.tsx b/services/web/frontend/js/features/ide-react/context/references-context.tsx index 3b250658f1..e698f00c49 100644 --- a/services/web/frontend/js/features/ide-react/context/references-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/references-context.tsx @@ -7,21 +7,31 @@ import { useCallback, useMemo, useState, + useRef, } from 'react' import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context' import { useConnectionContext } from '@/features/ide-react/context/connection-context' -import { postJSON } from '@/infrastructure/fetch-json' import { ShareJsDoc } from '@/features/ide-react/editor/share-js-doc' import { useFileTreeData } from '@/shared/context/file-tree-data-context' import { findDocEntityById } from '@/features/ide-react/util/find-doc-entity-by-id' import { IdeEvents } from '@/features/ide-react/create-ide-event-emitter' -import { debugConsole } from '@/utils/debugging' import useEventListener from '@/shared/hooks/use-event-listener' +import { useProjectContext } from '@/shared/context/project-context' +import { useEditorManagerContext } from './editor-manager-context' +import { signalWithTimeout } from '@/utils/abort-signal' +import { postJSON } from '@/infrastructure/fetch-json' +import { debugConsole } from '@/utils/debugging' +import { useFeatureFlag } from '@/shared/context/split-test-context' +import type { ReferenceIndexer } from '../references/reference-indexer' +import { AdvancedReferenceSearchResult } from '@/features/ide-react/references/types' export const ReferencesContext = createContext< | { referenceKeys: Set indexAllReferences: (shouldBroadcast: boolean) => Promise + searchLocalReferences: ( + query: string + ) => Promise } | undefined >(undefined) @@ -32,14 +42,18 @@ export const ReferencesProvider: FC = ({ const { fileTreeData } = useFileTreeData() const { eventEmitter, projectId } = useIdeReactContext() const { socket } = useConnectionContext() + const { projectSnapshot } = useProjectContext() + const { openDocs } = useEditorManagerContext() + const abortControllerRef = useRef(null) const [referenceKeys, setReferenceKeys] = useState(new Set()) + const clientSideReferences = useFeatureFlag('client-side-references') const [existingIndexHash, setExistingIndexHash] = useState< Record >({}) - const indexAllReferences = useCallback( + const indexAllReferencesServerside = useCallback( async (shouldBroadcast: boolean) => { return postJSON(`/project/${projectId}/references/indexAll`, { body: { @@ -57,6 +71,54 @@ export const ReferencesProvider: FC = ({ [projectId] ) + const indexerRef = useRef | null>(null) + if (clientSideReferences && indexerRef.current === null) { + indexerRef.current = import('../references/reference-indexer').then( + m => new m.ReferenceIndexer() + ) + } + + const indexAllReferencesLocally = useCallback( + async (shouldBroadcast: boolean) => { + abortControllerRef.current?.abort() + + if (!indexerRef.current) { + return + } + + abortControllerRef.current = new AbortController() + const signal = abortControllerRef.current.signal + + await openDocs.awaitBufferedOps(signalWithTimeout(signal, 5000)) + await projectSnapshot.refresh() + + if (signal.aborted) { + return + } + + const indexer = await indexerRef.current + const keys = await indexer.updateFromSnapshot(projectSnapshot, { signal }) + if (signal.aborted) { + return + } + setReferenceKeys(keys) + if (shouldBroadcast) { + // Inform other clients about change in keys + await postJSON(`/project/${projectId}/references/indexAll`, { + body: { shouldBroadcast: true }, + }).catch(error => { + // allow the request to fail + debugConsole.error(error) + }) + } + }, + [projectSnapshot, openDocs, projectId] + ) + + const indexAllReferences = clientSideReferences + ? indexAllReferencesLocally + : indexAllReferencesServerside + const indexReferencesIfDocModified = useCallback( (doc: ShareJsDoc, shouldBroadcast: boolean) => { // avoid reindexing references if the bib file has not changed since the @@ -115,9 +177,13 @@ export const ReferencesProvider: FC = ({ // We only need to grab the references when the editor first loads, // not on every reconnect socket.on('references:keys:updated', (keys, allDocs) => { - setReferenceKeys(oldKeys => - allDocs ? new Set(keys) : new Set([...oldKeys, ...keys]) - ) + if (clientSideReferences) { + indexAllReferences(false) + } else { + setReferenceKeys(oldDocs => + allDocs ? new Set(keys) : new Set([...oldDocs, ...keys]) + ) + } }) indexAllReferences(false) } @@ -127,14 +193,26 @@ export const ReferencesProvider: FC = ({ return () => { eventEmitter.off('project:joined', handleProjectJoined) } - }, [eventEmitter, indexAllReferences, socket]) + }, [eventEmitter, indexAllReferences, socket, clientSideReferences]) + + const searchLocalReferences = useCallback( + async (query: string): Promise => { + if (!indexerRef.current) { + return { hits: [] } + } + const indexer = await indexerRef.current + return await indexer.search(query) + }, + [] + ) const value = useMemo( () => ({ referenceKeys, indexAllReferences, + searchLocalReferences, }), - [indexAllReferences, referenceKeys] + [indexAllReferences, referenceKeys, searchLocalReferences] ) return ( diff --git a/services/web/frontend/js/features/ide-react/references/basic-reference-index.ts b/services/web/frontend/js/features/ide-react/references/basic-reference-index.ts new file mode 100644 index 0000000000..0aa2f02af8 --- /dev/null +++ b/services/web/frontend/js/features/ide-react/references/basic-reference-index.ts @@ -0,0 +1,24 @@ +import { ReferenceIndex } from './reference-index' +import { Changes } from './types' + +export default class BasicReferenceIndex extends ReferenceIndex { + fileIndex: Map> = new Map() + + updateIndex({ updates, deletes }: Changes): Set { + for (const path of deletes) { + this.fileIndex.delete(path) + } + for (const { path, content } of updates) { + const fileReferences: Set = new Set() + const entries = this.parseEntries(content) + for (const entry of entries) { + fileReferences.add(entry.EntryKey) + } + this.fileIndex.set(path, fileReferences) + } + this.keys = new Set( + this.fileIndex.values().flatMap(entry => Array.from(entry)) + ) + return this.keys + } +} diff --git a/services/web/frontend/js/features/ide-react/references/bib2json.js b/services/web/frontend/js/features/ide-react/references/bib2json.js new file mode 100644 index 0000000000..b105ff3f7e --- /dev/null +++ b/services/web/frontend/js/features/ide-react/references/bib2json.js @@ -0,0 +1,1966 @@ +/* eslint-disable */ +/** + * Parser.js + * Copyright 2012-13 Mayank Lahiri + * mlahiri@gmail.com + * Released under the BSD License. + * + * Modifications 2016 Sharelatex + * Modifications 2017-2020 Overleaf + * + * A forgiving Bibtex parser that can: + * + * (1) operate in streaming or block mode, extracting entries as dictionaries. + * (2) convert Latex special characters to UTF-8. + * (3) best-effort parse malformed entries. + * (4) run in a CommonJS environment or a browser, without any dependencies. + * (5) be advanced-compiled by Google Closure Compiler. + * + * Handwritten as a labor of love, not auto-generated from a grammar. + * + * Modes of usage: + * + * (1) Synchronous, string + * + * var entries = BibtexParser(text); + * console.log(entries); + * + * (2) Asynchronous, stream + * + * function entryCallback(entry) { console.log(entry); } + * var parser = new BibtexParser(entryCallback); + * parser.parse(chunk1); + * parser.parse(chunk2); + * ... + * + * @param {text|function(Object)} arg0 Either a Bibtex string or callback + * function for processing parsed entries. + * @param {array} allowedKeys optimization: do not output key/value pairs that are not on this allowlist + * @constructor + */ +function BibtexParser(arg0, allowedKeys) { + // Determine how this function is to be used + if (typeof arg0 === 'string') { + // Passed a string, synchronous call without 'new' + const entries = [] + function accumulator(entry) { + entries.push(entry) + } + const parser = new BibtexParser(accumulator, allowedKeys) + parser.parse(arg0) + return { + entries, + errors: parser.getErrors(), + } + } + if (typeof arg0 !== 'function') { + throw 'Invalid parser construction.' + } + this.ALLOWEDKEYS_ = allowedKeys || [] + this.reset_(arg0) + this.initMacros_() + return this +} + +/** @enum {number} */ +BibtexParser.prototype.STATES_ = { + ENTRY_OR_JUNK: 0, + OBJECT_TYPE: 1, + ENTRY_KEY: 2, + KV_KEY: 3, + EQUALS: 4, + KV_VALUE: 5, +} +BibtexParser.prototype.reset_ = function (arg0) { + /** @private */ this.DATA_ = {} + /** @private */ this.CALLBACK_ = arg0 + /** @private */ this.CHAR_ = 0 + /** @private */ this.LINE_ = 1 + /** @private */ this.CHAR_IN_LINE_ = 0 + /** @private */ this.SKIPWS_ = true + /** @private */ this.SKIPCOMMENT_ = true + /** @private */ this.SKIPKVPAIR_ = false + /** @private */ this.PARSETMP_ = {} + /** @private */ this.SKIPTILLEOL_ = false + /** @private */ this.VALBRACES_ = null + /** @private */ this.BRACETYPE_ = null + /** @private */ this.BRACECOUNT_ = 0 + /** @private */ this.STATE_ = this.STATES_.ENTRY_OR_JUNK + /** @private */ this.ERRORS_ = [] +} +/** @private */ BibtexParser.prototype.ENTRY_TYPES_ = { + inproceedings: 1, + proceedings: 2, + article: 3, + techreport: 4, + misc: 5, + mastersthesis: 6, + book: 7, + phdthesis: 8, + incollection: 9, + unpublished: 10, + inbook: 11, + manual: 12, + periodical: 13, + booklet: 14, + masterthesis: 15, + conference: 16, + /* additional fields from biblatex */ + artwork: 17, + audio: 18, + bibnote: 19, + bookinbook: 20, + collection: 21, + commentary: 22, + customa: 23, + customb: 24, + customc: 25, + customd: 26, + custome: 27, + customf: 28, + image: 29, + inreference: 30, + jurisdiction: 31, + legal: 32, + legislation: 33, + letter: 34, + movie: 35, + music: 36, + mvbook: 37, + mvcollection: 38, + mvproceedings: 39, + mvreference: 40, + online: 41, + patent: 42, + performance: 43, + reference: 44, + report: 45, + review: 46, + set: 47, + software: 48, + standard: 49, + suppbook: 50, + suppcollection: 51, + thesis: 52, + video: 53, +} +BibtexParser.prototype.initMacros_ = function () { + // macros can be extended by the user via + // @string { macroName = "macroValue" } + /** @private */ this.MACROS_ = { + jan: 'January', + feb: 'February', + mar: 'March', + apr: 'April', + may: 'May', + jun: 'June', + jul: 'July', + aug: 'August', + sep: 'September', + oct: 'October', + nov: 'November', + dec: 'December', + Jan: 'January', + Feb: 'February', + Mar: 'March', + Apr: 'April', + May: 'May', + Jun: 'June', + Jul: 'July', + Aug: 'August', + Sep: 'September', + Oct: 'October', + Nov: 'November', + Dec: 'December', + } +} + +/** + * Gets an array of all errors encountered during parsing. + * Array entries are of the format: + * [ line number, character in line, character in stream, error text ] + * + * @returns Array + * @public + */ +BibtexParser.prototype.getErrors = function () { + return this.ERRORS_ +} + +/** + * Processes a chunk of data + * @public + */ +BibtexParser.prototype.parse = function (chunk) { + for (let i = 0; i < chunk.length; i++) this.processCharacter_(chunk[i]) +} + +/** + * Logs error at current stream position. + * + * @private + */ +BibtexParser.prototype.error_ = function (text) { + this.ERRORS_.push([this.LINE_, this.CHAR_IN_LINE_, this.CHAR_, text]) +} + +/** + * Called after an entire entry has been parsed from the stream. + * Performs post-processing and invokes the entry callback pointed to by + * this.CALLBACK_. Parsed (but unprocessed) entry data is in this.DATA_. + */ +BibtexParser.prototype.processEntry_ = function () { + const data = this.DATA_ + if (data.Fields) + for (const f in data.Fields) { + let raw = data.Fields[f] + + // Convert Latex/Bibtex special characters to UTF-8 equivalents + for (let i = 0; i < this.CHARCONV_.length; i++) { + const re = this.CHARCONV_[i][0] + const rep = this.CHARCONV_[i][1] + raw = raw.replace(re, rep) + } + + // Basic substitutions + raw = raw + .replace(/[\n\r\t]/g, ' ') + .replace(/\s\s+/g, ' ') + .replace(/^\s+|\s+$/g, '') + + // Remove braces and backslashes + const len = raw.length + let processedArr = [] + for (let i = 0; i < len; i++) { + let c = raw[i] + let skip = false + if (c == '\\' && i < len - 1) c = raw[++i] + else { + if (c == '{' || c == '}') skip = true + } + if (!skip) processedArr.push(c) + } + data.Fields[f] = processedArr.join('') + processedArr = null + } + + if (data.ObjectType == 'string') { + for (const f in data.Fields) { + this.MACROS_[f] = data.Fields[f] + } + } else { + // Parsed a new Bibtex entry + this.CALLBACK_(data) + } +} + +/** + * Processes next character in the stream, invoking the callback after + * each entry has been found and processed. + * + * @private + * @param {string} c Next character in input stream + */ +BibtexParser.prototype.processCharacter_ = function (c) { + // Housekeeping + this.CHAR_++ + this.CHAR_IN_LINE_++ + if (c == '\n') { + this.LINE_++ + this.CHAR_IN_LINE_ = 1 + } + + // Convenience states for skipping whitespace when needed + if (this.SKIPTILLEOL_) { + if (c == '\n') this.SKIPTILLEOL_ = false + return + } + if (this.SKIPCOMMENT_ && c == '%') { + this.SKIPTILLEOL_ = true + return + } + if (this.SKIPWS_ && /\s/.test(c)) return + this.SKIPWS_ = false + this.SKIPCOMMENT_ = false + this.SKIPTILLEOL_ = false + + // Main state machine + let AnotherIteration = true + while (AnotherIteration) { + // console.log(this.LINE_, this.CHAR_IN_LINE_, this.STATE_, c) + AnotherIteration = false + switch (this.STATE_) { + // -- Scan for an object marker ('@') + // -- Reset temporary data structure in case previous entry was garbled + case this.STATES_.ENTRY_OR_JUNK: + if (c == '@') { + // SUCCESS: Parsed a valid start-of-object marker. + // NEXT_STATE: OBJECT_TYPE + this.STATE_ = this.STATES_.OBJECT_TYPE + this.DATA_ = { + ObjectType: '', + } + } + this.BRACETYPE_ = null + this.SKIPWS_ = true + this.SKIPCOMMENT_ = true + break + + // Start at first non-whitespace character after start-of-object '@' + // -- Accept [A-Za-z], break on non-matching character + // -- Populate this.DATA_.EntryType and this.DATA_.ObjectType + case this.STATES_.OBJECT_TYPE: + if (/[A-Za-z]/.test(c)) { + this.DATA_.ObjectType += c.toLowerCase() + this.SKIPWS_ = true + this.SKIPCOMMENT_ = true + } else { + // Break from state and validate object type + const ot = this.DATA_.ObjectType + if (ot == 'comment') { + this.STATE_ = this.STATES_.ENTRY_OR_JUNK + } else { + if (ot == 'string') { + this.DATA_.ObjectType = ot + this.DATA_.Fields = {} + this.BRACETYPE_ = c + this.BRACECOUNT_ = 1 + this.STATE_ = this.STATES_.KV_KEY + this.SKIPWS_ = true + this.SKIPCOMMENT_ = true + this.PARSETMP_ = { + Key: '', + } + } else { + if (ot == 'preamble') { + this.STATE_ = this.STATES_.ENTRY_OR_JUNK + } else { + if (ot in this.ENTRY_TYPES_) { + // SUCCESS: Parsed a valid object type. + // NEXT_STATE: ENTRY_KEY + this.DATA_.ObjectType = 'entry' + this.DATA_.EntryType = ot + this.DATA_.EntryKey = '' + this.STATE_ = this.STATES_.ENTRY_KEY + AnotherIteration = true + } else { + // ERROR: Unrecognized object type. + // NEXT_STATE: ENTRY_OR_JUNK + this.error_( + 'Unrecognized object type: "' + this.DATA_.ObjectType + '"' + ) + this.STATE_ = this.STATES_.ENTRY_OR_JUNK + } + } + } + } + } + break + + // Start at first non-alphabetic character after an entry type + // -- Populate this.DATA_.EntryKey + case this.STATES_.ENTRY_KEY: + if ((c === '{' || c === '(') && this.BRACETYPE_ == null) { + this.BRACETYPE_ = c + this.BRACECOUNT_ = 1 + this.SKIPWS_ = true + this.SKIPCOMMENT_ = true + break + } + if (/[,%\s]/.test(c)) { + if (this.DATA_.EntryKey.length < 1) { + // Skip comments and whitespace before entry key + this.SKIPWS_ = true + this.SKIPCOMMENT_ = true + } else { + if (this.BRACETYPE_ == null) { + // ERROR: No opening brace for object + // NEXT_STATE: ENTRY_OR_JUNK + this.error_('No opening brace for object.') + this.STATE_ = this.STATES_.ENTRY_OR_JUNK + } else { + // SUCCESS: Parsed an entry key + // NEXT_STATE: KV_KEY + this.SKIPWS_ = true + this.SKIPCOMMENT_ = true + AnotherIteration = true + this.STATE_ = this.STATES_.KV_KEY + this.PARSETMP_.Key = '' + this.DATA_.Fields = {} + } + } + } else { + this.DATA_.EntryKey += c + this.SKIPWS_ = false + this.SKIPCOMMENT_ = false + } + break + + // Start at first non-whitespace/comment character after entry key. + // -- Populate this.PARSETMP_.Key + case this.STATES_.KV_KEY: + // Test for end of entry + if ( + (c == '}' && this.BRACETYPE_ == '{') || + (c == ')' && this.BRACETYPE_ == '(') + ) { + // SUCCESS: Parsed an entry, possible incomplete + // NEXT_STATE: ENTRY_OR_JUNK + this.processEntry_() + this.SKIPWS_ = true + this.SKIPCOMMENT_ = true + this.STATE_ = this.STATES_.ENTRY_OR_JUNK + break + } + if (/[\-A-Za-z:]/.test(c)) { + // Add to key + this.PARSETMP_.Key += c + this.SKIPWS_ = false + this.SKIPCOMMENT_ = false + } else { + // Either end of key or we haven't encountered start of key + if (this.PARSETMP_.Key.length < 1) { + // Keep going till we see a key + this.SKIPWS_ = true + this.SKIPCOMMENT_ = true + } else { + // SUCCESS: Found full key in K/V pair + // NEXT_STATE: EQUALS + this.SKIPWS_ = true + this.SKIPCOMMENT_ = true + this.STATE_ = this.STATES_.EQUALS + AnotherIteration = true + + if (this.DATA_.ObjectType !== 'string') { + // this entry is not a macro + // normalize the key to lower case + this.PARSETMP_.Key = this.PARSETMP_.Key.toLowerCase() + + // optimization: skip key/value pairs that are not on the allowlist + this.SKIPKVPAIR_ = + // has allowedKeys set + this.ALLOWEDKEYS_.length && + // key is not on the allowlist + this.ALLOWEDKEYS_.indexOf(this.PARSETMP_.Key) === -1 + } else { + this.SKIPKVPAIR_ = false + } + } + } + break + + // Start at first non-alphabetic character after K/V pair key. + case this.STATES_.EQUALS: + if ( + (c == '}' && this.BRACETYPE_ == '{') || + (c == ')' && this.BRACETYPE_ == '(') + ) { + // ERROR: K/V pair with key but no value + // NEXT_STATE: ENTRY_OR_JUNK + this.error_( + 'Key-value pair has key "' + this.PARSETMP_.Key + '", but no value.' + ) + this.processEntry_() + this.SKIPWS_ = true + this.SKIPCOMMENT_ = true + this.STATE_ = this.STATES_.ENTRY_OR_JUNK + break + } + if (c == '=') { + // SUCCESS: found an equal signs separating key and value + // NEXT_STATE: KV_VALUE + this.SKIPWS_ = true + this.SKIPCOMMENT_ = true + this.STATE_ = this.STATES_.KV_VALUE + this.PARSETMP_.Value = [] + this.VALBRACES_ = { '"': [], '{': [] } + } + break + + // Start at first non-whitespace/comment character after '=' + // -- Populate this.PARSETMP_.Value + case this.STATES_.KV_VALUE: + const delim = this.VALBRACES_ + // valueCharsArray is the list of characters that make up the + // current value + const valueCharsArray = this.PARSETMP_.Value + let doneParsingValue = false + + // Test for special characters + if (c == '"' || c == '{' || c == '}' || c == ',') { + if (c == ',') { + // This comma can mean: + // (1) just another comma literal + // (2) end of a macro reference + if (delim['"'].length + delim['{'].length === 0) { + // end of a macro reference + const macro = this.PARSETMP_.Value.join('').trim() + if (macro in this.MACROS_) { + // Successful macro reference + this.PARSETMP_.Value = [this.MACROS_[macro]] + } else { + // Reference to an undefined macro + this.error_('Reference to an undefined macro: ' + macro) + } + doneParsingValue = true + } + } + if (c == '"') { + // This quote can mean: + // (1) opening delimiter + // (2) closing delimiter + // (3) literal, if we have a '{' on the stack + if (delim['"'].length + delim['{'].length === 0) { + // opening delimiter + delim['"'].push(this.CHAR_) + this.SKIPWS_ = false + this.SKIPCOMMENT_ = false + break + } + if ( + delim['"'].length == 1 && + delim['{'].length == 0 && + (valueCharsArray.length == 0 || + valueCharsArray[valueCharsArray.length - 1] != '\\') + ) { + // closing delimiter + doneParsingValue = true + } else { + // literal, add to value + } + } + if (c == '{') { + // This brace can mean: + // (1) opening delimiter + // (2) stacked verbatim delimiter + if ( + valueCharsArray.length == 0 || + valueCharsArray[valueCharsArray.length - 1] != '\\' + ) { + delim['{'].push(this.CHAR_) + this.SKIPWS_ = false + this.SKIPCOMMENT_ = false + } else { + // literal, add to value + } + } + if (c == '}') { + // This brace can mean: + // (1) closing delimiter + // (2) closing stacked verbatim delimiter + // (3) end of object definition if value was a macro + if (delim['"'].length + delim['{'].length === 0) { + // end of object definition, after macro + const macro = this.PARSETMP_.Value.join('').trim() + if (macro in this.MACROS_) { + // Successful macro reference + this.PARSETMP_.Value = [this.MACROS_[macro]] + } else { + // Reference to an undefined macro + this.error_('Reference to an undefined macro: ' + macro) + } + AnotherIteration = true + doneParsingValue = true + } else { + // sometimes imported bibs will have {\},{\\}, {\\\}, {\\\\}, etc for whitespace, + // which would otherwise break the parsing. we watch for these occurences of + // 1+ backslashes in an empty bracket pair to gracefully handle the malformed bib file + const doubleSlash = + valueCharsArray.length >= 2 && + valueCharsArray[valueCharsArray.length - 1] === '\\' && // for \\} + valueCharsArray[valueCharsArray.length - 2] === '\\' + const singleSlash = + valueCharsArray.length >= 2 && + valueCharsArray[valueCharsArray.length - 1] === '\\' && // for {\} + valueCharsArray[valueCharsArray.length - 2] === '{' + + if ( + valueCharsArray.length == 0 || + valueCharsArray[valueCharsArray.length - 1] != '\\' || // for } + doubleSlash || + singleSlash + ) { + if (delim['{'].length > 0) { + // pop stack for stacked verbatim delimiter + delim['{'].splice(delim['{'].length - 1, 1) + if (delim['{'].length + delim['"'].length == 0) { + // closing delimiter + doneParsingValue = true + } else { + // end verbatim block + } + } + } else { + // literal, add to value + } + } + } + } + + // If here, then we are either done parsing the value or + // have a literal that should be added to the value. + if (doneParsingValue) { + // SUCCESS: value parsed + // NEXT_STATE: KV_KEY + this.SKIPWS_ = true + this.SKIPCOMMENT_ = true + this.STATE_ = this.STATES_.KV_KEY + if (!this.SKIPKVPAIR_) { + this.DATA_.Fields[this.PARSETMP_.Key] = + this.PARSETMP_.Value.join('') + } + this.PARSETMP_ = { Key: '' } + this.VALBRACES_ = null + } else { + this.PARSETMP_.Value.push(c) + if (this.PARSETMP_.Value.length >= 1000 * 20) { + this.PARSETMP_.Value = [] + this.STATE_ = this.STATES_.ENTRY_OR_JUNK + this.DATA_ = { ObjectType: '' } + this.BRACETYPE_ = null + this.SKIPWS_ = true + this.SKIPCOMMENT_ = true + } + } + break + } // end switch (this.STATE_) + } // end while(AnotherIteration) +} // end function processCharacter + +/** @private */ BibtexParser.prototype.CHARCONV_ = [ + [/\\space /g, '\u0020'], + [/\\textdollar /g, '\u0024'], + [/\\textquotesingle /g, '\u0027'], + [/\\ast /g, '\u002A'], + [/\\textbackslash /g, '\u005C'], + [/\\\^\{\}/g, '\u005E'], + [/\\textasciigrave /g, '\u0060'], + [/\\lbrace /g, '\u007B'], + [/\\vert /g, '\u007C'], + [/\\rbrace /g, '\u007D'], + [/\\textasciitilde /g, '\u007E'], + [/\\textexclamdown /g, '\u00A1'], + [/\\textcent /g, '\u00A2'], + [/\\textsterling /g, '\u00A3'], + [/\\textcurrency /g, '\u00A4'], + [/\\textyen /g, '\u00A5'], + [/\\textbrokenbar /g, '\u00A6'], + [/\\textsection /g, '\u00A7'], + [/\\textasciidieresis /g, '\u00A8'], + [/\\textcopyright /g, '\u00A9'], + [/\\textordfeminine /g, '\u00AA'], + [/\\guillemotleft /g, '\u00AB'], + [/\\lnot /g, '\u00AC'], + [/\\textregistered /g, '\u00AE'], + [/\\textasciimacron /g, '\u00AF'], + [/\\textdegree /g, '\u00B0'], + [/\\pm /g, '\u00B1'], + [/\\textasciiacute /g, '\u00B4'], + [/\\mathrm\{\\mu\}/g, '\u00B5'], + [/\\textparagraph /g, '\u00B6'], + [/\\cdot /g, '\u00B7'], + [/\\c\{\}/g, '\u00B8'], + [/\\textordmasculine /g, '\u00BA'], + [/\\guillemotright /g, '\u00BB'], + [/\\textonequarter /g, '\u00BC'], + [/\\textonehalf /g, '\u00BD'], + [/\\textthreequarters /g, '\u00BE'], + [/\\textquestiondown /g, '\u00BF'], + [/\\`\{A\}/g, '\u00C0'], + [/\\'\{A\}/g, '\u00C1'], + [/\\\^\{A\}/g, '\u00C2'], + [/\\~\{A\}/g, '\u00C3'], + [/\\"\{A\}/g, '\u00C4'], + [/\\AA /g, '\u00C5'], + [/\\AE /g, '\u00C6'], + [/\\c\{C\}/g, '\u00C7'], + [/\\`\{E\}/g, '\u00C8'], + [/\\'\{E\}/g, '\u00C9'], + [/\\\^\{E\}/g, '\u00CA'], + [/\\"\{E\}/g, '\u00CB'], + [/\\`\{I\}/g, '\u00CC'], + [/\\'\{I\}/g, '\u00CD'], + [/\\\^\{I\}/g, '\u00CE'], + [/\\"\{I\}/g, '\u00CF'], + [/\\DH /g, '\u00D0'], + [/\\~\{N\}/g, '\u00D1'], + [/\\`\{O\}/g, '\u00D2'], + [/\\'\{O\}/g, '\u00D3'], + [/\\\^\{O\}/g, '\u00D4'], + [/\\~\{O\}/g, '\u00D5'], + [/\\"\{O\}/g, '\u00D6'], + [/\\texttimes /g, '\u00D7'], + [/\\O /g, '\u00D8'], + [/\\`\{U\}/g, '\u00D9'], + [/\\'\{U\}/g, '\u00DA'], + [/\\\^\{U\}/g, '\u00DB'], + [/\\"\{U\}/g, '\u00DC'], + [/\\'\{Y\}/g, '\u00DD'], + [/\\TH /g, '\u00DE'], + [/\\ss /g, '\u00DF'], + [/\\`\{a\}/g, '\u00E0'], + [/\\'\{a\}/g, '\u00E1'], + [/\\\^\{a\}/g, '\u00E2'], + [/\\~\{a\}/g, '\u00E3'], + [/\\"\{a\}/g, '\u00E4'], + [/\\aa /g, '\u00E5'], + [/\\ae /g, '\u00E6'], + [/\\c\{c\}/g, '\u00E7'], + [/\\`\{e\}/g, '\u00E8'], + [/\\'\{e\}/g, '\u00E9'], + [/\\\^\{e\}/g, '\u00EA'], + [/\\"\{e\}/g, '\u00EB'], + [/\\`\{\\i\}/g, '\u00EC'], + [/\\'\{\\i\}/g, '\u00ED'], + [/\\\^\{\\i\}/g, '\u00EE'], + [/\\"\{\\i\}/g, '\u00EF'], + [/\\dh /g, '\u00F0'], + [/\\~\{n\}/g, '\u00F1'], + [/\\`\{o\}/g, '\u00F2'], + [/\\'\{o\}/g, '\u00F3'], + [/\\\^\{o\}/g, '\u00F4'], + [/\\~\{o\}/g, '\u00F5'], + [/\\"\{o\}/g, '\u00F6'], + [/\\div /g, '\u00F7'], + [/\\o /g, '\u00F8'], + [/\\`\{u\}/g, '\u00F9'], + [/\\'\{u\}/g, '\u00FA'], + [/\\\^\{u\}/g, '\u00FB'], + [/\\"\{u\}/g, '\u00FC'], + [/\\'\{y\}/g, '\u00FD'], + [/\\th /g, '\u00FE'], + [/\\"\{y\}/g, '\u00FF'], + [/\\=\{A\}/g, '\u0100'], + [/\\=\{a\}/g, '\u0101'], + [/\\u\{A\}/g, '\u0102'], + [/\\u\{a\}/g, '\u0103'], + [/\\k\{A\}/g, '\u0104'], + [/\\k\{a\}/g, '\u0105'], + [/\\'\{C\}/g, '\u0106'], + [/\\'\{c\}/g, '\u0107'], + [/\\\^\{C\}/g, '\u0108'], + [/\\\^\{c\}/g, '\u0109'], + [/\\.\{C\}/g, '\u010A'], + [/\\.\{c\}/g, '\u010B'], + [/\\v\{C\}/g, '\u010C'], + [/\\v\{c\}/g, '\u010D'], + [/\\v\{D\}/g, '\u010E'], + [/\\v\{d\}/g, '\u010F'], + [/\\DJ /g, '\u0110'], + [/\\dj /g, '\u0111'], + [/\\=\{E\}/g, '\u0112'], + [/\\=\{e\}/g, '\u0113'], + [/\\u\{E\}/g, '\u0114'], + [/\\u\{e\}/g, '\u0115'], + [/\\.\{E\}/g, '\u0116'], + [/\\.\{e\}/g, '\u0117'], + [/\\k\{E\}/g, '\u0118'], + [/\\k\{e\}/g, '\u0119'], + [/\\v\{E\}/g, '\u011A'], + [/\\v\{e\}/g, '\u011B'], + [/\\\^\{G\}/g, '\u011C'], + [/\\\^\{g\}/g, '\u011D'], + [/\\u\{G\}/g, '\u011E'], + [/\\u\{g\}/g, '\u011F'], + [/\\.\{G\}/g, '\u0120'], + [/\\.\{g\}/g, '\u0121'], + [/\\c\{G\}/g, '\u0122'], + [/\\c\{g\}/g, '\u0123'], + [/\\\^\{H\}/g, '\u0124'], + [/\\\^\{h\}/g, '\u0125'], + [/\\Elzxh /g, '\u0127'], + [/\\~\{I\}/g, '\u0128'], + [/\\~\{\\i\}/g, '\u0129'], + [/\\=\{I\}/g, '\u012A'], + [/\\=\{\\i\}/g, '\u012B'], + [/\\u\{I\}/g, '\u012C'], + [/\\u\{\\i\}/g, '\u012D'], + [/\\k\{I\}/g, '\u012E'], + [/\\k\{i\}/g, '\u012F'], + [/\\.\{I\}/g, '\u0130'], + [/\\i /g, '\u0131'], + [/\\\^\{J\}/g, '\u0134'], + [/\\\^\{\\j\}/g, '\u0135'], + [/\\c\{K\}/g, '\u0136'], + [/\\c\{k\}/g, '\u0137'], + [/\\'\{L\}/g, '\u0139'], + [/\\'\{l\}/g, '\u013A'], + [/\\c\{L\}/g, '\u013B'], + [/\\c\{l\}/g, '\u013C'], + [/\\v\{L\}/g, '\u013D'], + [/\\v\{l\}/g, '\u013E'], + [/\\L /g, '\u0141'], + [/\\l /g, '\u0142'], + [/\\'\{N\}/g, '\u0143'], + [/\\'\{n\}/g, '\u0144'], + [/\\c\{N\}/g, '\u0145'], + [/\\c\{n\}/g, '\u0146'], + [/\\v\{N\}/g, '\u0147'], + [/\\v\{n\}/g, '\u0148'], + [/\\NG /g, '\u014A'], + [/\\ng /g, '\u014B'], + [/\\=\{O\}/g, '\u014C'], + [/\\=\{o\}/g, '\u014D'], + [/\\u\{O\}/g, '\u014E'], + [/\\u\{o\}/g, '\u014F'], + [/\\H\{O\}/g, '\u0150'], + [/\\H\{o\}/g, '\u0151'], + [/\\OE /g, '\u0152'], + [/\\oe /g, '\u0153'], + [/\\'\{R\}/g, '\u0154'], + [/\\'\{r\}/g, '\u0155'], + [/\\c\{R\}/g, '\u0156'], + [/\\c\{r\}/g, '\u0157'], + [/\\v\{R\}/g, '\u0158'], + [/\\v\{r\}/g, '\u0159'], + [/\\'\{S\}/g, '\u015A'], + [/\\'\{s\}/g, '\u015B'], + [/\\\^\{S\}/g, '\u015C'], + [/\\\^\{s\}/g, '\u015D'], + [/\\c\{S\}/g, '\u015E'], + [/\\c\{s\}/g, '\u015F'], + [/\\v\{S\}/g, '\u0160'], + [/\\v\{s\}/g, '\u0161'], + [/\\c\{T\}/g, '\u0162'], + [/\\c\{t\}/g, '\u0163'], + [/\\v\{T\}/g, '\u0164'], + [/\\v\{t\}/g, '\u0165'], + [/\\~\{U\}/g, '\u0168'], + [/\\~\{u\}/g, '\u0169'], + [/\\=\{U\}/g, '\u016A'], + [/\\=\{u\}/g, '\u016B'], + [/\\u\{U\}/g, '\u016C'], + [/\\u\{u\}/g, '\u016D'], + [/\\r\{U\}/g, '\u016E'], + [/\\r\{u\}/g, '\u016F'], + [/\\H\{U\}/g, '\u0170'], + [/\\H\{u\}/g, '\u0171'], + [/\\k\{U\}/g, '\u0172'], + [/\\k\{u\}/g, '\u0173'], + [/\\\^\{W\}/g, '\u0174'], + [/\\\^\{w\}/g, '\u0175'], + [/\\\^\{Y\}/g, '\u0176'], + [/\\\^\{y\}/g, '\u0177'], + [/\\"\{Y\}/g, '\u0178'], + [/\\'\{Z\}/g, '\u0179'], + [/\\'\{z\}/g, '\u017A'], + [/\\.\{Z\}/g, '\u017B'], + [/\\.\{z\}/g, '\u017C'], + [/\\v\{Z\}/g, '\u017D'], + [/\\v\{z\}/g, '\u017E'], + [/\\texthvlig /g, '\u0195'], + [/\\textnrleg /g, '\u019E'], + [/\\eth /g, '\u01AA'], + [/\\textdoublepipe /g, '\u01C2'], + [/\\'\{g\}/g, '\u01F5'], + [/\\Elztrna /g, '\u0250'], + [/\\Elztrnsa /g, '\u0252'], + [/\\Elzopeno /g, '\u0254'], + [/\\Elzrtld /g, '\u0256'], + [/\\Elzschwa /g, '\u0259'], + [/\\varepsilon /g, '\u025B'], + [/\\Elzpgamma /g, '\u0263'], + [/\\Elzpbgam /g, '\u0264'], + [/\\Elztrnh /g, '\u0265'], + [/\\Elzbtdl /g, '\u026C'], + [/\\Elzrtll /g, '\u026D'], + [/\\Elztrnm /g, '\u026F'], + [/\\Elztrnmlr /g, '\u0270'], + [/\\Elzltlmr /g, '\u0271'], + [/\\Elzltln /g, '\u0272'], + [/\\Elzrtln /g, '\u0273'], + [/\\Elzclomeg /g, '\u0277'], + [/\\textphi /g, '\u0278'], + [/\\Elztrnr /g, '\u0279'], + [/\\Elztrnrl /g, '\u027A'], + [/\\Elzrttrnr /g, '\u027B'], + [/\\Elzrl /g, '\u027C'], + [/\\Elzrtlr /g, '\u027D'], + [/\\Elzfhr /g, '\u027E'], + [/\\Elzrtls /g, '\u0282'], + [/\\Elzesh /g, '\u0283'], + [/\\Elztrnt /g, '\u0287'], + [/\\Elzrtlt /g, '\u0288'], + [/\\Elzpupsil /g, '\u028A'], + [/\\Elzpscrv /g, '\u028B'], + [/\\Elzinvv /g, '\u028C'], + [/\\Elzinvw /g, '\u028D'], + [/\\Elztrny /g, '\u028E'], + [/\\Elzrtlz /g, '\u0290'], + [/\\Elzyogh /g, '\u0292'], + [/\\Elzglst /g, '\u0294'], + [/\\Elzreglst /g, '\u0295'], + [/\\Elzinglst /g, '\u0296'], + [/\\textturnk /g, '\u029E'], + [/\\Elzdyogh /g, '\u02A4'], + [/\\Elztesh /g, '\u02A7'], + [/\\textasciicaron /g, '\u02C7'], + [/\\Elzverts /g, '\u02C8'], + [/\\Elzverti /g, '\u02CC'], + [/\\Elzlmrk /g, '\u02D0'], + [/\\Elzhlmrk /g, '\u02D1'], + [/\\Elzsbrhr /g, '\u02D2'], + [/\\Elzsblhr /g, '\u02D3'], + [/\\Elzrais /g, '\u02D4'], + [/\\Elzlow /g, '\u02D5'], + [/\\textasciibreve /g, '\u02D8'], + [/\\textperiodcentered /g, '\u02D9'], + [/\\r\{\}/g, '\u02DA'], + [/\\k\{\}/g, '\u02DB'], + [/\\texttildelow /g, '\u02DC'], + [/\\H\{\}/g, '\u02DD'], + [/\\tone\{55\}/g, '\u02E5'], + [/\\tone\{44\}/g, '\u02E6'], + [/\\tone\{33\}/g, '\u02E7'], + [/\\tone\{22\}/g, '\u02E8'], + [/\\tone\{11\}/g, '\u02E9'], + [/\\cyrchar\\C/g, '\u030F'], + [/\\Elzpalh /g, '\u0321'], + [/\\Elzrh /g, '\u0322'], + [/\\Elzsbbrg /g, '\u032A'], + [/\\Elzxl /g, '\u0335'], + [/\\Elzbar /g, '\u0336'], + [/\\'\{A\}/g, '\u0386'], + [/\\'\{E\}/g, '\u0388'], + [/\\'\{H\}/g, '\u0389'], + [/\\'\{\}\{I\}/g, '\u038A'], + [/\\'\{\}O/g, '\u038C'], + [/\\mathrm\{'Y\}/g, '\u038E'], + [/\\mathrm\{'\\Omega\}/g, '\u038F'], + [/\\acute\{\\ddot\{\\iota\}\}/g, '\u0390'], + [/\\Alpha /g, '\u0391'], + [/\\Beta /g, '\u0392'], + [/\\Gamma /g, '\u0393'], + [/\\Delta /g, '\u0394'], + [/\\Epsilon /g, '\u0395'], + [/\\Zeta /g, '\u0396'], + [/\\Eta /g, '\u0397'], + [/\\Theta /g, '\u0398'], + [/\\Iota /g, '\u0399'], + [/\\Kappa /g, '\u039A'], + [/\\Lambda /g, '\u039B'], + [/\\Xi /g, '\u039E'], + [/\\Pi /g, '\u03A0'], + [/\\Rho /g, '\u03A1'], + [/\\Sigma /g, '\u03A3'], + [/\\Tau /g, '\u03A4'], + [/\\Upsilon /g, '\u03A5'], + [/\\Phi /g, '\u03A6'], + [/\\Chi /g, '\u03A7'], + [/\\Psi /g, '\u03A8'], + [/\\Omega /g, '\u03A9'], + [/\\mathrm\{\\ddot\{I\}\}/g, '\u03AA'], + [/\\mathrm\{\\ddot\{Y\}\}/g, '\u03AB'], + [/\\'\{\$\\alpha\$\}/g, '\u03AC'], + [/\\acute\{\\epsilon\}/g, '\u03AD'], + [/\\acute\{\\eta\}/g, '\u03AE'], + [/\\acute\{\\iota\}/g, '\u03AF'], + [/\\acute\{\\ddot\{\\upsilon\}\}/g, '\u03B0'], + [/\\alpha /g, '\u03B1'], + [/\\beta /g, '\u03B2'], + [/\\gamma /g, '\u03B3'], + [/\\delta /g, '\u03B4'], + [/\\epsilon /g, '\u03B5'], + [/\\zeta /g, '\u03B6'], + [/\\eta /g, '\u03B7'], + [/\\texttheta /g, '\u03B8'], + [/\\iota /g, '\u03B9'], + [/\\kappa /g, '\u03BA'], + [/\\lambda /g, '\u03BB'], + [/\\mu /g, '\u03BC'], + [/\\nu /g, '\u03BD'], + [/\\xi /g, '\u03BE'], + [/\\pi /g, '\u03C0'], + [/\\rho /g, '\u03C1'], + [/\\varsigma /g, '\u03C2'], + [/\\sigma /g, '\u03C3'], + [/\\tau /g, '\u03C4'], + [/\\upsilon /g, '\u03C5'], + [/\\varphi /g, '\u03C6'], + [/\\chi /g, '\u03C7'], + [/\\psi /g, '\u03C8'], + [/\\omega /g, '\u03C9'], + [/\\ddot\{\\iota\}/g, '\u03CA'], + [/\\ddot\{\\upsilon\}/g, '\u03CB'], + [/\\'\{o\}/g, '\u03CC'], + [/\\acute\{\\upsilon\}/g, '\u03CD'], + [/\\acute\{\\omega\}/g, '\u03CE'], + [/\\Pisymbol\{ppi022\}\{87\}/g, '\u03D0'], + [/\\textvartheta /g, '\u03D1'], + [/\\Upsilon /g, '\u03D2'], + [/\\phi /g, '\u03D5'], + [/\\varpi /g, '\u03D6'], + [/\\Stigma /g, '\u03DA'], + [/\\Digamma /g, '\u03DC'], + [/\\digamma /g, '\u03DD'], + [/\\Koppa /g, '\u03DE'], + [/\\Sampi /g, '\u03E0'], + [/\\varkappa /g, '\u03F0'], + [/\\varrho /g, '\u03F1'], + [/\\textTheta /g, '\u03F4'], + [/\\backepsilon /g, '\u03F6'], + [/\\cyrchar\\CYRYO /g, '\u0401'], + [/\\cyrchar\\CYRDJE /g, '\u0402'], + [/\\cyrchar\{\\'\\CYRG\}/g, '\u0403'], + [/\\cyrchar\\CYRIE /g, '\u0404'], + [/\\cyrchar\\CYRDZE /g, '\u0405'], + [/\\cyrchar\\CYRII /g, '\u0406'], + [/\\cyrchar\\CYRYI /g, '\u0407'], + [/\\cyrchar\\CYRJE /g, '\u0408'], + [/\\cyrchar\\CYRLJE /g, '\u0409'], + [/\\cyrchar\\CYRNJE /g, '\u040A'], + [/\\cyrchar\\CYRTSHE /g, '\u040B'], + [/\\cyrchar\{\\'\\CYRK\}/g, '\u040C'], + [/\\cyrchar\\CYRUSHRT /g, '\u040E'], + [/\\cyrchar\\CYRDZHE /g, '\u040F'], + [/\\cyrchar\\CYRA /g, '\u0410'], + [/\\cyrchar\\CYRB /g, '\u0411'], + [/\\cyrchar\\CYRV /g, '\u0412'], + [/\\cyrchar\\CYRG /g, '\u0413'], + [/\\cyrchar\\CYRD /g, '\u0414'], + [/\\cyrchar\\CYRE /g, '\u0415'], + [/\\cyrchar\\CYRZH /g, '\u0416'], + [/\\cyrchar\\CYRZ /g, '\u0417'], + [/\\cyrchar\\CYRI /g, '\u0418'], + [/\\cyrchar\\CYRISHRT /g, '\u0419'], + [/\\cyrchar\\CYRK /g, '\u041A'], + [/\\cyrchar\\CYRL /g, '\u041B'], + [/\\cyrchar\\CYRM /g, '\u041C'], + [/\\cyrchar\\CYRN /g, '\u041D'], + [/\\cyrchar\\CYRO /g, '\u041E'], + [/\\cyrchar\\CYRP /g, '\u041F'], + [/\\cyrchar\\CYRR /g, '\u0420'], + [/\\cyrchar\\CYRS /g, '\u0421'], + [/\\cyrchar\\CYRT /g, '\u0422'], + [/\\cyrchar\\CYRU /g, '\u0423'], + [/\\cyrchar\\CYRF /g, '\u0424'], + [/\\cyrchar\\CYRH /g, '\u0425'], + [/\\cyrchar\\CYRC /g, '\u0426'], + [/\\cyrchar\\CYRCH /g, '\u0427'], + [/\\cyrchar\\CYRSH /g, '\u0428'], + [/\\cyrchar\\CYRSHCH /g, '\u0429'], + [/\\cyrchar\\CYRHRDSN /g, '\u042A'], + [/\\cyrchar\\CYRERY /g, '\u042B'], + [/\\cyrchar\\CYRSFTSN /g, '\u042C'], + [/\\cyrchar\\CYREREV /g, '\u042D'], + [/\\cyrchar\\CYRYU /g, '\u042E'], + [/\\cyrchar\\CYRYA /g, '\u042F'], + [/\\cyrchar\\cyra /g, '\u0430'], + [/\\cyrchar\\cyrb /g, '\u0431'], + [/\\cyrchar\\cyrv /g, '\u0432'], + [/\\cyrchar\\cyrg /g, '\u0433'], + [/\\cyrchar\\cyrd /g, '\u0434'], + [/\\cyrchar\\cyre /g, '\u0435'], + [/\\cyrchar\\cyrzh /g, '\u0436'], + [/\\cyrchar\\cyrz /g, '\u0437'], + [/\\cyrchar\\cyri /g, '\u0438'], + [/\\cyrchar\\cyrishrt /g, '\u0439'], + [/\\cyrchar\\cyrk /g, '\u043A'], + [/\\cyrchar\\cyrl /g, '\u043B'], + [/\\cyrchar\\cyrm /g, '\u043C'], + [/\\cyrchar\\cyrn /g, '\u043D'], + [/\\cyrchar\\cyro /g, '\u043E'], + [/\\cyrchar\\cyrp /g, '\u043F'], + [/\\cyrchar\\cyrr /g, '\u0440'], + [/\\cyrchar\\cyrs /g, '\u0441'], + [/\\cyrchar\\cyrt /g, '\u0442'], + [/\\cyrchar\\cyru /g, '\u0443'], + [/\\cyrchar\\cyrf /g, '\u0444'], + [/\\cyrchar\\cyrh /g, '\u0445'], + [/\\cyrchar\\cyrc /g, '\u0446'], + [/\\cyrchar\\cyrch /g, '\u0447'], + [/\\cyrchar\\cyrsh /g, '\u0448'], + [/\\cyrchar\\cyrshch /g, '\u0449'], + [/\\cyrchar\\cyrhrdsn /g, '\u044A'], + [/\\cyrchar\\cyrery /g, '\u044B'], + [/\\cyrchar\\cyrsftsn /g, '\u044C'], + [/\\cyrchar\\cyrerev /g, '\u044D'], + [/\\cyrchar\\cyryu /g, '\u044E'], + [/\\cyrchar\\cyrya /g, '\u044F'], + [/\\cyrchar\\cyryo /g, '\u0451'], + [/\\cyrchar\\cyrdje /g, '\u0452'], + [/\\cyrchar\{\\'\\cyrg\}/g, '\u0453'], + [/\\cyrchar\\cyrie /g, '\u0454'], + [/\\cyrchar\\cyrdze /g, '\u0455'], + [/\\cyrchar\\cyrii /g, '\u0456'], + [/\\cyrchar\\cyryi /g, '\u0457'], + [/\\cyrchar\\cyrje /g, '\u0458'], + [/\\cyrchar\\cyrlje /g, '\u0459'], + [/\\cyrchar\\cyrnje /g, '\u045A'], + [/\\cyrchar\\cyrtshe /g, '\u045B'], + [/\\cyrchar\{\\'\\cyrk\}/g, '\u045C'], + [/\\cyrchar\\cyrushrt /g, '\u045E'], + [/\\cyrchar\\cyrdzhe /g, '\u045F'], + [/\\cyrchar\\CYROMEGA /g, '\u0460'], + [/\\cyrchar\\cyromega /g, '\u0461'], + [/\\cyrchar\\CYRYAT /g, '\u0462'], + [/\\cyrchar\\CYRIOTE /g, '\u0464'], + [/\\cyrchar\\cyriote /g, '\u0465'], + [/\\cyrchar\\CYRLYUS /g, '\u0466'], + [/\\cyrchar\\cyrlyus /g, '\u0467'], + [/\\cyrchar\\CYRIOTLYUS /g, '\u0468'], + [/\\cyrchar\\cyriotlyus /g, '\u0469'], + [/\\cyrchar\\CYRBYUS /g, '\u046A'], + [/\\cyrchar\\CYRIOTBYUS /g, '\u046C'], + [/\\cyrchar\\cyriotbyus /g, '\u046D'], + [/\\cyrchar\\CYRKSI /g, '\u046E'], + [/\\cyrchar\\cyrksi /g, '\u046F'], + [/\\cyrchar\\CYRPSI /g, '\u0470'], + [/\\cyrchar\\cyrpsi /g, '\u0471'], + [/\\cyrchar\\CYRFITA /g, '\u0472'], + [/\\cyrchar\\CYRIZH /g, '\u0474'], + [/\\cyrchar\\CYRUK /g, '\u0478'], + [/\\cyrchar\\cyruk /g, '\u0479'], + [/\\cyrchar\\CYROMEGARND /g, '\u047A'], + [/\\cyrchar\\cyromegarnd /g, '\u047B'], + [/\\cyrchar\\CYROMEGATITLO /g, '\u047C'], + [/\\cyrchar\\cyromegatitlo /g, '\u047D'], + [/\\cyrchar\\CYROT /g, '\u047E'], + [/\\cyrchar\\cyrot /g, '\u047F'], + [/\\cyrchar\\CYRKOPPA /g, '\u0480'], + [/\\cyrchar\\cyrkoppa /g, '\u0481'], + [/\\cyrchar\\cyrthousands /g, '\u0482'], + [/\\cyrchar\\cyrhundredthousands /g, '\u0488'], + [/\\cyrchar\\cyrmillions /g, '\u0489'], + [/\\cyrchar\\CYRSEMISFTSN /g, '\u048C'], + [/\\cyrchar\\cyrsemisftsn /g, '\u048D'], + [/\\cyrchar\\CYRRTICK /g, '\u048E'], + [/\\cyrchar\\cyrrtick /g, '\u048F'], + [/\\cyrchar\\CYRGUP /g, '\u0490'], + [/\\cyrchar\\cyrgup /g, '\u0491'], + [/\\cyrchar\\CYRGHCRS /g, '\u0492'], + [/\\cyrchar\\cyrghcrs /g, '\u0493'], + [/\\cyrchar\\CYRGHK /g, '\u0494'], + [/\\cyrchar\\cyrghk /g, '\u0495'], + [/\\cyrchar\\CYRZHDSC /g, '\u0496'], + [/\\cyrchar\\cyrzhdsc /g, '\u0497'], + [/\\cyrchar\\CYRZDSC /g, '\u0498'], + [/\\cyrchar\\cyrzdsc /g, '\u0499'], + [/\\cyrchar\\CYRKDSC /g, '\u049A'], + [/\\cyrchar\\cyrkdsc /g, '\u049B'], + [/\\cyrchar\\CYRKVCRS /g, '\u049C'], + [/\\cyrchar\\cyrkvcrs /g, '\u049D'], + [/\\cyrchar\\CYRKHCRS /g, '\u049E'], + [/\\cyrchar\\cyrkhcrs /g, '\u049F'], + [/\\cyrchar\\CYRKBEAK /g, '\u04A0'], + [/\\cyrchar\\cyrkbeak /g, '\u04A1'], + [/\\cyrchar\\CYRNDSC /g, '\u04A2'], + [/\\cyrchar\\cyrndsc /g, '\u04A3'], + [/\\cyrchar\\CYRNG /g, '\u04A4'], + [/\\cyrchar\\cyrng /g, '\u04A5'], + [/\\cyrchar\\CYRPHK /g, '\u04A6'], + [/\\cyrchar\\cyrphk /g, '\u04A7'], + [/\\cyrchar\\CYRABHHA /g, '\u04A8'], + [/\\cyrchar\\cyrabhha /g, '\u04A9'], + [/\\cyrchar\\CYRSDSC /g, '\u04AA'], + [/\\cyrchar\\cyrsdsc /g, '\u04AB'], + [/\\cyrchar\\CYRTDSC /g, '\u04AC'], + [/\\cyrchar\\cyrtdsc /g, '\u04AD'], + [/\\cyrchar\\CYRY /g, '\u04AE'], + [/\\cyrchar\\cyry /g, '\u04AF'], + [/\\cyrchar\\CYRYHCRS /g, '\u04B0'], + [/\\cyrchar\\cyryhcrs /g, '\u04B1'], + [/\\cyrchar\\CYRHDSC /g, '\u04B2'], + [/\\cyrchar\\cyrhdsc /g, '\u04B3'], + [/\\cyrchar\\CYRTETSE /g, '\u04B4'], + [/\\cyrchar\\cyrtetse /g, '\u04B5'], + [/\\cyrchar\\CYRCHRDSC /g, '\u04B6'], + [/\\cyrchar\\cyrchrdsc /g, '\u04B7'], + [/\\cyrchar\\CYRCHVCRS /g, '\u04B8'], + [/\\cyrchar\\cyrchvcrs /g, '\u04B9'], + [/\\cyrchar\\CYRSHHA /g, '\u04BA'], + [/\\cyrchar\\cyrshha /g, '\u04BB'], + [/\\cyrchar\\CYRABHCH /g, '\u04BC'], + [/\\cyrchar\\cyrabhch /g, '\u04BD'], + [/\\cyrchar\\CYRABHCHDSC /g, '\u04BE'], + [/\\cyrchar\\cyrabhchdsc /g, '\u04BF'], + [/\\cyrchar\\CYRpalochka /g, '\u04C0'], + [/\\cyrchar\\CYRKHK /g, '\u04C3'], + [/\\cyrchar\\cyrkhk /g, '\u04C4'], + [/\\cyrchar\\CYRNHK /g, '\u04C7'], + [/\\cyrchar\\cyrnhk /g, '\u04C8'], + [/\\cyrchar\\CYRCHLDSC /g, '\u04CB'], + [/\\cyrchar\\cyrchldsc /g, '\u04CC'], + [/\\cyrchar\\CYRAE /g, '\u04D4'], + [/\\cyrchar\\cyrae /g, '\u04D5'], + [/\\cyrchar\\CYRSCHWA /g, '\u04D8'], + [/\\cyrchar\\cyrschwa /g, '\u04D9'], + [/\\cyrchar\\CYRABHDZE /g, '\u04E0'], + [/\\cyrchar\\cyrabhdze /g, '\u04E1'], + [/\\cyrchar\\CYROTLD /g, '\u04E8'], + [/\\cyrchar\\cyrotld /g, '\u04E9'], + [/\\hspace\{0.6em\}/g, '\u2002'], + [/\\hspace\{1em\}/g, '\u2003'], + [/\\hspace\{0.33em\}/g, '\u2004'], + [/\\hspace\{0.25em\}/g, '\u2005'], + [/\\hspace\{0.166em\}/g, '\u2006'], + [/\\hphantom\{0\}/g, '\u2007'], + [/\\hphantom\{,\}/g, '\u2008'], + [/\\hspace\{0.167em\}/g, '\u2009'], + [/\\mkern1mu /g, '\u200A'], + [/\\textendash /g, '\u2013'], + [/\\textemdash /g, '\u2014'], + [/\\rule\{1em\}\{1pt\}/g, '\u2015'], + [/\\Vert /g, '\u2016'], + [/\\Elzreapos /g, '\u201B'], + [/\\textquotedblleft /g, '\u201C'], + [/\\textquotedblright /g, '\u201D'], + [/\\textdagger /g, '\u2020'], + [/\\textdaggerdbl /g, '\u2021'], + [/\\textbullet /g, '\u2022'], + [/\\ldots /g, '\u2026'], + [/\\textperthousand /g, '\u2030'], + [/\\textpertenthousand /g, '\u2031'], + [/\\backprime /g, '\u2035'], + [/\\guilsinglleft /g, '\u2039'], + [/\\guilsinglright /g, '\u203A'], + [/\\mkern4mu /g, '\u205F'], + [/\\nolinebreak /g, '\u2060'], + [/\\ensuremath\{\\Elzpes\}/g, '\u20A7'], + [/\\mbox\{\\texteuro\} /g, '\u20AC'], + [/\\dddot /g, '\u20DB'], + [/\\ddddot /g, '\u20DC'], + [/\\mathbb\{C\}/g, '\u2102'], + [/\\mathscr\{g\}/g, '\u210A'], + [/\\mathscr\{H\}/g, '\u210B'], + [/\\mathfrak\{H\}/g, '\u210C'], + [/\\mathbb\{H\}/g, '\u210D'], + [/\\hslash /g, '\u210F'], + [/\\mathscr\{I\}/g, '\u2110'], + [/\\mathfrak\{I\}/g, '\u2111'], + [/\\mathscr\{L\}/g, '\u2112'], + [/\\mathscr\{l\}/g, '\u2113'], + [/\\mathbb\{N\}/g, '\u2115'], + [/\\cyrchar\\textnumero /g, '\u2116'], + [/\\wp /g, '\u2118'], + [/\\mathbb\{P\}/g, '\u2119'], + [/\\mathbb\{Q\}/g, '\u211A'], + [/\\mathscr\{R\}/g, '\u211B'], + [/\\mathfrak\{R\}/g, '\u211C'], + [/\\mathbb\{R\}/g, '\u211D'], + [/\\Elzxrat /g, '\u211E'], + [/\\texttrademark /g, '\u2122'], + [/\\mathbb\{Z\}/g, '\u2124'], + [/\\Omega /g, '\u2126'], + [/\\mho /g, '\u2127'], + [/\\mathfrak\{Z\}/g, '\u2128'], + [/\\ElsevierGlyph\{2129\}/g, '\u2129'], + [/\\AA /g, '\u212B'], + [/\\mathscr\{B\}/g, '\u212C'], + [/\\mathfrak\{C\}/g, '\u212D'], + [/\\mathscr\{e\}/g, '\u212F'], + [/\\mathscr\{E\}/g, '\u2130'], + [/\\mathscr\{F\}/g, '\u2131'], + [/\\mathscr\{M\}/g, '\u2133'], + [/\\mathscr\{o\}/g, '\u2134'], + [/\\aleph /g, '\u2135'], + [/\\beth /g, '\u2136'], + [/\\gimel /g, '\u2137'], + [/\\daleth /g, '\u2138'], + [/\\textfrac\{1\}\{3\}/g, '\u2153'], + [/\\textfrac\{2\}\{3\}/g, '\u2154'], + [/\\textfrac\{1\}\{5\}/g, '\u2155'], + [/\\textfrac\{2\}\{5\}/g, '\u2156'], + [/\\textfrac\{3\}\{5\}/g, '\u2157'], + [/\\textfrac\{4\}\{5\}/g, '\u2158'], + [/\\textfrac\{1\}\{6\}/g, '\u2159'], + [/\\textfrac\{5\}\{6\}/g, '\u215A'], + [/\\textfrac\{1\}\{8\}/g, '\u215B'], + [/\\textfrac\{3\}\{8\}/g, '\u215C'], + [/\\textfrac\{5\}\{8\}/g, '\u215D'], + [/\\textfrac\{7\}\{8\}/g, '\u215E'], + [/\\leftarrow /g, '\u2190'], + [/\\uparrow /g, '\u2191'], + [/\\rightarrow /g, '\u2192'], + [/\\downarrow /g, '\u2193'], + [/\\leftrightarrow /g, '\u2194'], + [/\\updownarrow /g, '\u2195'], + [/\\nwarrow /g, '\u2196'], + [/\\nearrow /g, '\u2197'], + [/\\searrow /g, '\u2198'], + [/\\swarrow /g, '\u2199'], + [/\\nleftarrow /g, '\u219A'], + [/\\nrightarrow /g, '\u219B'], + [/\\arrowwaveright /g, '\u219C'], + [/\\arrowwaveright /g, '\u219D'], + [/\\twoheadleftarrow /g, '\u219E'], + [/\\twoheadrightarrow /g, '\u21A0'], + [/\\leftarrowtail /g, '\u21A2'], + [/\\rightarrowtail /g, '\u21A3'], + [/\\mapsto /g, '\u21A6'], + [/\\hookleftarrow /g, '\u21A9'], + [/\\hookrightarrow /g, '\u21AA'], + [/\\looparrowleft /g, '\u21AB'], + [/\\looparrowright /g, '\u21AC'], + [/\\leftrightsquigarrow /g, '\u21AD'], + [/\\nleftrightarrow /g, '\u21AE'], + [/\\Lsh /g, '\u21B0'], + [/\\Rsh /g, '\u21B1'], + [/\\ElsevierGlyph\{21B3\}/g, '\u21B3'], + [/\\curvearrowleft /g, '\u21B6'], + [/\\curvearrowright /g, '\u21B7'], + [/\\circlearrowleft /g, '\u21BA'], + [/\\circlearrowright /g, '\u21BB'], + [/\\leftharpoonup /g, '\u21BC'], + [/\\leftharpoondown /g, '\u21BD'], + [/\\upharpoonright /g, '\u21BE'], + [/\\upharpoonleft /g, '\u21BF'], + [/\\rightharpoonup /g, '\u21C0'], + [/\\rightharpoondown /g, '\u21C1'], + [/\\downharpoonright /g, '\u21C2'], + [/\\downharpoonleft /g, '\u21C3'], + [/\\rightleftarrows /g, '\u21C4'], + [/\\dblarrowupdown /g, '\u21C5'], + [/\\leftrightarrows /g, '\u21C6'], + [/\\leftleftarrows /g, '\u21C7'], + [/\\upuparrows /g, '\u21C8'], + [/\\rightrightarrows /g, '\u21C9'], + [/\\downdownarrows /g, '\u21CA'], + [/\\leftrightharpoons /g, '\u21CB'], + [/\\rightleftharpoons /g, '\u21CC'], + [/\\nLeftarrow /g, '\u21CD'], + [/\\nLeftrightarrow /g, '\u21CE'], + [/\\nRightarrow /g, '\u21CF'], + [/\\Leftarrow /g, '\u21D0'], + [/\\Uparrow /g, '\u21D1'], + [/\\Rightarrow /g, '\u21D2'], + [/\\Downarrow /g, '\u21D3'], + [/\\Leftrightarrow /g, '\u21D4'], + [/\\Updownarrow /g, '\u21D5'], + [/\\Lleftarrow /g, '\u21DA'], + [/\\Rrightarrow /g, '\u21DB'], + [/\\rightsquigarrow /g, '\u21DD'], + [/\\DownArrowUpArrow /g, '\u21F5'], + [/\\forall /g, '\u2200'], + [/\\complement /g, '\u2201'], + [/\\partial /g, '\u2202'], + [/\\exists /g, '\u2203'], + [/\\nexists /g, '\u2204'], + [/\\varnothing /g, '\u2205'], + [/\\nabla /g, '\u2207'], + [/\\in /g, '\u2208'], + [/\\not\\in /g, '\u2209'], + [/\\ni /g, '\u220B'], + [/\\not\\ni /g, '\u220C'], + [/\\prod /g, '\u220F'], + [/\\coprod /g, '\u2210'], + [/\\sum /g, '\u2211'], + [/\\mp /g, '\u2213'], + [/\\dotplus /g, '\u2214'], + [/\\setminus /g, '\u2216'], + [/\\circ /g, '\u2218'], + [/\\bullet /g, '\u2219'], + [/\\surd /g, '\u221A'], + [/\\propto /g, '\u221D'], + [/\\infty /g, '\u221E'], + [/\\rightangle /g, '\u221F'], + [/\\angle /g, '\u2220'], + [/\\measuredangle /g, '\u2221'], + [/\\sphericalangle /g, '\u2222'], + [/\\mid /g, '\u2223'], + [/\\nmid /g, '\u2224'], + [/\\parallel /g, '\u2225'], + [/\\nparallel /g, '\u2226'], + [/\\wedge /g, '\u2227'], + [/\\vee /g, '\u2228'], + [/\\cap /g, '\u2229'], + [/\\cup /g, '\u222A'], + [/\\int /g, '\u222B'], + [/\\int\\!\\int /g, '\u222C'], + [/\\int\\!\\int\\!\\int /g, '\u222D'], + [/\\oint /g, '\u222E'], + [/\\surfintegral /g, '\u222F'], + [/\\volintegral /g, '\u2230'], + [/\\clwintegral /g, '\u2231'], + [/\\ElsevierGlyph\{2232\}/g, '\u2232'], + [/\\ElsevierGlyph\{2233\}/g, '\u2233'], + [/\\therefore /g, '\u2234'], + [/\\because /g, '\u2235'], + [/\\Colon /g, '\u2237'], + [/\\ElsevierGlyph\{2238\}/g, '\u2238'], + [/\\mathbin\{\{:\}\\!\\!\{\-\}\\!\\!\{:\}\}/g, '\u223A'], + [/\\homothetic /g, '\u223B'], + [/\\sim /g, '\u223C'], + [/\\backsim /g, '\u223D'], + [/\\lazysinv /g, '\u223E'], + [/\\wr /g, '\u2240'], + [/\\not\\sim /g, '\u2241'], + [/\\ElsevierGlyph\{2242\}/g, '\u2242'], + [/\\NotEqualTilde /g, '\u2242-00338'], + [/\\simeq /g, '\u2243'], + [/\\not\\simeq /g, '\u2244'], + [/\\cong /g, '\u2245'], + [/\\approxnotequal /g, '\u2246'], + [/\\not\\cong /g, '\u2247'], + [/\\approx /g, '\u2248'], + [/\\not\\approx /g, '\u2249'], + [/\\approxeq /g, '\u224A'], + [/\\tildetrpl /g, '\u224B'], + [/\\not\\apid /g, '\u224B-00338'], + [/\\allequal /g, '\u224C'], + [/\\asymp /g, '\u224D'], + [/\\Bumpeq /g, '\u224E'], + [/\\NotHumpDownHump /g, '\u224E-00338'], + [/\\bumpeq /g, '\u224F'], + [/\\NotHumpEqual /g, '\u224F-00338'], + [/\\doteq /g, '\u2250'], + [/\\not\\doteq/g, '\u2250-00338'], + [/\\doteqdot /g, '\u2251'], + [/\\fallingdotseq /g, '\u2252'], + [/\\risingdotseq /g, '\u2253'], + [/\\eqcirc /g, '\u2256'], + [/\\circeq /g, '\u2257'], + [/\\estimates /g, '\u2259'], + [/\\ElsevierGlyph\{225A\}/g, '\u225A'], + [/\\starequal /g, '\u225B'], + [/\\triangleq /g, '\u225C'], + [/\\ElsevierGlyph\{225F\}/g, '\u225F'], + [/\\not =/g, '\u2260'], + [/\\equiv /g, '\u2261'], + [/\\not\\equiv /g, '\u2262'], + [/\\leq /g, '\u2264'], + [/\\geq /g, '\u2265'], + [/\\leqq /g, '\u2266'], + [/\\geqq /g, '\u2267'], + [/\\lneqq /g, '\u2268'], + [/\\lvertneqq /g, '\u2268-0FE00'], + [/\\gneqq /g, '\u2269'], + [/\\gvertneqq /g, '\u2269-0FE00'], + [/\\ll /g, '\u226A'], + [/\\NotLessLess /g, '\u226A-00338'], + [/\\gg /g, '\u226B'], + [/\\NotGreaterGreater /g, '\u226B-00338'], + [/\\between /g, '\u226C'], + [/\\not\\kern\-0.3em\\times /g, '\u226D'], + [/\\not/g, '\u226F'], + [/\\not\\leq /g, '\u2270'], + [/\\not\\geq /g, '\u2271'], + [/\\lessequivlnt /g, '\u2272'], + [/\\greaterequivlnt /g, '\u2273'], + [/\\ElsevierGlyph\{2274\}/g, '\u2274'], + [/\\ElsevierGlyph\{2275\}/g, '\u2275'], + [/\\lessgtr /g, '\u2276'], + [/\\gtrless /g, '\u2277'], + [/\\notlessgreater /g, '\u2278'], + [/\\notgreaterless /g, '\u2279'], + [/\\prec /g, '\u227A'], + [/\\succ /g, '\u227B'], + [/\\preccurlyeq /g, '\u227C'], + [/\\succcurlyeq /g, '\u227D'], + [/\\precapprox /g, '\u227E'], + [/\\NotPrecedesTilde /g, '\u227E-00338'], + [/\\succapprox /g, '\u227F'], + [/\\NotSucceedsTilde /g, '\u227F-00338'], + [/\\not\\prec /g, '\u2280'], + [/\\not\\succ /g, '\u2281'], + [/\\subset /g, '\u2282'], + [/\\supset /g, '\u2283'], + [/\\not\\subset /g, '\u2284'], + [/\\not\\supset /g, '\u2285'], + [/\\subseteq /g, '\u2286'], + [/\\supseteq /g, '\u2287'], + [/\\not\\subseteq /g, '\u2288'], + [/\\not\\supseteq /g, '\u2289'], + [/\\subsetneq /g, '\u228A'], + [/\\varsubsetneqq /g, '\u228A-0FE00'], + [/\\supsetneq /g, '\u228B'], + [/\\varsupsetneq /g, '\u228B-0FE00'], + [/\\uplus /g, '\u228E'], + [/\\sqsubset /g, '\u228F'], + [/\\NotSquareSubset /g, '\u228F-00338'], + [/\\sqsupset /g, '\u2290'], + [/\\NotSquareSuperset /g, '\u2290-00338'], + [/\\sqsubseteq /g, '\u2291'], + [/\\sqsupseteq /g, '\u2292'], + [/\\sqcap /g, '\u2293'], + [/\\sqcup /g, '\u2294'], + [/\\oplus /g, '\u2295'], + [/\\ominus /g, '\u2296'], + [/\\otimes /g, '\u2297'], + [/\\oslash /g, '\u2298'], + [/\\odot /g, '\u2299'], + [/\\circledcirc /g, '\u229A'], + [/\\circledast /g, '\u229B'], + [/\\circleddash /g, '\u229D'], + [/\\boxplus /g, '\u229E'], + [/\\boxminus /g, '\u229F'], + [/\\boxtimes /g, '\u22A0'], + [/\\boxdot /g, '\u22A1'], + [/\\vdash /g, '\u22A2'], + [/\\dashv /g, '\u22A3'], + [/\\top /g, '\u22A4'], + [/\\perp /g, '\u22A5'], + [/\\truestate /g, '\u22A7'], + [/\\forcesextra /g, '\u22A8'], + [/\\Vdash /g, '\u22A9'], + [/\\Vvdash /g, '\u22AA'], + [/\\VDash /g, '\u22AB'], + [/\\nvdash /g, '\u22AC'], + [/\\nvDash /g, '\u22AD'], + [/\\nVdash /g, '\u22AE'], + [/\\nVDash /g, '\u22AF'], + [/\\vartriangleleft /g, '\u22B2'], + [/\\vartriangleright /g, '\u22B3'], + [/\\trianglelefteq /g, '\u22B4'], + [/\\trianglerighteq /g, '\u22B5'], + [/\\original /g, '\u22B6'], + [/\\image /g, '\u22B7'], + [/\\multimap /g, '\u22B8'], + [/\\hermitconjmatrix /g, '\u22B9'], + [/\\intercal /g, '\u22BA'], + [/\\veebar /g, '\u22BB'], + [/\\rightanglearc /g, '\u22BE'], + [/\\ElsevierGlyph\{22C0\}/g, '\u22C0'], + [/\\ElsevierGlyph\{22C1\}/g, '\u22C1'], + [/\\bigcap /g, '\u22C2'], + [/\\bigcup /g, '\u22C3'], + [/\\diamond /g, '\u22C4'], + [/\\cdot /g, '\u22C5'], + [/\\star /g, '\u22C6'], + [/\\divideontimes /g, '\u22C7'], + [/\\bowtie /g, '\u22C8'], + [/\\ltimes /g, '\u22C9'], + [/\\rtimes /g, '\u22CA'], + [/\\leftthreetimes /g, '\u22CB'], + [/\\rightthreetimes /g, '\u22CC'], + [/\\backsimeq /g, '\u22CD'], + [/\\curlyvee /g, '\u22CE'], + [/\\curlywedge /g, '\u22CF'], + [/\\Subset /g, '\u22D0'], + [/\\Supset /g, '\u22D1'], + [/\\Cap /g, '\u22D2'], + [/\\Cup /g, '\u22D3'], + [/\\pitchfork /g, '\u22D4'], + [/\\lessdot /g, '\u22D6'], + [/\\gtrdot /g, '\u22D7'], + [/\\verymuchless /g, '\u22D8'], + [/\\verymuchgreater /g, '\u22D9'], + [/\\lesseqgtr /g, '\u22DA'], + [/\\gtreqless /g, '\u22DB'], + [/\\curlyeqprec /g, '\u22DE'], + [/\\curlyeqsucc /g, '\u22DF'], + [/\\not\\sqsubseteq /g, '\u22E2'], + [/\\not\\sqsupseteq /g, '\u22E3'], + [/\\Elzsqspne /g, '\u22E5'], + [/\\lnsim /g, '\u22E6'], + [/\\gnsim /g, '\u22E7'], + [/\\precedesnotsimilar /g, '\u22E8'], + [/\\succnsim /g, '\u22E9'], + [/\\ntriangleleft /g, '\u22EA'], + [/\\ntriangleright /g, '\u22EB'], + [/\\ntrianglelefteq /g, '\u22EC'], + [/\\ntrianglerighteq /g, '\u22ED'], + [/\\vdots /g, '\u22EE'], + [/\\cdots /g, '\u22EF'], + [/\\upslopeellipsis /g, '\u22F0'], + [/\\downslopeellipsis /g, '\u22F1'], + [/\\barwedge /g, '\u2305'], + [/\\perspcorrespond /g, '\u2306'], + [/\\lceil /g, '\u2308'], + [/\\rceil /g, '\u2309'], + [/\\lfloor /g, '\u230A'], + [/\\rfloor /g, '\u230B'], + [/\\recorder /g, '\u2315'], + [/\\mathchar"2208/g, '\u2316'], + [/\\ulcorner /g, '\u231C'], + [/\\urcorner /g, '\u231D'], + [/\\llcorner /g, '\u231E'], + [/\\lrcorner /g, '\u231F'], + [/\\frown /g, '\u2322'], + [/\\smile /g, '\u2323'], + [/\\langle /g, '\u2329'], + [/\\rangle /g, '\u232A'], + [/\\ElsevierGlyph\{E838\}/g, '\u233D'], + [/\\Elzdlcorn /g, '\u23A3'], + [/\\lmoustache /g, '\u23B0'], + [/\\rmoustache /g, '\u23B1'], + [/\\textvisiblespace /g, '\u2423'], + [/\\ding\{172\}/g, '\u2460'], + [/\\ding\{173\}/g, '\u2461'], + [/\\ding\{174\}/g, '\u2462'], + [/\\ding\{175\}/g, '\u2463'], + [/\\ding\{176\}/g, '\u2464'], + [/\\ding\{177\}/g, '\u2465'], + [/\\ding\{178\}/g, '\u2466'], + [/\\ding\{179\}/g, '\u2467'], + [/\\ding\{180\}/g, '\u2468'], + [/\\ding\{181\}/g, '\u2469'], + [/\\circledS /g, '\u24C8'], + [/\\Elzdshfnc /g, '\u2506'], + [/\\Elzsqfnw /g, '\u2519'], + [/\\diagup /g, '\u2571'], + [/\\ding\{110\}/g, '\u25A0'], + [/\\square /g, '\u25A1'], + [/\\blacksquare /g, '\u25AA'], + [/\\fbox\{~~\}/g, '\u25AD'], + [/\\Elzvrecto /g, '\u25AF'], + [/\\ElsevierGlyph\{E381\}/g, '\u25B1'], + [/\\ding\{115\}/g, '\u25B2'], + [/\\bigtriangleup /g, '\u25B3'], + [/\\blacktriangle /g, '\u25B4'], + [/\\vartriangle /g, '\u25B5'], + [/\\blacktriangleright /g, '\u25B8'], + [/\\triangleright /g, '\u25B9'], + [/\\ding\{116\}/g, '\u25BC'], + [/\\bigtriangledown /g, '\u25BD'], + [/\\blacktriangledown /g, '\u25BE'], + [/\\triangledown /g, '\u25BF'], + [/\\blacktriangleleft /g, '\u25C2'], + [/\\triangleleft /g, '\u25C3'], + [/\\ding\{117\}/g, '\u25C6'], + [/\\lozenge /g, '\u25CA'], + [/\\bigcirc /g, '\u25CB'], + [/\\ding\{108\}/g, '\u25CF'], + [/\\Elzcirfl /g, '\u25D0'], + [/\\Elzcirfr /g, '\u25D1'], + [/\\Elzcirfb /g, '\u25D2'], + [/\\ding\{119\}/g, '\u25D7'], + [/\\Elzrvbull /g, '\u25D8'], + [/\\Elzsqfl /g, '\u25E7'], + [/\\Elzsqfr /g, '\u25E8'], + [/\\Elzsqfse /g, '\u25EA'], + [/\\bigcirc /g, '\u25EF'], + [/\\ding\{72\}/g, '\u2605'], + [/\\ding\{73\}/g, '\u2606'], + [/\\ding\{37\}/g, '\u260E'], + [/\\ding\{42\}/g, '\u261B'], + [/\\ding\{43\}/g, '\u261E'], + [/\\rightmoon /g, '\u263E'], + [/\\mercury /g, '\u263F'], + [/\\venus /g, '\u2640'], + [/\\male /g, '\u2642'], + [/\\jupiter /g, '\u2643'], + [/\\saturn /g, '\u2644'], + [/\\uranus /g, '\u2645'], + [/\\neptune /g, '\u2646'], + [/\\pluto /g, '\u2647'], + [/\\aries /g, '\u2648'], + [/\\taurus /g, '\u2649'], + [/\\gemini /g, '\u264A'], + [/\\cancer /g, '\u264B'], + [/\\leo /g, '\u264C'], + [/\\virgo /g, '\u264D'], + [/\\libra /g, '\u264E'], + [/\\scorpio /g, '\u264F'], + [/\\sagittarius /g, '\u2650'], + [/\\capricornus /g, '\u2651'], + [/\\aquarius /g, '\u2652'], + [/\\pisces /g, '\u2653'], + [/\\ding\{171\}/g, '\u2660'], + [/\\diamond /g, '\u2662'], + [/\\ding\{168\}/g, '\u2663'], + [/\\ding\{170\}/g, '\u2665'], + [/\\ding\{169\}/g, '\u2666'], + [/\\quarternote /g, '\u2669'], + [/\\eighthnote /g, '\u266A'], + [/\\flat /g, '\u266D'], + [/\\natural /g, '\u266E'], + [/\\sharp /g, '\u266F'], + [/\\ding\{33\}/g, '\u2701'], + [/\\ding\{34\}/g, '\u2702'], + [/\\ding\{35\}/g, '\u2703'], + [/\\ding\{36\}/g, '\u2704'], + [/\\ding\{38\}/g, '\u2706'], + [/\\ding\{39\}/g, '\u2707'], + [/\\ding\{40\}/g, '\u2708'], + [/\\ding\{41\}/g, '\u2709'], + [/\\ding\{44\}/g, '\u270C'], + [/\\ding\{45\}/g, '\u270D'], + [/\\ding\{46\}/g, '\u270E'], + [/\\ding\{47\}/g, '\u270F'], + [/\\ding\{48\}/g, '\u2710'], + [/\\ding\{49\}/g, '\u2711'], + [/\\ding\{50\}/g, '\u2712'], + [/\\ding\{51\}/g, '\u2713'], + [/\\ding\{52\}/g, '\u2714'], + [/\\ding\{53\}/g, '\u2715'], + [/\\ding\{54\}/g, '\u2716'], + [/\\ding\{55\}/g, '\u2717'], + [/\\ding\{56\}/g, '\u2718'], + [/\\ding\{57\}/g, '\u2719'], + [/\\ding\{58\}/g, '\u271A'], + [/\\ding\{59\}/g, '\u271B'], + [/\\ding\{60\}/g, '\u271C'], + [/\\ding\{61\}/g, '\u271D'], + [/\\ding\{62\}/g, '\u271E'], + [/\\ding\{63\}/g, '\u271F'], + [/\\ding\{64\}/g, '\u2720'], + [/\\ding\{65\}/g, '\u2721'], + [/\\ding\{66\}/g, '\u2722'], + [/\\ding\{67\}/g, '\u2723'], + [/\\ding\{68\}/g, '\u2724'], + [/\\ding\{69\}/g, '\u2725'], + [/\\ding\{70\}/g, '\u2726'], + [/\\ding\{71\}/g, '\u2727'], + [/\\ding\{73\}/g, '\u2729'], + [/\\ding\{74\}/g, '\u272A'], + [/\\ding\{75\}/g, '\u272B'], + [/\\ding\{76\}/g, '\u272C'], + [/\\ding\{77\}/g, '\u272D'], + [/\\ding\{78\}/g, '\u272E'], + [/\\ding\{79\}/g, '\u272F'], + [/\\ding\{80\}/g, '\u2730'], + [/\\ding\{81\}/g, '\u2731'], + [/\\ding\{82\}/g, '\u2732'], + [/\\ding\{83\}/g, '\u2733'], + [/\\ding\{84\}/g, '\u2734'], + [/\\ding\{85\}/g, '\u2735'], + [/\\ding\{86\}/g, '\u2736'], + [/\\ding\{87\}/g, '\u2737'], + [/\\ding\{88\}/g, '\u2738'], + [/\\ding\{89\}/g, '\u2739'], + [/\\ding\{90\}/g, '\u273A'], + [/\\ding\{91\}/g, '\u273B'], + [/\\ding\{92\}/g, '\u273C'], + [/\\ding\{93\}/g, '\u273D'], + [/\\ding\{94\}/g, '\u273E'], + [/\\ding\{95\}/g, '\u273F'], + [/\\ding\{96\}/g, '\u2740'], + [/\\ding\{97\}/g, '\u2741'], + [/\\ding\{98\}/g, '\u2742'], + [/\\ding\{99\}/g, '\u2743'], + [/\\ding\{100\}/g, '\u2744'], + [/\\ding\{101\}/g, '\u2745'], + [/\\ding\{102\}/g, '\u2746'], + [/\\ding\{103\}/g, '\u2747'], + [/\\ding\{104\}/g, '\u2748'], + [/\\ding\{105\}/g, '\u2749'], + [/\\ding\{106\}/g, '\u274A'], + [/\\ding\{107\}/g, '\u274B'], + [/\\ding\{109\}/g, '\u274D'], + [/\\ding\{111\}/g, '\u274F'], + [/\\ding\{112\}/g, '\u2750'], + [/\\ding\{113\}/g, '\u2751'], + [/\\ding\{114\}/g, '\u2752'], + [/\\ding\{118\}/g, '\u2756'], + [/\\ding\{120\}/g, '\u2758'], + [/\\ding\{121\}/g, '\u2759'], + [/\\ding\{122\}/g, '\u275A'], + [/\\ding\{123\}/g, '\u275B'], + [/\\ding\{124\}/g, '\u275C'], + [/\\ding\{125\}/g, '\u275D'], + [/\\ding\{126\}/g, '\u275E'], + [/\\ding\{161\}/g, '\u2761'], + [/\\ding\{162\}/g, '\u2762'], + [/\\ding\{163\}/g, '\u2763'], + [/\\ding\{164\}/g, '\u2764'], + [/\\ding\{165\}/g, '\u2765'], + [/\\ding\{166\}/g, '\u2766'], + [/\\ding\{167\}/g, '\u2767'], + [/\\ding\{182\}/g, '\u2776'], + [/\\ding\{183\}/g, '\u2777'], + [/\\ding\{184\}/g, '\u2778'], + [/\\ding\{185\}/g, '\u2779'], + [/\\ding\{186\}/g, '\u277A'], + [/\\ding\{187\}/g, '\u277B'], + [/\\ding\{188\}/g, '\u277C'], + [/\\ding\{189\}/g, '\u277D'], + [/\\ding\{190\}/g, '\u277E'], + [/\\ding\{191\}/g, '\u277F'], + [/\\ding\{192\}/g, '\u2780'], + [/\\ding\{193\}/g, '\u2781'], + [/\\ding\{194\}/g, '\u2782'], + [/\\ding\{195\}/g, '\u2783'], + [/\\ding\{196\}/g, '\u2784'], + [/\\ding\{197\}/g, '\u2785'], + [/\\ding\{198\}/g, '\u2786'], + [/\\ding\{199\}/g, '\u2787'], + [/\\ding\{200\}/g, '\u2788'], + [/\\ding\{201\}/g, '\u2789'], + [/\\ding\{202\}/g, '\u278A'], + [/\\ding\{203\}/g, '\u278B'], + [/\\ding\{204\}/g, '\u278C'], + [/\\ding\{205\}/g, '\u278D'], + [/\\ding\{206\}/g, '\u278E'], + [/\\ding\{207\}/g, '\u278F'], + [/\\ding\{208\}/g, '\u2790'], + [/\\ding\{209\}/g, '\u2791'], + [/\\ding\{210\}/g, '\u2792'], + [/\\ding\{211\}/g, '\u2793'], + [/\\ding\{212\}/g, '\u2794'], + [/\\ding\{216\}/g, '\u2798'], + [/\\ding\{217\}/g, '\u2799'], + [/\\ding\{218\}/g, '\u279A'], + [/\\ding\{219\}/g, '\u279B'], + [/\\ding\{220\}/g, '\u279C'], + [/\\ding\{221\}/g, '\u279D'], + [/\\ding\{222\}/g, '\u279E'], + [/\\ding\{223\}/g, '\u279F'], + [/\\ding\{224\}/g, '\u27A0'], + [/\\ding\{225\}/g, '\u27A1'], + [/\\ding\{226\}/g, '\u27A2'], + [/\\ding\{227\}/g, '\u27A3'], + [/\\ding\{228\}/g, '\u27A4'], + [/\\ding\{229\}/g, '\u27A5'], + [/\\ding\{230\}/g, '\u27A6'], + [/\\ding\{231\}/g, '\u27A7'], + [/\\ding\{232\}/g, '\u27A8'], + [/\\ding\{233\}/g, '\u27A9'], + [/\\ding\{234\}/g, '\u27AA'], + [/\\ding\{235\}/g, '\u27AB'], + [/\\ding\{236\}/g, '\u27AC'], + [/\\ding\{237\}/g, '\u27AD'], + [/\\ding\{238\}/g, '\u27AE'], + [/\\ding\{239\}/g, '\u27AF'], + [/\\ding\{241\}/g, '\u27B1'], + [/\\ding\{242\}/g, '\u27B2'], + [/\\ding\{243\}/g, '\u27B3'], + [/\\ding\{244\}/g, '\u27B4'], + [/\\ding\{245\}/g, '\u27B5'], + [/\\ding\{246\}/g, '\u27B6'], + [/\\ding\{247\}/g, '\u27B7'], + [/\\ding\{248\}/g, '\u27B8'], + [/\\ding\{249\}/g, '\u27B9'], + [/\\ding\{250\}/g, '\u27BA'], + [/\\ding\{251\}/g, '\u27BB'], + [/\\ding\{252\}/g, '\u27BC'], + [/\\ding\{253\}/g, '\u27BD'], + [/\\ding\{254\}/g, '\u27BE'], + [/\\longleftarrow /g, '\u27F5'], + [/\\longrightarrow /g, '\u27F6'], + [/\\longleftrightarrow /g, '\u27F7'], + [/\\Longleftarrow /g, '\u27F8'], + [/\\Longrightarrow /g, '\u27F9'], + [/\\Longleftrightarrow /g, '\u27FA'], + [/\\longmapsto /g, '\u27FC'], + [/\\sim\\joinrel\\leadsto/g, '\u27FF'], + [/\\ElsevierGlyph\{E212\}/g, '\u2905'], + [/\\UpArrowBar /g, '\u2912'], + [/\\DownArrowBar /g, '\u2913'], + [/\\ElsevierGlyph\{E20C\}/g, '\u2923'], + [/\\ElsevierGlyph\{E20D\}/g, '\u2924'], + [/\\ElsevierGlyph\{E20B\}/g, '\u2925'], + [/\\ElsevierGlyph\{E20A\}/g, '\u2926'], + [/\\ElsevierGlyph\{E211\}/g, '\u2927'], + [/\\ElsevierGlyph\{E20E\}/g, '\u2928'], + [/\\ElsevierGlyph\{E20F\}/g, '\u2929'], + [/\\ElsevierGlyph\{E210\}/g, '\u292A'], + [/\\ElsevierGlyph\{E21C\}/g, '\u2933'], + [/\\ElsevierGlyph\{E21D\}/g, '\u2933-00338'], + [/\\ElsevierGlyph\{E21A\}/g, '\u2936'], + [/\\ElsevierGlyph\{E219\}/g, '\u2937'], + [/\\Elolarr /g, '\u2940'], + [/\\Elorarr /g, '\u2941'], + [/\\ElzRlarr /g, '\u2942'], + [/\\ElzrLarr /g, '\u2944'], + [/\\Elzrarrx /g, '\u2947'], + [/\\LeftRightVector /g, '\u294E'], + [/\\RightUpDownVector /g, '\u294F'], + [/\\DownLeftRightVector /g, '\u2950'], + [/\\LeftUpDownVector /g, '\u2951'], + [/\\LeftVectorBar /g, '\u2952'], + [/\\RightVectorBar /g, '\u2953'], + [/\\RightUpVectorBar /g, '\u2954'], + [/\\RightDownVectorBar /g, '\u2955'], + [/\\DownLeftVectorBar /g, '\u2956'], + [/\\DownRightVectorBar /g, '\u2957'], + [/\\LeftUpVectorBar /g, '\u2958'], + [/\\LeftDownVectorBar /g, '\u2959'], + [/\\LeftTeeVector /g, '\u295A'], + [/\\RightTeeVector /g, '\u295B'], + [/\\RightUpTeeVector /g, '\u295C'], + [/\\RightDownTeeVector /g, '\u295D'], + [/\\DownLeftTeeVector /g, '\u295E'], + [/\\DownRightTeeVector /g, '\u295F'], + [/\\LeftUpTeeVector /g, '\u2960'], + [/\\LeftDownTeeVector /g, '\u2961'], + [/\\UpEquilibrium /g, '\u296E'], + [/\\ReverseUpEquilibrium /g, '\u296F'], + [/\\RoundImplies /g, '\u2970'], + [/\\ElsevierGlyph\{E214\}/g, '\u297C'], + [/\\ElsevierGlyph\{E215\}/g, '\u297D'], + [/\\Elztfnc /g, '\u2980'], + [/\\ElsevierGlyph\{3018\}/g, '\u2985'], + [/\\Elroang /g, '\u2986'], + [/\\ElsevierGlyph\{E291\}/g, '\u2994'], + [/\\Elzddfnc /g, '\u2999'], + [/\\Angle /g, '\u299C'], + [/\\Elzlpargt /g, '\u29A0'], + [/\\ElsevierGlyph\{E260\}/g, '\u29B5'], + [/\\ElsevierGlyph\{E61B\}/g, '\u29B6'], + [/\\ElzLap /g, '\u29CA'], + [/\\Elzdefas /g, '\u29CB'], + [/\\LeftTriangleBar /g, '\u29CF'], + [/\\NotLeftTriangleBar /g, '\u29CF-00338'], + [/\\RightTriangleBar /g, '\u29D0'], + [/\\NotRightTriangleBar /g, '\u29D0-00338'], + [/\\ElsevierGlyph\{E372\}/g, '\u29DC'], + [/\\blacklozenge /g, '\u29EB'], + [/\\RuleDelayed /g, '\u29F4'], + [/\\Elxuplus /g, '\u2A04'], + [/\\ElzThr /g, '\u2A05'], + [/\\Elxsqcup /g, '\u2A06'], + [/\\ElzInf /g, '\u2A07'], + [/\\ElzSup /g, '\u2A08'], + [/\\ElzCint /g, '\u2A0D'], + [/\\clockoint /g, '\u2A0F'], + [/\\ElsevierGlyph\{E395\}/g, '\u2A10'], + [/\\sqrint /g, '\u2A16'], + [/\\ElsevierGlyph\{E25A\}/g, '\u2A25'], + [/\\ElsevierGlyph\{E25B\}/g, '\u2A2A'], + [/\\ElsevierGlyph\{E25C\}/g, '\u2A2D'], + [/\\ElsevierGlyph\{E25D\}/g, '\u2A2E'], + [/\\ElzTimes /g, '\u2A2F'], + [/\\ElsevierGlyph\{E25E\}/g, '\u2A34'], + [/\\ElsevierGlyph\{E25E\}/g, '\u2A35'], + [/\\ElsevierGlyph\{E259\}/g, '\u2A3C'], + [/\\amalg /g, '\u2A3F'], + [/\\ElzAnd /g, '\u2A53'], + [/\\ElzOr /g, '\u2A54'], + [/\\ElsevierGlyph\{E36E\}/g, '\u2A55'], + [/\\ElOr /g, '\u2A56'], + [/\\perspcorrespond /g, '\u2A5E'], + [/\\Elzminhat /g, '\u2A5F'], + [/\\ElsevierGlyph\{225A\}/g, '\u2A63'], + [/\\stackrel\{*\}\{=\}/g, '\u2A6E'], + [/\\Equal /g, '\u2A75'], + [/\\leqslant /g, '\u2A7D'], + [/\\nleqslant /g, '\u2A7D-00338'], + [/\\geqslant /g, '\u2A7E'], + [/\\ngeqslant /g, '\u2A7E-00338'], + [/\\lessapprox /g, '\u2A85'], + [/\\gtrapprox /g, '\u2A86'], + [/\\lneq /g, '\u2A87'], + [/\\gneq /g, '\u2A88'], + [/\\lnapprox /g, '\u2A89'], + [/\\gnapprox /g, '\u2A8A'], + [/\\lesseqqgtr /g, '\u2A8B'], + [/\\gtreqqless /g, '\u2A8C'], + [/\\eqslantless /g, '\u2A95'], + [/\\eqslantgtr /g, '\u2A96'], + [/\\Pisymbol\{ppi020\}\{117\}/g, '\u2A9D'], + [/\\Pisymbol\{ppi020\}\{105\}/g, '\u2A9E'], + [/\\NestedLessLess /g, '\u2AA1'], + [/\\NotNestedLessLess /g, '\u2AA1-00338'], + [/\\NestedGreaterGreater /g, '\u2AA2'], + [/\\NotNestedGreaterGreater /g, '\u2AA2-00338'], + [/\\preceq /g, '\u2AAF'], + [/\\not\\preceq /g, '\u2AAF-00338'], + [/\\succeq /g, '\u2AB0'], + [/\\not\\succeq /g, '\u2AB0-00338'], + [/\\precneqq /g, '\u2AB5'], + [/\\succneqq /g, '\u2AB6'], + [/\\precapprox /g, '\u2AB7'], + [/\\succapprox /g, '\u2AB8'], + [/\\precnapprox /g, '\u2AB9'], + [/\\succnapprox /g, '\u2ABA'], + [/\\subseteqq /g, '\u2AC5'], + [/\\nsubseteqq /g, '\u2AC5-00338'], + [/\\supseteqq /g, '\u2AC6'], + [/\\nsupseteqq/g, '\u2AC6-00338'], + [/\\subsetneqq /g, '\u2ACB'], + [/\\supsetneqq /g, '\u2ACC'], + [/\\ElsevierGlyph\{E30D\}/g, '\u2AEB'], + [/\\Elztdcol /g, '\u2AF6'], + [/\\ElsevierGlyph\{300A\}/g, '\u300A'], + [/\\ElsevierGlyph\{300B\}/g, '\u300B'], + [/\\ElsevierGlyph\{3018\}/g, '\u3018'], + [/\\ElsevierGlyph\{3019\}/g, '\u3019'], + [/\\openbracketleft /g, '\u301A'], + [/\\openbracketright /g, '\u301B'], +] + +if (typeof module !== 'undefined' && module.exports) { + module.exports = BibtexParser +} diff --git a/services/web/frontend/js/features/ide-react/references/reference-index.ts b/services/web/frontend/js/features/ide-react/references/reference-index.ts new file mode 100644 index 0000000000..d11003dac6 --- /dev/null +++ b/services/web/frontend/js/features/ide-react/references/reference-index.ts @@ -0,0 +1,43 @@ +import Bib2Json from './bib2json' +import { AdvancedReferenceSearchResult, Bib2JsonEntry, Changes } from './types' + +export abstract class ReferenceIndex { + keys: Set = new Set() + + abstract updateIndex({ updates, deletes }: Changes): void + async search(_query: string): Promise { + return { hits: [] } + } + + getKeys(): Set { + return this.keys + } + + parseEntries(content: string): Bib2JsonEntry[] { + const allowedFields = ['author', 'journal', 'title', 'year', 'date'] + // @ts-expect-error Bib2Json works as both a constructor and a function + const { entries } = Bib2Json(content, allowedFields) + for (const entry of entries) { + if (entry.Fields?.year) { + entry.Fields.year = parseInt(entry.Fields.year).toString() + if (entry.Fields.year === 'NaN') { + delete entry.Fields.year + } + } + setDefaultFields(entry.Fields) + } + return entries + } +} + +function setDefaultFields( + fields: Partial +): Bib2JsonEntry['Fields'] { + const requiredFields = ['author', 'journal', 'title', 'date', 'year'] as const + for (const field of requiredFields) { + if (!fields[field]) { + fields[field] = '' + } + } + return fields as Bib2JsonEntry['Fields'] +} diff --git a/services/web/frontend/js/features/ide-react/references/reference-indexer.ts b/services/web/frontend/js/features/ide-react/references/reference-indexer.ts new file mode 100644 index 0000000000..1d36726f3d --- /dev/null +++ b/services/web/frontend/js/features/ide-react/references/reference-indexer.ts @@ -0,0 +1,137 @@ +import { ProjectSnapshot } from '@/infrastructure/project-snapshot' +import { generateSHA1Hash } from '@/shared/utils/sha1' +import { AdvancedReferenceSearchResult, Changes } from './types' +import { debugConsole } from '@/utils/debugging' +import type { ReferenceWorkerResponse } from './references.worker' + +const ONE_MB = 1024 * 1024 +const MAX_BIB_DATA_SIZE = 6 * ONE_MB + +export class ReferenceIndexer { + private fileIndexHash: Map = new Map() + private worker: Worker + private updateResolve: ((result: Set) => void) | null = null + private searchResolve: + | ((result: AdvancedReferenceSearchResult) => void) + | null = null + + constructor() { + this.worker = new Worker( + /* webpackChunkName: "references-worker" */ + new URL('./references.worker.ts', import.meta.url), + { type: 'module' } + ) + this.worker.addEventListener('message', evt => this.handleMessage(evt)) + } + + private handleMessage(event: MessageEvent) { + const data = event.data as ReferenceWorkerResponse + if (data.type === 'searchResult' && this.searchResolve) { + this.searchResolve(data.result) + this.searchResolve = null + } else if (data.type === 'updateKeys' && this.updateResolve) { + this.updateResolve(data.keys) + this.updateResolve = null + } else { + debugConsole.warn('Received unknown message from worker:', data.type) + } + } + + async updateFromSnapshot( + snapshot: Pick< + ProjectSnapshot, + | 'getDocPaths' + | 'getDocContents' + | 'getBinaryFilePathsWithHash' + | 'getBinaryFileContents' + >, + { + dataLimit = MAX_BIB_DATA_SIZE, + signal, + }: { dataLimit?: number; signal: AbortSignal } + ): Promise> { + const nextFileHashIndex = new Map(this.fileIndexHash) + const previousPaths = new Set(this.fileIndexHash.keys()) + let dataBudget = dataLimit + const docs = snapshot + .getDocPaths() + .filter(path => path.toLowerCase().endsWith('.bib')) + + const changes: Changes = { updates: [], deletes: [] } + for (const path of docs) { + previousPaths.delete(path) + if (dataBudget <= 0) { + continue + } + const content = snapshot.getDocContents(path)?.slice(0, dataBudget) + if (content == null) { + continue + } + dataBudget -= content.length + const hash = generateSHA1Hash(content) + const possibleMatch = nextFileHashIndex.get(path) + if (possibleMatch === undefined || possibleMatch !== hash) { + // New or changed file + nextFileHashIndex.set(path, hash) + changes.updates.push({ path, content }) + } + } + + const files = snapshot + .getBinaryFilePathsWithHash() + .filter(({ path }) => path.toLowerCase().endsWith('.bib')) + .sort((a, b) => a.size - b.size) + + for (const { path, hash, size } of files) { + if (signal.aborted) { + debugConsole.warn('Aborted indexing references due to signal') + return new Set() + } + + previousPaths.delete(path) + if (nextFileHashIndex.get(path) === hash) { + dataBudget -= size + // Already indexed + continue + } + if (dataBudget <= 0) { + continue + } + const content = await snapshot.getBinaryFileContents(path, { + maxSize: dataBudget, + }) + dataBudget -= content.length + nextFileHashIndex.set(path, hash) + changes.updates.push({ path, content }) + } + + previousPaths.forEach(path => { + // Deleted file + changes.deletes.push(path) + nextFileHashIndex.delete(path) + }) + + if (dataBudget <= 0) { + debugConsole.warn('Data budget exceeded while updating references index') + } + + this.fileIndexHash = nextFileHashIndex + + this.worker.postMessage({ + type: 'update', + changes, + }) + + return new Promise(resolve => { + this.updateResolve = resolve + }) + } + + async search(query: string): Promise { + this.worker.postMessage({ type: 'search', query }) + const { promise, resolve } = + Promise.withResolvers() + this.searchResolve = resolve + return promise + } +} diff --git a/services/web/frontend/js/features/ide-react/references/references.worker.ts b/services/web/frontend/js/features/ide-react/references/references.worker.ts new file mode 100644 index 0000000000..fd7cdfc537 --- /dev/null +++ b/services/web/frontend/js/features/ide-react/references/references.worker.ts @@ -0,0 +1,47 @@ +import BasicReferenceIndex from './basic-reference-index' +import { ReferenceIndex } from './reference-index' +import { AdvancedReferenceSearchResult, Changes } from './types' +import importOverleafModules from '../../../../macros/import-overleaf-module.macro' + +interface IndexConstructor { + new (): ReferenceIndex +} + +const indices = importOverleafModules('referenceIndices') as { + import: { default: IndexConstructor } + path: string +}[] + +export type ReferenceWorkerRequest = + | { type: 'update'; changes: Changes } + | { type: 'search'; query: string } + +export type ReferenceWorkerResponse = + | { type: 'updateKeys'; keys: Set } + | { type: 'searchResult'; result: AdvancedReferenceSearchResult } + +function createIndex(): ReferenceIndex { + const Klass = indices[0]?.import.default ?? BasicReferenceIndex + return new Klass() +} + +const indexer: ReferenceIndex = createIndex() + +self.addEventListener('message', async (event: MessageEvent) => { + const message = event.data as ReferenceWorkerRequest + switch (message.type) { + case 'update': + indexer.updateIndex(message.changes) + self.postMessage({ type: 'updateKeys', keys: indexer.getKeys() }) + break + + case 'search': { + const result = await indexer.search(message.query) + self.postMessage({ type: 'searchResult', result }) + break + } + + default: + console.error('Unknown message type:', message) + } +}) diff --git a/services/web/frontend/js/features/ide-react/references/types.ts b/services/web/frontend/js/features/ide-react/references/types.ts new file mode 100644 index 0000000000..ace908ab2a --- /dev/null +++ b/services/web/frontend/js/features/ide-react/references/types.ts @@ -0,0 +1,23 @@ +export type Bib2JsonEntry = { + EntryKey: string + Fields: { + author: string + date: string + journal: string + title: string + year: string + } +} + +export type AdvancedReferenceSearchResult = { + hits: { + _source: Bib2JsonEntry + }[] +} + +export type ReferenceEntry = Map + +export type Changes = { + updates: { path: string; content: string }[] + deletes: string[] +} diff --git a/services/web/frontend/js/features/pdf-preview/components/visual-preview.tsx b/services/web/frontend/js/features/pdf-preview/components/visual-preview.tsx index 7eeb7dfdac..c5d93598b7 100644 --- a/services/web/frontend/js/features/pdf-preview/components/visual-preview.tsx +++ b/services/web/frontend/js/features/pdf-preview/components/visual-preview.tsx @@ -56,6 +56,9 @@ export const VisualPreview: FC<{ view: EditorView }> = ({ view }) => { labels: new Set(), packageNames: new Set(), referenceKeys: new Set(), + searchLocalReferences() { + return Promise.resolve({ hits: [] }) + }, commands: [], fileTreeData, }) diff --git a/services/web/frontend/js/features/source-editor/extensions/language.ts b/services/web/frontend/js/features/source-editor/extensions/language.ts index 721fb48587..932dd97b16 100644 --- a/services/web/frontend/js/features/source-editor/extensions/language.ts +++ b/services/web/frontend/js/features/source-editor/extensions/language.ts @@ -10,6 +10,7 @@ import { indentUnit, LanguageDescription } from '@codemirror/language' import { updateHasEffect } from '../utils/effects' import { Folder } from '../../../../../types/folder' import { Command } from '@/features/ide-react/context/metadata-context' +import { AdvancedReferenceSearchResult } from '@/features/ide-react/references/types' export const languageLoadedEffect = StateEffect.define() export const hasLanguageLoadedEffect = updateHasEffect(languageLoadedEffect) @@ -25,6 +26,9 @@ export type Metadata = { packageNames: Set commands: Command[] referenceKeys: Set + searchLocalReferences: ( + query: string + ) => Promise fileTreeData: Folder } diff --git a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts index 5e8f1d1fd7..f660a27317 100644 --- a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts +++ b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts @@ -104,7 +104,7 @@ function useCodeMirrorScope(view: EditorView) { const { showVisual: visual, trackChanges } = useEditorPropertiesContext() - const { referenceKeys } = useReferencesContext() + const { referenceKeys, searchLocalReferences } = useReferencesContext() const ranges = useRangesContext() const threads = useThreadsContext() @@ -227,6 +227,7 @@ function useCodeMirrorScope(view: EditorView) { const metadataRef = useRef({ ...metadata, referenceKeys, + searchLocalReferences, fileTreeData, }) @@ -246,6 +247,14 @@ function useCodeMirrorScope(view: EditorView) { }) }, [view, referenceKeys]) + // listen to project reference search updates + useEffect(() => { + metadataRef.current.searchLocalReferences = searchLocalReferences + window.setTimeout(() => { + view.dispatch(setMetadata(metadataRef.current)) + }) + }, [view, searchLocalReferences]) + // listen to project root folder updates useEffect(() => { if (fileTreeData) { diff --git a/services/web/frontend/js/infrastructure/project-snapshot.ts b/services/web/frontend/js/infrastructure/project-snapshot.ts index 976eb9d63f..a0d385ca02 100644 --- a/services/web/frontend/js/infrastructure/project-snapshot.ts +++ b/services/web/frontend/js/infrastructure/project-snapshot.ts @@ -61,6 +61,30 @@ export class ProjectSnapshot { return allPaths.filter(path => this.snapshot.getFile(path)?.isEditable()) } + /** + * Get the list of paths to binary files. + */ + getBinaryFilePathsWithHash(): { path: string; hash: string; size: number }[] { + const allPaths = this.snapshot.getFilePathnames() + const paths = [] + for (const path of allPaths) { + const file = this.snapshot.getFile(path) + if (file == null || file.isEditable()) { + continue + } + const hash = file.getHash() + const size = file.getByteLength() + if (hash == null) { + continue + } + if (size == null) { + continue + } + paths.push({ path, hash, size }) + } + return paths + } + /** * Get the doc content at the given path. */ @@ -72,6 +96,18 @@ export class ProjectSnapshot { return file.getContent({ filterTrackedDeletes: true }) ?? null } + async getBinaryFileContents( + path: string, + options?: { maxSize?: number } + ): Promise { + const file = this.snapshot.getFile(path) + const hash = file?.getHash() + if (hash == null) { + return null + } + return await this.blobStore.getString(hash, options) + } + /** * Immediately start a refresh */ @@ -166,8 +202,11 @@ class SimpleBlobStore { this.projectId = projectId } - async getString(hash: string): Promise { - return await fetchBlob(this.projectId, hash) + async getString( + hash: string, + options?: { maxSize?: number } + ): Promise { + return await fetchBlob(this.projectId, hash, options) } async getObject(hash: string) { @@ -226,11 +265,50 @@ async function fetchLatestChanges( } } -async function fetchBlob(projectId: string, hash: string): Promise { +async function fetchBlob( + projectId: string, + hash: string, + options?: { maxSize?: number } +): Promise { const url = `/project/${projectId}/blob/${hash}` + if (options?.maxSize) { + return await fetchTextFileWithSizeLimit(url, options.maxSize) + } const res = await fetch(url) if (!res.ok) { throw new FetchError('Failed to fetch blob', url, undefined, res) } return await res.text() } + +async function fetchTextFileWithSizeLimit(url: string, maxSize: number) { + let result = '' + try { + const abortController = new AbortController() + const response = await fetch(url, { + signal: abortController.signal, + }) + + if (!response.ok) { + throw new Error('Failed to fetch blob') + } + if (!response.body) { + throw new Error('Response body is empty') + } + + const reader = response.body.pipeThrough(new TextDecoderStream()) + for await (const chunk of reader) { + result += chunk + if (result.length > maxSize) { + abortController.abort() + } + } + } catch (error: any) { + if (error?.name === 'AbortError') { + // This is fine, we just return the result we have so far + } else { + throw error + } + } + return result.slice(0, maxSize) +} diff --git a/services/web/test/frontend/features/ide-react/unit/references/basic-reference-index.test.ts b/services/web/test/frontend/features/ide-react/unit/references/basic-reference-index.test.ts new file mode 100644 index 0000000000..e5fe318584 --- /dev/null +++ b/services/web/test/frontend/features/ide-react/unit/references/basic-reference-index.test.ts @@ -0,0 +1,104 @@ +import BasicReferenceIndex from '@/features/ide-react/references/basic-reference-index' +import { expect } from 'chai' + +const entry1 = `@article{sample2023, + author = {John Doe}, + title = {Sample Title}, + journal = {Sample Journal}, + year = {2023}, + date = {2023-01-01} +}` + +const entry2 = `@book{example2022, + author = {Jane Smith}, + title = {Example Book}, + journal = {Example Journal}, + year = {2022} + date = {2022-05-15} +}` + +const entry3 = `@inproceedings{test2021, + author = {Alice Johnson}, + title = {Test Conference Paper}, + booktitle = {Test Conference}, + year = {2021}, + date = {2021-10-10} +} +` + +const fileWithMultipleEntries = `${entry1}\n${entry2}` + +const addEntry1 = { path: 'file1.bib', content: entry1 } +const addEntry2 = { path: 'file2.bib', content: entry2 } +const addEntry3 = { path: 'file3.bib', content: entry3 } +const addFileWithMultipleEntries = { + path: 'file5.bib', + content: fileWithMultipleEntries, +} +const deleteEntry2 = 'file2.bib' + +describe('BasicReferenceIndex', function () { + beforeEach(function () { + this.index = new BasicReferenceIndex() + }) + + it('starts with an empty index', function () { + expect(this.index.fileIndex.size).to.equal(0) + expect(this.index.keys.size).to.equal(0) + }) + + describe('updateIndex', function () { + it('Adds entry to index and keys', function () { + const changes = { updates: [addEntry1], deletes: [] } + const keys = this.index.updateIndex(changes) + expect(this.index.fileIndex.size).to.equal(1) + expect(this.index.fileIndex.get('file1.bib')).to.deep.equal( + new Set(['sample2023']) + ) + expect(keys).to.deep.equal(new Set(['sample2023'])) + }) + + it("doesn't forget existing keys when adding new entries", function () { + const changes = { updates: [addEntry1, addEntry2], deletes: [] } + const keys = this.index.updateIndex(changes) + expect(this.index.fileIndex.size).to.equal(2) + expect(keys).to.deep.equal(new Set(['sample2023', 'example2022'])) + + const additionalChanges = { updates: [addEntry3], deletes: [] } + const updatedKeys = this.index.updateIndex(additionalChanges) + expect(this.index.fileIndex.size).to.equal(3) + expect(updatedKeys).to.deep.equal( + new Set(['sample2023', 'example2022', 'test2021']) + ) + }) + + it('removes keys when files are deleted', function () { + const changes = { + updates: [addEntry1, addEntry2, addEntry3], + deletes: [], + } + this.index.updateIndex(changes) + expect(this.index.fileIndex.size).to.equal(3) + expect(this.index.keys).to.deep.equal( + new Set(['sample2023', 'example2022', 'test2021']) + ) + + const deletionChanges = { updates: [], deletes: [deleteEntry2] } + const keysAfterDeletion = this.index.updateIndex(deletionChanges) + expect(this.index.fileIndex.size).to.equal(2) + expect(keysAfterDeletion).to.deep.equal( + new Set(['sample2023', 'test2021']) + ) + }) + + it('handles multiple entries in a single file', function () { + const changes = { updates: [addFileWithMultipleEntries], deletes: [] } + const keys = this.index.updateIndex(changes) + expect(this.index.fileIndex.size).to.equal(1) + expect(this.index.fileIndex.get('file5.bib')).to.deep.equal( + new Set(['sample2023', 'example2022']) + ) + expect(keys).to.deep.equal(new Set(['sample2023', 'example2022'])) + }) + }) +}) diff --git a/services/web/test/frontend/features/ide-react/unit/references/reference-index.test.ts b/services/web/test/frontend/features/ide-react/unit/references/reference-index.test.ts new file mode 100644 index 0000000000..788f8cdebd --- /dev/null +++ b/services/web/test/frontend/features/ide-react/unit/references/reference-index.test.ts @@ -0,0 +1,83 @@ +import { expect } from 'chai' +import { ReferenceIndex } from '@/features/ide-react/references/reference-index' + +class TestedReferenceIndex extends ReferenceIndex { + updateIndex(): void { + throw new Error('This is a test implementation') + } +} +describe('ReferenceIndex', function () { + beforeEach(function () { + this.index = new TestedReferenceIndex() + }) + + describe('parseEntries', function () { + it('should parse bib entry', function () { + const content = ` +@article{sample2023, + author = {John Doe}, + title = {Sample Title}, + journal = {Sample Journal}, + year = {2023}, + date = {2023-01-01} +} +` + const entries = this.index.parseEntries(content) + expect(entries).to.have.lengthOf(1) + expect(entries[0]).to.deep.equal({ + EntryKey: 'sample2023', + EntryType: 'article', + Fields: { + author: 'John Doe', + title: 'Sample Title', + journal: 'Sample Journal', + year: '2023', + date: '2023-01-01', + }, + ObjectType: 'entry', + }) + }) + + it('should default missing fields to empty strings', function () { + const content = `@article{sample2023, + author = {John Doe}, + title = {Sample Title} +}` + const entries = this.index.parseEntries(content) + expect(entries).to.have.lengthOf(1) + expect(entries[0]).to.deep.equal({ + EntryKey: 'sample2023', + EntryType: 'article', + Fields: { + author: 'John Doe', + title: 'Sample Title', + journal: '', + year: '', + date: '', + }, + ObjectType: 'entry', + }) + }) + + it('should handle multiple entries', function () { + const content = `@article{sample2023, + author = {John Doe}, + title = {Sample Title}, + journal = {Sample Journal}, + year = {2023}, + date = {2023-01-01} +} +@book{example2022, + author = {Jane Smith}, + title = {Example Book}, + journal = {Example Journal}, + year = {2022}, + date = {2022-05-15} +}` + const entries = this.index.parseEntries(content) + expect(entries).to.have.lengthOf(2) + expect(entries[0].EntryKey).to.equal('sample2023') + expect(entries[1].EntryKey).to.equal('example2022') + }) + }) +}) diff --git a/services/web/test/frontend/features/ide-react/unit/references/reference-indexer.spec.ts b/services/web/test/frontend/features/ide-react/unit/references/reference-indexer.spec.ts new file mode 100644 index 0000000000..67c20a3c31 --- /dev/null +++ b/services/web/test/frontend/features/ide-react/unit/references/reference-indexer.spec.ts @@ -0,0 +1,326 @@ +import { ReferenceIndexer } from '@/features/ide-react/references/reference-indexer' +import { generateMD5Hash } from '@/shared/utils/md5' +import sinon from 'sinon' + +const entry1 = `@article{sample2023, + author = {John Doe}, + title = {Sample Title}, + journal = {Sample Journal}, + year = {2023}, + date = {2023-01-01} +}` + +const entry2 = `@book{example2022, + author = {Jane Smith}, + title = {Example Book}, + journal = {Example Journal}, + year = {2022} + date = {2022-05-15} +}` + +const entry3 = `@article{sample2024, + author = {John Doe}, + title = {Sample Title}, + journal = {Sample Journal}, + year = {2024}, + date = {2024-01-01} +}` + +const entry4 = `@book{example2025, + author = {Jane Smith}, + title = {Example Book}, + journal = {Example Journal}, + year = {2025} + date = {2025-05-15} +}` + +const snapshotWithData = ({ + docs, + files, +}: { + docs?: Record + files?: Record +}) => { + return { + getDocPaths: sinon.spy(() => Object.keys(docs ?? {})), + getDocContents: sinon.spy(path => (docs ? (docs[path] ?? null) : null)), + getBinaryFilePathsWithHash: sinon.spy(() => { + return Object.entries(files ?? {}).map(([path, content]) => ({ + path, + hash: generateMD5Hash(content), + size: content.length, + })) + }), + getBinaryFileContents: sinon.spy(async path => + files ? (files[path] ?? null) : null + ), + } +} + +const IGNORED_SIGNAL = new AbortController().signal + +describe('ReferenceIndexer', function () { + it('it should index bib docs', async function () { + const referencer = new ReferenceIndexer() + const snapshot = snapshotWithData({ + docs: { + 'refs.bib': entry1, + 'refs2.bib': entry2, + 'other.tex': 'Not a bib file', + }, + }) + const result = await referencer.updateFromSnapshot(snapshot, { + signal: IGNORED_SIGNAL, + }) + expect(snapshot.getDocPaths).to.have.been.calledOnce + expect(snapshot.getDocContents).to.have.been.calledTwice + expect(snapshot.getDocContents).to.have.been.calledWith('refs.bib') + expect(snapshot.getDocContents).to.have.been.calledWith('refs2.bib') + expect(snapshot.getDocContents).to.not.have.been.calledWith('other.tex') + expect(snapshot.getBinaryFileContents).to.not.have.been.called + expect(result).to.deep.equal(new Set(['sample2023', 'example2022'])) + }) + + it('it should index bib binary files', async function () { + const referencer = new ReferenceIndexer() + const snapshot = snapshotWithData({ + files: { + 'refs.bib': entry1, + 'refs2.bib': entry2, + 'image.png': 'Not a bib file', + }, + }) + const result = await referencer.updateFromSnapshot(snapshot, { + signal: IGNORED_SIGNAL, + }) + expect(snapshot.getDocPaths).to.have.been.calledOnce + expect(snapshot.getDocContents).to.not.have.been.called + expect(snapshot.getBinaryFilePathsWithHash).to.have.been.calledOnce + expect(snapshot.getBinaryFileContents).to.have.been.calledTwice + expect(snapshot.getBinaryFileContents).to.have.been.calledWith('refs.bib') + expect(snapshot.getBinaryFileContents).to.have.been.calledWith('refs2.bib') + expect(snapshot.getBinaryFileContents).to.not.have.been.calledWith( + 'image.png' + ) + expect(result).to.deep.equal(new Set(['sample2023', 'example2022'])) + }) + + it('it should index both bib docs and binary files', async function () { + const referencer = new ReferenceIndexer() + const snapshot = snapshotWithData({ + docs: { + 'refs.bib': entry1, + 'other.tex': 'Not a bib file', + }, + files: { + 'refs2.bib': entry2, + 'image.png': 'Not a bib file', + }, + }) + const result = await referencer.updateFromSnapshot(snapshot, { + signal: IGNORED_SIGNAL, + }) + expect(snapshot.getDocPaths).to.have.been.calledOnce + expect(snapshot.getDocContents).to.have.been.calledOnce + expect(snapshot.getDocContents).to.have.been.calledWith('refs.bib') + expect(snapshot.getDocContents).to.not.have.been.calledWith('other.tex') + expect(snapshot.getBinaryFilePathsWithHash).to.have.been.calledOnce + expect(snapshot.getBinaryFileContents).to.have.been.calledOnce + expect(snapshot.getBinaryFileContents).to.have.been.calledWith('refs2.bib') + expect(snapshot.getBinaryFileContents).to.not.have.been.calledWith( + 'image.png' + ) + expect(result).to.deep.equal(new Set(['sample2023', 'example2022'])) + }) + + it('should not fetch binary files if unchanged', async function () { + const referencer = new ReferenceIndexer() + const initialSnapshot = snapshotWithData({ + files: { + 'refs.bib': entry1, + }, + }) + const initialResult = await referencer.updateFromSnapshot(initialSnapshot, { + signal: IGNORED_SIGNAL, + }) + expect(initialSnapshot.getDocPaths).to.have.been.calledOnce + expect(initialSnapshot.getBinaryFilePathsWithHash).to.have.been.calledOnce + expect(initialSnapshot.getBinaryFileContents).to.have.been.calledOnceWith( + 'refs.bib' + ) + expect(initialResult).to.deep.equal(new Set(['sample2023'])) + + // Second snapshot with same files, should not fetch contents again + const secondSnapshot = snapshotWithData({ + files: { + 'refs.bib': entry1, + }, + }) + const secondResult = await referencer.updateFromSnapshot(secondSnapshot, { + signal: IGNORED_SIGNAL, + }) + expect(secondSnapshot.getDocPaths).to.have.been.calledOnce + expect(secondSnapshot.getBinaryFilePathsWithHash).to.have.been.calledOnce + expect(secondSnapshot.getBinaryFileContents).to.not.have.been.called + expect(secondResult).to.deep.equal(new Set(['sample2023'])) + }) + + it('should fetch changed binary file', async function () { + const referencer = new ReferenceIndexer() + const initialSnapshot = snapshotWithData({ + files: { + 'refs.bib': entry1, + }, + }) + const initialResult = await referencer.updateFromSnapshot(initialSnapshot, { + signal: IGNORED_SIGNAL, + }) + expect(initialSnapshot.getDocPaths).to.have.been.calledOnce + expect(initialSnapshot.getBinaryFilePathsWithHash).to.have.been.calledOnce + expect(initialSnapshot.getBinaryFileContents).to.have.been.calledOnceWith( + 'refs.bib' + ) + expect(initialResult).to.deep.equal(new Set(['sample2023'])) + + // Second snapshot with a different file, should fetch contents again + const secondSnapshot = snapshotWithData({ + files: { + 'refs.bib': entry2, + }, + }) + const secondResult = await referencer.updateFromSnapshot(secondSnapshot, { + signal: IGNORED_SIGNAL, + }) + expect(secondSnapshot.getDocPaths).to.have.been.calledOnce + expect(secondSnapshot.getBinaryFilePathsWithHash).to.have.been.calledOnce + expect(initialSnapshot.getBinaryFileContents).to.have.been.calledOnceWith( + 'refs.bib' + ) + expect(secondResult).to.deep.equal(new Set(['example2022'])) + }) + + it('should update changed doc', async function () { + const referencer = new ReferenceIndexer() + const initialSnapshot = snapshotWithData({ + docs: { + 'refs.bib': entry1, + }, + }) + const initialResult = await referencer.updateFromSnapshot(initialSnapshot, { + signal: IGNORED_SIGNAL, + }) + expect(initialResult).to.deep.equal(new Set(['sample2023'])) + const secondSnapshot = snapshotWithData({ + docs: { + 'refs.bib': entry2, + }, + }) + const secondResult = await referencer.updateFromSnapshot(secondSnapshot, { + signal: IGNORED_SIGNAL, + }) + expect(secondResult).to.deep.equal(new Set(['example2022'])) + }) + + it('should notice deleted files', async function () { + const referencer = new ReferenceIndexer() + const initialSnapshot = snapshotWithData({ + files: { + 'refs.bib': entry1, + 'refs2.bib': entry2, + }, + }) + const initialResult = await referencer.updateFromSnapshot(initialSnapshot, { + signal: IGNORED_SIGNAL, + }) + expect(initialSnapshot.getDocPaths).to.have.been.calledOnce + expect(initialSnapshot.getBinaryFilePathsWithHash).to.have.been.calledOnce + expect(initialSnapshot.getBinaryFileContents).to.have.been.calledTwice + expect(initialResult).to.deep.equal(new Set(['sample2023', 'example2022'])) + + // Second snapshot with one file removed, should update index + const secondSnapshot = snapshotWithData({ + files: { + 'refs.bib': entry1, + }, + }) + const secondResult = await referencer.updateFromSnapshot(secondSnapshot, { + signal: IGNORED_SIGNAL, + }) + expect(secondSnapshot.getDocPaths).to.have.been.calledOnce + expect(secondSnapshot.getBinaryFilePathsWithHash).to.have.been.calledOnce + expect(secondSnapshot.getBinaryFileContents).to.not.have.been.called + expect(secondResult).to.deep.equal(new Set(['sample2023'])) + }) + + it('should notice deleted docs', async function () { + const referencer = new ReferenceIndexer() + const initialSnapshot = snapshotWithData({ + docs: { + 'refs.bib': entry1, + 'refs2.bib': entry2, + }, + }) + const initialResult = await referencer.updateFromSnapshot(initialSnapshot, { + signal: IGNORED_SIGNAL, + }) + expect(initialSnapshot.getDocPaths).to.have.been.calledOnce + expect(initialSnapshot.getDocContents).to.have.been.calledTwice + expect(initialResult).to.deep.equal(new Set(['sample2023', 'example2022'])) + + // Second snapshot with one doc removed, should update index + const secondSnapshot = snapshotWithData({ + docs: { + 'refs.bib': entry1, + }, + }) + const secondResult = await referencer.updateFromSnapshot(secondSnapshot, { + signal: IGNORED_SIGNAL, + }) + expect(secondSnapshot.getDocPaths).to.have.been.calledOnce + expect(secondSnapshot.getDocContents).to.have.been.calledOnce + expect(secondResult).to.deep.equal(new Set(['sample2023'])) + }) + + it('should abort when signalled', async function () { + const referencer = new ReferenceIndexer() + const snapshot = snapshotWithData({ + files: { + 'refs.bib': entry1, + 'refs2.bib': entry2, + }, + }) + const controller = new AbortController() + controller.abort() + const result = await referencer.updateFromSnapshot(snapshot, { + signal: controller.signal, + }) + expect(result).to.deep.equal(new Set()) + }) + + it('should respect data budget', async function () { + async function testWithDataBudget(budget: number, keys: Set) { + const referencer = new ReferenceIndexer() + const snapshot = snapshotWithData({ + docs: { + 'a.bib': entry1, // 140 bytes + 'b.bib': entry2, // 140 bytes + 'c.bib': entry3, // 140 bytes + 'd.bib': entry4, // 140 bytes + }, + }) + const result = await referencer.updateFromSnapshot(snapshot, { + signal: IGNORED_SIGNAL, + dataLimit: budget, + }) + expect(result).to.deep.equal(keys) + } + + await testWithDataBudget( + 1000, + new Set(['sample2023', 'example2022', 'sample2024', 'example2025']) + ) + await testWithDataBudget(300, new Set(['sample2023', 'example2022'])) + await testWithDataBudget(200, new Set(['sample2023'])) + await testWithDataBudget(100, new Set()) + }) +}) diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx index fc8831efd5..8b29a79da6 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx @@ -381,6 +381,11 @@ describe('autocomplete', { scrollBehavior: false }, function () { value={{ referenceKeys: new Set(['ref-1', 'ref-2', 'ref-3']), indexAllReferences: cy.stub(), + searchLocalReferences() { + return Promise.resolve({ + hits: [], + }) + }, }} > {children} diff --git a/services/web/test/frontend/helpers/editor-providers.tsx b/services/web/test/frontend/helpers/editor-providers.tsx index 21a8624792..5387feee16 100644 --- a/services/web/test/frontend/helpers/editor-providers.tsx +++ b/services/web/test/frontend/helpers/editor-providers.tsx @@ -47,6 +47,7 @@ import { } from '@/shared/context/types/project-metadata' import { UserId } from '../../../types/user' import { ProjectCompiler } from '../../../types/project-settings' +import { ReferencesContext } from '@/features/ide-react/context/references-context' // these constants can be imported in tests instead of // using magic strings @@ -243,6 +244,7 @@ export function EditorProviders({ }), LayoutProvider: makeLayoutProvider(layoutContext), ProjectProvider: makeProjectProvider(project), + ReferencesProvider: makeReferencesProvider(), ...providers, }} > @@ -251,6 +253,27 @@ export function EditorProviders({ ) } +const makeReferencesProvider = () => { + const ReferencesProvider: FC = ({ children }) => { + return ( + Promise.resolve(), + searchLocalReferences() { + return Promise.resolve({ + hits: [], + }) + }, + }} + > + {children} + + ) + } + return ReferencesProvider +} + const makeConnectionProvider = (socket: Socket) => { const ConnectionProvider: FC = ({ children }) => { const [value] = useState(() => ({ diff --git a/services/web/test/frontend/infrastructure/project-snapshot.test.ts b/services/web/test/frontend/infrastructure/project-snapshot.test.ts index b3a78efed7..d231e1c1c5 100644 --- a/services/web/test/frontend/infrastructure/project-snapshot.test.ts +++ b/services/web/test/frontend/infrastructure/project-snapshot.test.ts @@ -37,6 +37,13 @@ describe('ProjectSnapshot', function () { contents: "We're done here", hash: 'dddddddddddddddddddddddddddddddddddddddd', }, + 'bibliography.bib': { + contents: + '@book{example2020,\n title={An example book},\n author={Doe, John},\n year={2020},\n publisher={Publisher}\n}\n'.repeat( + 60_000 + ), // 6.5MB + hash: 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + }, } const chunk = { @@ -68,6 +75,13 @@ describe('ProjectSnapshot', function () { byteLength: 97080, }, }, + { + pathname: 'bibliography.bib', + file: { + hash: files['bibliography.bib'].hash, + byteLength: files['bibliography.bib'].contents.length, + }, + }, ], timestamp: '2025-01-01T12:00:00.000Z', }, @@ -214,6 +228,45 @@ describe('ProjectSnapshot', function () { ) }) }) + + describe('getBinaryFilePathsWithHash()', function () { + it('returns the binary files', function () { + const binaries = snapshot.getBinaryFilePathsWithHash() + expect(binaries).to.deep.equal([ + { + path: 'frog.jpg', + hash: 'cccccccccccccccccccccccccccccccccccccccc', + size: 97080, + }, + { + path: 'bibliography.bib', + hash: files['bibliography.bib'].hash, + size: files['bibliography.bib'].contents.length, + }, + ]) + }) + }) + + describe('getBinaryFileContents', function () { + beforeEach(function () { + mockBlobs(['bibliography.bib']) + }) + + it('can fetch whole file', async function () { + const blob = await snapshot.getBinaryFileContents('bibliography.bib') + expect(blob).to.equal(files['bibliography.bib'].contents) + }) + + // NOTE: fetch-mock does not support the .response.body.pipeThrough API, + // so this test is skipped for now. + // eslint-disable-next-line mocha/no-skipped-tests + it.skip('can fetch part of file', async function () { + const blob = await snapshot.getBinaryFileContents('bibliography.bib', { + maxSize: 100, + }) + expect(blob).to.equal(files['bibliography.bib'].contents.slice(0, 100)) + }) + }) }) describe('concurrency', function () {