From 413273101633925f6fef08ef21fae0dcee9168a7 Mon Sep 17 00:00:00 2001 From: Maria Florencia Besteiro Gonzalez Date: Thu, 23 Jul 2026 17:02:50 +0200 Subject: [PATCH] Merge pull request #35680 from overleaf/mfb-migrate-bibtex-parser-to-core-feature Migrate BibTeX parser to core frontend feature GitOrigin-RevId: 274f64d31c1027c5eea9ec569e54dece57307220 --- .../features/bibtex/bibtex-entry-schema.mts | 13 + .../js/features/bibtex/bibtex-entry.mts | 174 ++++ .../js/features/bibtex/bibtex-field-value.mts | 278 ++++++ .../js/features/bibtex/bibtex-name.mts | 190 ++++ .../js/features/bibtex/bibtex-parser.ts | 198 ++++ .../js/features/bibtex/bibtex-purify.mts | 49 + .../js/features/bibtex/citation-key.mts | 68 ++ .../js/features/bibtex/entry-types.mts | 479 ++++++++++ .../js/features/bibtex/latex-to-unicode.mts | 876 ++++++++++++++++++ .../bibtex/positioned-bibtex-entry.ts | 14 + 10 files changed, 2339 insertions(+) create mode 100644 services/web/frontend/js/features/bibtex/bibtex-entry-schema.mts create mode 100644 services/web/frontend/js/features/bibtex/bibtex-entry.mts create mode 100644 services/web/frontend/js/features/bibtex/bibtex-field-value.mts create mode 100644 services/web/frontend/js/features/bibtex/bibtex-name.mts create mode 100644 services/web/frontend/js/features/bibtex/bibtex-parser.ts create mode 100644 services/web/frontend/js/features/bibtex/bibtex-purify.mts create mode 100644 services/web/frontend/js/features/bibtex/citation-key.mts create mode 100644 services/web/frontend/js/features/bibtex/entry-types.mts create mode 100644 services/web/frontend/js/features/bibtex/latex-to-unicode.mts create mode 100644 services/web/frontend/js/features/bibtex/positioned-bibtex-entry.ts diff --git a/services/web/frontend/js/features/bibtex/bibtex-entry-schema.mts b/services/web/frontend/js/features/bibtex/bibtex-entry-schema.mts new file mode 100644 index 0000000000..beec1be6ff --- /dev/null +++ b/services/web/frontend/js/features/bibtex/bibtex-entry-schema.mts @@ -0,0 +1,13 @@ +export type BibEntryInput = { + key: string + label: string + helperText: string +} + +export type BibEntryType = { + key: string + label: string + fields: string[] + optionalFields: string[] + requiredFields: (string | string[])[] +} diff --git a/services/web/frontend/js/features/bibtex/bibtex-entry.mts b/services/web/frontend/js/features/bibtex/bibtex-entry.mts new file mode 100644 index 0000000000..00b3bc18bc --- /dev/null +++ b/services/web/frontend/js/features/bibtex/bibtex-entry.mts @@ -0,0 +1,174 @@ +import { BibtexFieldValue } from './bibtex-field-value.mts' +import { getEntryType } from './entry-types.mts' +import { BibtexNameList } from './bibtex-name.mts' + +type Fields = ReadonlyMap + +type BibtexEntryOptions = { + type?: string + key?: string + fields?: Fields + id?: string + updatedAt?: Date +} + +/** + * Immutable BibTeX entry content. Position information lives separately on + * `PositionedBibtexEntry` so `BibtexEntry` references can stay stable across + * pure position shifts (e.g. when typing above an entry). + */ +export class BibtexEntry { + /** + * Entry type + */ + readonly type: string + + /** + * Citation key + */ + readonly key: string + + /** + * Fields + */ + readonly fields: Fields + + /** + * Last updated timestamp (set when loaded from the library) + */ + readonly updatedAt: Date | undefined + + private readonly _id: string + + /** + * Stable identifier. Defaults to the citation key for in-project entries; + * set to the database record ID for library entries. + */ + get id(): string { + return this._id || this.key + } + + constructor({ + type = 'article', + key = '', + fields = new Map(), + id = '', + updatedAt, + }: BibtexEntryOptions = {}) { + this.type = type + this.key = key + this.fields = fields + this._id = id + this.updatedAt = updatedAt + } + + setType(type: string) { + return new BibtexEntry({ ...this, type, id: this._id }) + } + + setKey(key: string) { + return new BibtexEntry({ ...this, key, id: this._id }) + } + + getField(field: string) { + return this.fields.get(field) ?? new BibtexFieldValue() + } + + getFields() { + return this.fields.keys() + } + + getFieldsArray() { + return Array.from(this.fields.entries()).map(([key, value]) => ({ + key, + value, + })) + } + + /** Returns all authors (and editors, as fallback) as a parsed BibtexNameList. */ + getAuthors(): BibtexNameList { + const authorStr = + this.getField('author').toDisplayString() || + this.getField('editor').toDisplayString() + return new BibtexNameList(authorStr) + } + + getTitle(): string { + return ( + this.getField('title').toDisplayString() || + this.getField('subtitle').toDisplayString() + ) + } + + getYear(): string { + const year = this.getField('year').toDisplayString() + if (year) return year + const dateStr = this.getField('date').toDisplayString() + const match = dateStr.match(/^\d{4}/) + return match ? match[0] : '' + } + + hasField(field: string) { + return this.fields.has(field) + } + + hasFields() { + return this.fields.size > 0 + } + + setField(field: string, value: BibtexFieldValue) { + const fields = new Map(this.fields) + fields.set(field, value) + return new BibtexEntry({ ...this, fields, id: this._id }) + } + + deleteField(field: string) { + const fields = new Map(this.fields) + fields.delete(field) + return new BibtexEntry({ ...this, fields, id: this._id }) + } + + toString(): string { + let bibEntry = `@${this.type}{${this.key},\n` + for (const [field, value] of this.fields) { + bibEntry += ` ${field} = ${value.toString()},\n` + } + bibEntry += '}' + return bibEntry + } + + getWarnings(): BibtexEntryWarning[] { + const warnings: BibtexEntryWarning[] = [] + + const missingFieldGroups = this.getMissingFieldGroups() + if (missingFieldGroups.length > 0) { + warnings.push(new MissingFieldsWarning(missingFieldGroups)) + } + return warnings + } + + getMissingFieldGroups(): string[][] { + const entryType = getEntryType(this.type) + const requiredFields = entryType.requiredFields + const missingFields: string[][] = [] + for (const requiredField of requiredFields) { + const group = Array.isArray(requiredField) + ? requiredField + : [requiredField] + if (!group.some(field => this.hasField(field))) { + missingFields.push(group) + } + } + return missingFields + } +} + +export class MissingFieldsWarning { + fieldGroups: string[][] + + constructor(fieldGroups: string[][]) { + this.fieldGroups = fieldGroups + } +} + +export type BibtexEntryWarning = MissingFieldsWarning diff --git a/services/web/frontend/js/features/bibtex/bibtex-field-value.mts b/services/web/frontend/js/features/bibtex/bibtex-field-value.mts new file mode 100644 index 0000000000..475b90de01 --- /dev/null +++ b/services/web/frontend/js/features/bibtex/bibtex-field-value.mts @@ -0,0 +1,278 @@ +import { latexToUnicode } from './latex-to-unicode.mts' +import { purifySpecialChars } from './bibtex-purify.mts' + +const IDENTIFIER_SYMBOLS = '!$&*+./:;<>?^`_|[]-' + +class StringLiteral { + private value: string + + constructor(value: string) { + this.value = value + } + + toString() { + return `{${this.value}}` + } + + toEditableString() { + return this.value.replaceAll('#', '##') + } + + toDisplayString() { + let s = this.value + s = latexToUnicode(s) + s = purifySpecialChars(s) + s = s.replaceAll(/[~\s]+/g, ' ') + s = s.replaceAll(/[{}\\]/g, '') + return s + } +} + +class NumberLiteral { + private value: string + + constructor(value: string) { + this.value = value + } + + toString() { + return this.value + } + + toEditableString() { + return this.value + } + + toDisplayString() { + return this.value + } +} + +class NamedString { + private name: string + + constructor(name: string) { + this.name = name + } + + toString() { + return this.name + } + + toEditableString() { + return `#${this.name}#` + } + + toDisplayString() { + return this.name + } +} + +type Part = StringLiteral | NumberLiteral | NamedString + +export class BibtexFieldValue { + private readonly parts: ReadonlyArray = [] + private bibtexString: string | null = null + private displayString: string | null = null + private editableString: string | null = null + + constructor(parts: Part[] = []) { + this.parts = parts + } + + addString(value: string) { + return new BibtexFieldValue([...this.parts, new StringLiteral(value)]) + } + + addNumber(value: string) { + return new BibtexFieldValue([...this.parts, new NumberLiteral(value)]) + } + + addNamedString(name: string) { + return new BibtexFieldValue([...this.parts, new NamedString(name)]) + } + + private invalidateCache() { + this.bibtexString = null + this.editableString = null + this.displayString = null + } + + toString() { + if (this.bibtexString == null) { + if (this.parts.length === 0) { + return '{}' + } + + this.bibtexString = this.parts.map(part => part.toString()).join(' # ') + } + return this.bibtexString + } + + /** + * Convert a BibtexFieldValue to editable field text. + * + * Steps: + * 1. String literals are copied as-is, but every '#' is escaped as '##'. + * 2. Numbers are copied as text. + * 3. Named strings are wrapped as '#name#'. + * 4. All parts are concatenated directly. + * + * Example: + * BibTeX: + * {This is } # author # {'s } # {#1 } # book # { from } # 1995 + * Editable: + * This is #author#'s ##1 #book# from 1995 + * + * Example: + * BibTeX: + * "The " # DS # " handbook" + * Editable: + * The #DS# handbook + */ + toEditableString() { + if (this.editableString == null) { + this.editableString = this.parts + .map(part => part.toEditableString()) + .join('') + } + return this.editableString + } + + /** + * Convert a BibtexFieldValue to a string suitable for display. + * + * - Replace tildes with spaces + * - Replace successions of whitespace characters with a single space + * - Trim leading and trailing whitespace + * - Remove curly braces and backslashes + */ + toDisplayString() { + if (this.displayString == null) { + let s = this.parts.map(part => part.toDisplayString()).join('') + s = s.replaceAll(/^\s+|\s+$/g, '') + this.displayString = s + } + return this.displayString + } + + /** + * Make a new BibtexFieldValue from an editable string + * + * Scan left-to-right: + * 1. '##' becomes a literal '#'. + * 2. '#identifier#' becomes a named string part. + * 3. Any other '#' is treated as a literal '#'. + * 4. Literal runs become braced strings and parts are joined with ' # '. + * + * Identifier rule: + * - First char: letter or one of ! $ & * + . / : ; < > ? ^ ` _ | [ ] - + * - Following chars: same set plus digits. + * - Cannot start with a digit. + * + * Example: + * Editable: + * This is #author#'s #1 book (#numberone) and here's a ##tag## + * BibTeX: + * {This is } # author # {'s #1 book (#numberone) and here's a #tag#} + * + * Example: + * Editable: + * This is from 1995 + * BibTeX: + * {This is from 1995} + */ + static fromEditableString(editable: string) { + let bibtexValue = new BibtexFieldValue() + let currentString = '' + let index = 0 + + while (index < editable.length) { + if (editable[index] !== '#') { + currentString += editable[index] + index += 1 + continue + } + + const nextIndex = index + 1 + const nextChar = editable[nextIndex] + + if (nextChar === '#') { + currentString += '#' + index += 2 + continue + } + + const identifierEnd = findBibtexIdentifierEndExclusive( + editable, + nextIndex + ) + if (identifierEnd !== null && editable[identifierEnd] === '#') { + if (currentString !== '') { + bibtexValue = bibtexValue.addString(currentString) + currentString = '' + } + + bibtexValue = bibtexValue.addNamedString( + editable.slice(nextIndex, identifierEnd) + ) + index = identifierEnd + 1 + continue + } + + currentString += '#' + index += 1 + } + + if (currentString !== '') { + bibtexValue = bibtexValue.addString(currentString) + } + + return bibtexValue + } +} + +function findBibtexIdentifierEndExclusive( + text: string, + startIndex: number +): number | null { + if (startIndex > text.length) { + throw new Error('startIndex must not be greater than text length') + } + + if ( + startIndex === text.length || + !isBibtexIdentifierStartChar(text[startIndex]) + ) { + return null + } + + let index = startIndex + 1 + while ( + index < text.length && + isBibtexIdentifierContinuationChar(text[index]) + ) { + index += 1 + } + return index +} + +function isBibtexIdentifierStartChar(char: string): boolean { + return isAsciiLetter(char) || IDENTIFIER_SYMBOLS.includes(char) +} + +function isBibtexIdentifierContinuationChar(char: string): boolean { + return isBibtexIdentifierStartChar(char) || isDigit(char) +} + +function isAsciiLetter(char: string): boolean { + return /^[A-Za-z]$/.test(char) +} + +function isDigit(char: string): boolean { + return /^[0-9]$/.test(char) +} + +export const __test = { + findBibtexIdentifierEndExclusive, +} diff --git a/services/web/frontend/js/features/bibtex/bibtex-name.mts b/services/web/frontend/js/features/bibtex/bibtex-name.mts new file mode 100644 index 0000000000..f03feecd35 --- /dev/null +++ b/services/web/frontend/js/features/bibtex/bibtex-name.mts @@ -0,0 +1,190 @@ +type VonLast = { von: string; last: string } +type FirstVonLast = { first: string; von: string; last: string } + +/** + * Splits "von Last" (the left-hand segment in BibTeX comma notation) into its + * von particle and Last components. The final word is always part of Last; + * preceding lowercase-initial words are the von particle. + */ +function parseVonLast(vonLast: string): VonLast { + const words = vonLast.split(/\s+/).filter(Boolean) + if (words.length === 0) return { von: '', last: '' } + if (words.length === 1) return { von: '', last: words[0] } + const mandatory = words[words.length - 1] + const preceding = words.slice(0, -1) + let lastVonIdx = -1 + for (let i = preceding.length - 1; i >= 0; i--) { + if (/^[a-z]/.test(preceding[i])) { + lastVonIdx = i + break + } + } + if (lastVonIdx === -1) return { von: '', last: words.join(' ') } + return { + von: preceding.slice(0, lastVonIdx + 1).join(' '), + last: [...preceding.slice(lastVonIdx + 1), mandatory].join(' '), + } +} + +/** + * Parses a "First von Last" name (no commas) into its three components. The + * final word is always part of Last; uppercase words following the last + * lowercase (von) word extend Last; lowercase words form the von particle. + */ +function parseFirstVonLast(name: string): FirstVonLast { + const words = name.split(/\s+/).filter(Boolean) + if (words.length === 0) return { first: '', von: '', last: '' } + if (words.length === 1) return { first: '', von: '', last: words[0] } + const mandatory = words[words.length - 1] + const preceding = words.slice(0, -1) + let firstVonIdx = -1 + let lastVonIdx = -1 + for (let i = 0; i < preceding.length; i++) { + if (/^[a-z]/.test(preceding[i])) { + if (firstVonIdx === -1) firstVonIdx = i + lastVonIdx = i + } + } + if (lastVonIdx === -1) { + return { first: preceding.join(' '), von: '', last: mandatory } + } + return { + first: preceding.slice(0, firstVonIdx).join(' '), + von: preceding.slice(firstVonIdx, lastVonIdx + 1).join(' '), + last: [...preceding.slice(lastVonIdx + 1), mandatory].join(' '), + } +} + +/** + * A single BibTeX author name, parsed into its constituents (First, von, Last, + * Suffix) from any of the three standard BibTeX name notations: + * + * "First von Last" + * "von Last, First" + * "von Last, Suffix, First" + */ +export class BibtexName { + readonly first: string + readonly von: string + readonly last: string + readonly suffix: string + + constructor(raw: string) { + const trimmed = raw.trim() + const segments = trimmed.split(',').map(s => s.trim()) + if (segments.length >= 3) { + // "von Last, Suffix, First" + const { von, last } = parseVonLast(segments[0]) + this.von = von + this.last = last + this.suffix = segments[1] + this.first = segments.slice(2).join(' ').trim() + } else if (segments.length === 2) { + // "von Last, First" + const { von, last } = parseVonLast(segments[0]) + this.von = von + this.last = last + this.suffix = '' + this.first = segments[1] + } else { + // "First von Last" + const { first, von, last } = parseFirstVonLast(trimmed) + this.first = first + this.von = von + this.last = last + this.suffix = '' + } + } + + /** Returns the name in "First von Last Suffix" display order. */ + toFirstLast(): string { + return [this.first, this.von, this.last, this.suffix] + .filter(Boolean) + .join(' ') + } + + /** Returns the "von Last" component (without First or Suffix). */ + toLast(): string { + return [this.von, this.last].filter(Boolean).join(' ') + } + + /** Returns the name in "von Last, First" display order (BibTeX comma notation). */ + toLastFirst(): string { + const vonLast = [this.von, this.last].filter(Boolean).join(' ') + return this.first ? `${vonLast}, ${this.first}` : vonLast + } +} + +/** + * A parsed BibTeX author/editor field. Names are split on " and " + * (case-insensitive). If the last token is "others" (case-insensitive), it is + * not included in `names` and `hasOthers` is set to `true` instead. + * + * Examples: + * "John Smith and Jane Doe" → names=[Smith, Doe], hasOthers=false + * "John Smith and others" → names=[Smith], hasOthers=true + * "others" → names=[others], hasOthers=false + * "others and John Smith" → names=[others, Smith], hasOthers=false + */ +export class BibtexNameList { + readonly names: BibtexName[] + readonly hasOthers: boolean + + constructor(raw: string) { + if (!raw.trim()) { + this.names = [] + this.hasOthers = false + return + } + const parts = raw + .split(/\s+and\s+/i) + .map(s => s.trim()) + .filter(Boolean) + if ( + parts.length > 1 && + parts[parts.length - 1].toLowerCase() === 'others' + ) { + this.hasOthers = true + this.names = parts.slice(0, -1).map(s => new BibtexName(s)) + } else { + this.hasOthers = false + this.names = parts.map(s => new BibtexName(s)) + } + } + + /** + * Joins the names into a formatted display string using the Oxford comma. + * When `hasOthers` is true, renders "et al." in place of the omitted token. + * + * Examples with `format = n => n.toFirstLast()`: + * [Smith] hasOthers=false → "Jane Smith" + * [Smith, Doe] hasOthers=false → "Jane Smith and John Doe" + * [Smith, Doe, Jones] → "Jane Smith, John Doe, and Bob Jones" + * [Smith] hasOthers=true → "Jane Smith et al." + */ + join(format: (name: BibtexName) => string = n => n.toFirstLast()): string { + const { names, hasOthers } = this + if (names.length === 0) return '' + const formatted = names.map(format) + if (hasOthers) { + if (formatted.length === 1) return `${formatted[0]} et al.` + return `${formatted.join(', ')}, et al.` + } + if (formatted.length === 1) return formatted[0] + if (formatted.length === 2) return `${formatted[0]} and ${formatted[1]}` + return `${formatted.slice(0, -1).join(', ')}, and ${formatted[formatted.length - 1]}` + } + + /** + * Returns a short author string using family names only. For 3+ names or + * when `hasOthers` is true, returns the first surname followed by "et al.". + * For exactly 2 names, joins their surnames with " & ". + */ + summarize(): string { + const { names, hasOthers } = this + if (names.length === 0) return '' + const surnames = names.map(n => [n.von, n.last].filter(Boolean).join(' ')) + if (hasOthers || surnames.length > 2) return `${surnames[0]} et al.` + return surnames.join(' & ') + } +} diff --git a/services/web/frontend/js/features/bibtex/bibtex-parser.ts b/services/web/frontend/js/features/bibtex/bibtex-parser.ts new file mode 100644 index 0000000000..108a3ad1b0 --- /dev/null +++ b/services/web/frontend/js/features/bibtex/bibtex-parser.ts @@ -0,0 +1,198 @@ +import { SyntaxNodeRef, Tree as LezerTree } from '@lezer/common' +import { + Junk, + Entry, + EntryType, + CitationKey, + Field, + FieldName, + StringLiteral, + NumberLiteral, + StringName, +} from '@/features/source-editor/lezer-bibtex/bibtex.terms.mjs' +import { parser as lezerParser } from '@/features/source-editor/lezer-bibtex/bibtex.mjs' +import { BibtexEntry } from './bibtex-entry.mts' +import { PositionedBibtexEntry } from './positioned-bibtex-entry' +import { BibtexFieldValue } from './bibtex-field-value.mts' + +type GetText = (from: number, to: number) => string + +export type ParseResult = { + entries: BibtexEntry[] + junk: string +} + +export function parseBibtex(input: string): ParseResult { + const tree = lezerParser.parse(input) + const parser = new BibtexParser(tree, (from, to) => input.slice(from, to)) + parser.parse() + return { + entries: parser.entries.map(positioned => positioned.entry), + junk: parser.junk, + } +} + +/** + * Walks a single Entry's subtree, accumulating type / key / fields, and + * yields a `BibtexEntry` via `build()`. The walker fires `enter` and `leave` + * events that the caller must dispatch — this matches lezer's + * `tree.iterate` enter/leave semantics so it can be plugged into both + * whole-tree parsers and incremental projection visitors. + */ +export class BibtexEntryAccumulator { + private type: string | null = null + private key: string | null = null + private fields: Map = new Map() + private fieldName: string | null = null + private fieldValue: BibtexFieldValue = new BibtexFieldValue() + + reset() { + this.type = null + this.key = null + this.fields = new Map() + this.fieldName = null + this.fieldValue = new BibtexFieldValue() + } + + /** + * Returns false when the node is fully handled and the caller should not + * descend into its children, matching lezer's iterate API. + */ + enter(node: SyntaxNodeRef, getText: GetText): boolean { + const type = node.type + if (type.is(EntryType)) { + this.type = getText(node.from, node.to).toLowerCase() + return false + } + if (type.is(CitationKey)) { + this.key = getText(node.from, node.to) + return false + } + if (type.is(FieldName)) { + this.fieldName = getText(node.from, node.to).toLowerCase() + return false + } + if (type.is(StringLiteral)) { + // Strip surrounding quotes/braces and collapse multi-line indentation. + let s = getText(node.from + 1, node.to - 1) + s = s.replaceAll(/\n\s*/g, ' ') + this.fieldValue = this.fieldValue.addString(s) + return false + } + if (type.is(NumberLiteral)) { + this.fieldValue = this.fieldValue.addNumber(getText(node.from, node.to)) + return false + } + if (type.is(StringName)) { + this.fieldValue = this.fieldValue.addNamedString( + getText(node.from, node.to) + ) + return false + } + return true + } + + leave(node: SyntaxNodeRef) { + if (node.type.is(Field)) { + if (this.fieldName != null) { + this.fields.set(this.fieldName, this.fieldValue) + } + this.fieldName = null + this.fieldValue = new BibtexFieldValue() + } + } + + /** + * Constructs a BibtexEntry from the accumulated state, or returns null if + * the entry was missing a type or key. + */ + build(): BibtexEntry | null { + if (this.type == null || this.key == null) return null + return new BibtexEntry({ + type: this.type, + key: this.key, + fields: this.fields, + }) + } +} + +class BibtexParser { + tree: LezerTree + getText: GetText + lineAt: ((pos: number) => number) | undefined + entries: PositionedBibtexEntry[] = [] + junk: string = '' + seenKeys: Set = new Set() + duplicateKeys: Set = new Set() + private accumulator: BibtexEntryAccumulator = new BibtexEntryAccumulator() + private inEntry: boolean = false + private entryFrom: number = 0 + private entryTo: number = 0 + + constructor( + tree: LezerTree, + getText: GetText, + options: { lineAt?: (pos: number) => number } = {} + ) { + this.tree = tree + this.getText = getText + this.lineAt = options.lineAt + } + + parse() { + this.tree.iterate({ + enter: this.enterNode.bind(this), + leave: this.leaveNode.bind(this), + }) + } + + enterNode(node: SyntaxNodeRef) { + const type = node.type + + if (type.is(Entry)) { + this.accumulator.reset() + this.inEntry = true + this.entryFrom = node.from + this.entryTo = node.to + return true + } + + if (type.is(Junk)) { + this.junk += this.getText(node.from, node.to) + '\n' + return false + } + + if (!this.inEntry) { + return true + } + return this.accumulator.enter(node, this.getText) + } + + leaveNode(node: SyntaxNodeRef) { + if (node.type.is(Entry)) { + this.storeEntry() + this.inEntry = false + } else if (this.inEntry) { + this.accumulator.leave(node) + } + } + + private storeEntry() { + const entry = this.accumulator.build() + if (!entry) return + if (this.seenKeys.has(entry.key)) { + this.duplicateKeys.add(entry.key) + return + } + this.seenKeys.add(entry.key) + this.entries.push( + Object.assign(new PositionedBibtexEntry(), { + entry, + from: this.entryFrom, + to: this.entryTo, + line: this.lineAt ? this.lineAt(this.entryFrom) : 0, + toLine: this.lineAt ? this.lineAt(this.entryTo) : 0, + }) + ) + } +} diff --git a/services/web/frontend/js/features/bibtex/bibtex-purify.mts b/services/web/frontend/js/features/bibtex/bibtex-purify.mts new file mode 100644 index 0000000000..7467953e8a --- /dev/null +++ b/services/web/frontend/js/features/bibtex/bibtex-purify.mts @@ -0,0 +1,49 @@ +/** + * Implements the BibTeX purify$ function for special characters + * + * Special characters are first level groupings of curly braces that start with + * a backslash (e.g. "This is a special character: {\textbf{hello}}"). Inside + * special characters, LaTeX commands are removed. + * + * For more details, see Tame the BeaST, section 10 + * (https://tug.ctan.org/info/bibtex/tamethebeast/ttb_en.pdf) + */ +export function purifySpecialChars(s: string) { + let result = '' + let currSpecialChar: string | null = null + let openBraces = 0 + for (let i = 0; i < s.length; i++) { + const c = s[i] + if (c === '{') { + openBraces += 1 + } else if (c === '}') { + openBraces -= 1 + } else if (c === '\\' && openBraces === 1) { + // Open a special character + currSpecialChar = '' + } + + if (currSpecialChar != null && openBraces === 0) { + // We just closed a special char: remove all LaTeX command and add to the + // result + result += currSpecialChar.replaceAll(/\\([A-Za-z]+|[^A-Za-z])\s*/g, '') + currSpecialChar = null + } + + // Accumulate the character either in the current special character or in + // the final result + if (currSpecialChar != null) { + currSpecialChar += c + } else { + result += c + } + } + + // If the string is malformed, just append the unprocessed special character + // as-is + if (currSpecialChar != null) { + result += currSpecialChar + } + + return result +} diff --git a/services/web/frontend/js/features/bibtex/citation-key.mts b/services/web/frontend/js/features/bibtex/citation-key.mts new file mode 100644 index 0000000000..f13128f7d0 --- /dev/null +++ b/services/web/frontend/js/features/bibtex/citation-key.mts @@ -0,0 +1,68 @@ +import { BibtexNameList } from './bibtex-name.mts' + +const YEAR_REGEX = /^\d{4}$/ + +/** + * Generates the base citation key ("Surname" + "Year", stripped of any + * non-alphanumeric characters) from a parsed author list and a year string. + * Returns '' if there's no first author or no valid 4-digit year — the + * caller uses this to know that no suggestion is possible yet. + * + * Examples: "John Doe" + "1994" -> "Doe1994" + * "John von Neumann" + "1965" -> "vonNeumann1965" + * "Catherine O'Hara" + "2012" -> "OHara2012" + */ +export function generateBaseCitationKey({ + authors, + year, +}: { + authors: BibtexNameList + year: string +}): string { + const firstAuthor = authors.names[0] + if (!firstAuthor) return '' + const surname = firstAuthor.toLast().replace(/[^A-Za-z0-9]/g, '') + if (!surname) return '' + if (!YEAR_REGEX.test(year.trim())) return '' + return surname + year.trim() +} + +/** + * Generates lowercase-letter suffixes in bijective base-26 order: '', 'a', + * 'b', ..., 'z', 'aa', 'ab', ..., 'zz', 'aaa', ... + */ +function* suffixes(): Generator { + yield '' + let length = 1 + while (true) { + const total = 26 ** length + for (let i = 0; i < total; i++) { + let n = i + let suffix = '' + for (let j = 0; j < length; j++) { + suffix = String.fromCharCode(97 + (n % 26)) + suffix + n = Math.floor(n / 26) + } + yield suffix + } + length++ + } +} + +/** + * Picks the first available citation key of the form `base`, `base` + 'a', + * `base` + 'b', etc. that isn't already taken. Compares candidates by exact + * string equality against `takenKeys`, not by prefix — `takenKeys` may + * contain unrelated keys that happen to share the same prefix (e.g. a + * manually-named `Doe1994Workshop`), which must not be mistaken for a + * suffix collision. + */ +export function pickAvailableKey(base: string, takenKeys: string[]): string { + const taken = new Set(takenKeys) + for (const suffix of suffixes()) { + const candidate = base + suffix + if (!taken.has(candidate)) return candidate + } + // unreachable: suffixes() is infinite + throw new Error('unreachable') +} diff --git a/services/web/frontend/js/features/bibtex/entry-types.mts b/services/web/frontend/js/features/bibtex/entry-types.mts new file mode 100644 index 0000000000..bf18bb343f --- /dev/null +++ b/services/web/frontend/js/features/bibtex/entry-types.mts @@ -0,0 +1,479 @@ +import type { BibEntryType } from './bibtex-entry-schema.mts' + +const entryTypes: Map = new Map() + +entryTypes.set('article', { + key: 'article', + label: 'Article', + fields: ['author', 'title', 'journal', 'journaltitle', 'year', 'date'], + requiredFields: [ + 'author', + 'title', + ['journal', 'journaltitle'], + ['year', 'date'], + ], + optionalFields: [ + 'translator', + 'annotator', + 'commentator', + 'subtitle', + 'titleaddon', + 'editor', + 'editora', + 'editorb', + 'editorc', + 'journalsubtitle', + 'journaltitleaddon', + 'issuetitle', + 'issuesubtitle', + 'issuetitleaddon', + 'language', + 'origlanguage', + 'series', + 'volume', + 'number', + 'eid', + 'issue', + 'month', + 'pages', + 'version', + 'note', + 'issn', + 'addendum', + 'pubstate', + 'doi', + 'eprint', + 'eprintclass', + 'eprinttype', + 'url', + 'urldate', + ], +}) +entryTypes.set('artwork', { + key: 'artwork', + label: 'Artwork', + fields: ['author', 'editor', 'title', 'year', 'date'], + requiredFields: [], + optionalFields: [], +}) +entryTypes.set('audio', { + key: 'audio', + label: 'Audio', + fields: ['author', 'editor', 'title', 'year', 'date'], + requiredFields: [], + optionalFields: [], +}) +entryTypes.set('book', { + key: 'book', + label: 'Book', + fields: ['author', 'editor', 'title', 'publisher', 'year', 'date'], + requiredFields: [['author', 'editor'], 'title', ['year', 'date']], + optionalFields: [], +}) +entryTypes.set('bookinbook', { + key: 'bookinbook', + label: 'Book in book', + fields: [ + 'author', + 'editor', + 'title', + 'booktitle', + 'chapter', + 'pages', + 'publisher', + 'year', + 'date', + ], + requiredFields: ['author', 'title', 'booktitle', ['year', 'date']], + optionalFields: [], +}) +entryTypes.set('booklet', { + key: 'booklet', + label: 'Booklet', + fields: ['author', 'editor', 'title', 'year', 'date'], + requiredFields: ['title'], + optionalFields: [], +}) +entryTypes.set('commentary', { + key: 'commentary', + label: 'Commentary', + fields: ['author', 'editor', 'title', 'year', 'date'], + requiredFields: [], + optionalFields: [], +}) +entryTypes.set('conference', { + key: 'conference', + label: 'Conference', + fields: ['author', 'title', 'booktitle', 'year', 'date'], + requiredFields: ['author', 'title', 'booktitle', ['year', 'date']], + optionalFields: [], +}) +entryTypes.set('collection', { + key: 'collection', + label: 'Collection', + fields: ['editor', 'title', 'year', 'date'], + requiredFields: ['editor', 'title', ['year', 'date']], + optionalFields: [], +}) +entryTypes.set('dataset', { + key: 'dataset', + label: 'Dataset', + fields: ['author', 'editor', 'title', 'year', 'date'], + requiredFields: [['author', 'editor'], 'title', ['year', 'date']], + optionalFields: [], +}) +entryTypes.set('electronic', { + key: 'electronic', + label: 'Electronic resource', + fields: ['author', 'editor', 'title', 'year', 'date', 'doi', 'eprint', 'url'], + requiredFields: [ + ['author', 'editor'], + 'title', + ['year', 'date'], + ['doi', 'eprint', 'url'], + ], + optionalFields: [], +}) +entryTypes.set('image', { + key: 'image', + label: 'Image', + fields: ['author', 'editor', 'title', 'year', 'date'], + requiredFields: [], + optionalFields: [], +}) +entryTypes.set('inproceedings', { + key: 'inproceedings', + label: 'In proceedings', + fields: ['author', 'title', 'booktitle', 'year', 'date'], + requiredFields: ['author', 'title', 'booktitle', ['year', 'date']], + optionalFields: [], +}) +entryTypes.set('inbook', { + key: 'inbook', + label: 'In book', + fields: [ + 'author', + 'editor', + 'title', + 'booktitle', + 'chapter', + 'pages', + 'publisher', + 'year', + 'date', + ], + requiredFields: [['author', 'editor'], 'title', ['year', 'date']], + optionalFields: [], +}) +entryTypes.set('incollection', { + key: 'incollection', + label: 'In collection', + fields: ['author', 'title', 'booktitle', 'publisher', 'year', 'date'], + requiredFields: ['author', 'title', 'booktitle', ['year', 'date']], + optionalFields: [], +}) +entryTypes.set('inreference', { + key: 'inreference', + label: 'In reference', + fields: ['author', 'editor', 'title', 'booktitle', 'year', 'date'], + requiredFields: ['author', 'title', 'editor', 'booktitle', ['year', 'date']], + optionalFields: [], +}) +entryTypes.set('jurisdiction', { + key: 'jurisdiction', + label: 'Jurisdiction', + fields: ['author', 'editor', 'title', 'year', 'date'], + requiredFields: [], + optionalFields: [], +}) +entryTypes.set('manual', { + key: 'manual', + label: 'Manual', + fields: ['author', 'editor', 'title', 'year', 'date'], + requiredFields: ['title'], + optionalFields: [], +}) +entryTypes.set('mastersthesis', { + key: 'mastersthesis', + label: "Master's thesis", + fields: ['author', 'title', 'institution', 'school', 'year', 'date'], + requiredFields: [ + 'author', + 'title', + ['school', 'institution'], + ['year', 'date'], + ], + optionalFields: [], +}) +entryTypes.set('misc', { + key: 'misc', + label: 'Miscellaneous', + fields: ['author', 'editor', 'title', 'year', 'date'], + requiredFields: [], + optionalFields: [], +}) +entryTypes.set('movie', { + key: 'movie', + label: 'Movie', + fields: ['author', 'editor', 'title', 'year', 'date'], + requiredFields: [], + optionalFields: [], +}) +entryTypes.set('music', { + key: 'music', + label: 'Music', + fields: ['author', 'editor', 'title', 'year', 'date'], + requiredFields: [], + optionalFields: [], +}) +entryTypes.set('mvbook', { + key: 'mvbook', + label: 'Multi-volume book', + fields: ['author', 'title', 'year', 'date'], + requiredFields: ['author', 'title', ['year', 'date']], + optionalFields: [], +}) +entryTypes.set('mvcollection', { + key: 'mvcollection', + label: 'Multi-volume collection', + fields: ['editor', 'title', 'year', 'date'], + requiredFields: ['editor', 'title', ['year', 'date']], + optionalFields: [], +}) +entryTypes.set('mvproceedings', { + key: 'mvproceedings', + label: 'Multi-volume proceedings', + fields: ['title', 'year', 'date'], + requiredFields: ['title', ['year', 'date']], + optionalFields: [], +}) +entryTypes.set('mvreference', { + key: 'mvreference', + label: 'Multi-volume reference', + fields: ['editor', 'title', 'year', 'date'], + requiredFields: ['editor', 'title', ['year', 'date']], + optionalFields: [], +}) +entryTypes.set('legal', { + key: 'legal', + label: 'Legal', + fields: ['author', 'editor', 'title', 'year', 'date'], + requiredFields: [], + optionalFields: [], +}) +entryTypes.set('legislation', { + key: 'legislation', + label: 'Legislation', + fields: ['author', 'editor', 'title', 'year', 'date'], + requiredFields: [], + optionalFields: [], +}) +entryTypes.set('letter', { + key: 'letter', + label: 'Letter', + fields: ['author', 'editor', 'title', 'year', 'date'], + requiredFields: [], + optionalFields: [], +}) +entryTypes.set('online', { + key: 'online', + label: 'Online resource', + fields: ['author', 'editor', 'title', 'year', 'date', 'doi', 'eprint', 'url'], + requiredFields: [ + ['author', 'editor'], + 'title', + ['year', 'date'], + ['doi', 'eprint', 'url'], + ], + optionalFields: [], +}) +entryTypes.set('patent', { + key: 'patent', + label: 'Patent', + fields: ['author', 'title', 'number', 'year', 'date'], + requiredFields: ['author', 'title', 'number', ['year', 'date']], + optionalFields: [], +}) +entryTypes.set('performance', { + key: 'performance', + label: 'Performance', + fields: ['author', 'editor', 'title', 'year', 'date'], + requiredFields: [], + optionalFields: [], +}) +entryTypes.set('periodical', { + key: 'periodical', + label: 'Periodical', + fields: ['editor', 'title', 'year', 'date'], + requiredFields: ['editor', 'title', ['year', 'date']], + optionalFields: [], +}) +entryTypes.set('phdthesis', { + key: 'phdthesis', + label: 'PhD thesis', + fields: ['author', 'title', 'institution', 'school', 'year', 'date'], + requiredFields: [ + 'author', + 'title', + ['school', 'institution'], + ['year', 'date'], + ], + optionalFields: [], +}) +entryTypes.set('proceedings', { + key: 'proceedings', + label: 'Proceedings', + fields: ['title', 'year', 'date'], + requiredFields: ['title', ['year', 'date']], + optionalFields: [], +}) +entryTypes.set('reference', { + key: 'reference', + label: 'Reference', + fields: ['editor', 'title', 'year', 'date'], + requiredFields: ['editor', 'title', ['year', 'date']], + optionalFields: [], +}) +entryTypes.set('report', { + key: 'report', + label: 'Report', + fields: ['author', 'title', 'type', 'institution', 'year', 'date'], + requiredFields: ['author', 'title', 'type', 'institution', ['year', 'date']], + optionalFields: [], +}) +entryTypes.set('review', { + key: 'review', + label: 'Review', + fields: ['author', 'editor', 'title', 'year', 'date'], + requiredFields: [], + optionalFields: [], +}) +entryTypes.set('software', { + key: 'software', + label: 'Software', + fields: ['author', 'editor', 'title', 'year', 'date'], + requiredFields: [], + optionalFields: [], +}) +entryTypes.set('standard', { + key: 'standard', + label: 'Standard', + fields: ['author', 'editor', 'title', 'year', 'date'], + requiredFields: [], + optionalFields: [], +}) +entryTypes.set('suppbook', { + key: 'suppbook', + label: 'Supplemental material in book', + fields: [ + 'author', + 'editor', + 'title', + 'booktitle', + 'chapter', + 'pages', + 'publisher', + 'year', + 'date', + ], + requiredFields: ['author', 'title', 'booktitle', ['year', 'date']], + optionalFields: [], +}) +entryTypes.set('suppcollection', { + key: 'suppcollection', + label: 'Supplemental material in collection', + fields: ['author', 'editor', 'title', 'booktitle', 'year', 'date'], + requiredFields: ['author', 'title', 'editor', 'booktitle', ['year', 'date']], + optionalFields: [], +}) +entryTypes.set('suppperiodical', { + key: 'suppperiodical', + label: 'Supplemental material in periodical', + fields: ['author', 'title', 'journaltitle', 'year', 'date'], + optionalFields: [], + requiredFields: ['author', 'title', 'journaltitle', ['year', 'date']], +}) +entryTypes.set('techreport', { + key: 'techreport', + label: 'Tech report', + fields: ['author', 'title', 'institution', 'year', 'date'], + requiredFields: ['author', 'title', 'institution', ['year', 'date']], + optionalFields: [], +}) +entryTypes.set('thesis', { + key: 'thesis', + label: 'Thesis', + fields: ['author', 'title', 'type', 'institution', 'year', 'date'], + requiredFields: ['author', 'title', 'type', 'institution', ['year', 'date']], + optionalFields: [], +}) +entryTypes.set('unpublished', { + key: 'unpublished', + label: 'Unpublished', + fields: ['author', 'title', 'year', 'date', 'note'], + requiredFields: ['author', 'title'], + optionalFields: [], +}) +entryTypes.set('video', { + key: 'video', + label: 'Video', + fields: ['author', 'editor', 'title', 'year', 'date'], + requiredFields: [], + optionalFields: [], +}) +entryTypes.set('www', { + key: 'www', + label: 'WWW', + fields: ['author', 'editor', 'title', 'year', 'date', 'doi', 'eprint', 'url'], + requiredFields: [ + ['author', 'editor'], + 'title', + ['year', 'date'], + ['doi', 'eprint', 'url'], + ], + optionalFields: [], +}) + +function getMiscEntryType(): BibEntryType | undefined { + return entryTypes.get('misc') +} + +export function isEntryType(key: string): boolean { + return entryTypes.has(key) +} + +export function getEntryType(key: string): BibEntryType { + const entryType = entryTypes.get(key) + if (entryType != null) { + return entryType + } else { + return generateCustomEntryType(key) + } +} + +export function getAllEntryTypes(): BibEntryType[] { + return Array.from(entryTypes.values()) +} + +export function getAllEntryKeys(): string[] { + return Array.from(entryTypes.keys()) ?? [] +} + +export function generateCustomEntryType(key: string): BibEntryType { + const miscEntryType = getMiscEntryType() + return { + key, + label: key, + fields: miscEntryType?.fields || [], + optionalFields: miscEntryType?.optionalFields || [], + requiredFields: miscEntryType?.requiredFields || [], + } +} + +export function getTypeFields(type: string): string[] { + return ( + (!isEntryType(type) + ? getMiscEntryType()?.fields + : getEntryType(type)?.fields) || [] + ) +} diff --git a/services/web/frontend/js/features/bibtex/latex-to-unicode.mts b/services/web/frontend/js/features/bibtex/latex-to-unicode.mts new file mode 100644 index 0000000000..ca89e5f2fa --- /dev/null +++ b/services/web/frontend/js/features/bibtex/latex-to-unicode.mts @@ -0,0 +1,876 @@ +export function latexToUnicode(value: string) { + let s = value + + // First, replace the LaTeX commands we know about that take no argument + // + // A LaTeX command is one of: + // + // - a backslash followed by letters (\textbf) and optional spaces + // - a backslash followed by a single non-letter (\^) and optional spaces + s = s.replaceAll(/\\([a-zA-Z]+|[^a-zA-Z])\s*/g, noArgsReplacer) + + // Next, replace the LaTeX commands we know about that take one argument + // + // This first regex identifies arguments that are not inside braces. These + // can be: + // + // - a single character (\'e) + // - another LaTeX command (\^\alpha) - we don't support args for that command + s = s.replaceAll( + /\\([a-zA-Z]+|[^a-zA-Z])\s*([^{\\]|\\[^a-zA-Z]|[a-zA-Z]+)/g, + singleArgReplacer + ) + + // This second regex identifies arguments inside braces. We support two levels + // of braces. For example: + // + // - \'{e} + // - \mkbibquote{\'{e}cole} + // + // Because we support two levels of nested braces, we also need to run this + // twice. + for (let i = 0; i < 2; i++) { + s = s.replaceAll( + /\\([a-zA-Z]+|[^a-zA-Z])\s*{([^{}]*(?:{[^{}]*}[^{}]*)*)}/g, + singleArgReplacer + ) + } + return s.normalize('NFC') +} + +function noArgsReplacer(match: string, command: string) { + return NO_ARGS_REPLACEMENTS[command] ?? match +} + +function singleArgReplacer(match: string, command: string, arg: string) { + if (command === 'mkbibquote') { + return `"${arg}"` + } + + const replacement = SINGLE_ARG_REPLACEMENTS[command]?.[arg] + if (replacement != null) { + return replacement + } + + if (arg.length === 1) { + const combiningChar = COMBINING_CHAR_REPLACEMENTS[command] + if (combiningChar != null) { + return arg + combiningChar + } + } + + return match +} + +/** + * List of replacements from bib2json (https://github.com/mayanklahiri/bib2json) + * + * Copyright 2012-13 Mayank Lahiri + * mlahiri@gmail.com + * Released under the BSD License. + * + * Modifications 2016 Sharelatex + * Modifications 2017-2020 Overleaf + */ +const NO_ARGS_REPLACEMENTS: Record = { + aa: 'å', + AA: 'Å', + AE: 'Æ', + ae: 'æ', + aleph: 'ℵ', + allequal: '≌', + Alpha: 'Α', + alpha: 'α', + amalg: '⨿', + angle: '∠', + Angle: '⦜', + approxeq: '≊', + approxnotequal: '≆', + approx: '≈', + aquarius: '♒', + aries: '♈', + arrowwaveleft: '↜', + arrowwaveright: '↝', + ast: '*', + asymp: '≍', + backepsilon: '϶', + backprime: '‵', + backsimeq: '⋍', + backsim: '∽', + barwedge: '⌅', + because: '∵', + Beta: 'Β', + beta: 'β', + beth: 'ℶ', + between: '≬', + bigcap: '⋂', + bigcirc: '◯', + bigcup: '⋃', + bigtriangledown: '▽', + bigtriangleup: '△', + blacklozenge: '⧫', + blacksquare: '▪', + blacktriangledown: '▾', + blacktriangleleft: '◂', + blacktriangleright: '▸', + blacktriangle: '▴', + bowtie: '⋈', + boxdot: '⊡', + boxminus: '⊟', + boxplus: '⊞', + boxtimes: '⊠', + bullet: '∙', + Bumpeq: '≎', + bumpeq: '≏', + cancer: '♋', + capricornus: '♑', + cap: '∩', + Cap: '⋒', + cdots: '⋯', + cdot: '·', + Chi: 'Χ', + chi: 'χ', + circeq: '≗', + circlearrowleft: '↺', + circlearrowright: '↻', + circledast: '⊛', + circledcirc: '⊚', + circleddash: '⊝', + circledS: 'Ⓢ', + circ: '∘', + clockoint: '⨏', + clwintegral: '∱', + Colon: '∷', + complement: '∁', + cong: '≅', + coprod: '∐', + cup: '∪', + Cup: '⋓', + curlyeqprec: '⋞', + curlyeqsucc: '⋟', + curlyvee: '⋎', + curlywedge: '⋏', + curvearrowleft: '↶', + curvearrowright: '↷', + daleth: 'ℸ', + dashv: '⊣', + dblarrowupdown: '⇅', + ddddot: '⃜', + dddot: '⃛', + Delta: 'Δ', + delta: 'δ', + DH: 'Ð', + dh: 'ð', + diagup: '╱', + diamond: '⋄', + Digamma: 'Ϝ', + digamma: 'ϝ', + divideontimes: '⋇', + div: '÷', + DJ: 'Đ', + dj: 'đ', + doteqdot: '≑', + doteq: '≐', + dotplus: '∔', + DownArrowBar: '⤓', + downarrow: '↓', + Downarrow: '⇓', + DownArrowUpArrow: '⇵', + downdownarrows: '⇊', + downharpoonleft: '⇃', + downharpoonright: '⇂', + DownLeftRightVector: '⥐', + DownLeftTeeVector: '⥞', + DownLeftVectorBar: '⥖', + DownRightTeeVector: '⥟', + DownRightVectorBar: '⥗', + downslopeellipsis: '⋱', + eighthnote: '♪', + Elolarr: '⥀', + Elorarr: '⥁', + ElOr: '⩖', + Elroang: '⦆', + Elxsqcup: '⨆', + Elxuplus: '⨄', + ElzAnd: '⩓', + Elzbar: '̶', + Elzbtdl: 'ɬ', + ElzCint: '⨍', + Elzcirfb: '◒', + Elzcirfl: '◐', + Elzcirfr: '◑', + Elzclomeg: 'ɷ', + Elzddfnc: '⦙', + Elzdefas: '⧋', + Elzdlcorn: '⎣', + Elzdshfnc: '┆', + Elzdyogh: 'ʤ', + Elzesh: 'ʃ', + Elzfhr: 'ɾ', + Elzglst: 'ʔ', + Elzhlmrk: 'ˑ', + ElzInf: '⨇', + Elzinglst: 'ʖ', + Elzinvv: 'ʌ', + Elzinvw: 'ʍ', + ElzLap: '⧊', + Elzlmrk: 'ː', + Elzlow: '˕', + Elzlpargt: '⦠', + Elzltlmr: 'ɱ', + Elzltln: 'ɲ', + Elzminhat: '⩟', + Elzopeno: 'ɔ', + ElzOr: '⩔', + Elzpalh: '̡', + Elzpbgam: 'ɤ', + Elzpgamma: 'ɣ', + Elzpscrv: 'ʋ', + Elzpupsil: 'ʊ', + Elzrais: '˔', + Elzrarrx: '⥇', + Elzreapos: '‛', + Elzreglst: 'ʕ', + Elzrh: '̢', + ElzRlarr: '⥂', + ElzrLarr: '⥄', + Elzrl: 'ɼ', + Elzrtld: 'ɖ', + Elzrtll: 'ɭ', + Elzrtln: 'ɳ', + Elzrtlr: 'ɽ', + Elzrtls: 'ʂ', + Elzrtlt: 'ʈ', + Elzrtlz: 'ʐ', + Elzrttrnr: 'ɻ', + Elzrvbull: '◘', + Elzsbbrg: '̪', + Elzsblhr: '˓', + Elzsbrhr: '˒', + Elzschwa: 'ə', + Elzsqfl: '◧', + Elzsqfnw: '┙', + Elzsqfr: '◨', + Elzsqfse: '◪', + Elzsqspne: '⋥', + ElzSup: '⨈', + Elztdcol: '⫶', + Elztesh: 'ʧ', + Elztfnc: '⦀', + ElzThr: '⨅', + ElzTimes: '⨯', + Elztrna: 'ɐ', + Elztrnh: 'ɥ', + Elztrnmlr: 'ɰ', + Elztrnm: 'ɯ', + Elztrnrl: 'ɺ', + Elztrnr: 'ɹ', + Elztrnsa: 'ɒ', + Elztrnt: 'ʇ', + Elztrny: 'ʎ', + Elzverti: 'ˌ', + Elzverts: 'ˈ', + Elzvrecto: '▯', + Elzxh: 'ħ', + Elzxl: '̵', + Elzxrat: '℞', + Elzyogh: 'ʒ', + Epsilon: 'Ε', + epsilon: 'ε', + eqcirc: '≖', + eqslantgtr: '⪖', + eqslantless: '⪕', + Equal: '⩵', + equiv: '≡', + estimates: '≙', + Eta: 'Η', + eta: 'η', + eth: 'ƪ', + exists: '∃', + fallingdotseq: '≒', + flat: '♭', + forall: '∀', + forcesextra: '⊨', + frown: '⌢', + Gamma: 'Γ', + gamma: 'γ', + gemini: '♊', + geqq: '≧', + geqslant: '⩾', + geq: '≥', + gg: '≫', + gimel: 'ℷ', + gnapprox: '⪊', + gneqq: '≩', + gneq: '⪈', + gnsim: '⋧', + greaterequivlnt: '≳', + gtrapprox: '⪆', + gtrdot: '⋗', + gtreqless: '⋛', + gtreqqless: '⪌', + gtrless: '≷', + guillemotleft: '«', + guillemotright: '»', + guilsinglleft: '‹', + guilsinglright: '›', + hermitconjmatrix: '⊹', + homothetic: '∻', + hookleftarrow: '↩', + hookrightarrow: '↪', + hslash: 'ℏ', + image: '⊷', + infty: '∞', + intercal: '⊺', + int: '∫', + in: '∈', + Iota: 'Ι', + iota: 'ι', + i: 'ı', + jupiter: '♃', + Kappa: 'Κ', + kappa: 'κ', + Koppa: 'Ϟ', + Lambda: 'Λ', + lambda: 'λ', + langle: '〈', + lazysinv: '∾', + lbrace: '{', + lceil: '⌈', + ldots: '…', + leftarrowtail: '↢', + leftarrow: '←', + Leftarrow: '⇐', + LeftDownTeeVector: '⥡', + LeftDownVectorBar: '⥙', + leftharpoondown: '↽', + leftharpoonup: '↼', + leftleftarrows: '⇇', + leftrightarrows: '⇆', + leftrightarrow: '↔', + Leftrightarrow: '⇔', + leftrightharpoons: '⇋', + leftrightsquigarrow: '↭', + LeftRightVector: '⥎', + LeftTeeVector: '⥚', + leftthreetimes: '⋋', + LeftTriangleBar: '⧏', + LeftUpDownVector: '⥑', + LeftUpTeeVector: '⥠', + LeftUpVectorBar: '⥘', + LeftVectorBar: '⥒', + leo: '♌', + leqq: '≦', + leqslant: '⩽', + leq: '≤', + lessapprox: '⪅', + lessdot: '⋖', + lesseqgtr: '⋚', + lesseqqgtr: '⪋', + lessequivlnt: '≲', + lessgtr: '≶', + lfloor: '⌊', + libra: '♎', + llcorner: '⌞', + Lleftarrow: '⇚', + ll: '≪', + lmoustache: '⎰', + lnapprox: '⪉', + lneqq: '≨', + lneq: '⪇', + lnot: '¬', + lnsim: '⋦', + longleftarrow: '⟵', + Longleftarrow: '⟸', + longleftrightarrow: '⟷', + Longleftrightarrow: '⟺', + longmapsto: '⟼', + longrightarrow: '⟶', + Longrightarrow: '⟹', + looparrowleft: '↫', + looparrowright: '↬', + lozenge: '◊', + lrcorner: '⌟', + Lsh: '↰', + ltimes: '⋉', + L: 'Ł', + l: 'ł', + male: '♂', + mapsto: '↦', + measuredangle: '∡', + mercury: '☿', + mho: '℧', + mid: '∣', + mp: '∓', + multimap: '⊸', + mu: 'μ', + nabla: '∇', + natural: '♮', + nearrow: '↗', + neptune: '♆', + NestedGreaterGreater: '⪢', + NestedLessLess: '⪡', + nexists: '∄', + NG: 'Ŋ', + ng: 'ŋ', + ni: '∋', + nleftarrow: '↚', + nLeftarrow: '⇍', + nleftrightarrow: '↮', + nLeftrightarrow: '⇎', + nmid: '∤', + nolinebreak: '⁠', + notgreaterless: '≹', + notlessgreater: '≸', + nparallel: '∦', + nrightarrow: '↛', + nRightarrow: '⇏', + ntrianglelefteq: '⋬', + ntriangleleft: '⋪', + ntrianglerighteq: '⋭', + ntriangleright: '⋫', + nu: 'ν', + nvdash: '⊬', + nvDash: '⊭', + nVdash: '⊮', + nVDash: '⊯', + nwarrow: '↖', + odot: '⊙', + OE: 'Œ', + oe: 'œ', + oint: '∮', + Omega: 'Ω', + omega: 'ω', + ominus: '⊖', + openbracketleft: '〚', + openbracketright: '〛', + oplus: '⊕', + original: '⊶', + oslash: '⊘', + otimes: '⊗', + O: 'Ø', + o: 'ø', + parallel: '∥', + partial: '∂', + perp: '⊥', + perspcorrespond: '⩞', + Phi: 'Φ', + phi: 'ϕ', + pisces: '♓', + pitchfork: '⋔', + Pi: 'Π', + pi: 'π', + pluto: '♇', + pm: '±', + precapprox: '⪷', + preccurlyeq: '≼', + precedesnotsimilar: '⋨', + preceq: '⪯', + precnapprox: '⪹', + precneqq: '⪵', + prec: '≺', + prod: '∏', + propto: '∝', + Psi: 'Ψ', + psi: 'ψ', + quarternote: '♩', + rangle: '〉', + rbrace: '}', + rceil: '⌉', + recorder: '⌕', + ReverseUpEquilibrium: '⥯', + rfloor: '⌋', + Rho: 'Ρ', + rho: 'ρ', + rightanglearc: '⊾', + rightangle: '∟', + rightarrowtail: '↣', + rightarrow: '→', + Rightarrow: '⇒', + RightDownTeeVector: '⥝', + RightDownVectorBar: '⥕', + rightharpoondown: '⇁', + rightharpoonup: '⇀', + rightleftarrows: '⇄', + rightleftharpoons: '⇌', + rightmoon: '☾', + rightrightarrows: '⇉', + rightsquigarrow: '⇝', + RightTeeVector: '⥛', + rightthreetimes: '⋌', + RightTriangleBar: '⧐', + RightUpDownVector: '⥏', + RightUpTeeVector: '⥜', + RightUpVectorBar: '⥔', + RightVectorBar: '⥓', + risingdotseq: '≓', + rmoustache: '⎱', + RoundImplies: '⥰', + Rrightarrow: '⇛', + Rsh: '↱', + rtimes: '⋊', + RuleDelayed: '⧴', + sagittarius: '♐', + Sampi: 'Ϡ', + saturn: '♄', + scorpio: '♏', + searrow: '↘', + setminus: '∖', + sharp: '♯', + Sigma: 'Σ', + sigma: 'σ', + simeq: '≃', + sim: '∼', + smile: '⌣', + space: ' ', + sphericalangle: '∢', + sqcap: '⊓', + sqcup: '⊔', + sqrint: '⨖', + sqsubseteq: '⊑', + sqsubset: '⊏', + sqsupseteq: '⊒', + sqsupset: '⊐', + square: '□', + ss: 'ß', + starequal: '≛', + star: '⋆', + Stigma: 'Ϛ', + subseteqq: '⫅', + subseteq: '⊆', + subsetneqq: '⫋', + subsetneq: '⊊', + subset: '⊂', + Subset: '⋐', + succapprox: '≿', + succcurlyeq: '≽', + succeq: '⪰', + succnapprox: '⪺', + succneqq: '⪶', + succnsim: '⋩', + succ: '≻', + sum: '∑', + supseteqq: '⫆', + supseteq: '⊇', + supsetneqq: '⫌', + supsetneq: '⊋', + supset: '⊃', + Supset: '⋑', + surd: '√', + surfintegral: '∯', + swarrow: '↙', + taurus: '♉', + Tau: 'Τ', + tau: 'τ', + textasciiacute: '´', + textasciibreve: '˘', + textasciicaron: 'ˇ', + textasciidieresis: '¨', + textasciigrave: '`', + textasciimacron: '¯', + textasciitilde: '~', + textbackslash: '\\', + textbrokenbar: '¦', + textbullet: '•', + textcent: '¢', + textcopyright: '©', + textcurrency: '¤', + textdaggerdbl: '‡', + textdagger: '†', + textdegree: '°', + textdollar: '$', + textdoublepipe: 'ǂ', + textemdash: '—', + textendash: '–', + texteuro: '€', + textexclamdown: '¡', + texthvlig: 'ƕ', + textnrleg: 'ƞ', + textonehalf: '½', + textonequarter: '¼', + textordfeminine: 'ª', + textordmasculine: 'º', + textparagraph: '¶', + textperiodcentered: '˙', + textpertenthousand: '‱', + textperthousand: '‰', + textphi: 'ɸ', + textquestiondown: '¿', + textquotedblleft: '“', + textquotedblright: '”', + textquotesingle: "'", + textregistered: '®', + textsection: '§', + textsterling: '£', + texttheta: 'θ', + textTheta: 'ϴ', + textthreequarters: '¾', + texttildelow: '˜', + texttimes: '×', + texttrademark: '™', + textturnk: 'ʞ', + textvartheta: 'ϑ', + textvisiblespace: '␣', + textyen: '¥', + therefore: '∴', + Theta: 'Θ', + TH: 'Þ', + th: 'þ', + tildetrpl: '≋', + top: '⊤', + triangledown: '▿', + trianglelefteq: '⊴', + triangleleft: '◃', + triangleq: '≜', + trianglerighteq: '⊵', + triangleright: '▹', + truestate: '⊧', + twoheadleftarrow: '↞', + twoheadrightarrow: '↠', + ulcorner: '⌜', + UpArrowBar: '⤒', + uparrow: '↑', + Uparrow: '⇑', + updownarrow: '↕', + Updownarrow: '⇕', + UpEquilibrium: '⥮', + upharpoonleft: '↿', + upharpoonright: '↾', + uplus: '⊎', + upsilon: 'υ', + Upsilon: 'ϒ', + upslopeellipsis: '⋰', + upuparrows: '⇈', + uranus: '♅', + urcorner: '⌝', + varepsilon: 'ɛ', + varkappa: 'ϰ', + varnothing: '∅', + varphi: 'φ', + varpi: 'ϖ', + varrho: 'ϱ', + varsigma: 'ς', + vartriangleleft: '⊲', + vartriangleright: '⊳', + vartriangle: '▵', + vdash: '⊢', + Vdash: '⊩', + VDash: '⊫', + vdots: '⋮', + veebar: '⊻', + vee: '∨', + venus: '♀', + vert: '|', + Vert: '‖', + verymuchgreater: '⋙', + verymuchless: '⋘', + virgo: '♍', + volintegral: '∰', + Vvdash: '⊪', + wedge: '∧', + wp: '℘', + wr: '≀', + Xi: 'Ξ', + xi: 'ξ', + Zeta: 'Ζ', + zeta: 'ζ', +} + +const SINGLE_ARG_REPLACEMENTS: Record> = { + mathbb: { + A: '𝔸', + B: '𝔹', + C: 'ℂ', + D: '𝔻', + E: '𝔼', + F: '𝔽', + G: '𝔾', + H: 'ℍ', + I: '𝕀', + J: '𝕁', + K: '𝕂', + L: '𝕃', + M: '𝕄', + N: 'ℕ', + O: '𝕆', + P: 'ℙ', + Q: 'ℚ', + R: 'ℝ', + S: '𝕊', + T: '𝕋', + U: '𝕌', + V: '𝕍', + W: '𝕎', + X: '𝕏', + Y: '𝕐', + Z: 'ℤ', + a: '𝕒', + b: '𝕓', + c: '𝕔', + d: '𝕕', + e: '𝕖', + f: '𝕗', + g: '𝕘', + h: '𝕙', + i: '𝕚', + j: '𝕛', + k: '𝕜', + l: '𝕝', + m: '𝕞', + n: '𝕟', + o: '𝕠', + p: '𝕡', + q: '𝕢', + r: '𝕣', + s: '𝕤', + t: '𝕥', + u: '𝕦', + v: '𝕧', + w: '𝕨', + x: '𝕩', + y: '𝕪', + z: '𝕫', + }, + mathfrak: { + A: '𝔄', + B: '𝔅', + C: 'ℭ', + D: '𝔇', + E: '𝔈', + F: '𝔉', + G: '𝔊', + H: 'ℌ', + I: 'ℑ', + J: '𝔍', + K: '𝔎', + L: '𝔏', + M: '𝔐', + N: '𝔑', + O: '𝔒', + P: '𝔓', + Q: '𝔔', + R: 'ℜ', + S: '𝔖', + T: '𝔗', + U: '𝔘', + V: '𝔙', + W: '𝔚', + X: '𝔛', + Y: '𝔜', + Z: 'ℨ', + a: '𝔞', + b: '𝔟', + c: '𝔠', + d: '𝔡', + e: '𝔢', + f: '𝔣', + g: '𝔤', + h: '𝔥', + i: '𝔦', + j: '𝔧', + k: '𝔨', + l: '𝔩', + m: '𝔪', + n: '𝔫', + o: '𝔬', + p: '𝔭', + q: '𝔮', + r: '𝔯', + s: '𝔰', + t: '𝔱', + u: '𝔲', + v: '𝔳', + w: '𝔴', + x: '𝔵', + y: '𝔶', + z: '𝔷', + }, + mathscr: { + A: '𝒜', + B: 'ℬ', + C: '𝒞', + D: '𝒟', + E: 'ℰ', + F: 'ℱ', + G: '𝒢', + H: 'ℋ', + I: 'ℐ', + J: '𝒥', + K: '𝒦', + L: 'ℒ', + M: 'ℳ', + N: '𝒩', + O: '𝒪', + P: '𝒫', + Q: '𝒬', + R: 'ℛ', + S: '𝒮', + T: '𝒯', + U: '𝒰', + V: '𝒱', + W: '𝒲', + X: '𝒳', + Y: '𝒴', + Z: '𝒵', + a: '𝒶', + b: '𝒷', + c: '𝒸', + d: '𝒹', + e: 'ℯ', + f: '𝒻', + g: 'ℊ', + h: '𝒽', + i: '𝒾', + j: '𝒿', + k: '𝓀', + l: 'ℓ', + m: '𝓂', + n: '𝓃', + o: 'ℴ', + p: '𝓅', + q: '𝓆', + r: '𝓇', + s: '𝓈', + t: '𝓉', + u: '𝓊', + v: '𝓋', + w: '𝓌', + x: '𝓍', + y: '𝓎', + z: '𝓏', + }, +} + +const COMBINING_CHAR_REPLACEMENTS: Record = { + "'": '\u0301', + '"': '\u0308', + '.': '\u0307', + '=': '\u0304', + '^': '\u0302', + '`': '\u0300', + '~': '\u0303', + H: '\u030B', + acute: '\u0301', + b: '\u0331', + bar: '\u0305', + breve: '\u0306', + c: '\u0327', + check: '\u030C', + d: '\u0323', + dot: '\u0307', + ddot: '\u0308', + doubleunderline: '\u0333', + grave: '\u0300', + hat: '\u0302', + mathring: '\u030A', + overline: '\u0305', + r: '\u030A', + not: '\u0338', + slash: '\u0338', + strikethrough: '\u0335', + tilde: '\u0303', + u: '\u0306', + underline: '\u0332', + v: '\u030C', + vec: '\u20d7', +} diff --git a/services/web/frontend/js/features/bibtex/positioned-bibtex-entry.ts b/services/web/frontend/js/features/bibtex/positioned-bibtex-entry.ts new file mode 100644 index 0000000000..56343a3be6 --- /dev/null +++ b/services/web/frontend/js/features/bibtex/positioned-bibtex-entry.ts @@ -0,0 +1,14 @@ +import { ProjectionItem } from '@/features/source-editor/utils/tree-operations/projection' +import { BibtexEntry } from './bibtex-entry.mts' + +/** + * A BibtexEntry placed in a CodeMirror document. The `entry` reference is + * preserved across pure position shifts so identity-sensitive consumers + * (React.memo, MiniSearch indexes) can skip work when only positions changed. + * + * `from`/`to`/`line`/`toLine` come from `ProjectionItem` and are kept in sync + * by the projection module on every transaction. + */ +export class PositionedBibtexEntry extends ProjectionItem { + readonly entry: BibtexEntry = new BibtexEntry() +}