import re from bs4 import BeautifulSoup def skip_whitespace(text, i): """Advance index i past any whitespace.""" while i < len(text) and text[i].isspace(): i += 1 return i def parse_braced_argument(text, i): """ Given text and an index i that should point at an opening '{', return a tuple (argument_content, new_index) where argument_content is the full string inside the balanced braces and new_index is the position just after the matching '}'. """ if i >= len(text) or text[i] != '{': raise ValueError("Expected '{' at position {}".format(i)) i += 1 # skip the opening brace start = i level = 1 while i < len(text) and level > 0: if text[i] == '{': level += 1 elif text[i] == '}': level -= 1 i += 1 if level != 0: raise ValueError("Unbalanced braces starting at position {}".format(start-1)) # The argument content is from start to i-1 (excluding the closing brace) return text[start:i-1], i def parse_command(text, i): """ Parse a \multirow or \multicolumn command starting at index i. This function assumes the command has exactly three braced arguments. It processes each argument recursively. For the third argument, after recursive processing, it replaces any unescaped & with \&. Returns a tuple (command_text, new_index) where command_text is the reconstructed command. """ # Determine which command we have. if text.startswith(r"\multirow", i): command_name = r"\multirow" i += len(r"\multirow") elif text.startswith(r"\multicolumn", i): command_name = r"\multicolumn" i += len(r"\multicolumn") else: raise ValueError("Expected \\multirow or \\multicolumn at position {}".format(i)) # Skip whitespace between the command name and the first argument. i = skip_whitespace(text, i) args = [] # Expect exactly three arguments for arg_index in range(3): if i >= len(text) or text[i] != '{': raise ValueError("Expected '{' for argument {} at position {}".format(arg_index+1, i)) arg_content, i = parse_braced_argument(text, i) # Process the content recursively to catch nested commands processed_arg = clean_multi_cells(arg_content) if arg_index == 2: # For the cell text (third argument), replace any unescaped & processed_arg = re.sub(r'(?= len(s) or s[pos] != '{': raise ValueError("Expected '{' at position %d" % pos) pos += 1 # skip the opening brace content = "" depth = 1 while pos < len(s) and depth: char = s[pos] if char == '{': depth += 1 content += char elif char == '}': depth -= 1 if depth: content += char else: content += char pos += 1 if depth != 0: raise ValueError("Unmatched '{' in string.") return content, pos def parse_command_merge(s, pos): """ Parse a multirow or multicolumn command starting at s[pos]. If the content of the command contains a nested command, then recursively parse the inner command and merge its parameters with the outer ones. The merging is done so that the outer multirow’s parameters (e.g. rowspan and width) are kept while the inner command’s parameters (e.g. colspan, alignment) and its innermost content are returned. Returns a tuple (merged_dict, new_pos) where merged_dict is a dictionary containing the combined parameters and new_pos is the updated index after parsing the command. """ if s.startswith(r"\multirow", pos): newpos = pos + len(r"\multirow") # Parse the three required arguments for multirow: rowspan, width, and content. rowspan, newpos = parse_brace(s, newpos) width, newpos = parse_brace(s, newpos) content, newpos = parse_brace(s, newpos) # Look for a nested command (either \multirow or \multicolumn) in the content. index_mr = content.find(r"\multirow") index_mc = content.find(r"\multicolumn") if index_mr == -1 and index_mc == -1: # No nested command found; return this command’s details. return {"rowspan": rowspan.strip(), "width": width.strip(), "content": content.strip()}, newpos else: # At least one nested command is present. Pick the first occurrence. indices = [i for i in (index_mr, index_mc) if i != -1] first_index = min(indices) # Parse the inner (nested) command from within the content. inner, _ = parse_command_merge(content, first_index) # Merge: keep the outer multirow’s parameters and add the inner ones. merged = {"rowspan": rowspan.strip(), "width": width.strip()} merged.update(inner) return merged, newpos elif s.startswith(r"\multicolumn", pos): newpos = pos + len(r"\multicolumn") # Parse the three arguments for multicolumn: colspan, alignment, and content. colspan, newpos = parse_brace(s, newpos) alignment, newpos = parse_brace(s, newpos) content, newpos = parse_brace(s, newpos) # Look for a nested command in the content. index_mr = content.find(r"\multirow") index_mc = content.find(r"\multicolumn") if index_mr == -1 and index_mc == -1: return {"colspan": colspan.strip(), "alignment": alignment.strip(), "content": content.strip()}, newpos else: indices = [i for i in (index_mr, index_mc) if i != -1] first_index = min(indices) inner, _ = parse_command_merge(content, first_index) merged = {"colspan": colspan.strip(), "alignment": alignment.strip()} merged.update(inner) return merged, newpos # Not a recognized command starting at pos. return None, pos def extract_merged_commands(s): """ Scan through the LaTeX string s and extract merged multirow/multicolumn commands. For each command found, if there is nesting the parser merges the outer and inner parameters so that the final result includes both the rowspan (or width) and the colspan (or alignment) along with the innermost content. Returns a list of dictionaries. """ pos = 0 results = [] while pos < len(s): if s[pos] == '\\': res, newpos = parse_command_merge(s, pos) if res is not None: results.append(res) pos = newpos continue pos += 1 return results def remove_tags(html, tags_to_remove): soup = BeautifulSoup(html, "html.parser") # Loop through the tags to remove for tag_name in tags_to_remove: for tag in soup.find_all(tag_name): # Move the children of the tag to the parent tag tag.unwrap() # This removes the tag but keeps its contents # Return the modified HTML as a string return str(soup) def convert_th_to_td(html): """Replace all th tags with td tags """ soup = BeautifulSoup(html) for th_tag in soup.find_all('th'): th_tag.name = 'td' return str(soup) def replace_italic(text): pattern = re.compile(r'(?{content}" # Replace all occurrences of the pattern using the replacer function. return pattern.sub(italic_replacer, text) def replace_bold(text): pattern = re.compile(r'(?{content}" return pattern.sub(bold_replacer, text) def latex_table_to_html(latex_str, add_head_body = False): # Pattern to match the entire tabular environment table_pattern = r'\\begin{tabular}{([^}]*)}\s*(.*?)\\end{tabular}' def process_cell(cell): # Clean up cell content cell = cell.strip() out = extract_merged_commands(cell) if len(out) > 0: cell = process_cell(out[0]["content"])["content"] rowspan = int(out[0].get("rowspan", "1")) colspan = int(out[0].get("colspan", "1")) return { "content": cell, "colspan": colspan, "rowspan": rowspan } # Replace latex and markdown formatting with HTML tags cell = re.sub(r'\$([^$]*)\$', r'\1', cell) # Remove math mode cell = re.sub(r'\\textbf{([^}]*)}', r'\1', cell) # Convert latex bold cell = re.sub(r'\\textit{([^}]*)}', r'\1', cell) # Convert latex italic cell = replace_italic(cell) cell = replace_bold(cell) cell = cell.replace("\\$", "$").replace("\\%", "%").replace("\\newline", "\n").replace("\\textless", "<").replace("\\textgreater", ">").replace("\\*", "*").replace("\\_", "_").replace("\\backslash", "\\") # Replace \& with & in the cell text cell = cell.replace(r'\&', '&') cell = cell.replace('', '') # Preserve newlines for downstream row-splitting; clean other tokens cell = cell.replace('\\unknown', '').replace('\\<|unk|\\>', '').replace('', '').replace('', '') return { 'content': cell, 'colspan': 1, 'rowspan': 1 } def split_row(input_string): # Use a regular expression to split on '&' that is not preceded by a backslash return re.split(r'(?'] # Track cells for multirow multirow_tracker = set() # Process rows rows = re.split(r'\\\\', content) current_row = 0 for row in rows: if not row.strip(): continue row = row.strip() # Skip \hline if '\\hline' in row: row = row.replace('\\hline', '') if not row.strip(): continue row = clean_multi_cells(row) # Process cells cells = split_row(row) processed_cells = [process_cell(cell) for cell in cells] # Build per-cell line lists splitting on newline or
tokens def split_lines(text): parts = re.split(r'(?:\n|)+', text) return parts if parts is not None else [''] line_lists = [split_lines(cell['content']) for cell in processed_cells] max_lines = max(len(lst) for lst in line_lists) if line_lists else 1 # Emit one or more rows based on max_lines for line_idx in range(max_lines): if add_head_body: if current_row == 0: html.append(' ') if current_row == 1: html.append(' ') html.append(' ') current_col = 0 for col_idx, cell in enumerate(processed_cells): content_segment = line_lists[col_idx][line_idx] if line_idx < len(line_lists[col_idx]) else '' attrs = [] if cell['colspan'] > 1: attrs.append(f'colspan="{cell["colspan"]}"') # Only apply original rowspan to the first emitted line if cell['rowspan'] > 1 and line_idx == 0: attrs.append(f'rowspan="{cell["rowspan"]}"') for r in range(current_row + 1, current_row + cell['rowspan']): for c in range(current_col, current_col + cell['colspan']): multirow_tracker.add((r, c)) # If this position is covered by a prior rowspan, skip rendering a duplicate cell if cell['rowspan'] > 1 and line_idx > 0: current_col += cell['colspan'] continue if (current_row, current_col) in multirow_tracker and content_segment == '' and cell["colspan"] == 1 and cell["rowspan"] == 1: current_col += cell['colspan'] continue attr_str = ' ' + ' '.join(attrs) if attrs else '' cell_tag = 'td' html.append(f' <{cell_tag}{attr_str}>{content_segment}') current_col += cell['colspan'] if add_head_body and current_row == 0: html.append(' ') html.append(' ') current_row += 1 if add_head_body: html.append(' ') html.append('') return '\n'.join(html) # Convert all tabular environments in the input return re.sub(table_pattern, convert_table, latex_str, flags=re.DOTALL) def convert_single_table(table): """ Convert a single HTML table to Markdown format. Args: table: BeautifulSoup table element Returns: str: Markdown table string """ markdown_lines = [] rows = table.find_all('tr') for i, row in enumerate(rows): cells = row.find_all(['td', 'th']) if not cells: continue # Convert cells to text, handling nested elements row_data = [] for cell in cells: # Get text content, handling nested elements cell_text = cell.get_text(separator=' ', strip=True) # Escape pipe characters cell_text = cell_text.replace('|', '\\|') row_data.append(cell_text) # Add row to markdown markdown_lines.append('| ' + ' | '.join(row_data) + ' |') # Add separator after header row if i == 0: separator = '| ' + ' | '.join(['---'] * len(cells)) + ' |' markdown_lines.append(separator) return '\n'.join(markdown_lines) def convert_html_tables_to_markdown(html_content): """ Find all HTML tables and convert them to Markdown while preserving all other content. Args: html_content (str): HTML content that may contain tables Returns: str: HTML content with tables converted to Markdown """ soup = BeautifulSoup(html_content, 'html.parser') # Find all tables tables = soup.find_all('table') if not tables: return html_content # Return original content unchanged # Convert each table to markdown and replace it for table in tables: markdown_table = convert_single_table(table) # Create a new element to replace the table replacement = soup.new_string('\n' + markdown_table + '\n') table.replace_with(replacement) return str(soup)