feat: custom KaTeX macro (#9779)

Closes: #9759
Co-authored-by: naskya <m@naskya.net>
Reviewed-on: https://codeberg.org/calckey/calckey/pulls/9779
Co-authored-by: naskya <naskya@noreply.codeberg.org>
Co-committed-by: naskya <naskya@noreply.codeberg.org>
This commit is contained in:
naskya 2023-03-30 03:11:57 +00:00 committed by Kainoa Kanter
parent 9e7f493ec5
commit 9489543180
13 changed files with 407 additions and 3 deletions

View File

@ -942,6 +942,9 @@ license: "License"
indexPosts: "Index posts" indexPosts: "Index posts"
indexFrom: "Index from Post ID onwards (leave blank to index every post)" 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." 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: _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." description: "Reduces the effort of server moderation through automatically recognizing NSFW media via Machine Learning. This will slightly increase the load on the server."

View File

@ -939,6 +939,9 @@ moveFromDescription: "別のアカウントからこのアカウントにフォ
migrationConfirm: "本当にこのアカウントを {account} に引っ越しますか?一度引っ越しを行うと取り消せず、二度とこのアカウントを元の状態で使用することはできません。\nまた、引っ越し先のアカウントでエイリアスを作成したことを確認してください。" migrationConfirm: "本当にこのアカウントを {account} に引っ越しますか?一度引っ越しを行うと取り消せず、二度とこのアカウントを元の状態で使用することはできません。\nまた、引っ越し先のアカウントでエイリアスを作成したことを確認してください。"
defaultReaction: "リモートとローカルの投稿に対するデフォルトの絵文字リアクション" defaultReaction: "リモートとローカルの投稿に対するデフォルトの絵文字リアクション"
license: "ライセンス" license: "ライセンス"
customKaTeXMacro: "カスタムKaTeXマクロ"
customKaTeXMacroDescription: "数式入力を楽にするためのマクロを設定しましょう記法はLaTeXにおけるコマンドの定義と同様に \\newcommand{\\name}{content} または \\newcommand{\\add}[2]{#1 + #2} のように記述します。後者の例では \\add{3}{foo} が 3 + foo に展開されます。また、マクロの名前を囲む波括弧を丸括弧 () および角括弧 [] に変更した場合、マクロの引数に使用する括弧が変更されます。マクロの定義は一行に一つのみで、途中で改行はできません。マクロの定義が無効な行は無視されます。文字列を単純に置換する機能のみに対応していて、条件分岐などの高度な構文は使用できません。"
enableCustomKaTeXMacro: "カスタムKaTeXマクロを有効にする"
_sensitiveMediaDetection: _sensitiveMediaDetection:
description: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てることができます。サーバーの負荷が少し増えます。" description: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てることができます。サーバーの負荷が少し増えます。"

View File

@ -892,6 +892,9 @@ navbar: "导航栏"
shuffle: "随机" shuffle: "随机"
account: "账户" account: "账户"
move: "移动" 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: _sensitiveMediaDetection:
description: "可以使用机器学习技术自动检测敏感媒体,以便进行审核。服务器负载将略微增加。" description: "可以使用机器学习技术自动检测敏感媒体,以便进行审核。服务器负载将略微增加。"
sensitivity: "检测敏感度" sensitivity: "检测敏感度"

View File

@ -892,6 +892,9 @@ navbar: "導覽列"
shuffle: "隨機" shuffle: "隨機"
account: "帳戶" account: "帳戶"
move: "移動 " 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: _sensitiveMediaDetection:
description: "您可以使用機器學習自動檢測敏感媒體並將其用於審核。 伺服器的負荷會稍微增加。" description: "您可以使用機器學習自動檢測敏感媒體並將其用於審核。 伺服器的負荷會稍微增加。"
sensitivity: "檢測敏感度" sensitivity: "檢測敏感度"

View File

@ -7,7 +7,7 @@
</div> </div>
<div class="body"> <div class="body">
<div class="content"> <div class="content">
<Mfm :text="text.trim()" :author="$i" :i="$i"/> <Mfm :text="preprocess(text).trim()" :author="$i" :i="$i"/>
</div> </div>
</div> </div>
</div> </div>
@ -16,6 +16,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import { } from 'vue';
import { preprocess } from '@/scripts/preprocess';
const props = defineProps<{ const props = defineProps<{
text: string; text: string;

View File

@ -92,6 +92,7 @@ import { $i, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account'
import { uploadFile } from '@/scripts/upload'; import { uploadFile } from '@/scripts/upload';
import { deepClone } from '@/scripts/clone'; import { deepClone } from '@/scripts/clone';
import XCheatSheet from '@/components/MkCheatSheetDialog.vue'; import XCheatSheet from '@/components/MkCheatSheetDialog.vue';
import { preprocess } from '@/scripts/preprocess';
const modal = inject('modal'); const modal = inject('modal');
@ -200,7 +201,7 @@ const submitText = $computed((): string => {
}); });
const textLength = $computed((): number => { const textLength = $computed((): number => {
return length((text + imeText).trim()); return length((preprocess(text) + imeText).trim());
}); });
const maxTextLength = $computed((): number => { const maxTextLength = $computed((): number => {
@ -557,8 +558,10 @@ function deleteDraft() {
} }
async function post() { async function post() {
const processedText = preprocess(text);
let postData = { let postData = {
text: text === '' ? undefined : text, text: processedText === '' ? undefined : processedText,
fileIds: files.length > 0 ? files.map(f => f.id) : undefined, fileIds: files.length > 0 ? files.map(f => f.id) : undefined,
replyId: props.reply ? props.reply.id : undefined, replyId: props.reply ? props.reply.id : undefined,
renoteId: props.renote ? props.renote.id : quoteId ? quoteId : undefined, renoteId: props.renote ? props.renote.id : quoteId ? quoteId : undefined,

View File

@ -0,0 +1,53 @@
<template>
<div class="_formRoot">
<FormInfo class="_formBlock">{{ i18n.ts.customKaTeXMacroDescription }}</FormInfo>
<FormTextarea v-model="localCustomKaTeXMacro" manual-save tall class="_monospace _formBlock" style="tab-size: 2;">
<template #label>{{ i18n.ts.customKaTeXMacro }}</template>
</FormTextarea>
<FormSwitch v-model="enableCustomKaTeXMacro" class="_formBlock">{{ i18n.ts.enableCustomKaTeXMacro }}</FormSwitch>
</div>
</template>
<script lang="ts" setup>
import { ref, watch, computed } from 'vue';
import FormTextarea from '@/components/form/textarea.vue';
import FormInfo from '@/components/MkInfo.vue';
import FormSwitch from '@/components/form/switch.vue';
import * as os from '@/os';
import { unisonReload } from '@/scripts/unison-reload';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import { parseKaTeXMacros } from '@/scripts/katex-macro';
import { defaultStore } from '@/store';
const localCustomKaTeXMacro = ref(localStorage.getItem('customKaTeXMacro') ?? '');
const enableCustomKaTeXMacro = computed(defaultStore.makeGetterSetter('enableCustomKaTeXMacro'));
async function apply() {
localStorage.setItem('customKaTeXMacro', localCustomKaTeXMacro.value);
localStorage.setItem('customKaTeXMacroParsed', parseKaTeXMacros(localCustomKaTeXMacro.value));
const { canceled } = await os.confirm({
type: 'info',
text: i18n.ts.reloadToApplySetting,
});
if (canceled) return;
unisonReload();
}
watch(localCustomKaTeXMacro, async () => {
await apply();
});
const headerActions = $computed(() => []);
const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts.customKaTeXMacro,
icon: 'ph-radical ph-bold ph-lg',
});
</script>

View File

@ -98,6 +98,8 @@
<FormLink to="/settings/deck" class="_formBlock">{{ i18n.ts.deck }}</FormLink> <FormLink to="/settings/deck" class="_formBlock">{{ i18n.ts.deck }}</FormLink>
<FormLink to="/settings/custom-css" class="_formBlock"><template #icon><i class="ph-code ph-bold ph-lg"></i></template>{{ i18n.ts.customCss }}</FormLink> <FormLink to="/settings/custom-css" class="_formBlock"><template #icon><i class="ph-code ph-bold ph-lg"></i></template>{{ i18n.ts.customCss }}</FormLink>
<FormLink to="/settings/custom-katex-macro" class="_formBlock"><template #icon><i class="ph-radical ph-bold ph-lg"></i></template>{{ i18n.ts.customKaTeXMacro }}</FormLink>
</div> </div>
</template> </template>

View File

@ -87,6 +87,7 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [
'showUpdates', 'showUpdates',
'swipeOnDesktop', 'swipeOnDesktop',
'showAdminUpdates', 'showAdminUpdates',
'enableCustomKaTeXMacro',
]; ];
const coldDeviceStorageSaveKeys: (keyof typeof ColdDeviceStorage.default)[] = [ const coldDeviceStorageSaveKeys: (keyof typeof ColdDeviceStorage.default)[] = [
'lightTheme', 'lightTheme',

View File

@ -133,6 +133,11 @@ export const routes = [
name: "custom-css", name: "custom-css",
component: page(() => import("./pages/settings/custom-css.vue")), 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", path: "/account-info",
name: "account-info", name: "account-info",
@ -235,6 +240,11 @@ export const routes = [
name: "general", name: "general",
component: page(() => import("./pages/settings/custom-css.vue")), 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", path: "/accounts",
name: "profile", name: "profile",

View File

@ -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<number>(bracketKinds).fill(0); // current bracket depth for "()", "{}", and "[]"
let buffer = Array.from(Array<number[]>(bracketKinds), () => Array<number>(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<string>(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;
}

View File

@ -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;
}

View File

@ -289,6 +289,10 @@ export const defaultStore = markRaw(
where: "device", where: "device",
default: false, default: false,
}, },
enableCustomKaTeXMacro: {
where: "device",
default: false,
},
}), }),
); );