import type { Block, CodeBlock, Table, Line, Node as NodeType } from "../scrapbox-parser/mod.ts"; /** Scrapbox記法をMarkdown記法に変える */ export const convert = (blocks: Block[]): string => { /** 変換後の文字列 */ let latex = ""; /** 現在解析している行のインデントの深さ * * この方法だと番号つき箇条書きをを区別できないが、我慢する */ let level = 0; for (const block of blocks) { // タイトルは今のところ無視しておく if (block.type === "title") continue; // インデントを下げる if (block.indent > level) { for (let i = level; i < block.indent; i++) { latex += indent(`${ i !== level ? "\\item" : ""}\\begin{itemize}`, i * 2) + "\n"; } } // インデントを上げる if (block.indent < level) { for (let i = level; i > block.indent; i--) { latex += indent("\\end{itemize}", (i - 1) * 2) + "\n"; } } level = block.indent; latex += indent(`${level > 0 ? "\\item " : ""}${fromBlock(block)}`, level * 2) + "\n"; } // 全てのインデントを閉じる for (let i = level; i > 0; i--) { latex += indent("\\end{itemize}", (i - 1) * 2) + "\n"; } return latex; }; /** Blockの変換 */ const fromBlock = (block: CodeBlock | Table | Line): string => { switch (block.type) { case "codeBlock": return fromCodeBlock(block); case "table": return fromTable(block); case "line": return fromLine(block); } } /** CodeBlock記法の変換 * * indentは無視する */ const fromCodeBlock = (block: CodeBlock): string => `\\begin{lstlisting}[language=${escape(getFileType(block.fileName))},caption=${escape(block.fileName)},label=lang:${block.fileName},numbers=left] ${block.content} \\end{lstlisting}`; /** Table記法の変換 * * indentは無視する */ const fromTable = (table: Table): string => { const caption = `\\caption{${escape(table.fileName)}}\\label{table:${escape(table.fileName)}}`; // columnsの最大長を計算する const maxCol = Math.max(...table.cells.map((row) => row.length)); // 行を変換する const rows = table.cells .map( (row) => ` ${row.map( (column) => column.map((node) => fromNode(node)).join("") ).join(" & ")}\\\\` ); return `\\begin{table}[htbp] ${caption} \\centering \\begin{tabular}{${"c".repeat(maxCol)}} ${rows.length === 0 ? // 空の場合 "" : rows.length === 1 ? // 一行のみの場合は\midruleを入れない ` \\toprule ${rows.join("\n")} \\bottomrule` : ` \\toprule ${rows[0]} \\midrule ${rows.slice(1).join("\n")} \\bottomrule` } \\end{tabular} \\end{table}`; }; /** 行の変換 */ const fromLine = (line: Line): string => line.nodes.map((node) => fromNode(node)).join(""); /** Nodeを変換する */ const fromNode = (node: NodeType): string => { switch (node.type) { case "quote": return `\\begin{quote} ${indent(node.nodes.map((node) => fromNode(node)).join(""), 2)} \\end{quote}`; case "image": case "strongImage": return `\\begin{figure}[hbtp] iamge:\\url{${node.src}} \\end{figure}`; case "icon": case "strongIcon": return `\\textit{${escape(node.path)}}`; case "strong": return `\\textbf{${node.nodes.map((node) => fromNode(node)).join("")}}`; case "formula": return `$${node.formula}$`; case "decoration": { let result = node.nodes.map((node) => fromNode(node)).join(""); if (node.decos.includes("/")) result = `\\textit{${result}}`; if (node.decos.some((deco) => /\*-/.test(deco[0]))) { result = `\\textbf{${result}}`; } if (node.decos.includes("_")) result = `\\uline{${result}}`; if (node.decos.includes("-")) result = `\\sout{${result}}`; return result; } case "numberList": return `${node.number}. ${node.nodes.map((node) => fromNode(node)).join("")}`; case "helpfeel": return `\\lstinline!? ${escape(node.text)}!`; case "code": return `\\lstinline!${escape(node.text)}!`; case "commandLine": return `\\lstinline!${escape(node.symbol)} ${escape(node.text)}!`; case "link": return node.pathType === "absolute" ? node.content === "" ? `\\url{${node.href}}` : `\\href{${node.href}}{${escape(node.content)}}` : escape(node.href); case "googleMap": return `\\href{${node.url}}{${escape(node.place)}}`; case "hashTag": return escape(`#${node.href}`); case "blank": case "plain": return escape(node.text); } }; const extensionData = [ { extensions: ["javascript", "js"], fileType: "javascript", }, { extensions: ["typescript", "ts"], fileType: "typescript", }, { extensions: ["cpp", "hpp"], fileType: "C++", }, { extensions: ["c", "cc", "h"], fileType: "C", }, { extensions: ["cs", "csharp"], fileType: "cs", }, { extensions: ["markdown", "md"], fileType: "markdown", }, { extensions: ["htm", "html"], fileType: "html", }, { extensions: ["json"], fileType: "json", }, { extensions: ["xml"], fileType: "xml", }, { extensions: ["yaml", "yml"], fileType: "yaml", }, { extensions: ["toml"], fileType: "toml", }, { extensions: ["ini"], fileType: "ini", }, { extensions: ["tex", "sty"], fileType: "tex", }, { extensions: ["svg"], fileType: "svg", }, ]; /** ファイル名の拡張子から言語を取得する */ const getFileType = (filename: string): string => { const filenameExtention = filename.replace(/^.*\.(\w+)$/, "$1"); return extensionData .find((data) => data.extensions.includes(filenameExtention))?.fileType ?? ""; }; const escape =(text: string): string => text .replaceAll("\\", "\\textbackslash ") .replaceAll("#", "\\#") .replaceAll("%", "\\%") .replaceAll("$", "\\$") .replaceAll("&", "\\&") .replaceAll("~", "\\textasciitilde ") .replaceAll("^", "\\textasciicircum ") .replaceAll("_", "\\_") .replaceAll("{", "\\{") .replaceAll("}", "\\}") .replaceAll("|", "\\textbar ") .replaceAll("<", "\\textless ") .replaceAll(">", "\\textgreater "); const indent = (text: string, indentNum: number): string => text.split("\n").map((line) =>`${" ".repeat(indentNum)}${line}`).join("\n");