import pypandoc import os import re import tempfile def convert_docx_to_latex( docx_path: str, latex_path: str, generate_toc: bool = False, extract_media_to_path: str = None, latex_template_path: str = None, overleaf_compatible: bool = False, preserve_styles: bool = True, preserve_linebreaks: bool = True ) -> tuple[bool, str]: """ Converts a DOCX file to a LaTeX file using pypandoc with enhanced features. Args: docx_path: Path to the input .docx file. latex_path: Path to save the output .tex file. generate_toc: If True, attempts to generate a Table of Contents. extract_media_to_path: If specified, path to extract media to (e.g., "./media"). latex_template_path: If specified, path to a custom Pandoc LaTeX template file. overleaf_compatible: If True, makes images work in Overleaf with relative paths. preserve_styles: If True, preserves document styles like centering and alignment. preserve_linebreaks: If True, preserves line breaks and proper list formatting. Returns: A tuple (success: bool, message: str). """ extra_args = [] # Ensure standalone document (not fragment) extra_args.append("--standalone") # Basic options if generate_toc: extra_args.append("--toc") if extract_media_to_path: extra_args.append(f"--extract-media={extract_media_to_path}") if latex_template_path and os.path.isfile(latex_template_path): extra_args.append(f"--template={latex_template_path}") elif latex_template_path: pass # Template not found, Pandoc will handle the error # Enhanced features if overleaf_compatible: extra_args.extend([ "--resource-path=./", "--default-image-extension=png" ]) if preserve_styles: extra_args.extend([ "--from=docx+styles", "--wrap=preserve", "--columns=72", "--strip-comments" # Remove comments that might cause highlighting ]) if preserve_linebreaks: extra_args.extend([ "--preserve-tabs", "--wrap=preserve", "--reference-doc=" + docx_path # Use original Word doc as reference for formatting ]) # Create minimal Lua filter that preserves Word's original line breaks lua_filter_content = ''' function Para(elem) -- Preserve all line breaks exactly as they appear in Word -- This maintains Word's original pagination and formatting local new_content = {} for i, item in ipairs(elem.content) do if item.t == "SoftBreak" then -- Convert all soft breaks to line breaks to match Word's formatting table.insert(new_content, pandoc.LineBreak()) else table.insert(new_content, item) end end elem.content = new_content return elem end function LineBlock(elem) -- Preserve line blocks exactly as they are return elem end function Span(elem) -- Remove unwanted highlighting and formatting if elem.attributes and elem.attributes.style then -- Remove background colors and highlighting local style = elem.attributes.style if string.find(style, "background") or string.find(style, "highlight") then elem.attributes.style = nil end end return elem end function Div(elem) -- Remove unwanted div formatting that causes highlighting if elem.attributes and elem.attributes.style then local style = elem.attributes.style if string.find(style, "background") or string.find(style, "highlight") then elem.attributes.style = nil end end return elem end function RawBlock(elem) -- Preserve raw LaTeX blocks if elem.format == "latex" then return elem end end ''' # Create temporary Lua filter file with tempfile.NamedTemporaryFile(mode='w', suffix='.lua', delete=False) as f: f.write(lua_filter_content) lua_filter_path = f.name extra_args.append(f"--lua-filter={lua_filter_path}") try: # Perform conversion pypandoc.convert_file(docx_path, 'latex', outputfile=latex_path, extra_args=extra_args) # Clean up temporary Lua filter if created if preserve_linebreaks and 'lua_filter_path' in locals(): try: os.unlink(lua_filter_path) except OSError: pass # Apply post-processing enhancements (always applied for Unicode conversion) _apply_post_processing(latex_path, overleaf_compatible, preserve_styles, preserve_linebreaks, extract_media_to_path) # Generate status message enhancements = [] if overleaf_compatible: enhancements.append("Overleaf compatibility") if preserve_styles: enhancements.append("style preservation") if preserve_linebreaks: enhancements.append("line break preservation") if enhancements: enhancement_msg = f" with {', '.join(enhancements)}" else: enhancement_msg = "" return True, f"Conversion successful{enhancement_msg}!" except RuntimeError as e: # Clean up temporary Lua filter if created if preserve_linebreaks and 'lua_filter_path' in locals(): try: os.unlink(lua_filter_path) except OSError: pass return False, f"RuntimeError: Could not execute Pandoc. Please ensure Pandoc is installed and in your system's PATH. Error: {e}" except Exception as e: # Clean up temporary Lua filter if created if preserve_linebreaks and 'lua_filter_path' in locals(): try: os.unlink(lua_filter_path) except OSError: pass return False, f"Conversion failed: {e}" def _apply_post_processing(latex_path: str, overleaf_compatible: bool, preserve_styles: bool, preserve_linebreaks: bool, extract_media_to_path: str = None): """ Apply post-processing enhancements to the generated LaTeX file. """ try: with open(latex_path, 'r', encoding='utf-8') as f: content = f.read() # Always inject essential packages for compilation compatibility content = _inject_essential_packages(content) # Fix mixed mathematical expressions first to remove duplicated text content = _fix_mixed_mathematical_expressions(content) # Convert Unicode mathematical characters to LaTeX equivalents (always applied) content = _convert_unicode_math_characters(content) # Apply additional Unicode cleanup as a safety net content = _additional_unicode_cleanup(content) # Apply overleaf compatibility fixes if overleaf_compatible: content = _fix_image_paths_for_overleaf(content, extract_media_to_path) # Apply style preservation enhancements if preserve_styles: content = _inject_latex_packages(content) content = _add_centering_commands(content) # Apply line break preservation fixes if preserve_linebreaks: content = _fix_line_breaks_and_spacing(content) # Remove unwanted formatting and highlighting content = _remove_unwanted_formatting(content) # Fix common LaTeX compilation issues content = _fix_compilation_issues(content) # Write back the processed content with open(latex_path, 'w', encoding='utf-8') as f: f.write(content) except Exception as e: # Post-processing failures shouldn't break the conversion print(f"Warning: Post-processing failed: {e}") def _inject_essential_packages(content: str) -> str: """ Inject essential packages that are always needed for compilation. """ # Core packages that Pandoc might not include but are often needed essential_packages = [ r'\usepackage[utf8]{inputenc}', # UTF-8 input encoding r'\usepackage[T1]{fontenc}', # Font encoding r'\usepackage{graphicx}', # For images r'\usepackage{longtable}', # For tables r'\usepackage{booktabs}', # Better table formatting r'\usepackage{hyperref}', # For links (if not already included) r'\usepackage{amsmath}', # Mathematical formatting r'\usepackage{amssymb}', # Mathematical symbols r'\usepackage{textcomp}', # Additional text symbols ] documentclass_pattern = r'\\documentclass(?:\[[^\]]*\])?\{[^}]+\}' documentclass_match = re.search(documentclass_pattern, content) if documentclass_match: insert_pos = documentclass_match.end() packages_to_insert = [] for package in essential_packages: package_name = package.split('{')[1].split('}')[0].split(']')[0] # Extract package name if f'usepackage' not in content or package_name not in content: packages_to_insert.append(package) if packages_to_insert: package_block = '\n% Essential packages for compilation\n' + '\n'.join(packages_to_insert) + '\n' content = content[:insert_pos] + package_block + content[insert_pos:] # Add Unicode character definitions to handle any remaining problematic characters unicode_definitions = r''' % Unicode character definitions for LaTeX compatibility \DeclareUnicodeCharacter{2003}{ } % Em space \DeclareUnicodeCharacter{2002}{ } % En space \DeclareUnicodeCharacter{2009}{ } % Thin space \DeclareUnicodeCharacter{200A}{ } % Hair space \DeclareUnicodeCharacter{2004}{ } % Three-per-em space \DeclareUnicodeCharacter{2005}{ } % Four-per-em space \DeclareUnicodeCharacter{2006}{ } % Six-per-em space \DeclareUnicodeCharacter{2008}{ } % Punctuation space \DeclareUnicodeCharacter{202F}{ } % Narrow no-break space \DeclareUnicodeCharacter{2212}{-} % Unicode minus sign \DeclareUnicodeCharacter{2010}{-} % Hyphen \DeclareUnicodeCharacter{2011}{-} % Non-breaking hyphen \DeclareUnicodeCharacter{2013}{--} % En dash \DeclareUnicodeCharacter{2014}{---}% Em dash ''' # Insert Unicode definitions after packages but before \begin{document} begin_doc_match = re.search(r'\\begin\{document\}', content) if begin_doc_match: insert_pos_unicode = begin_doc_match.start() content = content[:insert_pos_unicode] + unicode_definitions + '\n' + content[insert_pos_unicode:] return content def _convert_unicode_math_characters(content: str) -> str: """ Convert Unicode mathematical characters to their LaTeX equivalents. """ # Dictionary of Unicode characters to LaTeX commands unicode_to_latex = { # Mathematical operators 'Δ': r'$\Delta$', # U+0394 - Greek capital letter delta 'δ': r'$\delta$', # U+03B4 - Greek small letter delta '∑': r'$\sum$', # U+2211 - N-ary summation '∏': r'$\prod$', # U+220F - N-ary product '∫': r'$\int$', # U+222B - Integral '∂': r'$\partial$', # U+2202 - Partial differential '∇': r'$\nabla$', # U+2207 - Nabla '√': r'$\sqrt{}$', # U+221A - Square root '∞': r'$\infty$', # U+221E - Infinity # Relations and equality '≈': r'$\approx$', # U+2248 - Almost equal to '≠': r'$\neq$', # U+2260 - Not equal to '≤': r'$\leq$', # U+2264 - Less-than or equal to '≥': r'$\geq$', # U+2265 - Greater-than or equal to '±': r'$\pm$', # U+00B1 - Plus-minus sign '∓': r'$\mp$', # U+2213 - Minus-or-plus sign '×': r'$\times$', # U+00D7 - Multiplication sign '÷': r'$\div$', # U+00F7 - Division sign '⋅': r'$\cdot$', # U+22C5 - Dot operator # Set theory and logic '∈': r'$\in$', # U+2208 - Element of '∉': r'$\notin$', # U+2209 - Not an element of '⊂': r'$\subset$', # U+2282 - Subset of '⊃': r'$\supset$', # U+2283 - Superset of '⊆': r'$\subseteq$', # U+2286 - Subset of or equal to '⊇': r'$\supseteq$', # U+2287 - Superset of or equal to '∪': r'$\cup$', # U+222A - Union '∩': r'$\cap$', # U+2229 - Intersection '∅': r'$\emptyset$', # U+2205 - Empty set '∀': r'$\forall$', # U+2200 - For all '∃': r'$\exists$', # U+2203 - There exists # Special symbols '∣': r'$|$', # U+2223 - Divides '∥': r'$\parallel$', # U+2225 - Parallel to '⊥': r'$\perp$', # U+22A5 - Up tack (perpendicular) '∠': r'$\angle$', # U+2220 - Angle '°': r'$^\circ$', # U+00B0 - Degree sign # Arrows '→': r'$\rightarrow$', # U+2192 - Rightwards arrow '←': r'$\leftarrow$', # U+2190 - Leftwards arrow '↔': r'$\leftrightarrow$', # U+2194 - Left right arrow '⇒': r'$\Rightarrow$', # U+21D2 - Rightwards double arrow '⇐': r'$\Leftarrow$', # U+21D0 - Leftwards double arrow '⇔': r'$\Leftrightarrow$', # U+21D4 - Left right double arrow # Accents and diacritics 'ˉ': r'$\bar{}$', # U+02C9 - Modifier letter macron 'ˆ': r'$\hat{}$', # U+02C6 - Modifier letter circumflex accent 'ˇ': r'$\check{}$', # U+02C7 - Caron '˜': r'$\tilde{}$', # U+02DC - Small tilde '˙': r'$\dot{}$', # U+02D9 - Dot above '¨': r'$\ddot{}$', # U+00A8 - Diaeresis # Special minus and spaces - using explicit Unicode escape sequences '−': r'-', # U+2212 - Minus sign (convert to regular hyphen) '\u2003': r' ', # U+2003 - Em space (convert to regular space) '\u2009': r' ', # U+2009 - Thin space (convert to regular space) '\u2002': r' ', # U+2002 - En space (convert to regular space) '\u2004': r' ', # U+2004 - Three-per-em space '\u2005': r' ', # U+2005 - Four-per-em space '\u2006': r' ', # U+2006 - Six-per-em space '\u2008': r' ', # U+2008 - Punctuation space '\u200A': r' ', # U+200A - Hair space '\u202F': r' ', # U+202F - Narrow no-break space # Greek letters (commonly used in math) 'α': r'$\alpha$', # U+03B1 'β': r'$\beta$', # U+03B2 'γ': r'$\gamma$', # U+03B3 'Γ': r'$\Gamma$', # U+0393 'ε': r'$\varepsilon$', # U+03B5 'ζ': r'$\zeta$', # U+03B6 'η': r'$\eta$', # U+03B7 'θ': r'$\theta$', # U+03B8 'Θ': r'$\Theta$', # U+0398 'ι': r'$\iota$', # U+03B9 'κ': r'$\kappa$', # U+03BA 'λ': r'$\lambda$', # U+03BB 'Λ': r'$\Lambda$', # U+039B 'μ': r'$\mu$', # U+03BC 'ν': r'$\nu$', # U+03BD 'ξ': r'$\xi$', # U+03BE 'Ξ': r'$\Xi$', # U+039E 'π': r'$\pi$', # U+03C0 'Π': r'$\Pi$', # U+03A0 'ρ': r'$\rho$', # U+03C1 'σ': r'$\sigma$', # U+03C3 'Σ': r'$\Sigma$', # U+03A3 'τ': r'$\tau$', # U+03C4 'υ': r'$\upsilon$', # U+03C5 'Υ': r'$\Upsilon$', # U+03A5 'φ': r'$\varphi$', # U+03C6 'Φ': r'$\Phi$', # U+03A6 'χ': r'$\chi$', # U+03C7 'ψ': r'$\psi$', # U+03C8 'Ψ': r'$\Psi$', # U+03A8 'ω': r'$\omega$', # U+03C9 'Ω': r'$\Omega$', # U+03A9 } # Apply conversions for unicode_char, latex_cmd in unicode_to_latex.items(): if unicode_char in content: content = content.replace(unicode_char, latex_cmd) # Additional aggressive Unicode space cleanup using regex # Handle various Unicode spaces more comprehensively content = re.sub(r'[\u2000-\u200F\u2028-\u202F\u205F\u3000]', ' ', content) # All Unicode spaces # Handle specific problematic Unicode characters that might not be in our dictionary content = re.sub(r'[\u2010-\u2015]', '-', content) # Various Unicode dashes content = re.sub(r'[\u2212]', '-', content) # Unicode minus sign # Handle specific cases where characters might appear in math environments # Fix double math mode (e.g., $\alpha$ inside already math mode) content = re.sub(r'\$\$([^$]+)\$\$', r'$\1$', content) # Convert display math to inline content = re.sub(r'\$\$([^$]*)\$([^$]*)\$\$', r'$\1\2$', content) # Fix broken math # Fix bar notation that might have been broken content = re.sub(r'\$\\bar\{\}\$([a-zA-Z])', r'$\\bar{\1}$', content) content = re.sub(r'([a-zA-Z])\$\\bar\{\}\$', r'$\\bar{\1}$', content) return content def _additional_unicode_cleanup(content: str) -> str: """ Additional aggressive Unicode cleanup to handle any characters that slip through. """ # Convert all common problematic Unicode spaces to regular spaces # This covers a wider range than the dictionary approach unicode_spaces = [ '\u00A0', # Non-breaking space '\u1680', # Ogham space mark '\u2000', # En quad '\u2001', # Em quad '\u2002', # En space '\u2003', # Em space '\u2004', # Three-per-em space '\u2005', # Four-per-em space '\u2006', # Six-per-em space '\u2007', # Figure space '\u2008', # Punctuation space '\u2009', # Thin space '\u200A', # Hair space '\u200B', # Zero width space '\u202F', # Narrow no-break space '\u205F', # Medium mathematical space '\u3000', # Ideographic space ] for unicode_space in unicode_spaces: content = content.replace(unicode_space, ' ') # Convert Unicode dashes unicode_dashes = [ '\u2010', # Hyphen '\u2011', # Non-breaking hyphen '\u2012', # Figure dash '\u2013', # En dash '\u2014', # Em dash '\u2015', # Horizontal bar '\u2212', # Minus sign ] for unicode_dash in unicode_dashes: if unicode_dash in ['\u2013', '\u2014']: # En and Em dashes content = content.replace(unicode_dash, '--') else: content = content.replace(unicode_dash, '-') # Use regex for any remaining problematic characters # Remove or replace any remaining Unicode characters that commonly cause issues content = re.sub(r'[\u2000-\u200F\u2028-\u202F\u205F\u3000]', ' ', content) content = re.sub(r'[\u2010-\u2015\u2212]', '-', content) return content def _fix_mixed_mathematical_expressions(content: str) -> str: """ Removes duplicated plain-text versions of mathematical expressions that Pandoc sometimes generates alongside the LaTeX version by deleting the plain text part when it is immediately followed by the LaTeX part. """ processed_content = content # A list of compiled regex patterns. # Each pattern matches a plain-text formula but only if it's followed # by its corresponding LaTeX version (using a positive lookahead). patterns_to_remove = [ # Pattern for: hq,k=x[nq,k]...h_{q,k} = x[n_{q,k}]... re.compile(r'h[qrs],k=x\[n[qrs],k\](?:,h[qrs],k=x\[n[qrs],k\])*\s*' + r'(?=h_{q,k}\s*=\s*x\\\[n_{q,k}\\\],)', re.UNICODE), # Pattern for: ∆hq,r,k=hq,k-hr,k...\Delta h_{q,r,k} = ... re.compile(r'(?:∆h[qrs],[qrs],k=h[qrs],k-h[qrs],k\s*)+' + r'(?=\\Delta\s*h_{q,r,k})', re.UNICODE), # Pattern for: RRk=tr,k+1-tr,kRR_k = ... re.compile(r'RRk=tr,k\+1-tr,k\s*' + r'(?=RR_k\s*=\s*t_{r,k\+1})', re.UNICODE), # Pattern for: Tmed=median{RRk}T_{\mathrm{med}} re.compile(r'Tmed=median\{RRk\}\s*' + r'(?=T_{\\mathrm{med}}\s*=\s*\\mathrm{median}\\{RR_k\\})', re.UNICODE), # Pattern for: Tk=[tr,k-Tmed2, tr,k+Tmed2]\mathcal{T}_k re.compile(r'Tk=\[tr,k-Tmed2,.*?tr,k\+Tmed2\]\s*' + r'(?=\\mathcal\{T\}_k\s*=\s*\\\[t_{r,k})', re.UNICODE | re.DOTALL), # Pattern for: h¯k=1|Ik|∑n∈Ikx[n]\bar h_k re.compile(r'h¯k=1\|Ik\|∑n∈Ikx\[n\]\s*' + r'(?=\\bar\s*h_k\s*=\s*\\frac)', re.UNICODE), # Pattern for: Mrs=median{∆hr,s,k}M_{rs} re.compile(r'Mrs=median\{∆hr,s,k\}\s*' + r'(?=M_{rs}\s*=\s*\\mathrm{median})', re.UNICODE), # Pattern for: ∆h¯k=h¯k-Mrs\Delta\bar h_k re.compile(r'∆h¯k=h¯k-Mrs\s*' + r'(?=\\Delta\\bar\s*h_k\s*=\s*\\bar\s*h_k)', re.UNICODE), ] for pattern in patterns_to_remove: processed_content = pattern.sub('', processed_content) return processed_content def _fix_compilation_issues(content: str) -> str: """ Fix common LaTeX compilation issues. """ # Fix \tightlist command if not defined if r'\tightlist' in content and r'\providecommand{\tightlist}' not in content: tightlist_def = r''' % Define \tightlist command for lists \providecommand{\tightlist}{% \setlength{\itemsep}{0pt}\setlength{\parskip}{0pt}} ''' # Insert after packages but before \begin{document} begin_doc_match = re.search(r'\\begin\{document\}', content) if begin_doc_match: insert_pos = begin_doc_match.start() content = content[:insert_pos] + tightlist_def + '\n' + content[insert_pos:] # Fix \euro command if used but not defined if r'\euro' in content and r'usepackage{eurosym}' not in content: content = re.sub( r'(\\usepackage\{[^}]+\}\s*\n)', r'\1\\usepackage{eurosym}\n', content, count=1 ) # Fix undefined references to figures/tables content = re.sub(r'\\ref\{fig:([^}]+)\}', r'Figure~\\ref{fig:\1}', content) content = re.sub(r'\\ref\{tab:([^}]+)\}', r'Table~\\ref{tab:\1}', content) # Ensure proper figure placement if r'\begin{figure}' in content: content = re.sub( r'\\begin\{figure\}(?!\[)', r'\\begin{figure}[htbp]', content ) # Ensure proper table placement if r'\begin{table}' in content: content = re.sub( r'\\begin\{table\}(?!\[)', r'\\begin{table}[htbp]', content ) return content def _fix_image_paths_for_overleaf(content: str, extract_media_to_path: str = None) -> str: """ Convert absolute image paths to relative paths for Overleaf compatibility. """ if extract_media_to_path: # Extract the media directory name media_dir = os.path.basename(extract_media_to_path.rstrip('/')) # Fix paths with task IDs like: task_id_media/media/image.png -> media/image.png # Pattern: \includegraphics{any_path/task_id_media/media/image.ext} # Replace with: \includegraphics{media/image.ext} pattern1 = r'\\includegraphics(\[[^\]]*\])?\{[^{}]*[a-f0-9\-]+_media[/\\]media[/\\]([^{}]+)\}' replacement1 = r'\\includegraphics\1{media/\2}' content = re.sub(pattern1, replacement1, content) # Fix paths like: task_id_media/media/image.png -> media/image.png (without includegraphics) pattern2 = r'[a-f0-9\-]+_media[/\\]media[/\\]' replacement2 = r'media/' content = re.sub(pattern2, replacement2, content) # Also handle regular media paths: /absolute/path/to/media/image.ext -> media/image.ext pattern3 = r'\\includegraphics(\[[^\]]*\])?\{[^{}]*[/\\]' + re.escape(media_dir) + r'[/\\]([^{}]+)\}' replacement3 = r'\\includegraphics\1{' + media_dir + r'/\2}' content = re.sub(pattern3, replacement3, content) return content def _remove_unwanted_formatting(content: str) -> str: """ Remove unwanted highlighting and formatting that causes visual issues. """ # Remove highlighting commands content = re.sub(r'\\colorbox\{[^}]*\}\{([^}]*)\}', r'\1', content) content = re.sub(r'\\hl\{([^}]*)\}', r'\1', content) content = re.sub(r'\\texthl\{([^}]*)\}', r'\1', content) content = re.sub(r'\\hlc\[[^\]]*\]\{([^}]*)\}', r'\1', content) # Remove table cell coloring content = re.sub(r'\\cellcolor\{[^}]*\}', '', content) content = re.sub(r'\\rowcolor\{[^}]*\}', '', content) content = re.sub(r'\\columncolor\{[^}]*\}', '', content) # Remove text background colors content = re.sub(r'\\textcolor\{[^}]*\}\{([^}]*)\}', r'\1', content) content = re.sub(r'\\color\{[^}]*\}', '', content) # Remove box formatting that might cause highlighting content = re.sub(r'\\fcolorbox\{[^}]*\}\{[^}]*\}\{([^}]*)\}', r'\1', content) content = re.sub(r'\\framebox\[[^\]]*\]\{([^}]*)\}', r'\1', content) # Remove soul package highlighting content = re.sub(r'\\sethlcolor\{[^}]*\}', '', content) content = re.sub(r'\\ul\{([^}]*)\}', r'\1', content) # Remove underline if causing issues return content def _inject_latex_packages(content: str) -> str: """ Inject additional LaTeX packages needed for enhanced formatting. """ # Essential packages for enhanced conversion essential_packages = [ r'\usepackage{graphicx}', # For images - ensure it's included r'\usepackage{longtable}', # For tables r'\usepackage{booktabs}', # Better table formatting r'\usepackage{array}', # Enhanced table formatting r'\usepackage{calc}', # For calculations r'\usepackage{url}', # For URLs ] # Style enhancement packages style_packages = [ r'\usepackage{float}', # Better float positioning r'\usepackage{adjustbox}', # For centering and scaling r'\usepackage{caption}', # Better caption formatting r'\usepackage{subcaption}', # For subfigures r'\usepackage{tabularx}', # Flexible table widths r'\usepackage{enumitem}', # Better list formatting r'\usepackage{setspace}', # Line spacing control r'\usepackage{ragged2e}', # Better text alignment r'\usepackage{amsmath}', # Mathematical formatting r'\usepackage{amssymb}', # Mathematical symbols r'\usepackage{needspace}', # Prevent orphaned lines and improve page breaks ] all_packages = essential_packages + style_packages # Find the position after \documentclass but before any existing \usepackage or \begin{document} documentclass_pattern = r'\\documentclass(?:\[[^\]]*\])?\{[^}]+\}' documentclass_match = re.search(documentclass_pattern, content) if documentclass_match: insert_pos = documentclass_match.end() # Find the next significant LaTeX command to insert before it # Look for existing \usepackage, \begin{document}, or other commands remaining_content = content[insert_pos:] next_command_match = re.search(r'\\(?:usepackage|begin\{document\}|title|author|date)', remaining_content) if next_command_match: insert_pos += next_command_match.start() # Check which packages are not already included packages_to_insert = [] for package in all_packages: package_name = package.replace(r'\usepackage{', '').replace('}', '') if f'usepackage{{{package_name}}}' not in content: packages_to_insert.append(package) if packages_to_insert: # Add packages with proper spacing package_block = '\n% Enhanced conversion packages\n' + '\n'.join(packages_to_insert) + '\n\n' content = content[:insert_pos] + package_block + content[insert_pos:] return content def _add_centering_commands(content: str) -> str: """ Add centering commands to figures and tables. """ # Add \centering to figure environments content = re.sub( r'(\\begin\{figure\}(?:\[[^\]]*\])?)\s*\n', r'\1\n\\centering\n', content ) # Add \centering to table environments content = re.sub( r'(\\begin\{table\}(?:\[[^\]]*\])?)\s*\n', r'\1\n\\centering\n', content ) return content def _fix_line_breaks_and_spacing(content: str) -> str: """ Minimal fixes to preserve Word's original formatting and pagination. """ # Remove unwanted highlighting and color commands content = re.sub(r'\\colorbox\{[^}]*\}\{([^}]*)\}', r'\1', content) content = re.sub(r'\\hl\{([^}]*)\}', r'\1', content) content = re.sub(r'\\texthl\{([^}]*)\}', r'\1', content) content = re.sub(r'\\cellcolor\{[^}]*\}', '', content) content = re.sub(r'\\rowcolor\{[^}]*\}', '', content) # Only fix critical spacing issues that break compilation # Preserve Word's original line breaks and spacing as much as possible # Ensure proper spacing around lists but don't change internal spacing content = re.sub(r'\n\\begin\{enumerate\}\n\n', r'\n\n\\begin{enumerate}\n', content) content = re.sub(r'\n\n\\end\{enumerate\}\n', r'\n\\end{enumerate}\n\n', content) content = re.sub(r'\n\\begin\{itemize\}\n\n', r'\n\n\\begin{itemize}\n', content) content = re.sub(r'\n\n\\end\{itemize\}\n', r'\n\\end{itemize}\n\n', content) # Minimal section spacing - preserve Word's pagination content = re.sub(r'\n(\\(?:sub)*section\{[^}]+\})\n\n', r'\n\n\1\n\n', content) # Only remove excessive spacing (3+ line breaks) but preserve double breaks content = re.sub(r'\n\n\n+', r'\n\n', content) # Ensure proper spacing around figures and tables content = re.sub(r'\n\\begin\{figure\}', r'\n\n\\begin{figure}', content) content = re.sub(r'\\end\{figure\}\n([A-Z])', r'\\end{figure}\n\n\1', content) content = re.sub(r'\n\\begin\{table\}', r'\n\n\\begin{table}', content) content = re.sub(r'\\end\{table\}\n([A-Z])', r'\\end{table}\n\n\1', content) return content if __name__ == '__main__': from docx import Document from docx.shared import Inches from PIL import Image import shutil # --- Helper Functions for DOCX and Template Creation --- def create_dummy_image(filename, size=(60, 60), color="red", img_format="PNG"): img = Image.new('RGB', size, color=color) img.save(filename, img_format) print(f"Created dummy image: {filename}") def create_test_docx_with_styles(filename): doc = Document() doc.add_heading("Document with Enhanced Features", level=1) # Add paragraph with text p1 = doc.add_paragraph("This document tests enhanced features including:") # Add numbered list doc.add_paragraph("First numbered item", style='List Number') doc.add_paragraph("Second numbered item", style='List Number') doc.add_paragraph("Third numbered item", style='List Number') # Add some text doc.add_paragraph("Here is some regular text between lists.") # Add bullet list doc.add_paragraph("First bullet point", style='List Bullet') doc.add_paragraph("Second bullet point", style='List Bullet') doc.add_heading("Image Section", level=2) doc.add_paragraph("Below is a test image:") doc.save(filename) print(f"Created test DOCX with styles: {filename}") def create_complex_docx(filename, img1_path, img2_path): doc = Document() doc.add_heading("Complex Document Title", level=1) doc.add_paragraph("Introduction to the complex document.") doc.add_heading("Image Section", level=2) doc.add_picture(img1_path, width=Inches(1.0)) doc.add_paragraph("Some text after the first image.") doc.add_picture(img2_path, width=Inches(1.0)) doc.add_heading("Conclusion Section", level=2) doc.add_paragraph("Final remarks.") doc.save(filename) print(f"Created complex DOCX: {filename}") # --- Test Files --- docx_styles = "test_enhanced_styles.docx" docx_complex = "test_complex_enhanced.docx" img1 = "dummy_img1.png" img2 = "dummy_img2.jpg" output_enhanced_test = "output_enhanced_test.tex" output_overleaf_test = "output_overleaf_test.tex" media_dir = "./media_enhanced" all_test_files = [docx_styles, docx_complex, img1, img2, output_enhanced_test, output_overleaf_test] all_test_dirs = [media_dir] # --- Create Test Files --- print("--- Setting up enhanced test files ---") create_dummy_image(img1, color="blue", img_format="PNG") create_dummy_image(img2, color="green", img_format="JPEG") create_test_docx_with_styles(docx_styles) create_complex_docx(docx_complex, img1, img2) print("--- Enhanced test file setup complete ---") # --- Test Enhanced Features --- print("\n--- Testing Enhanced Features ---") # Test 1: Style preservation and line breaks print("\n--- Test 1: Enhanced Style Preservation ---") success, msg = convert_docx_to_latex( docx_styles, output_enhanced_test, generate_toc=True, preserve_styles=True, preserve_linebreaks=True ) print(f"Enhanced Test: {success}, Msg: {msg}") if success and os.path.exists(output_enhanced_test): with open(output_enhanced_test, 'r') as f: content = f.read() checks = { 'packages': any(pkg in content for pkg in ['\\usepackage{float}', '\\usepackage{enumitem}']), 'toc': '\\tableofcontents' in content, 'sections': '\\section' in content, 'lists': '\\begin{enumerate}' in content or '\\begin{itemize}' in content } print(f"Enhanced verification: {checks}") # Test 2: Overleaf compatibility with images print("\n--- Test 2: Overleaf Compatibility ---") success, msg = convert_docx_to_latex( docx_complex, output_overleaf_test, extract_media_to_path=media_dir, overleaf_compatible=True, preserve_styles=True, preserve_linebreaks=True ) print(f"Overleaf Test: {success}, Msg: {msg}") if success and os.path.exists(output_overleaf_test): with open(output_overleaf_test, 'r') as f: content = f.read() media_check = 'media/' in content and '\\includegraphics' in content print(f"Overleaf compatibility check - relative paths: {media_check}") media_files_exist = os.path.exists(os.path.join(media_dir, 'media')) print(f"Media files extracted: {media_files_exist}") # --- Cleanup --- print("\n--- Cleaning up enhanced test files ---") for f_path in all_test_files: if os.path.exists(f_path): try: os.remove(f_path) print(f"Removed: {f_path}") except Exception as e: print(f"Error removing {f_path}: {e}") for d_path in all_test_dirs: if os.path.isdir(d_path): try: shutil.rmtree(d_path) print(f"Removed directory: {d_path}") except Exception as e: print(f"Error removing {d_path}: {e}") print("--- Enhanced testing completed ---")