UNPKG

legal-markdown-js

Version:

Node.js implementation of LegalMarkdown for processing legal documents with markdown and YAML - Complete feature parity with Ruby version

559 lines 17.8 kB
/** * @fileoverview LaTeX Parser Extension for Legal Markdown * * This module provides functionality to parse LaTeX (.tex) documents and convert * them to Legal Markdown format. It uses Pandoc for robust parsing with intelligent * fallback to basic regex-based parsing when Pandoc is not available or fails. * * Features: * - AST-based LaTeX parsing using Pandoc * - Fallback regex-based parsing for simple documents * - Content type detection and validation * - Section command conversion to Legal Markdown headers * - Text formatting preservation (textbf, textit, emph) * - Environment handling (enumerate, itemize, verbatim) * - Macro expansion and command processing * - Mathematical notation preservation * - Reference and label handling * * @example * ```typescript * import { convertLatexToLegalMarkdown, convertLatexToLegalMarkdownSync } from './latex-parser.js'; * * const latexContent = ` * \\documentclass{article} * \\begin{document} * * \\section{Introduction} * This is a sample LaTeX document with \\textbf{bold} and \\textit{italic} text. * * \\subsection{Terms} * Some content here. * * \\end{document} * `; * * // Async conversion (preferred) * const legalMarkdown = await convertLatexToLegalMarkdown(latexContent); * * // Sync conversion (fallback) * const legalMarkdownSync = convertLatexToLegalMarkdownSync(latexContent); * ``` */ import { ContentDetector } from './parsers/content-detector.js'; import { PandocFactory } from './parsers/pandoc-factory.js'; import { FallbackParsers } from './parsers/fallback-parsers.js'; /** * Convert LaTeX AST node to markdown */ function astToMarkdown(node, sectionLevel = 0) { // Handle string nodes if (typeof node === 'string') { return node; } // Handle nodes with content property if ('content' in node && Array.isArray(node.content)) { return node.content.map((child) => astToMarkdown(child, sectionLevel)).join(''); } // Handle specific node types if ('type' in node) { switch (node.type) { case 'root': return node.content .map((child, index) => { const result = astToMarkdown(child, sectionLevel); // Check if next node is a group that should be an argument if (child.type === 'macro' && index + 1 < node.content.length) { const nextNode = node.content[index + 1]; if (nextNode.type === 'group') { // This group is likely the argument for the macro return handleMacroWithGroup(child, nextNode, sectionLevel); } } return result; }) .filter((_, index) => { // Filter out groups that were processed as arguments if (index > 0 && node.content[index].type === 'group') { const prevNode = node.content[index - 1]; if (prevNode.type === 'macro') { return false; } } return true; }) .join(''); case 'macro': return handleMacro(node, sectionLevel); case 'environment': return handleEnvironment(node, sectionLevel); case 'parbreak': return '\n\n'; case 'comment': return ''; // Skip comments case 'group': return astToMarkdown(node.content, sectionLevel); case 'inlinemath': return `$${astToMarkdown(node.content, sectionLevel)}$`; case 'displaymath': return `$$${astToMarkdown(node.content, sectionLevel)}$$`; case 'verb': case 'verbatim': return `\`${node.content}\``; case 'string': return node.content || ''; case 'whitespace': return ' '; default: // For unknown types, try to extract content if ('content' in node) { return astToMarkdown(node.content, sectionLevel); } return ''; } } return ''; } /** * Handle macro with a group as argument */ function handleMacroWithGroup(macro, group, sectionLevel) { const command = macro.content; const content = extractContent(group); switch (command) { case 'textbf': case 'bf': return `**${content}**`; case 'textit': case 'emph': case 'it': return `*${content}*`; case 'texttt': return `\`${content}\``; case 'underline': return `__${content}__`; default: return content; } } /** * Handle LaTeX macros (commands) */ function handleMacro(node, sectionLevel) { const command = node.content; const args = node.args || []; switch (command) { // Document structure case 'documentclass': case 'usepackage': case 'maketitle': return ''; // Skip these case 'title': { const titleArg = args.find((arg) => arg && arg.content && arg.content.length > 0); if (titleArg) { const title = extractContent(titleArg); return `---\ntitle: "${title}"\n---\n`; } return ''; } case 'author': { const authorArg = args.find((arg) => arg && arg.content && arg.content.length > 0); if (authorArg) { const author = extractContent(authorArg); return `author: "${author}"\n`; } return ''; } case 'date': { const dateArg = args.find((arg) => arg && arg.content && arg.content.length > 0); if (dateArg) { const date = extractContent(dateArg); return `date: "${date}"\n`; } return ''; } // Sections case 'part': case 'chapter': case 'section': case 'subsection': case 'subsubsection': case 'paragraph': case 'subparagraph': return handleSection(command, args, sectionLevel); // Text formatting case 'textbf': case 'bf': { // Find the first non-empty argument const bfArg = args.find((arg) => arg && arg.content && arg.content.length > 0); if (bfArg) { return `**${extractContent(bfArg)}**`; } return ''; } case 'textit': case 'emph': case 'it': { const itArg = args.find((arg) => arg && arg.content && arg.content.length > 0); if (itArg) { return `*${extractContent(itArg)}*`; } return ''; } case 'texttt': { const ttArg = args.find((arg) => arg && arg.content && arg.content.length > 0); if (ttArg) { return `\`${extractContent(ttArg)}\``; } return ''; } case 'underline': { const ulArg = args.find((arg) => arg && arg.content && arg.content.length > 0); if (ulArg) { return `__${extractContent(ulArg)}__`; } return ''; } // Links case 'href': if (args[0] && args[1]) { const url = extractContent(args[0]); const text = extractContent(args[1]); return `[${text}](${url})`; } return ''; case 'url': if (args[0]) { const url = extractContent(args[0]); return `<${url}>`; } return ''; case 'cite': if (args[0]) { const cite = extractContent(args[0]); return `[${cite}]`; } return ''; case 'ref': if (args[0]) { const ref = extractContent(args[0]); return `[${ref}]`; } return ''; case 'label': if (args[0]) { const label = extractContent(args[0]); return `<a name="${label}"></a>`; } return ''; case 'footnote': if (args[0]) { const footnote = extractContent(args[0]); return `[^${footnoteCounter++}]`; } return ''; // Quotes case '`': // eslint-disable-next-line quotes return "'"; // eslint-disable-next-line quotes case "'": // eslint-disable-next-line quotes return "'"; case '``': return '"'; // eslint-disable-next-line quotes case "''": return '"'; // Line breaks and spacing case '\\\\': case 'newline': case 'linebreak': return '\n'; case 'par': return '\n\n'; // Skip these commands case 'newpage': case 'clearpage': case 'pagebreak': case 'noindent': case 'bigskip': case 'medskip': case 'smallskip': case 'vspace': case 'hspace': case 'indent': case 'centering': case 'raggedright': case 'raggedleft': case 'today': return ''; default: // For unknown macros, try to extract content from args if (args[0]) { return extractContent(args[0]); } return ''; } } /** * Handle LaTeX environments */ function handleEnvironment(node, sectionLevel) { const envName = node.env; const content = node.content; switch (envName) { case 'document': return astToMarkdown(content, sectionLevel); case 'itemize': return handleItemize(content, sectionLevel); case 'enumerate': return handleEnumerate(content, sectionLevel); case 'description': return handleDescription(content, sectionLevel); case 'verbatim': case 'lstlisting': return '```\n' + extractRawContent(content) + '\n```'; case 'minted': { const lang = node.args?.[0] ? extractContent(node.args[0]) : ''; return '```' + lang + '\n' + extractRawContent(content) + '\n```'; } case 'quote': case 'quotation': { const quoteContent = astToMarkdown(content, sectionLevel); return quoteContent .split('\n') .map(line => line.trim()) .filter(line => line) .map(line => `> ${line}`) .join('\n'); } default: // For unknown environments, just process content return astToMarkdown(content, sectionLevel); } } /** * Handle section commands */ function handleSection(command, args, currentLevel) { const sectionArg = args.find((arg) => arg && arg.content && arg.content.length > 0); if (!sectionArg) return ''; const title = extractContent(sectionArg); const levelMap = { part: 1, chapter: 1, section: 2, subsection: 3, subsubsection: 4, paragraph: 5, subparagraph: 5, }; const level = levelMap[command] || 2; const prefix = 'l'.repeat(level); return `\n${prefix}. ${title}\n`; } /** * Handle itemize (bullet list) environment */ function handleItemize(content, sectionLevel) { const items = extractListItems(content); return items.map(item => `- ${astToMarkdown(item, sectionLevel)}`).join('\n'); } /** * Handle enumerate (numbered list) environment */ function handleEnumerate(content, sectionLevel) { const items = extractListItems(content); return items .map((item, index) => `${index + 1}. ${astToMarkdown(item, sectionLevel)}`) .join('\n'); } /** * Handle description list environment */ function handleDescription(content, sectionLevel) { const items = extractListItems(content); return items .map(item => { // Look for optional argument in item macro if (item.type === 'macro' && item.content === 'item' && item.args?.[0]) { const term = extractContent(item.args[0]); const desc = item.args[1] ? extractContent(item.args[1]) : ''; return `**${term}**\n ${desc}`; } return astToMarkdown(item, sectionLevel); }) .join('\n\n'); } /** * Extract list items from content */ function extractListItems(content) { if (!Array.isArray(content)) { content = content.content || []; } const items = []; let currentItem = []; for (const node of content) { if (node.type === 'macro' && node.content === 'item') { if (currentItem.length > 0) { items.push(currentItem); } currentItem = []; } else { currentItem.push(node); } } if (currentItem.length > 0) { items.push(currentItem); } return items; } /** * Extract text content from AST node */ function extractContent(node) { if (typeof node === 'string') return node; if (Array.isArray(node)) return node.map(extractContent).join(''); // Handle argument nodes if (node.type === 'argument' && node.content) { return extractContent(node.content); } // Handle string nodes if (node.type === 'string' && node.content) { return node.content; } // Handle whitespace nodes if (node.type === 'whitespace') { return ' '; } // Handle group nodes if (node.type === 'group' && node.content) { return extractContent(node.content); } // Default case if (node.content) return extractContent(node.content); return ''; } /** * Extract raw content without processing */ function extractRawContent(node) { if (typeof node === 'string') return node; if (Array.isArray(node)) return node.join(''); if (node.content) return extractRawContent(node.content); return ''; } let footnoteCounter = 1; /** * Converts LaTeX content to legal markdown format using pandoc */ export async function parseLatex(content) { try { const parser = await PandocFactory.createForContent(content); if (parser) { const markdown = await parser.convert(content, 'latex', 'markdown'); return markdown.replace(/\n{3,}/g, '\n\n').trim(); } else { // Use fallback parser return FallbackParsers.convertLatexBasic(content); } } catch (error) { if (process.env.NODE_ENV !== 'test') { console.error('Error parsing LaTeX:', error); } // Use fallback parser on error return FallbackParsers.convertLatexBasic(content); } } /** * Synchronous version using fallback parser */ export function parseLatexSync(content) { return FallbackParsers.convertLatexBasic(content); } /** * Checks if content appears to be LaTeX */ export function isLatex(content) { return ContentDetector.needsLatexParser(content); } /** * Converts LaTeX content to Legal Markdown format (async version) * * This function provides robust conversion of LaTeX documents to Legal Markdown * format using Pandoc when available. It automatically detects LaTeX content * and only performs conversion when necessary, preserving non-LaTeX content unchanged. * * @param {string} content - The input content that may contain LaTeX * @returns {Promise<string>} A promise that resolves to Legal Markdown formatted content * @example * ```typescript * const latexContent = ` * \\section{Introduction} * This is a sample LaTeX document with \\textbf{bold} text. * * \\subsection{Terms} * Some content with \\textit{italic} formatting. * `; * * const legalMarkdown = await convertLatexToLegalMarkdown(latexContent); * console.log(legalMarkdown); * // Output: * // l. Introduction * // This is a sample LaTeX document with **bold** text. * // * // ll. Terms * // Some content with *italic* formatting. * ``` */ export async function convertLatexToLegalMarkdown(content) { // Only convert if it looks like LaTeX if (!isLatex(content)) { return content; } return await parseLatex(content); } /** * Converts LaTeX content to Legal Markdown format (sync version) * * This synchronous function provides fallback conversion of LaTeX documents * using regex-based parsing when Pandoc is not available. It's designed for * environments where async operations are not suitable or when simple * conversion is sufficient. * * @param {string} content - The input content that may contain LaTeX * @returns {string} Legal Markdown formatted content * @example * ```typescript * const latexContent = ` * \\section{Terms} * This agreement contains \\textbf{important} terms. * `; * * const legalMarkdown = convertLatexToLegalMarkdownSync(latexContent); * console.log(legalMarkdown); * // Output: * // l. Terms * // This agreement contains **important** terms. * ``` */ export function convertLatexToLegalMarkdownSync(content) { // Only convert if it looks like LaTeX if (!isLatex(content)) { return content; } return parseLatexSync(content); } //# sourceMappingURL=latex-parser.js.map