diff --git a/locales/en-US.yml b/locales/en-US.yml index bf02155fd0..d70ceda465 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -942,6 +942,9 @@ license: "License" indexPosts: "Index posts" indexFrom: "Index from Post ID onwards (leave blank to index every post)" indexNotice: "Now indexing. This will probably take a while, please don't restart your server for at least an hour." +customKaTeXMacro: "Custom KaTeX Macro" +customKaTeXMacroDescription: "Set up macros to write mathematical expressions easily! The notation conforms to the LaTeX command definitions and is written as \\newcommand{\\name}{content} or \\newcommand{\\name}[number of arguments]{content}. For example, \\newcommand{\\add}[2]{#1 + #2} will expand \\add{3}{foo} to 3 + foo. The curly brackets surrounding the macro name can be changed to round or square brackets. This affects the brackets used for arguments. One (and only one) macro can be defined per line, and you can't break the line in the middle of the definition. Invalid lines are simply ignored. Only simple string substitution functions are supported; advanced syntax, such as conditional branching, cannot be used here." +enableCustomKaTeXMacro: "Enable custom KaTeX macro" _sensitiveMediaDetection: description: "Reduces the effort of server moderation through automatically recognizing NSFW media via Machine Learning. This will slightly increase the load on the server." diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 67bf18b560..b7ead22e88 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -939,6 +939,9 @@ moveFromDescription: "別のアカウントからこのアカウントにフォ migrationConfirm: "本当にこのアカウントを {account} に引っ越しますか?一度引っ越しを行うと取り消せず、二度とこのアカウントを元の状態で使用することはできません。\nまた、引っ越し先のアカウントでエイリアスを作成したことを確認してください。" defaultReaction: "リモートとローカルの投稿に対するデフォルトの絵文字リアクション" license: "ライセンス" +customKaTeXMacro: "カスタムKaTeXマクロ" +customKaTeXMacroDescription: "数式入力を楽にするためのマクロを設定しましょう!記法はLaTeXにおけるコマンドの定義と同様に \\newcommand{\\name}{content} または \\newcommand{\\add}[2]{#1 + #2} のように記述します。後者の例では \\add{3}{foo} が 3 + foo に展開されます。また、マクロの名前を囲む波括弧を丸括弧 () および角括弧 [] に変更した場合、マクロの引数に使用する括弧が変更されます。マクロの定義は一行に一つのみで、途中で改行はできません。マクロの定義が無効な行は無視されます。文字列を単純に置換する機能のみに対応していて、条件分岐などの高度な構文は使用できません。" +enableCustomKaTeXMacro: "カスタムKaTeXマクロを有効にする" _sensitiveMediaDetection: description: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てることができます。サーバーの負荷が少し増えます。" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 310121c5ae..aa2700c570 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -892,6 +892,9 @@ navbar: "导航栏" shuffle: "随机" account: "账户" move: "移动" +customKaTeXMacro: "自定义 KaTeX 宏" +customKaTeXMacroDescription: "使用宏来轻松的输入数学表达式吧!宏的用法与 LaTeX 中的命令定义相同。你可以使用 \\newcommand{\\name}{content} 或 \\newcommand{\\name}[number of arguments]{content} 来输入数学表达式。举个例子,\\newcommand{\\add}[2]{#1 + #2} 会将 \\add{3}{foo} 展开为 3 + foo。此外,宏名称外的花括号 {} 可以被替换为圆括号 () 和方括号 [],这会影响用于参数的括号。每行只能够定义一个宏,无法在中间换行,且无效的行将被忽略。只支持简单字符串替换功能,不支持高级语法,如条件分支等。" +enableCustomKaTeXMacro: "启用自定义 KaTeX 宏" _sensitiveMediaDetection: description: "可以使用机器学习技术自动检测敏感媒体,以便进行审核。服务器负载将略微增加。" sensitivity: "检测敏感度" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index e7528f9a2d..46be6f8e1c 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -892,6 +892,9 @@ navbar: "導覽列" shuffle: "隨機" account: "帳戶" move: "移動 " +customKaTeXMacro: "自定義 KaTeX 宏" +customKaTeXMacroDescription: "使用宏來輕鬆的輸入數學表達式吧!宏的用法與 LaTeX 中的命令定義相同。你可以使用 \\newcommand{\\name}{content} 或 \\newcommand{\\name}[number of arguments]{content} 來輸入數學表達式。舉個例子,\\newcommand{\\add}[2]{#1 + #2} 會將 \\add{3}{foo} 展開為 3 + foo。此外,宏名稱外的花括號 {} 可以被替換為圓括號 () 和方括號 [],這會影響用於參數的括號。每行只能夠定義一個宏,無法在中間換行,且無效的行將被忽略。只支持簡單字符串替換功能,不支持高級語法,如條件分支等。" +enableCustomKaTeXMacro: "啟用自定義 KaTeX 宏" _sensitiveMediaDetection: description: "您可以使用機器學習自動檢測敏感媒體並將其用於審核。 伺服器的負荷會稍微增加。" sensitivity: "檢測敏感度" diff --git a/packages/client/src/components/MkNotePreview.vue b/packages/client/src/components/MkNotePreview.vue index a78b499654..18d21ca61b 100644 --- a/packages/client/src/components/MkNotePreview.vue +++ b/packages/client/src/components/MkNotePreview.vue @@ -7,7 +7,7 @@
- +
@@ -16,6 +16,7 @@ diff --git a/packages/client/src/pages/settings/general.vue b/packages/client/src/pages/settings/general.vue index 1e18b7aa67..bef23a15e9 100644 --- a/packages/client/src/pages/settings/general.vue +++ b/packages/client/src/pages/settings/general.vue @@ -98,6 +98,8 @@ {{ i18n.ts.deck }} {{ i18n.ts.customCss }} + + {{ i18n.ts.customKaTeXMacro }} diff --git a/packages/client/src/pages/settings/preferences-backups.vue b/packages/client/src/pages/settings/preferences-backups.vue index 62ad029f30..b9bc979f73 100644 --- a/packages/client/src/pages/settings/preferences-backups.vue +++ b/packages/client/src/pages/settings/preferences-backups.vue @@ -87,6 +87,7 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [ 'showUpdates', 'swipeOnDesktop', 'showAdminUpdates', + 'enableCustomKaTeXMacro', ]; const coldDeviceStorageSaveKeys: (keyof typeof ColdDeviceStorage.default)[] = [ 'lightTheme', diff --git a/packages/client/src/router.ts b/packages/client/src/router.ts index 48aad0820f..3529032b2b 100644 --- a/packages/client/src/router.ts +++ b/packages/client/src/router.ts @@ -133,6 +133,11 @@ export const routes = [ name: "custom-css", component: page(() => import("./pages/settings/custom-css.vue")), }, + { + path: "/custom-katex-macro", + name: "custom-katex-macro", + component: page(() => import("./pages/settings/custom-katex-macro.vue")), + }, { path: "/account-info", name: "account-info", @@ -235,6 +240,11 @@ export const routes = [ name: "general", component: page(() => import("./pages/settings/custom-css.vue")), }, + { + path: "/custom-katex-macro", + name: "general", + component: page(() => import("./pages/settings/custom-katex-macro.vue")), + }, { path: "/accounts", name: "profile", diff --git a/packages/client/src/scripts/katex-macro.ts b/packages/client/src/scripts/katex-macro.ts new file mode 100644 index 0000000000..cd240fff94 --- /dev/null +++ b/packages/client/src/scripts/katex-macro.ts @@ -0,0 +1,295 @@ +type KaTeXMacro = { + args: number; + rule: (string | number)[]; +}; + +function parseSingleKaTeXMacro(src: string): [string, KaTeXMacro] { + const invalid: [string, KaTeXMacro] = ["", { args: 0, rule: [] }]; + + const skipSpaces = (pos: number): number => { + while (src[pos] === " ") + ++pos; + return pos; + }; + + if (!src.startsWith("\\newcommand") || src.slice(-1) !== "}") + return invalid; + + // current index we are checking (= "\\newcommand".length) + let currentPos: number = 11; + currentPos = skipSpaces(currentPos); + + // parse {\name}, (\name), or [\name] + let bracket: string; + if (src[currentPos] === "{") + bracket = "{}"; + else if (src[currentPos] === "(") + bracket = "()"; + else if (src[currentPos] === "[") + bracket = "[]"; + else + return invalid; + + ++currentPos; + currentPos = skipSpaces(currentPos); + + if (src[currentPos] !== "\\") + return invalid; + + const closeNameBracketPos: number = src.indexOf(bracket[1], currentPos); + if (closeNameBracketPos === -1) + return invalid; + + const name: string = src.slice(currentPos + 1, closeNameBracketPos).trim(); + if (!/^[a-zA-Z]+$/.test(name)) + return invalid; + + currentPos = skipSpaces(closeNameBracketPos + 1); + + let macro: KaTeXMacro = { args: 0, rule: [] }; + + // parse [number of arguments] (optional) + if (src[currentPos] === "[") { + const closeArgsBracketPos: number = src.indexOf("]", currentPos); + macro.args = Number(src.slice(currentPos + 1, closeArgsBracketPos).trim()); + currentPos = closeArgsBracketPos + 1; + + if (Number.isNaN(macro.args) || macro.args < 0) + return invalid; + } else if (src[currentPos] === "{") { + macro.args = 0; + } else { + return invalid; + } + + currentPos = skipSpaces(currentPos); + + // parse {rule} + if (src[currentPos] !== "{") + return invalid; + + ++currentPos; + currentPos = skipSpaces(currentPos); + + while (currentPos < src.length - 1) { + let numbersignPos: number = -1; + let isEscaped: boolean = false; + + for (let i = currentPos; i < src.length - 1; ++i) { + if (src[i] !== "\\" && src[i] !== "#") { + isEscaped = false; + continue; + } + if (src[i] === "\\") { + isEscaped = !isEscaped; + continue; + } + if (!isEscaped && src[i] === "#") { + numbersignPos = i; + break; + } + } + if (numbersignPos === -1) { + macro.rule.push(src.slice(currentPos, -1)); + break; + } + + const argIndexEndPos = src.slice(numbersignPos + 1).search(/[^\d]/) + numbersignPos; + const argIndex: number = Number(src.slice(numbersignPos + 1, argIndexEndPos + 1)); + + if (Number.isNaN(argIndex) || argIndex < 1 || macro.args < argIndex) + return invalid; + + if (currentPos !== numbersignPos) + macro.rule.push(src.slice(currentPos, numbersignPos)); + macro.rule.push(argIndex); + + currentPos = argIndexEndPos + 1; + } + + if (macro.args === 0) + return [name, macro]; + else + return [name + bracket[0], macro]; +} + +export function parseKaTeXMacros(src: string): string { + let result: { [name: string]: KaTeXMacro } = {}; + + for (const s of src.split("\n")) { + const [name, macro]: [string, KaTeXMacro] = parseSingleKaTeXMacro(s.trim()); + if (name !== "") + result[name] = macro; + } + + return JSON.stringify(result); +} + +// returns [expanded text, whether something is expanded, how many times we can expand more] +// the boolean value is used for multi-pass expansions (macros can expand to other macros) +function expandKaTeXMacroOnce(src: string, macros: { [name: string]: KaTeXMacro }, maxNumberOfExpansions: number) + : [string, boolean, number] { + const bracketKinds = 3; + const openBracketId: { [bracket: string]: number } = {"(": 0, "{": 1, "[": 2}; + const closeBracketId: { [bracket: string]: number } = {")": 0, "}": 1, "]": 2}; + const openBracketFromId = ["(", "{", "["]; + const closeBracketFromId = [")", "}", "]"]; + + // mappings from open brackets to their corresponding close brackets + type BracketMapping = { [openBracketPos: number]: number }; + + const bracketMapping = ((): BracketMapping => { + let result: BracketMapping = {}; + const n = src.length; + + let depths = new Array(bracketKinds).fill(0); // current bracket depth for "()", "{}", and "[]" + let buffer = Array.from(Array(bracketKinds), () => Array(n)); + + let isEscaped = false; + + for (let i = 0; i < n; ++i) { + if (!isEscaped && src[i] === "\\" && i + 1 < n && ["{", "}", "\\"].includes(src[i+1])) { + isEscaped = true; + continue; + } + if (isEscaped + || (src[i] !== "\\" + && !openBracketFromId.includes(src[i]) + && !closeBracketFromId.includes(src[i]))) + { + isEscaped = false; + continue; + } + isEscaped = false; + + if (openBracketFromId.includes(src[i])) { + const id: number = openBracketId[src[i]]; + buffer[id][depths[id]] = i; + ++depths[id]; + } else if (closeBracketFromId.includes(src[i])) { + const id: number = closeBracketId[src[i]]; + if (depths[id] > 0) { + --depths[id]; + result[buffer[id][depths[id]]] = i; + } + } + } + + return result; + })(); + + function expandSingleKaTeXMacro(expandedArgs: string[], macroName: string): string { + let result = ""; + for (const block of macros[macroName].rule) { + if (typeof block === "string") + result += block; + else + result += expandedArgs[block - 1]; + } + return result; + } + + // only expand src.slice(beginPos, endPos) + function expandKaTeXMacroImpl(beginPos: number, endPos: number): [string, boolean] { + if (endPos <= beginPos) + return ["", false]; + + const raw: string = src.slice(beginPos, endPos); + const fallback: string = raw; // returned for invalid inputs or too many expansions + + if (maxNumberOfExpansions <= 0) + return [fallback, false]; + --maxNumberOfExpansions; + + // search for a custom macro + let checkedPos = beginPos - 1; + let macroName = ""; + let macroBackslashPos = 0; + + // for macros w/o args: unused + // w/ args: the first open bracket ("(", "{", or "[") after cmd name + let macroArgBeginPos = 0; + + // for macros w/o args: the end of cmd name + // w/ args: the closing bracket of the last arg + let macroArgEndPos = 0; + + while (checkedPos < endPos) { + checkedPos = src.indexOf("\\", checkedPos + 1); + + // there is no macro to expand + if (checkedPos === -1) + return [raw, false]; + + // is it a custom macro? + let nonAlphaPos = src.slice(checkedPos + 1).search(/[^A-Za-z]/) + checkedPos + 1; + + if (nonAlphaPos === checkedPos) + nonAlphaPos = endPos; + + let macroNameCandidate = src.slice(checkedPos + 1, nonAlphaPos); + if (macros.hasOwnProperty(macroNameCandidate)) { + // this is a custom macro without args + macroBackslashPos = checkedPos; + macroArgEndPos = nonAlphaPos - 1; + macroName = macroNameCandidate; + break; + } + + let nextOpenBracketPos = endPos; + for (let i = 0; i < bracketKinds; ++i) { + const pos = src.indexOf(openBracketFromId[i], checkedPos + 1); + if (pos !== -1 && pos < nextOpenBracketPos) + nextOpenBracketPos = pos; + } + + if (nextOpenBracketPos === endPos) + return [fallback, false]; // there is no open bracket + + macroNameCandidate += src[nextOpenBracketPos]; + + if (macros.hasOwnProperty(macroNameCandidate)) { + macroBackslashPos = checkedPos; + macroArgBeginPos = nextOpenBracketPos; + macroArgEndPos = nextOpenBracketPos; // to search the first arg from here + macroName = macroNameCandidate; + break; + } + } + + const numArgs: number = macros[macroName].args; + const openBracket: string = macroName.slice(-1); + + let expandedArgs = new Array(numArgs); + + for (let i = 0; i < numArgs; ++i) { + // find the first open bracket after what we've searched + const nextOpenBracketPos = src.indexOf(openBracket, macroArgEndPos); + if (nextOpenBracketPos === -1) + return [fallback, false]; // not enough arguments are provided + if (!bracketMapping[nextOpenBracketPos]) + return [fallback, false]; // found open bracket doesn't correspond to any close bracket + + macroArgEndPos = bracketMapping[nextOpenBracketPos]; + expandedArgs[i] = expandKaTeXMacroImpl(nextOpenBracketPos + 1, macroArgEndPos)[0]; + } + + return [src.slice(beginPos, macroBackslashPos) + + expandSingleKaTeXMacro(expandedArgs, macroName) + + expandKaTeXMacroImpl(macroArgEndPos + 1, endPos)[0], true]; + } + + const [expandedText, expandedFlag]: [string, boolean] = expandKaTeXMacroImpl(0, src.length); + return [expandedText, expandedFlag, maxNumberOfExpansions]; +} + +export function expandKaTeXMacro(src: string, macrosAsJSONString: string, maxNumberOfExpansions: number): string { + const macros = JSON.parse(macrosAsJSONString); + + let expandMore = true; + + while (expandMore && (0 < maxNumberOfExpansions)) + [src, expandMore, maxNumberOfExpansions] = expandKaTeXMacroOnce(src, macros, maxNumberOfExpansions); + + return src; +} diff --git a/packages/client/src/scripts/preprocess.ts b/packages/client/src/scripts/preprocess.ts new file mode 100644 index 0000000000..da163b2614 --- /dev/null +++ b/packages/client/src/scripts/preprocess.ts @@ -0,0 +1,23 @@ +import * as mfm from "mfm-js"; +import { defaultStore } from "@/store"; +import { expandKaTeXMacro } from "@/scripts/katex-macro"; + +export function preprocess(text: string): string { + if (defaultStore.state.enableCustomKaTeXMacro) { + const parsedKaTeXMacro = localStorage.getItem("customKaTeXMacroParsed") ?? "{}"; + const maxNumberOfExpansions = 200; // to prevent infinite expansion loops + + let nodes = mfm.parse(text); + + for (let node of nodes) { + if (node["type"] === "mathInline" || node["type"] === "mathBlock") { + node["props"]["formula"] + = expandKaTeXMacro(node["props"]["formula"], parsedKaTeXMacro, maxNumberOfExpansions); + } + } + + text = mfm.toString(nodes); + } + + return text; +} diff --git a/packages/client/src/store.ts b/packages/client/src/store.ts index ec171ab691..b918d7ac7d 100644 --- a/packages/client/src/store.ts +++ b/packages/client/src/store.ts @@ -289,6 +289,10 @@ export const defaultStore = markRaw( where: "device", default: false, }, + enableCustomKaTeXMacro: { + where: "device", + default: false, + }, }), );