#!/usr/bin/env python3 # -*- coding: utf-8 -*- # # Copyright (C) 1998-2026 Stephane Galland # # This program is free library; you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation; either version 3 of the # License, or any later version. # # This library is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library; see the file COPYING. If not, # write to the Free Software Foundation, Inc., 59 Temple Place - Suite # 330, Boston, MA 02111-1307, USA. """ Tools for extracting the list of the included figures in a TeX document. """ import os import re import textwrap import logging from typing import override, Any, Callable from autolatex2.tex.texobservers import Observer from autolatex2.tex.texparsers import Parser from autolatex2.tex.texparsers import TeXParser import autolatex2.utils.utilfunctions as genutils from autolatex2.tex.utils import TeXMacroParameter from autolatex2.utils.i18n import T import autolatex2.tex.extra_macros as extramacros EXPAND_REGISTRY : dict[str, Callable[[Any, Parser, list[TeXMacroParameter]], None]] = dict() EXTRA_EXPAND_REGISTRY : dict[str, Callable[[Any, Parser, list[TeXMacroParameter]], None]] = dict() # noinspection DuplicatedCode def expand_function(extra_macro : bool = False): """ Decorator to register functions with __expand__ prefix. :param extra_macro: Marks the function as part of th supporting features for the extra macros. Default is False. :type extra_macro: bool :return: the decorator. """ def decorator(func: Callable) -> Callable: # Store the function and its metadata # Remove "_expand__" prefix if not func.__name__.startswith('_expand__'): raise NameError('Function name must start with \'_expand__\'') func_name = str(func.__name__)[9:] if extra_macro: EXTRA_EXPAND_REGISTRY[func_name] = func else: EXPAND_REGISTRY[func_name] = func return func return decorator class ImageInclusions(Observer): """ Observer on TeX parsing for extracting included images in a TeX document. """ __MACROS : dict[str,str] = { 'input' : '!{}', 'include' : '!{}', 'includegraphics' : '![]!{}', 'graphicspath' : '![]!{}', 'pgfdeclareimage' : '![]!{}!{}', } def __init__(self, filename : str, include_extra_macros : bool): """ Constructor. :param filename: The name of the file to parse. :type filename: str :param include_extra_macros: Indicates if the extra macros must be included in the dependency analysis. :type include_extra_macros: bool """ self.__include_extra_macros = include_extra_macros if self.__include_extra_macros: self.__expand_registry = EXPAND_REGISTRY | EXTRA_EXPAND_REGISTRY else: self.__expand_registry = EXPAND_REGISTRY self.__filename : str = filename self.__basename : str = os.path.basename(os.path.splitext(filename)[0]) self.__directory_name : str = os.path.dirname(filename) self.__dynamic_preamble : list[str] = list() self.__include_paths : list[str] = list() self.__files_to_copy : set[str] = set() self.__source2target : dict[str,str] = dict() self.__target2source : dict[str,str] = dict() self.__reset() def __reset(self) : # Inclusion paths for pictures. self.__include_paths = list() if self.__directory_name: self.__include_paths.append(self.__directory_name) # Content of the TeX file to generate self.__files_to_copy = set() # Mapping between the files of the source TeX and the target TeX. self.__source2target = dict() self.__target2source = dict() @property def include_paths(self) -> list[str]: """ Replies the paths in which included files are search for. :return: The list of the inclusion path. :rtype: list """ return self.__include_paths def get_included_figures(self) -> set[str]: """ Replies the list of included figures. :rtype: list """ return self.__files_to_copy @property def basename(self) -> str: """ Replies the basename of the parsed file. :return: The basename of the parsed file. :rtype: str """ return self.__basename @basename.setter def basename(self, n : str): """ Set the basename of the parsed file. :param n: The basename of the parsed file. :type n: str """ self.__basename = n @property def dirname(self) -> str: """ Replies the dirname of the parsed file. :return: The dirname of the parsed file. :rtype: str """ return self.__directory_name @dirname.setter def dirname(self, n : str): """ Set the dirname of the parsed file. :param n: The dirname of the parsed file. :type n: str """ self.__directory_name = n @property def filename(self) -> str: """ Replies the filename of the parsed file. :return: The filename of the parsed file. :rtype: str """ return self.__filename @filename.setter def filename(self, n : str): """ Set the filename of the parsed file. :param n: The filename of the parsed file. :type n: str """ self.__filename = n @override def open_block(self, parser : Parser, text : str) -> str: """ Invoked when a block is opened. :param parser: reference to the parser. :type parser: Parser :param text: The text used for opening the block. :type text: str :return: the text that must replace the block opening in the output, or None if no replacement is needed. :rtype: str """ return '{' @override def close_block(self, parser : Parser, text : str) -> str: """ Invoked when a block is closed. :param parser: reference to the parser. :type parser: Parser :param text: The text used for opening the block. :type text: str :return: the text that must replace the block opening in the output, or None if no replacement is needed. :rtype: str """ return '}' @override def open_math(self, parser : Parser, inline : bool) -> str: """ Invoked when a math environment is opened. :param parser: reference to the parser. :type parser: Parser :param inline: Indicates if the math environment is inline or not. :type inline: bool :return: the text that must replace the block opening in the output, or None if no replacement is needed. :rtype: str """ return '$' if inline else '\\[' @override def close_math(self, parser : Parser, inline : bool) -> str: """ Invoked when a math environment is closed. :param parser: reference to the parser. :type parser: Parser :param inline: Indicates if the math environment is inline or not. :type inline: bool :return: the text that must replace the block opening in the output, or None if no replacement is needed. :rtype: str """ return '$' if inline else '\\]' @override def text(self, parser : Parser, text : str): """ Invoked when characters were found and must be output. :param parser: reference to the parser. :type parser: Parser :param text: the text to filter. :type text: str """ pass # noinspection DuplicatedCode @override def expand(self, parser : Parser, raw_text : str, name : str, *parameters : TeXMacroParameter) -> str: """ Expand the given macro on the given parameters. :param parser: reference to the parser. :type parser: Parser :param raw_text: The raw text that is the source of the expansion. :type raw_text: str :param name: Name of the macro. :type name: str :param parameters: Descriptions of the values passed to the TeX macro. :type parameters: dict[str,Any] :return: the result of expansion of the macro, or None to not replace the macro by something (the macro is used as-is) :rtype: str """ if name.startswith('\\'): callback_name = re.sub(r'\*', 'star', name[1:]) if callback_name in self.__expand_registry: func = self.__expand_registry[callback_name] r = func(self, parser, list(parameters)) if r is not None: return str(r) return raw_text @expand_function(extra_macro=True) def _expand__animatedfigureslide(self, parser : Parser, parameters : list[TeXMacroParameter]) -> str | None: return self._expand__figureslide(parser, parameters) @expand_function(extra_macro=True) def _expand__figureslide(self, parser : Parser, parameters : list[TeXMacroParameter]) -> str | None: assert len(parameters) > 2 self.__find_picture(parameters[2].text) return None @expand_function(extra_macro=True) def _expand__includeanimatedfigure(self, parser : Parser, parameters : list[TeXMacroParameter]) -> str | None: return self._expand__includegraphics(parser, parameters) @expand_function(extra_macro=True) def _expand__includeanimatedfigurewtex(self, parser : Parser, parameters : list[TeXMacroParameter]) -> str | None: return self._expand__includegraphics(parser, parameters) @expand_function(extra_macro=True) def _expand__includefigurewtex(self, parser : Parser, parameters : list[TeXMacroParameter]) -> str | None: return self._expand__includegraphics(parser, parameters) @expand_function(extra_macro=True) def _expand__includegraphicswtex(self, parser : Parser, parameters : list[TeXMacroParameter]) -> str | None: return self._expand__includegraphics(parser, parameters) # noinspection PyUnusedLocal @expand_function def _expand__includegraphics(self, parser : Parser, parameters : list[TeXMacroParameter]) -> str | None: assert len(parameters) > 1 self.__find_picture(parameters[1].text) return None # noinspection PyUnusedLocal @expand_function def _expand__libraryslide(self, parser : Parser, parameters : list[TeXMacroParameter]) -> str | None: assert len(parameters) > 1 self.__find_picture(parameters[1].text) return None # noinspection PyUnusedLocal @expand_function def _expand__graphicspath(self, parser : Parser, parameters: list[TeXMacroParameter]) -> str | None: assert len(parameters) > 1 t = parameters[1].text if t: r = re.match(r'^\s*(?:\{([^}]+)}|([^,]+))\s*[,;]?\s*(.*)$', t) while r: graphic_path = r.group(1) or r.group(2) if not os.path.isabs(graphic_path): graphic_path = os.path.normpath(os.path.join(self.__directory_name, graphic_path)) t = r.group(3) self.__include_paths.insert(0, graphic_path) r = re.match(r'^\s*(?:\{([^}]+)}|([^,]+))\s*[,;]?\s*(.*)$', t) if t else None return None @expand_function(extra_macro=True) def _expand__mfigurestar(self, parser : Parser, parameters: list[TeXMacroParameter]) -> str | None: return self._expand__mfigure(parser, parameters) @expand_function(extra_macro=True) def _expand__mfigurewtex(self, parser : Parser, parameters: list[TeXMacroParameter]) -> str | None: return self._expand__mfigure(parser, parameters) @expand_function(extra_macro=True) def _expand__mfigurewtexstar(self, parser : Parser, parameters: list[TeXMacroParameter]) -> str | None: return self._expand__mfigure(parser, parameters) # noinspection PyUnusedLocal @expand_function(extra_macro=True) def _expand__mfigure(self, parser : Parser, parameters: list[TeXMacroParameter]) -> str | None: assert len(parameters) > 2 self.__find_picture(parameters[2].text) return None @expand_function(extra_macro=True) def _expand__msubfigurestar(self, parser : Parser, parameters: list[TeXMacroParameter]) -> str | None: return self._expand__msubfigurestar(parser, parameters) # noinspection PyUnusedLocal @expand_function(extra_macro=True) def _expand__msubfigure(self, parser : Parser, parameters: list[TeXMacroParameter]) -> str | None: assert len(parameters) > 0 self.__find_picture(parameters[0].text) return None # noinspection PyUnusedLocal @expand_function(extra_macro=True) def _expand__partnerlogo(self, parser : Parser, parameters: list[TeXMacroParameter]) -> str | None: assert len(parameters) > 0 self.__find_picture(parameters[0].text) return None # noinspection PyUnusedLocal @expand_function(extra_macro=True) def _expand__resolvepicturename(self, parser : Parser, parameters: list[TeXMacroParameter]) -> str | None: assert len(parameters) > 0 self.__find_picture(parameters[0].text) return None # noinspection PyUnusedLocal @expand_function def _expand__pgfdeclareimage(self, parser : Parser, name: str, parameters: list[TeXMacroParameter]) -> str | None: assert len(parameters) > 2 self.__find_picture(parameters[2].text) return None @expand_function def _expand__include(self, parser : Parser, parameters: list[TeXMacroParameter]) -> str | None: return self._expand__input(parser, parameters) # noinspection DuplicatedCode @expand_function def _expand__input(self, parser : Parser, parameters: list[TeXMacroParameter]) -> str | None: assert len(parameters) > 2 self.__find_picture(parameters[2].text) filename = self.__make_filename(parameters[0].text, '.tex') with open(filename) as f: subcontent = f.read() subcontent += textwrap.dedent(""" %%======================================================= %%== END FILE: %s %%======================================================= """) % (os.path.basename(filename)) parser.put_back(subcontent) return textwrap.dedent(""" %%======================================================= %%== BEGIN FILE: %s %%======================================================= """) % (os.path.basename(filename)) def __make_filename(self, basename : str, ext : str = None) -> str: """ Create a valid filename for the flattening process. :param basename: The basename. :param basename: str :param ext: The filename extension (default: None). :param ext: str """ if ext and not basename.endswith(ext): name = basename + ext else: name = basename if not os.path.isabs(name): return os.path.join(self.dirname, name) return name # noinspection DuplicatedCode def __create_mapping(self, filename : str, ext : str) -> str: """ Compute a unique filename, and map it to the source file. :param filename: The filename to translate. :type filename: str :param ext: The filename extension to remove. :type ext: str :return: The unique basename. :rtype: str """ name = os.path.basename(filename) if ext and name.endswith(ext): name = name[0:(-len(ext))] bn = name i = 0 while (name + ext) in self.__target2source: name = "%s_%d" % (bn, i) i += 1 self.__target2source[name + ext] = filename self.__source2target[filename] = name + ext return name # noinspection DuplicatedCode def __find_picture(self, tex_name : str) -> tuple[str,str]: """ Find a picture. :param tex_name: The name of the picture in the TeX document. :type tex_name: str :return: the tuple (target filename, the prefix to add before the macro) :rtype: tuple[str,str] """ # Search in the registered/found bitmaps if self.__source2target: for src in self.__source2target: if src == tex_name: return os.path.basename(self.__source2target[src]), '' prefix = '' filename = self.__make_filename(tex_name) if not os.path.isfile(filename): texexts = ('.pdftex_t', '.pstex_t', '.pdf_tex', '.ps_tex', '.tex') figexts = ( '.pdf', '.eps', '.ps', '.png', '.jpeg', '.jpg', '.gif', '.bmp') exts = figexts + texexts ofilename = filename obasename = genutils.basename(tex_name, *exts) filenames = {} # Search in the registered paths template = obasename for path in self.include_paths: for ext in figexts: fullname = os.path.join(path, template + ext) fullname = self.__make_filename(fullname) if os.path.isfile(fullname): filenames[fullname] = False for ext in texexts: fullname = os.path.join(path, template + ext) fullname = self.__make_filename(fullname, '') if os.path.isfile(fullname): filenames[fullname] = True # Search in the folder, i.e. the document directory. if not filenames: template = os.path.join(os.path.dirname(ofilename), genutils.basename(ofilename, *exts)) for ext in figexts: fn = template + ext if os.path.isfile(fn): filenames[fn] = False for ext in texexts: fn = template + ext if os.path.isfile(fn): filenames[fn] = True if not filenames: logging.error(T('Picture not found: %s'), tex_name) else: selected_name1 = None selected_name2 = None for filename in filenames: ext = str(os.path.splitext(filename)[1]) or '' tex_name = self.__create_mapping(filename, ext) + ext if filenames[filename]: if not selected_name1: selected_name1 = (tex_name, filename) else: self.__files_to_copy.add(os.path.normpath(filename)) selected_name2 = tex_name if selected_name1: tex_name, filename = selected_name1 logging.debug(T('Embedding %s'), filename) with open(filename) as f: file_content = f.read() # Replacing the filename in the newly embedded TeX file if self.__source2target: for source in self.__source2target: file_content = file_content.replace('{' + source + '}', '{' + self.__source2target[source] + '}') bsn = os.path.basename(tex_name) prefix += textwrap.dedent(""" %%======================================================= %%== BEGIN FILE: %s %%======================================================= \\begin{filecontents*}{%s} %s \\end{filecontents*} %%======================================================= """) % (bsn, bsn, file_content) self.__dynamic_preamble.append('\\usepackage{filecontents}') elif selected_name2: tex_name = selected_name2 else: ext = os.path.splitext(tex_name)[1] or '' tex_name = self.__create_mapping(filename, ext) + ext self.__files_to_copy.add(os.path.normpath(filename)) return tex_name, prefix # noinspection DuplicatedCode def _analyze_document(self): """ Analyze the tex document for extracting information. :return: The content of the file. """ with open(self.filename) as f: content = f.read() parser = TeXParser() parser.observer = self parser.filename = self.filename if self.__include_extra_macros: for k, v in extramacros.ALL_EXTRA_MACROS.items(): parser.add_text_mode_macro(k, v) parser.add_math_mode_macro(k, v) for k, v in self.__MACROS.items(): parser.add_text_mode_macro(k, v) parser.add_math_mode_macro(k, v) parser.parse(content) def run(self) -> bool: """ Make the input file standalone. :return: True if the execution is a success, otherwise False. """ self.__reset() self._analyze_document() return True @override def comment(self, parser: Parser, raw: str, comment: str) -> str | None: return None @override def find_macro(self, parser: Parser, name: str, special: bool, math: bool) -> str | None: return None