├── latexpp ├── doc │ └── __init__.py ├── fixes │ ├── builtin │ │ ├── __init__.py │ │ ├── skip.py │ │ └── remaining_pragmas.py │ ├── pkg │ │ ├── __init__.py │ │ ├── cleveref.py │ │ ├── phfthm.py │ │ └── phfparen.py │ ├── __init__.py │ ├── newcommand.py │ ├── preamble.py │ ├── deps.py │ ├── comments.py │ ├── environment_contents.py │ ├── regional_fix.py │ ├── archive.py │ ├── macro_subst.py │ ├── ifsimple.py │ ├── input.py │ ├── usepackage.py │ ├── bib.py │ └── figures.py ├── __init__.py ├── _lpp_parsing.py ├── macro_subst_helper.py ├── __main__.py └── pragma_fix.py ├── MANIFEST.in ├── doc ├── example_custom_fix │ ├── myfixes │ │ ├── __init__.py │ │ └── mycustomfix.py │ ├── testdoc.tex │ └── lppconfig.yml ├── latexpp.pragma_fix.rst ├── latexpp.macro_subst_helper.rst ├── latexpp.preprocessor.rst ├── api-latexpp.rst ├── latexpp.fix.rst ├── Makefile ├── index.rst ├── make.bat ├── howitworks.rst ├── impl-pylatexenc.rst ├── pragmas.rst ├── fixes.rst ├── conf.py ├── lppconfig.rst ├── _static │ └── custom.css ├── customfix.rst └── howtouse.rst ├── .travis.yml ├── .gitignore ├── test ├── helper_latex_to_nodes_dict.py ├── test_fixes_deps.py ├── test_latexpp.py ├── test_fixes_preamble.py ├── test_fixes_bib.py ├── test_fixes_macro_subst.py ├── test_fixes_environment_contents.py ├── test_fixes_usepackage.py ├── test_fixes_input.py ├── test_fixes_ifsimple.py ├── test_fixes_comments.py ├── test_fixes_figures.py ├── test_fixes_labels.py ├── test_fixes_pkg_phfqit.py ├── test_fixes_newcommand.py └── helpers.py ├── pyproject.toml ├── .readthedocs.yaml ├── .github └── workflows │ ├── codeql.yml │ └── tests-ci.yml ├── License.txt └── README.rst /latexpp/doc/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include License.txt 2 | -------------------------------------------------------------------------------- /latexpp/fixes/builtin/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /latexpp/fixes/pkg/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /doc/example_custom_fix/myfixes/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /latexpp/fixes/__init__.py: -------------------------------------------------------------------------------- 1 | r""" 2 | Package containing a collection of fixes that you can apply to your LaTeX 3 | code 4 | """ 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # 2 | # Travis CI config file 3 | # 4 | 5 | language: python 6 | 7 | python: 8 | - "3.7" 9 | 10 | # command to run tests 11 | script: 12 | - PYTHONPATH=. pytest 13 | 14 | -------------------------------------------------------------------------------- /doc/example_custom_fix/testdoc.tex: -------------------------------------------------------------------------------- 1 | \documentclass[11pt]{article} 2 | 3 | \begin{document} 4 | 5 | [Chair rotates, revealing a dark menacing figure stroking a white cat.] 6 | 7 | \greet{Mr. Bond} 8 | 9 | \end{document} 10 | -------------------------------------------------------------------------------- /latexpp/fixes/newcommand.py: -------------------------------------------------------------------------------- 1 | import pylatexenc 2 | 3 | try: 4 | import pylatexenc.latexnodes 5 | 6 | from ._newcommand_pylatexenc3 import Expand 7 | except ImportError: 8 | 9 | from ._newcommand_pylatexenc2 import Expand 10 | -------------------------------------------------------------------------------- /doc/latexpp.pragma_fix.rst: -------------------------------------------------------------------------------- 1 | Module `latexpp.pragma_fix` — fix class working with lpp-pragmas 2 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 3 | 4 | .. automodule:: latexpp.pragma_fix 5 | 6 | .. autoclass:: latexpp.pragma_fix.PragmaFix 7 | :members: 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | .DS_Store 3 | 4 | __pycache__ 5 | *.py[cdo] 6 | 7 | .python-version 8 | 9 | junk 10 | 11 | build 12 | dist 13 | 14 | *.egg-info 15 | 16 | doc/_build 17 | doc/example_custom_fix/latexpp_output 18 | 19 | doccases_test 20 | 21 | *.JUNK_DRAFT 22 | 23 | .python-version 24 | -------------------------------------------------------------------------------- /doc/latexpp.macro_subst_helper.rst: -------------------------------------------------------------------------------- 1 | Module `latexpp.macro_subst_helper` — helper for macro replacements 2 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 3 | 4 | .. automodule:: latexpp.macro_subst_helper 5 | 6 | .. autoclass:: latexpp.macro_subst_helper.MacroSubstHelper 7 | :members: 8 | -------------------------------------------------------------------------------- /doc/latexpp.preprocessor.rst: -------------------------------------------------------------------------------- 1 | Module `latexpp.preprocessor` — the main preprocessor class 2 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 3 | 4 | .. automodule:: latexpp.preprocessor 5 | 6 | .. autoclass:: latexpp.preprocessor.LatexPreprocessor 7 | :members: 8 | :member-order: bysource 9 | 10 | -------------------------------------------------------------------------------- /doc/api-latexpp.rst: -------------------------------------------------------------------------------- 1 | API documentation for the *latexpp* package 2 | ------------------------------------------- 3 | 4 | Here is the API documentation for the various `latexpp` modules and classes. 5 | 6 | 7 | .. toctree:: 8 | 9 | latexpp.fix 10 | latexpp.macro_subst_helper 11 | latexpp.pragma_fix 12 | latexpp.preprocessor 13 | 14 | -------------------------------------------------------------------------------- /doc/latexpp.fix.rst: -------------------------------------------------------------------------------- 1 | Module `latexpp.fix` — the base fix class 2 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 3 | 4 | .. automodule:: latexpp.fix 5 | 6 | Base fix class 7 | -------------- 8 | 9 | .. autoclass:: latexpp.fix.BaseFix 10 | :members: 11 | 12 | .. autoclass:: latexpp.fix.DontFixThisNode 13 | :show-inheritance: 14 | :members: 15 | 16 | 17 | Base class for multi-stage fixes 18 | -------------------------------- 19 | 20 | .. autoclass:: latexpp.fix.BaseMultiStageFix 21 | :show-inheritance: 22 | :members: 23 | 24 | 25 | -------------------------------------------------------------------------------- /test/helper_latex_to_nodes_dict.py: -------------------------------------------------------------------------------- 1 | import os 2 | import os.path 3 | 4 | import logging 5 | import fileinput 6 | import json 7 | 8 | from pylatexenc import latexwalker 9 | 10 | from helpers import nodelist_to_d 11 | 12 | 13 | if __name__ == '__main__': 14 | 15 | in_latex = '' 16 | for line in fileinput.input(): 17 | in_latex += line 18 | 19 | nodelist = latexwalker.LatexWalker(in_latex, tolerant_parsing=False).get_latex_nodes()[0] 20 | 21 | d = nodelist_to_d(nodelist) 22 | 23 | print(repr(d)) 24 | 25 | print(json.dumps(d, indent=4)) 26 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = . 8 | BUILDDIR = _build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | .. latexpp documentation master file, created by 2 | sphinx-quickstart on Thu Aug 1 02:17:58 2019. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | *latexpp* documentation 7 | ======================= 8 | 9 | [*latexpp* version: |version|] 10 | 11 | .. toctree:: 12 | :maxdepth: 1 13 | :caption: Contents: 14 | 15 | howtouse 16 | howitworks 17 | lppconfig 18 | fixes 19 | pragmas 20 | customfix 21 | impl-pylatexenc 22 | api-latexpp 23 | 24 | 25 | Indices and tables 26 | ================== 27 | 28 | * :ref:`genindex` 29 | 30 | * :ref:`search` 31 | -------------------------------------------------------------------------------- /test/test_fixes_deps.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest 3 | 4 | import helpers 5 | 6 | from latexpp.fixes import deps 7 | 8 | class TestCopyFiles(unittest.TestCase): 9 | 10 | def test_simple(self): 11 | 12 | lpp = helpers.MockLPP() 13 | lpp.install_fix( deps.CopyFiles(['a.sty', 'b.clo', 'fig/myfig.jpg']) ) 14 | 15 | lpp.execute("") 16 | 17 | self.assertEqual( 18 | lpp.copied_files, 19 | [ 20 | ('a.sty', '/TESTOUT/a.sty'), 21 | ('b.clo', '/TESTOUT/b.clo'), 22 | ('fig/myfig.jpg', '/TESTOUT/fig/myfig.jpg'), 23 | ] 24 | ) 25 | 26 | 27 | 28 | if __name__ == '__main__': 29 | helpers.test_main() 30 | -------------------------------------------------------------------------------- /latexpp/fixes/preamble.py: -------------------------------------------------------------------------------- 1 | 2 | from latexpp.fix import BaseFix 3 | 4 | 5 | class AddPreamble(BaseFix): 6 | r""" 7 | Include arbitrary LaTeX code before ``\begin{document}``. 8 | 9 | Arguments: 10 | 11 | - `preamble`: the additional code to include before ``\begin{document}``. 12 | """ 13 | def __init__(self, preamble=None, fromfile=None): 14 | super().__init__() 15 | self.preamble = preamble 16 | if fromfile: 17 | with open(fromfile) as f: 18 | if self.preamble and self.preamble[-1:] != "\n": 19 | self.preamble += "\n" 20 | self.preamble += f.read() 21 | 22 | def add_preamble(self, **kwargs): 23 | return self.preamble 24 | -------------------------------------------------------------------------------- /latexpp/fixes/builtin/skip.py: -------------------------------------------------------------------------------- 1 | 2 | #from pylatexenc.latexwalker import LatexCommentNode, LatexMacroNode 3 | 4 | from latexpp.pragma_fix import PragmaFix 5 | 6 | 7 | class SkipPragma(PragmaFix): 8 | r""" 9 | This fix remove all sections in the LaTeX source marked by the LPP-pragma:: 10 | 11 | %%!lpp skip { 12 | ... 13 | %%!lpp } 14 | 15 | .. note:: 16 | 17 | You should NOT invoke this fix directly, it is automatically 18 | included for you! 19 | """ 20 | def __init__(self): 21 | super().__init__() 22 | 23 | def fix_pragma_scope(self, nodelist, jstart, jend, instruction, args): 24 | 25 | if instruction != 'skip': 26 | return jend 27 | 28 | nodelist[jstart:jend] = [] 29 | 30 | return jstart 31 | -------------------------------------------------------------------------------- /doc/example_custom_fix/lppconfig.yml: -------------------------------------------------------------------------------- 1 | # latexpp config for this example 2 | # 3 | # This is YAML syntax -- google "YAML tutorial" to get a quick intro. 4 | # Be careful with spaces since indentation is important. 5 | 6 | # the master LaTeX document -- this file will not be modified, all 7 | # output will be produced in the output_dir 8 | fname: 'testdoc.tex' 9 | 10 | # output file(s) will be created in this directory, originals will 11 | # not be modified 12 | output_dir: 'latexpp_output' 13 | 14 | # main document file name in the output directory 15 | output_fname: 'main.tex' 16 | 17 | # specify list of fixes to apply, in the given order 18 | fixes: 19 | 20 | # Custom fix 21 | - name: 'myfixes.mycustomfix.MyGreetingFix' 22 | config: 23 | greeting: "I've been expecting you, %(name)s." 24 | 25 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "latexpp" 3 | version = "0.2.0a4" 4 | description = "Latex preprocessor — apply macro definitions, remove comments, and more" 5 | authors = ["Philippe Faist"] 6 | license = "MIT" 7 | readme = "README.rst" 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.7" 11 | pylatexenc = ">=3.0a15" 12 | colorlog = ">=6.0.0a4" 13 | PyYAML = "^6.0" 14 | 15 | [tool.poetry.scripts] 16 | latexpp = 'latexpp.__main__:main' 17 | 18 | [tool.poetry.group.dev.dependencies] 19 | pytest = "^7.3.1" 20 | toml = "^0.10.2" 21 | 22 | [tool.poetry.group.builddoc] 23 | optional = true 24 | 25 | [tool.poetry.group.builddoc.dependencies] 26 | Sphinx = ">=5.0.0" 27 | sphinx-issues = ">=3.0.0" 28 | 29 | [build-system] 30 | requires = ["poetry-core"] 31 | build-backend = "poetry.core.masonry.api" 32 | -------------------------------------------------------------------------------- /test/test_latexpp.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import toml 4 | import os.path 5 | 6 | import latexpp 7 | 8 | # thanks https://github.com/python-poetry/poetry/issues/144#issuecomment-877835259 9 | 10 | class TestHardcodedPackageVersion(unittest.TestCase): 11 | 12 | def test_versions_are_in_sync(self): 13 | """Checks if the pyproject.toml and package.__init__.py __version__ are in sync.""" 14 | 15 | path = os.path.join( os.path.dirname(__file__), '..', "pyproject.toml" ) 16 | with open(path) as fpp: 17 | pyproject = toml.loads(fpp.read()) 18 | pyproject_version = pyproject["tool"]["poetry"]["version"] 19 | 20 | package_init_version = latexpp.__version__ 21 | 22 | self.assertEqual(package_init_version, pyproject_version) 23 | 24 | 25 | if __name__ == '__main__': 26 | unittest.main() 27 | -------------------------------------------------------------------------------- /doc/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # Configuration for readthedocs.org 3 | # 4 | 5 | # See poetry builds on RTD: 6 | # 7 | # https://docs.readthedocs.io/en/stable/build-customization.html#install-dependencies-with-poetry 8 | # 9 | 10 | 11 | version: 2 12 | 13 | build: 14 | 15 | os: "ubuntu-22.04" 16 | 17 | tools: 18 | python: "3.10" 19 | 20 | jobs: 21 | 22 | post_install: 23 | # Install poetry 24 | # https://python-poetry.org/docs/#installing-manually 25 | - 'pip install poetry' 26 | 27 | # Install dependencies with 'docs' dependency group 28 | # https://python-poetry.org/docs/managing-dependencies/#dependency-groups 29 | # VIRTUAL_ENV needs to be set manually for now. 30 | # See https://github.com/readthedocs/readthedocs.org/pull/11152/ 31 | - 'VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --with builddoc' 32 | 33 | 34 | sphinx: 35 | configuration: doc/conf.py 36 | builder: 'dirhtml' 37 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | 10 | jobs: 11 | analyze: 12 | name: Analyze 13 | runs-on: ubuntu-latest 14 | permissions: 15 | actions: read 16 | contents: read 17 | security-events: write 18 | 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | language: [ python ] 23 | 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v3 27 | 28 | - name: Initialize CodeQL 29 | uses: github/codeql-action/init@v2 30 | with: 31 | languages: ${{ matrix.language }} 32 | queries: +security-and-quality 33 | 34 | - name: Autobuild 35 | uses: github/codeql-action/autobuild@v2 36 | 37 | - name: Perform CodeQL Analysis 38 | uses: github/codeql-action/analyze@v2 39 | with: 40 | category: "/language:${{ matrix.language }}" 41 | -------------------------------------------------------------------------------- /test/test_fixes_preamble.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest 3 | 4 | import helpers 5 | 6 | from latexpp.fixes import preamble 7 | 8 | class TestAddPreamble(unittest.TestCase): 9 | 10 | def test_simple(self): 11 | 12 | lpp = helpers.MockLPP() 13 | lpp.install_fix( preamble.AddPreamble(preamble=r""" 14 | % use this package: 15 | \usepackage{mycoolpackage} 16 | 17 | % also keep these definitions: 18 | \newcommand\hello[2][world]{Hello #1. #2} 19 | """) ) 20 | 21 | self.assertEqual( 22 | lpp.execute(r""" 23 | \documentclass{article} 24 | 25 | \usepackage{amsmath} 26 | 27 | \begin{document} 28 | Hello world. 29 | \end{document} 30 | """), 31 | r""" 32 | \documentclass{article} 33 | 34 | \usepackage{amsmath} 35 | 36 | 37 | %%% 38 | 39 | % use this package: 40 | \usepackage{mycoolpackage} 41 | 42 | % also keep these definitions: 43 | \newcommand\hello[2][world]{Hello #1. #2} 44 | 45 | %%% 46 | \begin{document} 47 | Hello world. 48 | \end{document} 49 | """ 50 | ) 51 | 52 | 53 | 54 | 55 | 56 | 57 | if __name__ == '__main__': 58 | helpers.test_main() 59 | -------------------------------------------------------------------------------- /License.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Philippe Faist 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/test_fixes_bib.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest 3 | 4 | import helpers 5 | 6 | from latexpp.fixes import bib 7 | 8 | class TestApplyAliases(unittest.TestCase): 9 | 10 | def test_simple(self): 11 | 12 | latex = r""" 13 | \bibalias{alias1}{target1} 14 | \bibalias{alias2}{target2} 15 | \begin{document} 16 | Some text~\cite{alias1,target3} and see also~\citep{alias2}. 17 | \bibliography{mybib1,bib2} 18 | \end{document} 19 | """ 20 | 21 | lpp = helpers.MockLPP( mock_files={ 22 | 'TESTDOC.bbl': r"\relax\bibdata{XYZ}\bibcite{mybib1}{24}" # etc. this is random stuff here 23 | }) 24 | lpp.install_fix( bib.CopyAndInputBbl() ) 25 | lpp.install_fix( bib.ApplyAliases() ) 26 | 27 | self.assertEqual( 28 | lpp.execute(latex), 29 | r""" 30 | 31 | 32 | \begin{document} 33 | Some text~\cite{target1,target3} and see also~\citep{target2}. 34 | \input{TESTMAIN.bbl} 35 | \end{document} 36 | """ 37 | ) 38 | 39 | self.assertEqual(lpp.copied_files, [('TESTDOC.bbl', '/TESTOUT/TESTMAIN.bbl')]) 40 | 41 | 42 | 43 | 44 | if __name__ == '__main__': 45 | helpers.test_main() 46 | -------------------------------------------------------------------------------- /latexpp/fixes/builtin/remaining_pragmas.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logger = logging.getLogger(__name__) 4 | 5 | #from pylatexenc import latexwalker 6 | 7 | from latexpp.pragma_fix import PragmaFix 8 | 9 | 10 | class ReportRemainingPragmas(PragmaFix): 11 | 12 | def fix_pragma_scope(self, nodelist, jstart, jend, instruction, args): 13 | n = nodelist[jstart] 14 | ne = nodelist[jend-1] 15 | logger.warning( 16 | "Found unconsumed pragma ‘%s’, did you forget to invoke a fix? " 17 | "on lines %d--%d (?)", 18 | n.comment, 19 | n.parsing_state.lpp_latex_walker.pos_to_lineno_colno(n.pos)[0], 20 | ne.parsing_state.lpp_latex_walker.pos_to_lineno_colno(ne.pos)[0] 21 | ) 22 | return jend 23 | 24 | def fix_pragma_simple(self, nodelist, j, instruction, args): 25 | n = nodelist[j] 26 | logger.warning( 27 | "Found unconsumed pragma ‘%s’, did you forget to invoke a fix? " 28 | "on line %d (?)", 29 | n.comment, 30 | n.parsing_state.lpp_latex_walker.pos_to_lineno_colno(n.pos)[0] 31 | ) 32 | return j+1 33 | -------------------------------------------------------------------------------- /latexpp/__init__.py: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | # 3 | # Copyright (c) 2019 Philippe Faist 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in all 13 | # copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | # SOFTWARE. 22 | # 23 | 24 | __version__ = "0.2.0a4" 25 | -------------------------------------------------------------------------------- /.github/workflows/tests-ci.yml: -------------------------------------------------------------------------------- 1 | 2 | name: 'tests-ci' 3 | 4 | on: 5 | push: 6 | branches: [ "main" ] 7 | pull_request: 8 | branches: [ "main" ] 9 | 10 | jobs: 11 | tests-ci: 12 | 13 | strategy: 14 | fail-fast: true 15 | matrix: 16 | python-version: 17 | - "3.8" 18 | - "3.9" 19 | - "3.10" 20 | - "3.11" 21 | poetry-version: 22 | - "1.4" 23 | os: 24 | - 'ubuntu-latest' 25 | 26 | runs-on: '${{ matrix.os }}' 27 | 28 | steps: 29 | - uses: actions/checkout@v3 30 | - uses: actions/setup-python@v4 31 | with: 32 | python-version: '${{ matrix.python-version }}' 33 | - name: 'Set up poetry - Run image' 34 | uses: abatilo/actions-poetry@v2 35 | with: 36 | poetry-version: '${{ matrix.poetry-version }}' 37 | - name: 'Poetry Install' 38 | run: poetry install 39 | 40 | - name: 'Setup TeX Live (latex is needed for some latexpp tests)' 41 | uses: teatimeguest/setup-texlive-action@v2 42 | with: 43 | packages: >- 44 | scheme-basic 45 | cleveref 46 | hyperref 47 | crossreftools 48 | etoolbox 49 | 50 | - name: 'Run tests' 51 | run: poetry run pytest 52 | -------------------------------------------------------------------------------- /doc/howitworks.rst: -------------------------------------------------------------------------------- 1 | How *latexpp* works 2 | ------------------- 3 | 4 | The ``latexpp`` preprocessor relies on `pylatexenc 2.0 5 | `_ to parse the latex document into an 6 | internal node structure. For instance, the chunk of latex code:: 7 | 8 | Hello, \textit{world}! % show a greeting 9 | 10 | will be parsed into a list of four nodes, a ‘normal characters node’ ``"Hello, 11 | "``, a ‘macro node’ ``\textit`` with argument a ‘group node’ ``{world}`` which 12 | itself contains a ‘normal characters node’ ``world``, a ‘normal characters node’ 13 | ``"! "``, and a ‘latex comment node’ ``% show a greeting``. The structure is 14 | recursive, with e.g. macro arguments and environment contents themselves 15 | represented as nodes which can contain further macros and environments. See 16 | `pylatexenc.latexwalker 17 | `_ for more 18 | information. The `pylatexenc` library has a list of some known macros and 19 | environments, and knows how to parse their arguments. Some fixes in `latexpp` 20 | add their own macro and environment definitions. 21 | 22 | Once the latex document is parsed into the node structure, then the fix classes 23 | are given a chance one by one to traverse the node structure and make the 24 | necessary fixes. After all the fixes have been applied, the resulting structure 25 | is written to the output file as latex code. 26 | -------------------------------------------------------------------------------- /test/test_fixes_macro_subst.py: -------------------------------------------------------------------------------- 1 | 2 | import os.path 3 | import unittest 4 | 5 | import helpers 6 | 7 | from latexpp.fixes import macro_subst 8 | 9 | 10 | class TestSubst(unittest.TestCase): 11 | 12 | def test_simple_1(self): 13 | 14 | lpp = helpers.MockLPP() 15 | lpp.install_fix( macro_subst.Subst( 16 | macros={ 17 | 'abc': r'\textbf{ABC}', 18 | 'xyz': dict(argspec='*[{', repl=r'\chapter%(1)s[{Opt title: %(2)s}]{Title: %(3)s}') 19 | }, 20 | environments={ 21 | 'equation*': r'\[%(body)s\]' 22 | } 23 | ) ) 24 | 25 | self.assertEqual( 26 | lpp.execute(r""" 27 | Hello guys. Just testin': \abc. 28 | 29 | \xyz*{Yo} 30 | 31 | \begin{equation*} 32 | \alpha = \beta 33 | \end{equation*} 34 | 35 | \xyz[Hey]{Ya} 36 | """), 37 | r""" 38 | Hello guys. Just testin': \textbf{ABC}. 39 | 40 | \chapter*[{Opt title: }]{Title: Yo} 41 | 42 | \[ 43 | \alpha = \beta 44 | \] 45 | 46 | \chapter[{Opt title: Hey}]{Title: Ya} 47 | """ 48 | ) 49 | 50 | 51 | 52 | def test_recursive(self): 53 | 54 | lpp = helpers.MockLPP() 55 | lpp.install_fix( macro_subst.Subst( 56 | macros={ 57 | 'ket': dict(argspec='{', repl=r'\lvert{%(1)s}\rangle'), 58 | 'rhostate': r'\hat\rho', 59 | }, 60 | ) ) 61 | 62 | self.assertEqual( 63 | lpp.execute(r"""\ket\rhostate"""), 64 | r"""\lvert{\hat\rho}\rangle""" 65 | ) 66 | 67 | 68 | 69 | 70 | if __name__ == '__main__': 71 | helpers.test_main() 72 | -------------------------------------------------------------------------------- /test/test_fixes_environment_contents.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest 3 | 4 | import helpers 5 | 6 | from latexpp.fixes import environment_contents 7 | 8 | class TestInsertPrePost(unittest.TestCase): 9 | 10 | def test_post_contents(self): 11 | 12 | lpp = helpers.MockLPP() 13 | lpp.install_fix( 14 | environment_contents.InsertPrePost( 15 | environmentnames=['proof','myproof'], 16 | post_contents=r'\qed', 17 | ) 18 | ) 19 | 20 | self.assertEqual( 21 | lpp.execute(r""" 22 | \documentclass{article} 23 | \begin{document} 24 | Hello world. 25 | \begin{proof} 26 | Proof of this and that. 27 | \end{proof} 28 | \end{document} 29 | """), 30 | r""" 31 | \documentclass{article} 32 | \begin{document} 33 | Hello world. 34 | \begin{proof} 35 | Proof of this and that. 36 | \qed\end{proof} 37 | \end{document} 38 | """ 39 | ) 40 | 41 | def test_pre_contents(self): 42 | 43 | lpp = helpers.MockLPP() 44 | lpp.install_fix( 45 | environment_contents.InsertPrePost( 46 | environmentnames=['proof','myproof'], 47 | pre_contents='\n'+r'(proof \textbf{starts} here)', 48 | ) 49 | ) 50 | 51 | self.assertEqual( 52 | lpp.execute(r""" 53 | \documentclass{article} 54 | \begin{document} 55 | Hello world. 56 | \begin{proof} 57 | Proof of this and that. 58 | \end{proof} 59 | \end{document} 60 | """), 61 | r""" 62 | \documentclass{article} 63 | \begin{document} 64 | Hello world. 65 | \begin{proof} 66 | (proof \textbf{starts} here) 67 | Proof of this and that. 68 | \end{proof} 69 | \end{document} 70 | """ 71 | ) 72 | 73 | 74 | 75 | 76 | 77 | 78 | if __name__ == '__main__': 79 | helpers.test_main() 80 | -------------------------------------------------------------------------------- /test/test_fixes_usepackage.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest 3 | 4 | import helpers 5 | 6 | from latexpp.fixes import usepackage 7 | 8 | class TestRemovePkgs(unittest.TestCase): 9 | 10 | def test_simple(self): 11 | 12 | lpp = helpers.MockLPP() 13 | lpp.install_fix( usepackage.RemovePkgs(['phfparen', 'mymacros']) ) 14 | 15 | self.assertEqual( 16 | lpp.execute(r""" 17 | \documentclass{article} 18 | 19 | \usepackage{amsmath} 20 | \usepackage{phfparen} 21 | \usepackage[someoptions,moreoptions]{mymacros}% 22 | 23 | \begin{document} 24 | Hello world. 25 | \end{document} 26 | """), 27 | r""" 28 | \documentclass{article} 29 | 30 | \usepackage{amsmath} 31 | 32 | % 33 | 34 | \begin{document} 35 | Hello world. 36 | \end{document} 37 | """ 38 | ) 39 | 40 | 41 | class TestCopyLocalPkgs(unittest.TestCase): 42 | 43 | def test_simple(self): 44 | 45 | mock_files = { 46 | # list of files that "exist" 47 | "mymacros.sty": "%test", 48 | "cleveref.sty": "%test", 49 | } 50 | usepackage.os_path = helpers.FakeOsPath(list(mock_files.keys())) 51 | 52 | lpp = helpers.MockLPP(mock_files) 53 | lpp.install_fix( usepackage.CopyLocalPkgs() ) 54 | 55 | lpp.execute(r""" 56 | \documentclass{article} 57 | 58 | \usepackage{amsmath} 59 | \usepackage{phfparen} 60 | \usepackage[someoptions,moreoptions]{mymacros}% 61 | \usepackage{cleveref} 62 | 63 | \begin{document} 64 | Hello world. 65 | \end{document} 66 | """) 67 | self.assertEqual( 68 | lpp.copied_files, 69 | [ 70 | ('mymacros.sty', '/TESTOUT/mymacros.sty'), 71 | ('cleveref.sty', '/TESTOUT/cleveref.sty'), 72 | ] 73 | ) 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | if __name__ == '__main__': 82 | helpers.test_main() 83 | -------------------------------------------------------------------------------- /latexpp/fixes/deps.py: -------------------------------------------------------------------------------- 1 | 2 | import logging 3 | 4 | logger = logging.getLogger(__name__) 5 | 6 | from latexpp.fix import BaseFix 7 | 8 | 9 | class CopyFiles(BaseFix): 10 | """ 11 | Copies the given files to the output directory. Use this for dependencies 12 | that aren't obvious, like a custom LaTeX class. 13 | 14 | For packages, you can use 15 | :py:class:`latexpp.fixes.usepackage.CopyLocalPkgs`. For figures, consider 16 | using :py:class:`latexpp.fixes.figures.CopyAndRenameFigs`. 17 | 18 | Arguments: 19 | 20 | - `files`: a list of files to include in the output directory. The files 21 | are not renamed and subdirectories are preserved. 22 | 23 | Each element in `files` is either: 24 | 25 | - a single file name, in which case the destination file name and 26 | relative path is preserved, or 27 | 28 | - a dictionary of the form ``{'from': source_file, 'to': dest_file}``, 29 | in which case the file `source_file` is copied to `dest_file`, where 30 | `dest_file` is relative to the output directory. 31 | 32 | Example:: 33 | 34 | fixes: 35 | [...] 36 | - name: 'latexpp.fixes.deps.CopyFiles' 37 | config: 38 | files: 39 | # copy my-suppl-mat-xyz.pdf -> output/SupplMaterial.pdf 40 | - from: my-suppl-mat-xyz.pdf 41 | to: SupplMaterial.pdf 42 | # copy ReplyReferees.pdf -> output/ReplyReferees.pdf 43 | - ReplyReferees.pdf 44 | """ 45 | 46 | def __init__(self, files=[]): 47 | super().__init__() 48 | self.files = files 49 | 50 | def initialize(self, **kwargs): 51 | 52 | for fn in self.files: 53 | if isinstance(fn, dict): 54 | fn_from, fn_to = fn['from'], fn['to'] 55 | else: 56 | fn_from, fn_to = fn, fn 57 | self.lpp.copy_file(fn_from, fn_to) 58 | 59 | -------------------------------------------------------------------------------- /latexpp/fixes/comments.py: -------------------------------------------------------------------------------- 1 | 2 | from pylatexenc.latexwalker import LatexCommentNode, LatexMacroNode 3 | 4 | from latexpp.fix import BaseFix 5 | 6 | 7 | class RemoveComments(BaseFix): 8 | r""" 9 | Remove all LaTeX comments from the latex document. 10 | 11 | Arguments: 12 | 13 | - `leave_percent`: If `True` (the default), then a full LaTeX comment is 14 | replaced by an empty comment, i.e., a single percent sign and whatever 15 | whitespace followed the comment (the whitespace is anyways ignored by 16 | LaTeX). If `False`, then the comment and following whitespace is removed 17 | entirely. 18 | """ 19 | def __init__(self, leave_percent=True, collapse=True): 20 | super().__init__() 21 | self.leave_percent = leave_percent 22 | self.collapse = collapse 23 | 24 | def fix_node(self, n, prev_node=None, **kwargs): 25 | 26 | if n.isNodeType(LatexCommentNode): 27 | if n.comment.startswith('%!lpp'): 28 | # DO NOT remove LPP pragmas -- they will be needed by other fixes. 29 | return None 30 | 31 | if self.leave_percent: 32 | # sys.stderr.write("Ignoring comment: '%s'\n"% node.comment) 33 | if self.collapse and prev_node \ 34 | and prev_node.isNodeType(LatexCommentNode): 35 | # previous node is already a comment, ignore this one. But update 36 | # previous node's post_space 37 | prev_node.comment_post_space = n.comment_post_space 38 | return [] 39 | return "%"+n.comment_post_space 40 | else: 41 | if prev_node is not None and prev_node.isNodeType(LatexMacroNode): 42 | if not prev_node.macro_post_space and \ 43 | (not prev_node.nodeargd or not prev_node.nodeargd.argnlist 44 | or all((not a) for a in prev_node.nodeargd.argnlist)): 45 | # macro has neither post-space nor any arguments, so add 46 | # space to ensure LaTeX code stays valid 47 | prev_node.macro_post_space = ' ' 48 | 49 | return [] # remove entirely. 50 | 51 | return None 52 | -------------------------------------------------------------------------------- /latexpp/fixes/environment_contents.py: -------------------------------------------------------------------------------- 1 | import logging 2 | logger = logging.getLogger(__name__) 3 | 4 | from pylatexenc.latexwalker import LatexEnvironmentNode 5 | 6 | from latexpp.fix import BaseFix 7 | 8 | 9 | class InsertPrePost(BaseFix): 10 | r""" 11 | Find instances of a specific environment and insert contents in its body, 12 | before or after the existing contents. 13 | 14 | This fix can be useful for instance to add a ``\qed`` command in some LaTeX 15 | styles at the end of proofs (``\begin{proof} ... \end{proof}`` → 16 | ``\begin{proof} ... \qed\end{proof}``). 17 | 18 | Arguments: 19 | 20 | - `environmentnames` is a list of names of environments upon which to act 21 | (e.g., ``['proof']``; 22 | 23 | - `pre_contents` is arbitrary LaTeX code to insert at the beginning of the 24 | environment body, for each environment encountered whose name is in 25 | `environmentnames`; 26 | 27 | - `post_contents` is arbitrary LaTeX code to insert at the end of the 28 | environment body, for each environment encountered whose name is in 29 | `environmentnames`. 30 | """ 31 | def __init__(self, environmentnames=None, pre_contents=None, post_contents=None): 32 | super().__init__() 33 | self.environmentnames = list(environmentnames) if environmentnames else [] 34 | self.pre_contents = pre_contents 35 | self.post_contents = post_contents 36 | 37 | def fix_node(self, n, **kwargs): 38 | 39 | if n.isNodeType(LatexEnvironmentNode) \ 40 | and n.environmentname in self.environmentnames: 41 | 42 | # process the children nodes, including environment arguments etc. 43 | self.preprocess_child_nodes(n) 44 | 45 | # insert pre-/post- content to body 46 | if self.pre_contents is not None: 47 | # insert the pre- content 48 | pre_nodes = self.parse_nodes(self.pre_contents, n.parsing_state) 49 | n.nodelist[:0] = pre_nodes 50 | if self.post_contents is not None: 51 | # insert the post- content 52 | post_nodes = self.parse_nodes(self.post_contents, n.parsing_state) 53 | n.nodelist[len(n.nodelist):] = post_nodes 54 | 55 | return n 56 | 57 | return None 58 | -------------------------------------------------------------------------------- /doc/impl-pylatexenc.rst: -------------------------------------------------------------------------------- 1 | 2 | .. _implementation-notes-pylatexenc: 3 | 4 | Implementation notes for `pylatexenc` usage 5 | =========================================== 6 | 7 | We use `pylatexenc` to parse the latex code into a data structure of nodes. See 8 | https://pylatexenc.readthedocs.io/en/latest/latexwalker for more information. 9 | 10 | There is a small API difference however between `pylatexenc` and `latexpp` 11 | regarding how to get the latex code associated with a node. 12 | 13 | .. note:: 14 | 15 | The following remark below addresses how to get the raw latex code associated 16 | with a node. Within a fix's :py:func:`~latexpp.fix.BaseFix.fix_node()` 17 | method, however, you should probably be using 18 | :py:func:`~latexpp.fix.BaseFix.preprocess_contents_latex()` or 19 | :py:func:`~latexpp.fix.BaseFix.preprocess_latex()` instead, which also ensure 20 | that the fix is applied recursively to argument nodes and to children nodes. 21 | 22 | If you use the `node.to_latex()` method discussed below, it's up to you to 23 | ensure that the fix is properly applied to all children nodes as well. 24 | 25 | With `pylatexenc.latexwalker`, each node class has a `latex_verbatim()` method 26 | that returns the piece of the original string that that node represents. But 27 | because here we're changing the node properties, we need to actually recompose 28 | the latex code from the updated node properties. That is, if we used 29 | `node.latex_verbatim()`, then the result would not actually reflect any changes 30 | made to the node properties such as macro name or arguments. 31 | 32 | The solution that `latexpp` introduced is to use a special, internal 33 | :py:class:`~pylatexenc.latexwalker.LatexWalker` subclass that tags on all nodes 34 | an additional method `to_latex()` that recomposes the latex code associated with 35 | the node, directly from the node attributes. This way, calling 36 | `node.to_latex()` is guaranteed to use the up-to-date information from the node 37 | attributes. 38 | 39 | In an effort to avoid bugs, the method `node.latex_verbatim()` is disabled and 40 | will throw an error. Simply use `node.to_latex()` instead. 41 | 42 | **TL;DR**: use `node.to_latex()` instead of `node.latex_verbatim()`. But you 43 | should probably be using 44 | :py:func:`~latexpp.fix.BaseFix.preprocess_contents_latex()` anyway. 45 | -------------------------------------------------------------------------------- /latexpp/fixes/regional_fix.py: -------------------------------------------------------------------------------- 1 | 2 | #from pylatexenc.latexwalker import LatexCommentNode, LatexMacroNode 3 | 4 | from latexpp.pragma_fix import PragmaFix 5 | 6 | 7 | class Apply(PragmaFix): 8 | r""" 9 | Apply a regional fix, i.e., apply a set of rules to a delimited section of 10 | your document. 11 | 12 | This fix looks for one or more delimited sections in your document (see 13 | below) whose name matches the given argument `region`. On those delimited 14 | sections, a specified list of custom fixes are executed. The fixes to run 15 | are specified in the `fixes` argument, with a format that is exactly the 16 | same as the global `fixes:` dictionary key in the `lppconfig.yml` file. 17 | 18 | The section of your document that you would like to apply the specified 19 | fixes to is marked using a ``%%!lpp`` pragma instruction of the form:: 20 | 21 | %%!lpp regional-fix My-Region-Name-1 { 22 | ... 23 | ... 24 | %%!lpp } 25 | 26 | Arguments: 27 | 28 | - `region`: is the name of the region that the given extra fixes should be 29 | applied to. In your LaTeX code, you should have a ``%%!lpp regional-fix`` 30 | pragma instruction that delimits the sections of code on which these fixes 31 | should be applied. In the example above, `region="My-Region-Name-1"`. 32 | 33 | - `fixes`: a data structure of fixes configuration like the main `fixes` 34 | section of the `latexpp` configuration. You can specify here any fixes 35 | that you could specify for the document at the top level. 36 | """ 37 | def __init__(self, region=None, fixes=None): 38 | super().__init__() 39 | self.region = region 40 | self.fixes = fixes if fixes else [] 41 | self.subpp = None 42 | 43 | 44 | def initialize(self): 45 | self.subpp = self.lpp.create_subpreprocessor() 46 | self.subpp.install_fixes_from_config(self.fixes) 47 | self.subpp.initialize() 48 | 49 | def fix_pragma_scope(self, nodelist, jstart, jend, instruction, args): 50 | 51 | if instruction != 'regional-fix': 52 | return jend # skip 53 | 54 | region_name, = args 55 | 56 | if region_name != self.region: 57 | return jend # skip 58 | 59 | newnodes = self.subpp.preprocess(nodelist[jstart+1:jend-1]) 60 | 61 | nodelist[jstart:jend] = newnodes 62 | 63 | return jstart+len(newnodes) 64 | -------------------------------------------------------------------------------- /doc/pragmas.rst: -------------------------------------------------------------------------------- 1 | Latexpp Pragmas 2 | --------------- 3 | 4 | Pragmas are special comments in your LaTeX document that influence how `latexpp` 5 | processes your document. 6 | 7 | There is currently only a single pragma that is built into `latexpp`: the `skip` 8 | pragma. Other pragms have to be enabled by specific fixes. 9 | 10 | 11 | The `skip` built-in pragma 12 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 13 | 14 | The simplest example—and perhaps the most useful 15 | pragma—is the one that instructs `latexpp` to skip a section of code entirely: 16 | 17 | .. code-block:: latex 18 | 19 | ... 20 | %%!lpp skip { 21 | \def\someComplexCommand#1that latexpp{% 22 | \expandafter\will\never{be able to parse}% 23 | correctly} 24 | %%!lpp } 25 | ... 26 | 27 | General pragma syntax 28 | ~~~~~~~~~~~~~~~~~~~~~ 29 | 30 | A simple pragma has the syntax: 31 | 32 | .. code-block:: latex 33 | 34 | ... 35 | %%!lpp pragma arguments 36 | ... 37 | 38 | where `pragma` is a pragma name and `arguments` are further information that can 39 | be provided to whatever fix parses the pragma. The pragma must start with the 40 | exact string ``%%!lpp``: two percent signs (the first initiating the LaTeX 41 | comment), an exclamation mark, and the letters ``lpp`` in lowercase with no 42 | spaces between these components. The entire pragma instruction must be on one 43 | line. The pragma name is separated from ``%%!lpp`` by whitespace. Any arguments 44 | are processed like a shell command line: Arguments are separated by spaces and 45 | can be quoted using single or double quotes (we use :py:class:`~shlex.shlex` to 46 | split the the argument list). 47 | 48 | A scoped pragma has the syntax: 49 | 50 | .. code-block:: latex 51 | 52 | ... 53 | %%!lpp pragma arguments { 54 | ... 55 | %%!lpp } 56 | ... 57 | 58 | The pragma instruction is exactly the same as for a simple pragma, except that 59 | it must finish with an opening brace character '{' separated by whitespace from 60 | the rest of the pragma instruction. (Trailing whitespace after the brace is 61 | ignored.) The scope is closed using a ``%%!lpp`` marker as for a simple pragma, 62 | whitespace, and a closing brace. 63 | 64 | To write a custom fix that parses pragmas, see :ref:`customfix`. 65 | 66 | 67 | Other pragmas associated with specific fixes 68 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 69 | 70 | See fixes: 71 | 72 | - :py:class:`latexpp.fixes.regional_fix.Apply`. 73 | 74 | - more might come in the future! 75 | 76 | 77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /latexpp/fixes/pkg/cleveref.py: -------------------------------------------------------------------------------- 1 | import re 2 | import os 3 | import os.path 4 | import logging 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | #from pylatexenc.macrospec import MacroSpec, MacroStandardArgsParser 9 | #from pylatexenc import latexwalker 10 | 11 | from latexpp.fix import BaseFix 12 | 13 | 14 | 15 | class ApplyPoorMan(BaseFix): 16 | r""" 17 | Applies the replacements provided by `cleveref`\ 's "poor man" mode. 18 | 19 | .. warning:: 20 | 21 | OBSOLETE: It is strongly recommended to use the 22 | :py:class:`latexpp.fixes.ref.ExpandRefs` fix instead, which supports 23 | `cleveref` references. 24 | 25 | Make sure you use `cleveref` with the ``[poorman]`` package option, like 26 | this:: 27 | 28 | \usepackage[poorman]{cleveref} 29 | 30 | After this fix, the file no longer depends on the {cleveref} package. Note, 31 | there are some limitations of cleveref's "poor man" mode that we can't get 32 | around here. 33 | """ 34 | def __init__(self): 35 | super().__init__() 36 | 37 | def fix_node(self, n, **kwargs): 38 | return None 39 | 40 | def finalize(self, **kwargs): 41 | # read the cleveref-generated .sed file 42 | sedfn = re.sub(r'(\.(la)?tex)$', '', self.lpp.main_doc_fname) + '.sed' 43 | if not os.path.exists(sedfn): 44 | logger.error(r"Cannot find file %s. Are you sure you provided the " 45 | r"[poorman] option to \usepackage[poorman]{cleveref} " 46 | r"and that you ran (pdf)latex?") 47 | self.lpp.check_autofile_up_to_date(sedfn) 48 | 49 | replacements = [] 50 | with open(sedfn) as sedf: 51 | for sedline in sedf: 52 | sedline = sedline.strip() 53 | if sedline: 54 | s, pat, repl, g = sedline.split('/') 55 | pat = sed_to_py_re(pat) 56 | replacements.append( (re.compile(pat), repl) ) 57 | 58 | lpp = self.lpp # ### NEEDED, RIGHT ?? 59 | 60 | # now apply these replacements onto the final file 61 | with open(os.path.join(lpp.output_dir, lpp.main_doc_output_fname)) as of: 62 | main_out = of.read() 63 | 64 | for rep in replacements: 65 | main_out = rep[0].sub(rep[1], main_out) 66 | 67 | # re-write replaced stuff onto the final file 68 | with open(os.path.join(lpp.output_dir, lpp.main_doc_output_fname), 'w') as of: 69 | of.write(main_out) 70 | 71 | 72 | -------------------------------------------------------------------------------- /doc/example_custom_fix/myfixes/mycustomfix.py: -------------------------------------------------------------------------------- 1 | import logging 2 | logger = logging.getLogger(__name__) # log messages 3 | 4 | from pylatexenc.macrospec import MacroSpec, EnvironmentSpec 5 | from pylatexenc import latexwalker 6 | 7 | from latexpp.fix import BaseFix 8 | 9 | class MyGreetingFix(BaseFix): 10 | r""" 11 | The documentation for my custom fix goes here. 12 | """ 13 | 14 | def __init__(self, greeting='Hi there, %(name)s!'): 15 | self.greeting = greeting 16 | super().__init__() 17 | 18 | def specs(self, **kwargs): 19 | return dict(macros=[ 20 | # tell the parser that \greet is a macro that takes a 21 | # single mandatory argument 22 | MacroSpec("greet", "{") 23 | ]) 24 | 25 | def fix_node(self, n, **kwargs): 26 | 27 | if (n.isNodeType(latexwalker.LatexMacroNode) 28 | and n.macroname == 'greet'): 29 | 30 | # \greet{Someone} encountered in the document 31 | 32 | # Even if we declared the \greet macro to accept an 33 | # argument, it might happen in some cases that n.nodeargd 34 | # is None or has no arguments. This happens, e.g. for 35 | # ``\newcommand{\greet}...``. In such cases, leave this 36 | # \greet unchanged: 37 | if n.nodeargd is None or not n.nodeargd.argnlist: 38 | return None # no change 39 | 40 | # make sure arguments are preprocessed, too, and 41 | # then get the argument as LaTeX code: 42 | arg = self.preprocess_contents_latex(n.nodeargd.argnlist[0]) 43 | 44 | # return the new LaTeX code to put in place of the entire 45 | # \greet{XXX} invocation. Here, we use the string stored 46 | # in self.greeting. We assume that that string has a 47 | # '%(name)s' in it that can replace with the name of the 48 | # person to greet (the macro argument that we just got). 49 | # We use the % operator in python for this cause it's 50 | # handy. 51 | 52 | # use logger.debug(), logger.info(), logger.warning(), 53 | # logger.error() to print out messages, debug() will be 54 | # visible if latexpp is called with --verbose 55 | logger.debug("Creating greeting for %s", arg) 56 | 57 | # don't forget to use raw strings r'...' for latex code, 58 | # to avoid having to escape all the \'s 59 | return r'\emph{' + self.greeting % {"name": arg} + '}' 60 | 61 | return None 62 | -------------------------------------------------------------------------------- /test/test_fixes_input.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest 3 | 4 | import helpers 5 | 6 | from latexpp.fixes import input 7 | 8 | class TestEvalInput(unittest.TestCase): 9 | 10 | def test_simple(self): 11 | 12 | input.os_path = helpers.FakeOsPath([ 13 | # list of files that "exist" 14 | 'chapter1.tex', 15 | 'chapter2.latex', 16 | ]) 17 | 18 | ei = input.EvalInput() 19 | 20 | ei._read_file_contents = lambda fn: { 21 | 'chapter1.tex': r""" 22 | \chapter[first]{Chapter uno} 23 | This is the \emph{contents} of ``Chapter 1.'' 24 | """, 25 | 'chapter2.latex': r"""\chapter{The second of chapters} 26 | Here is the \textbf{contents} of ``Chapter 2!'' 27 | """, 28 | }.get(fn) 29 | 30 | lpp = helpers.MockLPP() 31 | lpp.install_fix( ei ) 32 | 33 | self.assertEqual( 34 | lpp.execute(r""" 35 | Hello, this might be an introduction: 36 | \[ a + b = c\ . \] 37 | 38 | \input{chapter1.tex} 39 | 40 | \include{chapter2} 41 | """), 42 | r""" 43 | Hello, this might be an introduction: 44 | \[ a + b = c\ . \] 45 | 46 | 47 | \chapter[first]{Chapter uno} 48 | This is the \emph{contents} of ``Chapter 1.'' 49 | 50 | 51 | \clearpage 52 | \chapter{The second of chapters} 53 | Here is the \textbf{contents} of ``Chapter 2!'' 54 | 55 | """ 56 | ) 57 | 58 | self.assertEqual( 59 | lpp.copied_files, 60 | [ ] 61 | ) 62 | 63 | 64 | 65 | class TestCopyInputDeps(unittest.TestCase): 66 | 67 | def test_simple(self): 68 | 69 | input.os_path = helpers.FakeOsPath([ 70 | # list of files that "exist" 71 | 'chapter1.tex', 72 | 'chapter2.latex', 73 | ]) 74 | 75 | mock_files = { 76 | 'chapter1.tex': r""" 77 | \chapter[first]{Chapter uno} 78 | This is the \emph{contents} of ``Chapter 1.'' 79 | """, 80 | 'chapter2.latex': r"""\chapter{The second of chapters} 81 | Here is the \textbf{contents} of ``Chapter 2!'' 82 | """, 83 | } 84 | 85 | lpp = helpers.MockLPP(mock_files=mock_files) 86 | lpp.install_fix( input.CopyInputDeps() ) 87 | 88 | self.assertEqual( 89 | lpp.execute(r""" 90 | Hello, this might be an introduction: 91 | \[ a + b = c\ . \] 92 | 93 | \input{chapter1.tex} 94 | 95 | \include{chapter2} 96 | """), 97 | r""" 98 | Hello, this might be an introduction: 99 | \[ a + b = c\ . \] 100 | 101 | \input{chapter1.tex} 102 | 103 | \include{chapter2} 104 | """ 105 | ) # no change 106 | 107 | self.assertEqual( 108 | lpp.wrote_executed_files, 109 | mock_files 110 | ) 111 | 112 | 113 | 114 | if __name__ == '__main__': 115 | helpers.test_main() 116 | -------------------------------------------------------------------------------- /test/test_fixes_ifsimple.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest 3 | 4 | import helpers 5 | 6 | from latexpp.fixes import ifsimple 7 | 8 | class TestApplyIf(unittest.TestCase): 9 | 10 | def test_iftrue(self): 11 | 12 | lpp = helpers.MockLPP() 13 | lpp.install_fix(ifsimple.ApplyIf()) 14 | 15 | self.assertEqual( 16 | lpp.execute(r""" 17 | \iftrue TRUE\fi 18 | """), 19 | r""" 20 | TRUE""" 21 | ) 22 | 23 | def test_iftrue_else(self): 24 | 25 | lpp = helpers.MockLPP() 26 | lpp.install_fix(ifsimple.ApplyIf()) 27 | 28 | self.assertEqual( 29 | lpp.execute(r""" 30 | \iftrue TRUE\else FALSE\fi 31 | """), 32 | r""" 33 | TRUE""" 34 | ) 35 | 36 | def test_iffalse(self): 37 | 38 | lpp = helpers.MockLPP() 39 | lpp.install_fix(ifsimple.ApplyIf()) 40 | 41 | self.assertEqual( 42 | lpp.execute(r""" 43 | \iffalse TRUE\fi 44 | """), 45 | r""" 46 | """ 47 | ) 48 | 49 | def test_iffalse_else(self): 50 | 51 | lpp = helpers.MockLPP() 52 | lpp.install_fix(ifsimple.ApplyIf()) 53 | 54 | self.assertEqual( 55 | lpp.execute(r""" 56 | \iffalse TRUE\else FALSE\fi 57 | """), 58 | r""" 59 | FALSE""" 60 | ) 61 | 62 | 63 | def test_nested_ifs_1(self): 64 | 65 | lpp = helpers.MockLPP() 66 | lpp.install_fix(ifsimple.ApplyIf()) 67 | 68 | self.assertEqual( 69 | lpp.execute(r""" 70 | \iftrue 71 | \iffalse 72 | A-A 73 | \else 74 | A-B 75 | \iffalse\else 76 | A-B-A 77 | \fi 78 | \fi 79 | \fi 80 | """), 81 | r""" 82 | A-B 83 | A-B-A 84 | """ 85 | ) 86 | 87 | 88 | 89 | def test_newif(self): 90 | 91 | lpp = helpers.MockLPP() 92 | lpp.install_fix(ifsimple.ApplyIf()) 93 | 94 | self.assertEqual( 95 | lpp.execute(r""" 96 | \newif\ifA 97 | \Atrue 98 | \newif\ifB 99 | \ifB 100 | \Afalse 101 | \else 102 | \ifA A is TRUE!\fi 103 | \fi 104 | """), 105 | r""" 106 | A is TRUE!""" 107 | ) 108 | 109 | 110 | def test_in_groups_and_environments(self): 111 | 112 | lpp = helpers.MockLPP() 113 | lpp.install_fix(ifsimple.ApplyIf()) 114 | 115 | self.assertEqual( 116 | lpp.execute(r""" 117 | \newif\ifA 118 | \Atrue 119 | \newif\ifB 120 | \begin{document} 121 | \ifB 122 | \Afalse 123 | \else 124 | \textbf{\ifA A is TRUE!\fi} 125 | \fi 126 | \end{document} 127 | """), 128 | r""" 129 | \begin{document} 130 | \textbf{A is TRUE!} 131 | \end{document} 132 | """ 133 | ) 134 | 135 | 136 | 137 | 138 | 139 | if __name__ == '__main__': 140 | helpers.test_main() 141 | -------------------------------------------------------------------------------- /test/test_fixes_comments.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest 3 | 4 | import helpers 5 | 6 | from latexpp.fixes import comments 7 | 8 | class TestRemoveComments(unittest.TestCase): 9 | 10 | def test_simple_1(self): 11 | 12 | latex = r"""Line with % comment here 13 | 14 | % line comment on its own 15 | % and a second line 16 | 17 | Also a \itshape% comment after a macro 18 | % and also a second line 19 | some italic text.""" 20 | 21 | lpp = helpers.MockLPP() 22 | lpp.install_fix( comments.RemoveComments() ) 23 | 24 | self.assertEqual( 25 | lpp.execute(latex), 26 | r"""Line with % 27 | 28 | % 29 | 30 | Also a \itshape% 31 | some italic text.""" 32 | ) 33 | 34 | def test_simple_1b(self): 35 | 36 | # test that collapsing comments do respect the post-space of the last 37 | # comment 38 | 39 | latex = r"""Line with % comment here 40 | 41 | \begin{stuff} 42 | % line comment on its own 43 | % and a second line 44 | stuff... 45 | \end{stuff} 46 | 47 | Also a \itshape% comment after a macro 48 | % and also a second line 49 | some italic text.""" 50 | 51 | lpp = helpers.MockLPP() 52 | lpp.install_fix( comments.RemoveComments() ) 53 | 54 | self.assertEqual( 55 | lpp.execute(latex), 56 | r"""Line with % 57 | 58 | \begin{stuff} 59 | % 60 | stuff... 61 | \end{stuff} 62 | 63 | Also a \itshape% 64 | some italic text.""" 65 | ) 66 | 67 | def test_leave_percent(self): 68 | 69 | latex = r"""Line with % comment here 70 | some text. 71 | % line comment on its own 72 | % and a second line 73 | 74 | Also a \itshape% comment after a macro 75 | % and also a second line 76 | some italic text.""" 77 | 78 | lpp = helpers.MockLPP() 79 | lpp.install_fix( comments.RemoveComments(leave_percent=False) ) 80 | 81 | self.assertEqual( 82 | lpp.execute(latex), 83 | r"""Line with some text. 84 | 85 | 86 | Also a \itshape some italic text.""" 87 | ) 88 | 89 | def test_no_collapse(self): 90 | 91 | latex = r"""Line with % comment here 92 | 93 | \begin{stuff} 94 | % line comment on its own 95 | % and a second line 96 | stuff... 97 | \end{stuff} 98 | 99 | Also a \itshape% comment after a macro 100 | % and also a second line 101 | some italic text.""" 102 | 103 | lpp = helpers.MockLPP() 104 | lpp.install_fix( comments.RemoveComments(collapse=False) ) 105 | 106 | self.assertEqual( 107 | lpp.execute(latex), 108 | r"""Line with % 109 | 110 | \begin{stuff} 111 | % 112 | % 113 | stuff... 114 | \end{stuff} 115 | 116 | Also a \itshape% 117 | % 118 | some italic text.""" 119 | ) 120 | 121 | 122 | 123 | 124 | 125 | if __name__ == '__main__': 126 | helpers.test_main() 127 | -------------------------------------------------------------------------------- /doc/fixes.rst: -------------------------------------------------------------------------------- 1 | List of fixes 2 | ------------- 3 | 4 | Here is a list of all "fixes" that you can apply to your latex document. 5 | 6 | Each class corresponds to a fix that you can list in your ``fixes:`` section of 7 | your ``lppconfig.yml`` file. See :ref:`howtouse` and :ref:`lppconfig`. 8 | 9 | Arguments indicated in parentheses are provided by corresponding YAML keys in 10 | the ``lppconfig.yml`` config file. For instance, the instruction 11 | 12 | .. code-block:: yaml 13 | 14 | fixes: 15 | ... 16 | - name: 'latexpp.fixes.figures.CopyAndRenameFigs' 17 | config: 18 | # template name for figure output file name 19 | fig_rename: '{fig_counter:02}-{orig_fig_basename}{fig_ext}' 20 | # start at figure # 11 21 | start_fig_counter: 11 22 | ... 23 | 24 | translates to the fix instantiation (python class):: 25 | 26 | latexpp.fixes.figures.CopyAndRenameFigs( 27 | fig_rename="{fig_counter:02}-{orig_fig_basename}{fig_ext}", 28 | start_fig_counter=11 29 | ) 30 | 31 | 32 | .. contents:: Categories of Fixes: 33 | :local: 34 | 35 | 36 | 37 | General document contents & formatting 38 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 39 | 40 | .. autoclass:: latexpp.fixes.input.EvalInput 41 | 42 | .. autoclass:: latexpp.fixes.comments.RemoveComments 43 | 44 | Expanding custom macros 45 | ~~~~~~~~~~~~~~~~~~~~~~~ 46 | 47 | .. autoclass:: latexpp.fixes.newcommand.Expand 48 | 49 | .. autoclass:: latexpp.fixes.macro_subst.Subst 50 | 51 | Tweaking document contents 52 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 53 | 54 | .. autoclass:: latexpp.fixes.environment_contents.InsertPrePost 55 | 56 | .. autoclass:: latexpp.fixes.ifsimple.ApplyIf 57 | 58 | References and citations 59 | ~~~~~~~~~~~~~~~~~~~~~~~~ 60 | 61 | .. autoclass:: latexpp.fixes.ref.ExpandRefs 62 | 63 | .. autoclass:: latexpp.fixes.labels.RenameLabels 64 | 65 | .. autoclass:: latexpp.fixes.bib.CopyAndInputBbl 66 | 67 | .. autoclass:: latexpp.fixes.bib.ApplyAliases 68 | 69 | Figures 70 | ~~~~~~~ 71 | 72 | .. autoclass:: latexpp.fixes.figures.CopyAndRenameFigs 73 | 74 | Preamble, packages, local files, and other dependencies 75 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 76 | 77 | .. autoclass:: latexpp.fixes.usepackage.CopyLocalPkgs 78 | 79 | .. autoclass:: latexpp.fixes.usepackage.RemovePkgs 80 | 81 | .. autoclass:: latexpp.fixes.preamble.AddPreamble 82 | 83 | .. autoclass:: latexpp.fixes.deps.CopyFiles 84 | 85 | Act on parts of your document 86 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 87 | 88 | .. autoclass:: latexpp.fixes.regional_fix.Apply 89 | 90 | Create archive with all files 91 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 92 | 93 | .. autoclass:: latexpp.fixes.archive.CreateArchive 94 | 95 | 96 | Package-specific fixes 97 | ~~~~~~~~~~~~~~~~~~~~~~ 98 | 99 | These fix classes expand the definitions that are provided by a given package in 100 | order to remove the dependency of a document on that package. 101 | 102 | .. autoclass:: latexpp.fixes.pkg.cleveref.ApplyPoorMan 103 | 104 | .. autoclass:: latexpp.fixes.pkg.phfqit.ExpandQitObjects 105 | 106 | .. autoclass:: latexpp.fixes.pkg.phfqit.ExpandMacros 107 | 108 | .. autoclass:: latexpp.fixes.pkg.phfthm.Expand 109 | 110 | .. autoclass:: latexpp.fixes.pkg.phfparen.Expand 111 | 112 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # http://www.sphinx-doc.org/en/master/config 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) 16 | 17 | import latexpp 18 | 19 | # -- Project information ----------------------------------------------------- 20 | 21 | project = 'latexpp' 22 | copyright = '2019, Philippe Faist' 23 | author = 'Philippe Faist' 24 | version = latexpp.__version__ 25 | 26 | 27 | # -- General configuration --------------------------------------------------- 28 | 29 | # Master document name 30 | master_doc = 'index' 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | extensions = [ 36 | 'sphinx.ext.autodoc', 37 | 'sphinx.ext.intersphinx', 38 | ] 39 | 40 | # Add any paths that contain templates here, relative to this directory. 41 | templates_path = ['_templates'] 42 | 43 | # List of patterns, relative to source directory, that match files and 44 | # directories to ignore when looking for source files. 45 | # This pattern also affects html_static_path and html_extra_path. 46 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 47 | 48 | 49 | 50 | # Example configuration for intersphinx: refer to the Python standard library. 51 | intersphinx_mapping = { 52 | 'python': ('https://docs.python.org/3', None), 53 | 'pylatexenc': ('https://pylatexenc.readthedocs.io/en/latest/', None), 54 | } 55 | 56 | 57 | 58 | # -- Options for HTML output ------------------------------------------------- 59 | 60 | # The theme to use for HTML and HTML Help pages. See the documentation for 61 | # a list of builtin themes. 62 | # 63 | html_theme = 'alabaster' 64 | 65 | html_theme_options = { 66 | # customization 67 | 'github_button': True, 68 | 'github_type': 'star', 69 | 'github_count': False, 70 | 'github_user': 'phfaist', 71 | 'github_repo': 'latexpp', 72 | # appearance 73 | 'font_family': "'IBM Plex Serif', serif", 74 | 'font_size': "16px", 75 | 'head_font_family': "'IBM Plex Serif', serif", 76 | 'code_font_family': "'IBM Plex Mono', monospace", 77 | 'code_font_size': "0.9em", 78 | # colors 79 | 'body_text': 'rgb(49, 54, 60)', 80 | 'link': 'rgb(16,90,121)', 81 | 'link_hover': 'rgb(147,2,28)', 82 | 'anchor': 'rgba(16,90,121,0.12)', 83 | 'anchor_hover_bg': 'rgba(109, 137, 149, 0.12)', 84 | 'anchor_hover_fg': 'rgb(109, 137, 149)', 85 | # 'gray_1': 'rgb(40,40,40)', 86 | # 'gray_2': 'rgba(0,0,0,0.06)', # color of code blocks --> pre_bg 87 | # 'gray_3': 'blue', 88 | 'pre_bg': 'rgba(0,0,0,0.06)', 89 | 'sidebar_text': 'rgb(40,40,50)', 90 | } 91 | 92 | # Add any paths that contain custom static files (such as style sheets) here, 93 | # relative to this directory. They are copied after the builtin static files, 94 | # so a file named "default.css" will overwrite the builtin "default.css". 95 | html_static_path = [ '_static' ] 96 | -------------------------------------------------------------------------------- /test/test_fixes_figures.py: -------------------------------------------------------------------------------- 1 | 2 | import os.path 3 | import unittest 4 | 5 | import helpers 6 | 7 | from latexpp.fixes import figures 8 | 9 | 10 | class TestCopyAndRenameFigs(unittest.TestCase): 11 | 12 | def test_simple_1(self): 13 | 14 | figures.os_path = helpers.FakeOsPath([ 15 | # list of files that "exist" 16 | 'fig/intro.png', 17 | 'my_diagram.jpg', 18 | 'v088338-1993.out.eps', 19 | 'fignew/results schematic.pdf', 20 | 'fignew/results schematic 2.jpg' 21 | ]) 22 | 23 | lpp = helpers.MockLPP() 24 | lpp.install_fix( figures.CopyAndRenameFigs() ) 25 | 26 | self.assertEqual( 27 | lpp.execute(r""" 28 | \includegraphics[width=\textwidth]{fig/intro} 29 | \includegraphics[width=\textwidth]{my_diagram.jpg} 30 | \includegraphics{fignew/results schematic} 31 | \includegraphics{v088338-1993.out} 32 | """), 33 | r""" 34 | \includegraphics[width=\textwidth]{fig-01.png} 35 | \includegraphics[width=\textwidth]{fig-02.jpg} 36 | \includegraphics{fig-03.pdf} 37 | \includegraphics{fig-04.eps} 38 | """ 39 | ) 40 | 41 | self.assertEqual( 42 | lpp.copied_files, 43 | [ 44 | ('fig/intro.png', '/TESTOUT/fig-01.png'), 45 | ('my_diagram.jpg', '/TESTOUT/fig-02.jpg'), 46 | ('fignew/results schematic.pdf', '/TESTOUT/fig-03.pdf'), 47 | ('v088338-1993.out.eps', '/TESTOUT/fig-04.eps'), 48 | ] 49 | ) 50 | 51 | def test_simple_2(self): 52 | 53 | figures.os_path = helpers.FakeOsPath([ 54 | # list of files that "exist" 55 | 'fig/intro.png', 56 | 'my_diagram.jpg', 57 | 'v088338-1993.out.eps', 58 | 'fignew/results schematic.pdf', 59 | 'fignew/results schematic 2.jpg' 60 | ]) 61 | 62 | 63 | lpp = helpers.MockLPP() 64 | lpp.install_fix( figures.CopyAndRenameFigs( 65 | start_fig_counter=9, 66 | fig_rename='fig/{fig_counter}/{orig_fig_basename}{fig_ext}', 67 | 68 | ) ) 69 | 70 | self.assertEqual( 71 | lpp.execute(r""" 72 | \includegraphics[width=\textwidth]{fig/intro} 73 | \includegraphics[width=\textwidth]{my_diagram.jpg} 74 | \includegraphics{fignew/results schematic} 75 | \includegraphics{v088338-1993.out} 76 | """), 77 | r""" 78 | \includegraphics[width=\textwidth]{fig/9/intro.png} 79 | \includegraphics[width=\textwidth]{fig/10/my_diagram.jpg} 80 | \includegraphics{fig/11/results schematic.pdf} 81 | \includegraphics{fig/12/v088338-1993.out.eps} 82 | """ 83 | ) 84 | 85 | self.assertEqual( 86 | lpp.copied_files, 87 | [ 88 | ('fig/intro.png', '/TESTOUT/fig/9/intro.png'), 89 | ('my_diagram.jpg', '/TESTOUT/fig/10/my_diagram.jpg'), 90 | ('fignew/results schematic.pdf', '/TESTOUT/fig/11/results schematic.pdf'), 91 | ('v088338-1993.out.eps', '/TESTOUT/fig/12/v088338-1993.out.eps'), 92 | ] 93 | ) 94 | 95 | 96 | 97 | if __name__ == '__main__': 98 | helpers.test_main() 99 | -------------------------------------------------------------------------------- /latexpp/fixes/pkg/phfthm.py: -------------------------------------------------------------------------------- 1 | 2 | import logging 3 | 4 | logger = logging.getLogger(__name__) 5 | 6 | #from pylatexenc.macrospec import SpecialsSpec, ParsedMacroArgs, MacroStandardArgsParser 7 | from pylatexenc import latexwalker 8 | 9 | from latexpp.fix import BaseFix 10 | 11 | 12 | 13 | class Expand(BaseFix): 14 | """ 15 | Expand theorem definitions to remove {phfthm} package dependency. 16 | """ 17 | def __init__(self, 18 | deftheorems=['theorem', 'lemma', 'proposition', 'corollary'], 19 | proofenvs=dict(proof='proof'), 20 | ref_type=r'\cref{%s}', 21 | proof_of_name='Proof of %s', 22 | use_shared_counter=False, 23 | define_thmheading=False): 24 | super().__init__() 25 | 26 | self.deftheorems = deftheorems 27 | self.proofenvs = dict(proofenvs) 28 | self.ref_type = ref_type 29 | self.proof_of_name = proof_of_name 30 | self.use_shared_counter = use_shared_counter 31 | self.define_thmheading = define_thmheading 32 | 33 | def add_preamble(self): 34 | 35 | p = [ ] 36 | 37 | sharedcounteroption = "" 38 | if self.use_shared_counter is True: 39 | sharedcounteroption = "[phfthmcounter]" 40 | p.append( r"\newcounter{phfthmcounter}" ) 41 | elif self.use_shared_counter: 42 | sharedcounteroption = "[" + self.use_shared_counter + "]" 43 | 44 | if isinstance(self.deftheorems, str): 45 | # raw preamble 46 | p.append(self.deftheorems) 47 | else: 48 | p.append(r"\usepackage{amsthm}") 49 | for t in self.deftheorems: 50 | p.append( r"\newtheorem{%s}%s{%s}"%(t, sharedcounteroption, t.capitalize()) ) 51 | 52 | if self.define_thmheading: 53 | p.append(r""" 54 | \newenvironment{thmheading}[1]{% 55 | \par\medskip\noindent\textbf{#1}.~\itshape 56 | }{% 57 | \par\medskip 58 | } 59 | """) 60 | 61 | return "\n".join(p) 62 | 63 | 64 | def fix_node(self, n, **kwargs): 65 | 66 | if n.isNodeType(latexwalker.LatexMacroNode) and n.macroname == 'noproofref': 67 | return '' 68 | 69 | if n.isNodeType(latexwalker.LatexEnvironmentNode) and \ 70 | n.environmentname in self.proofenvs: 71 | proofenv = self.proofenvs[n.environmentname] 72 | if n.nodeargd.argnlist[0] is not None: # optional argument to proof 73 | if n.nodeargd.argnlist[0].isNodeType(latexwalker.LatexGroupNode): 74 | optargstr = self.preprocess_latex(n.nodeargd.argnlist[0].nodelist).strip() 75 | if optargstr.startswith('**'): 76 | # have \begin{proof}[**thm:label] .. \end{proof} 77 | # --> replace with \begin{proof} .. \end{proof} 78 | return r'\begin{%s}'%(proofenv) \ 79 | + self.preprocess_latex(n.nodelist) + r'\end{%s}'%(proofenv) 80 | if optargstr.startswith('*'): 81 | # have \begin{proof}[*thm:label] ... \end{proof} 82 | # replace with \begin{proof}[Proof of ] ... \end{proof} 83 | reflbl = optargstr[1:] 84 | return r'\begin{%s}['%(proofenv) \ 85 | + self.proof_of_name%(self.ref_type%(reflbl)) + ']' \ 86 | + self.preprocess_latex(n.nodelist) + r'\end{%s}'%(proofenv) 87 | 88 | return None 89 | -------------------------------------------------------------------------------- /doc/lppconfig.rst: -------------------------------------------------------------------------------- 1 | .. _lppconfig: 2 | 3 | The ``lppconfig.yml`` configuration file 4 | ---------------------------------------- 5 | 6 | The ``lppconfig.yml`` file is placed in the current working directory where your 7 | main original LaTeX document is developed. It specifies how to process the 8 | document and "fix" it, and where to output the "fixed" version. 9 | 10 | See :ref:`howtouse` for a sample ``lppconfig.yml`` file. 11 | 12 | The ``lppconfig.yml`` file is a YAML file. `Google "YAML tutorial" 13 | `_ to get an idea. It's 14 | intuitive and easy to read. In ``lppconfig.yml`` you define the necessary 15 | information to run `latexpp` on a latex document. 16 | 17 | ``lppconfig.yml`` file structure 18 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 19 | 20 | using the following fields that are specified at 21 | the root level of the file: 22 | 23 | - `fname: ` — specifies the master LaTeX document 24 | that you would like to process. 25 | 26 | If your LaTeX document includes content from other LaTeX files with ``\input`` 27 | or ``\include`` commands, then you need to specify the master document here. 28 | Make sure you use a fix like :py:class:`latexpp.fixes.input.EvalInput` to 29 | follow ``\input`` directives. 30 | 31 | If there are different documents that you would like to process independently, 32 | then you should use different ``lppconfig.yml`` files. You can either place 33 | the documents in separate directories with their corresponding 34 | ``lppconfig.yml`` files, or you can name the config files 35 | ``lppconfig-mydoc1.yml``, ``lppconfig-myotherdoc.yml`` and then run 36 | `latexpp -p mydoc1` or `latexpp -p myotherdoc` to run `latexpp` using the 37 | corresponding settings. 38 | 39 | - `output_dir: ` — where to write the output files. This 40 | should be a nonexisting directory in which the preprocessed latex document is 41 | written (along with possible dependencies depending on the fixes that were 42 | invoked). 43 | 44 | - `output_fname: ` — how to name the main latex document in the 45 | output directory. 46 | 47 | - `fixes: ` — a list of which fixes to apply with a corresponding 48 | configuration. See below. 49 | 50 | Specifying the fixes 51 | ~~~~~~~~~~~~~~~~~~~~ 52 | 53 | With the `fixes:` key you specify a list of fixes and the corresponding 54 | configuration: 55 | 56 | .. code-block:: yaml 57 | 58 | fixes: 59 | - 60 | - 61 | ... 62 | 63 | Each `` must be either a string or a dictionary. If `` 64 | is a string, then it is the fully qualified python name of the fix class, such 65 | as ``latexpp.fixes.figures.CopyAndRenameFigs``, in which case the fix is invoked 66 | without specifying a custom configuration. If it is a dictionary, 67 | `` should have the following structure: 68 | 69 | .. code-block:: yaml 70 | 71 | name: 72 | config: 73 | : 74 | : 75 | 76 | where `` is the fully qualified python name of the 77 | fix class, such as ``latexpp.fixes.figures.CopyAndRenameFigs``. The fix class 78 | will be invoked with the given configuration. Namely, the fix class will be 79 | instantiated with the given key-value pairs as constructor arguments. 80 | 81 | You should check the documentation of the individual fix classes to see what 82 | arguments they accept. Arguments to fix class constructors are always passed as 83 | keyword arguments. 84 | -------------------------------------------------------------------------------- /latexpp/fixes/archive.py: -------------------------------------------------------------------------------- 1 | import os 2 | import os.path 3 | import datetime 4 | import zipfile 5 | import tarfile 6 | import logging 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | from latexpp.fix import BaseFix 11 | 12 | 13 | class FnArchive: 14 | """ 15 | A context manager that provides a simple unified interface for `ZipFile` and 16 | `TarFile`. 17 | """ 18 | def __init__(self, basefname, artype): 19 | self.basefname = basefname 20 | artypeparts = artype.split('.', maxsplit=1) 21 | if len(artypeparts) > 1: 22 | self.artype, self.arcompr = artypeparts 23 | else: 24 | self.artype, self.arcompr = artype, None 25 | self.fname = self.basefname + '.' + self.artype 26 | if self.arcompr: 27 | self.fname += '.' + self.arcompr 28 | 29 | def __enter__(self): 30 | if self.artype == 'zip': 31 | assert not self.arcompr 32 | self.f = zipfile.ZipFile(self.fname, "w", 33 | compression=zipfile.ZIP_DEFLATED) 34 | 35 | self.f.__enter__() 36 | return self 37 | if self.artype == 'tar': 38 | self.f = tarfile.open(self.fname, "w:%s"%self.arcompr) 39 | self.f.__enter__() 40 | return self 41 | 42 | raise ValueError("Unknown archive type: {}".format(self.artype)) 43 | 44 | 45 | def add_file(self, fname, arfname=None): 46 | if arfname is None: 47 | arfname = fname 48 | logger.debug("%s: Adding %s", os.path.relpath(self.fname), arfname) 49 | if self.artype == 'zip': 50 | return self.f.write(fname, arfname) 51 | elif self.artype == 'tar': 52 | return self.f.add(fname, arfname) 53 | 54 | def __exit__(self, *args, **kwargs): 55 | return self.f.__exit__(*args, **kwargs) 56 | 57 | 58 | 59 | class CreateArchive(BaseFix): 60 | r""" 61 | Create an archive with all the generated files. 62 | 63 | This rule must be the last rule! 64 | 65 | Arguments: 66 | 67 | - `use_root_dir`: If `True`, then the archive will contain a single 68 | directory with all the files. The directory name is the same as the output 69 | directory. If `False`, then all the files are placed in the archive at 70 | the root level. 71 | 72 | - `use_date`: If `True`, the current date/time is appended to the archive 73 | file name. 74 | 75 | - `archive_type`: One of 'zip', 'tar', 'tar.gz', 'tar.bz2', 'tar.xz'. 76 | """ 77 | def __init__(self, use_root_dir=True, use_date=True, archive_type='zip'): 78 | super().__init__() 79 | self.use_root_dir = use_root_dir 80 | self.use_date = use_date 81 | self.archive_type = archive_type 82 | 83 | 84 | def finalize(self, **kwargs): 85 | # all set, we can create the archive 86 | 87 | lpp = self.lpp 88 | 89 | arbasename = lpp.output_dir 90 | if self.use_date: 91 | arbasename += '-'+datetime.datetime.now().strftime('%Y%m%d-%H%M%S') 92 | 93 | if self.use_root_dir: 94 | base_dir = os.path.relpath(lpp.output_dir) 95 | else: 96 | base_dir = '' 97 | 98 | with FnArchive(arbasename, self.archive_type) as far: 99 | for fn in lpp.output_files: 100 | far.add_file(os.path.join(lpp.output_dir, fn), 101 | os.path.join(base_dir, fn)) 102 | 103 | logger.info("Created archive %s", os.path.relpath(far.fname)) 104 | -------------------------------------------------------------------------------- /test/test_fixes_labels.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest 3 | 4 | import helpers 5 | 6 | from latexpp.fixes import labels 7 | 8 | class TestRenameLabels(unittest.TestCase): 9 | 10 | maxDiff = None 11 | 12 | def test_simple(self): 13 | 14 | lpp = helpers.MockLPP() 15 | lpp.install_fix( labels.RenameLabels(use_hash_length=16, 16 | use_hash_encoding='hex') ) 17 | 18 | self.assertEqual( 19 | lpp.execute(r"""\documentclass{article} 20 | \begin{document} 21 | Here is equation~\eqref{eq:funny-equation}: 22 | \begin{align} 23 | a + b = c\ . 24 | \label{eq:funny-equation} 25 | \end{align} 26 | And here is the glorious~\cref{thm:that-arse-of-a-theorem}. 27 | \begin{theorem} 28 | \label{thm:that-arse-of-a-theorem} 29 | Theorem statement here. 30 | \end{theorem} 31 | Multiple labels appear here 32 | as~\cref{thm:that-arse-of-a-theorem,eq:funny-equation,eq:unknown}. 33 | \end{document} 34 | """), 35 | r"""\documentclass{article} 36 | \begin{document} 37 | Here is equation~\eqref{eq:ce3135e517010c8e}: 38 | \begin{align} 39 | a + b = c\ . 40 | \label{eq:ce3135e517010c8e} 41 | \end{align} 42 | And here is the glorious~\cref{thm:a94d9b09fe949376}. 43 | \begin{theorem} 44 | \label{thm:a94d9b09fe949376} 45 | Theorem statement here. 46 | \end{theorem} 47 | Multiple labels appear here 48 | as~\cref{thm:a94d9b09fe949376,eq:ce3135e517010c8e,eq:unknown}. 49 | \end{document} 50 | """ 51 | ) 52 | 53 | 54 | def test_cpageref(self): 55 | 56 | lpp = helpers.MockLPP() 57 | lpp.install_fix( labels.RenameLabels(use_hash_length=16, 58 | use_hash_encoding='hex') ) 59 | 60 | self.assertEqual( 61 | lpp.execute(r"""\documentclass{article} 62 | \begin{document} 63 | Here is equation~\eqref{eq:funny-equation}: 64 | \begin{align} 65 | a + b = c\ . 66 | \label{eq:funny-equation} 67 | \end{align} 68 | And here is the glorious~\cpageref{thm:that-arse-of-a-theorem}. 69 | \begin{theorem} 70 | \label{thm:that-arse-of-a-theorem} 71 | Theorem statement here. 72 | \end{theorem} 73 | Multiple labels appear here 74 | as~\cpageref{thm:that-arse-of-a-theorem,eq:funny-equation,eq:unknown}. 75 | \end{document} 76 | """), 77 | r"""\documentclass{article} 78 | \begin{document} 79 | Here is equation~\eqref{eq:ce3135e517010c8e}: 80 | \begin{align} 81 | a + b = c\ . 82 | \label{eq:ce3135e517010c8e} 83 | \end{align} 84 | And here is the glorious~\cpageref{thm:a94d9b09fe949376}. 85 | \begin{theorem} 86 | \label{thm:a94d9b09fe949376} 87 | Theorem statement here. 88 | \end{theorem} 89 | Multiple labels appear here 90 | as~\cpageref{thm:a94d9b09fe949376,eq:ce3135e517010c8e,eq:unknown}. 91 | \end{document} 92 | """ 93 | ) 94 | 95 | 96 | def test_proof_ref(self): 97 | 98 | lpp = helpers.MockLPP() 99 | lpp.install_fix( labels.RenameLabels( 100 | use_hash_length=16, 101 | use_hash_encoding='hex', 102 | label_rename_fmt='L.%(hash)s', 103 | hack_phfthm_proofs=True, 104 | ) ) 105 | 106 | self.assertEqual( 107 | lpp.execute(r"""\documentclass{article} 108 | \begin{document} 109 | \begin{theorem} 110 | \label{thm:that-arse-of-a-theorem} 111 | Theorem statement here. 112 | \end{theorem} 113 | The proof of \cref{thm:that-arse-of-a-theorem} is on 114 | page \cpageref{proof:thm:that-arse-of-a-theorem}. 115 | \begin{proof}[*thm:that-arse-of-a-theorem] 116 | Proof here. 117 | \end{proof} 118 | \end{document} 119 | """), 120 | r"""\documentclass{article} 121 | \begin{document} 122 | \begin{theorem} 123 | \label{L.a94d9b09fe949376} 124 | Theorem statement here. 125 | \end{theorem} 126 | The proof of \cref{L.a94d9b09fe949376} is on 127 | page \cpageref{proof:L.a94d9b09fe949376}. 128 | \begin{proof}[*L.a94d9b09fe949376] 129 | Proof here. 130 | \end{proof} 131 | \end{document} 132 | """ 133 | ) 134 | 135 | 136 | 137 | if __name__ == '__main__': 138 | import logging 139 | logging.basicConfig(level=logging.DEBUG) 140 | helpers.test_main() 141 | -------------------------------------------------------------------------------- /latexpp/_lpp_parsing.py: -------------------------------------------------------------------------------- 1 | #import re 2 | import functools 3 | 4 | import logging 5 | 6 | from pylatexenc import latexwalker 7 | 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | def _no_latex_verbatim(*args, **kwargs): 13 | raise RuntimeError("Cannot use latex_verbatim() because the nodes might change.") 14 | 15 | 16 | class LatexCodeRecomposer: 17 | def __init__(self): 18 | super().__init__() 19 | 20 | def node_to_latex(self, n): 21 | #print("*** node_to_latex: ", repr(n)) 22 | 23 | if n.isNodeType(latexwalker.LatexGroupNode): 24 | return n.delimiters[0] + "".join(self.node_to_latex(n) for n in n.nodelist) \ 25 | + n.delimiters[1] 26 | 27 | elif n.isNodeType(latexwalker.LatexCharsNode): 28 | return n.chars 29 | 30 | elif n.isNodeType(latexwalker.LatexCommentNode): 31 | return '%' + n.comment + n.comment_post_space 32 | 33 | elif n.isNodeType(latexwalker.LatexMacroNode): 34 | # macro maybe with arguments 35 | return '\\'+n.macroname+n.macro_post_space + self.args_to_latex(n) 36 | 37 | elif n.isNodeType(latexwalker.LatexEnvironmentNode): 38 | # get environment behavior definition. 39 | return (r'\begin{' + n.environmentname + '}' + self.args_to_latex(n) + 40 | "".join( self.node_to_latex(n) for n in n.nodelist ) + 41 | r'\end{' + n.environmentname + '}') 42 | 43 | elif n.isNodeType(latexwalker.LatexSpecialsNode): 44 | # specials maybe with arguments 45 | return n.specials_chars + self.args_to_latex(n) 46 | 47 | elif n.isNodeType(latexwalker.LatexMathNode): 48 | return n.delimiters[0] + "".join( self.node_to_latex(n) for n in n.nodelist ) \ 49 | + n.delimiters[1] 50 | 51 | else: 52 | raise ValueError("Unknown node type: {}".format(n.__class__.__name__)) 53 | 54 | def args_to_latex(self, n): 55 | if n.nodeargd and hasattr(n.nodeargd, 'args_to_latex'): 56 | return n.nodeargd.args_to_latex(recomposer=self) 57 | if n.nodeargd is None or n.nodeargd.argspec is None \ 58 | or n.nodeargd.argnlist is None: 59 | # no arguments or unknown argument structure 60 | return '' 61 | return ''.join( (self.node_to_latex(n) if n else '') 62 | for n in n.nodeargd.argnlist ) 63 | 64 | 65 | class _LPPParsingState(latexwalker.ParsingState): 66 | def __init__(self, lpp_latex_walker, **kwargs): 67 | super().__init__(**kwargs) 68 | self.lpp_latex_walker = lpp_latex_walker 69 | self._fields = tuple(list(self._fields)+['lpp_latex_walker']) 70 | 71 | 72 | 73 | class _LPPLatexWalker(latexwalker.LatexWalker): 74 | def __init__(self, *args, latex_context=None, **kwargs): 75 | self.lpp = kwargs.pop('lpp') 76 | 77 | super().__init__(*args, latex_context=latex_context, **kwargs) 78 | 79 | # for severe debugging 80 | #self.debug_nodes = True 81 | 82 | # add back-reference to latexwalker in all latex nodes, for convenience 83 | self.default_parsing_state = _LPPParsingState( 84 | lpp_latex_walker=self, 85 | **self.default_parsing_state.get_fields() 86 | ) 87 | 88 | 89 | def make_node(self, *args, **kwargs): 90 | node = super().make_node(*args, **kwargs) 91 | 92 | # forbid method latex_verbatim() 93 | node.latex_verbatim = _no_latex_verbatim 94 | 95 | # add method to_latex() that reconstructs the latex dynamically from the 96 | # node structure 97 | node.to_latex = functools.partial(self.node_to_latex, node) 98 | 99 | #print("*** debug -> made node ", node) 100 | 101 | return node 102 | 103 | def make_nodelist(self, nodelist, *args, **kwargs): 104 | try: 105 | nodelist = super().make_nodelist(nodelist, *args, **kwargs) 106 | except Exception: 107 | nodelist = list(nodelist) 108 | 109 | # forbid method latex_verbatim() 110 | nodelist.latex_verbatim = _no_latex_verbatim 111 | 112 | # add method to_latex() that reconstructs the latex dynamically from the 113 | # node structure 114 | nodelist.to_latex = functools.partial(self.nodelist_to_latex, nodelist) 115 | 116 | #print("*** debug -> made node list ", nodelist) 117 | 118 | return nodelist 119 | 120 | 121 | 122 | 123 | def node_to_latex(self, n): 124 | return LatexCodeRecomposer().node_to_latex(n) 125 | 126 | def nodelist_to_latex(self, nodelist): 127 | return ''.join(self.node_to_latex(n) for n in nodelist) 128 | 129 | def pos_to_lineno_colno(self, pos, **kwargs): 130 | if pos is None: 131 | return {} if kwargs.get('as_dict', False) else None 132 | return super().pos_to_lineno_colno(pos, **kwargs) 133 | 134 | -------------------------------------------------------------------------------- /test/test_fixes_pkg_phfqit.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest 3 | 4 | import helpers 5 | 6 | from latexpp.fixes.pkg import phfqit 7 | 8 | class TestExpandMacros(unittest.TestCase): 9 | 10 | def test_simple(self): 11 | 12 | latex = r""" 13 | \begin{document} 14 | Ket $\ket\phistate$, bra $\bra\phistate$, 15 | projector $\proj\phistate$, 16 | and a fraction $\frac\phistate 2$. 17 | \begin{gather} 18 | \Hmax{\hat\rho} = \ldots \\ 19 | \Hmax[\epsilon]{\hat\rho} = \ldots \\ 20 | \Hmin{\hat\sigma} = \ldots \\ 21 | \Hmin[\epsilon']{\hat\sigma} = \ldots 22 | \end{gather} 23 | \end{document} 24 | """ 25 | 26 | lpp = helpers.MockLPP() 27 | lpp.install_fix( 28 | phfqit.ExpandMacros(**{ 29 | 'ops': { 30 | 'eig': 'eig', 31 | 'Proj': 'Proj', 32 | }, 33 | 34 | # for kets and bras 35 | 'subst_space': { 36 | 'phfqitKetsBarSpace': r'\hspace*{0.2ex}', 37 | 'phfqitKetsRLAngleSpace': r'\hspace*{-0.25ex}', 38 | }, 39 | 40 | 'subst': { 41 | 'phistate': r'\hat\phi', 42 | 43 | # these macros need to go here and not in 'ExpandQitObjects' 44 | # because of their non-standard argument structure 45 | 'Hmax': { 46 | 'qitargspec': '[{', 47 | 'repl': '{S}_{0}^{%(1)s}({%(2)s})', 48 | }, 49 | 'Hmin': { 50 | 'qitargspec': '[{', 51 | 'repl': r'{S}_{\infty}^{%(1)s}({%(2)s})', 52 | }, 53 | }, 54 | }) 55 | ) 56 | 57 | result = lpp.execute(latex) 58 | 59 | self.assertEqual( 60 | result, 61 | r""" 62 | \begin{document} 63 | Ket $\lvert {\hat\phi}\rangle $, bra $\langle {\hat\phi}\rvert $, 64 | projector $\lvert {\hat\phi}\rangle \hspace*{-0.25ex}\langle{\hat\phi}\rvert $, 65 | and a fraction $\frac{\hat\phi}2$. 66 | \begin{gather} 67 | {S}_{0}^{}({\hat\rho}) = \ldots \\ 68 | {S}_{0}^{\epsilon}({\hat\rho}) = \ldots \\ 69 | {S}_{\infty}^{}({\hat\sigma}) = \ldots \\ 70 | {S}_{\infty}^{\epsilon'}({\hat\sigma}) = \ldots 71 | \end{gather} 72 | \end{document} 73 | """ 74 | ) 75 | 76 | 77 | def test_macro_filter(self): 78 | 79 | lpp = helpers.MockLPP() 80 | lpp.install_fix( 81 | phfqit.ExpandMacros( 82 | subst=dict( 83 | TestMacro=dict( 84 | qitargspec='[[', 85 | repl=r'\mathcal{T}_{%(1)s}^{%(2.delimited:(,))s}', 86 | ), 87 | ), 88 | ), 89 | ) 90 | lpp.install_fix( 91 | phfqit.ExpandQitObjects(wrap_delimited_in_latex_group=True) 92 | ) 93 | 94 | latex = r""" 95 | \begin{document} 96 | $\TestMacro[][\phi](\rho)$ and $\TestMacro(\rho)$. 97 | \end{document} 98 | """ 99 | 100 | result = lpp.execute(latex) 101 | 102 | self.assertEqual( 103 | result, 104 | r""" 105 | \begin{document} 106 | $\mathcal{T}_{}^{(\phi)}(\rho)$ and $\mathcal{T}_{}^{}(\rho)$. 107 | \end{document} 108 | """ 109 | ) 110 | 111 | 112 | 113 | class TestExpandQitObjects(unittest.TestCase): 114 | 115 | def test_Hfnbase(self): 116 | 117 | lpp = helpers.MockLPP() 118 | lpp.install_fix( 119 | phfqit.ExpandQitObjects(wrap_delimited_in_latex_group=True) 120 | ) 121 | 122 | latex = r""" 123 | \begin{document} 124 | $\Hfn(\rho)$ and $\Hfn_a^{b}`\bigg(\sum \rho_j)$. 125 | \end{document} 126 | """ 127 | 128 | result = lpp.execute(latex) 129 | 130 | self.assertEqual( 131 | result, 132 | r""" 133 | \begin{document} 134 | ${H}({\rho})$ and ${H}_{a}^{b}\biggl ({\sum \rho_j}\biggr )$. 135 | \end{document} 136 | """ 137 | ) 138 | 139 | def test_Hfnbase_Hfnphi(self): 140 | 141 | lpp = helpers.MockLPP() 142 | lpp.install_fix( 143 | phfqit.ExpandMacros( 144 | subst=dict( 145 | Hfnphi=dict( 146 | qitargspec='[`(', 147 | repl='\Hfn_{%(1)s}%(2.delimited)s(%(3)s)', 148 | ), 149 | ), 150 | ), 151 | ) 152 | lpp.install_fix( 153 | phfqit.ExpandQitObjects(wrap_delimited_in_latex_group=True) 154 | ) 155 | 156 | latex = r""" 157 | \begin{document} 158 | $\Hfnphi[\phi](\rho)$ and $\Hfnphi[\phi_x]`\bigg(\sum \rho_j)$. 159 | \end{document} 160 | """ 161 | 162 | result = lpp.execute(latex) 163 | 164 | self.assertEqual( 165 | result, 166 | r""" 167 | \begin{document} 168 | ${H}_{\phi}({\rho})$ and ${H}_{\phi_x}\biggl ({\sum \rho_j}\biggr )$. 169 | \end{document} 170 | """ 171 | ) 172 | 173 | 174 | if __name__ == '__main__': 175 | helpers.test_main() 176 | -------------------------------------------------------------------------------- /latexpp/fixes/macro_subst.py: -------------------------------------------------------------------------------- 1 | 2 | import logging 3 | logger = logging.getLogger(__name__) 4 | 5 | #from pylatexenc.latexwalker import LatexMacroNode, LatexEnvironmentNode 6 | 7 | from latexpp.macro_subst_helper import MacroSubstHelper 8 | 9 | from latexpp.fix import BaseFix 10 | 11 | 12 | class Subst(BaseFix): 13 | r""" 14 | Define macros and environments that will be replaced by corresponding custom 15 | LaTeX code. 16 | 17 | .. admonition:: Update 18 | 19 | See :py:class:`latexpp.fixes.newcommand.Expand` for a fix that 20 | automatically detects ``\newcommand`` instructions and performs 21 | replacements in the document body. The difference between that fix and 22 | this fix is that here, you need to specify all defined macros with their 23 | substitution text manually. There, everything is automatically 24 | detected. 25 | 26 | Arguments: 27 | 28 | - `macros`: a dictionary of macro substitution rules ``{: 29 | , ...}``. The key `` is the macro name without 30 | the leading backslash. 31 | 32 | The value `` is a dictionary ``{'argspec': , 33 | 'repl': }``, where `` specifies the argument structure of 34 | the macro and `` is the replacement string. If `` is 35 | a string, then the string is interpreted as the `` and '' 36 | is set to an empty string (which indicates that the macro does not 37 | expect any arguments). 38 | 39 | The `` is a string of characters '*', '[', or '{' which 40 | indicate the nature of the macro arguments: 41 | 42 | - A '*' indicates a corresponding optional * in the LaTeX source 43 | (starred macro variant); 44 | 45 | - a '[' indicates an optional argument delimited in square brackets; 46 | and 47 | 48 | - a '{' indicates a mandatory argument. 49 | 50 | The argument values can be referred to in the replacement string 51 | `` using the syntax '%(n)s' where `n` is the argument number, 52 | i.e., the index in the argspec string. 53 | 54 | For instance:: 55 | 56 | macros={ 57 | 'includegraphics': {'argspec': '[{', 'repl': '<%(2)s>'} 58 | } 59 | 60 | would replace all ``\includegraphics`` calls by the string 61 | ``<``\ `filename`\ ``>``, while ignoring any optional argument if it is present. 62 | (``\includegraphics`` has an optional argument and a mandatory 63 | argument.) 64 | 65 | You can also use ``%(macroname)s`` in the `` string, which will 66 | expand to the name of the macro without the leading backslash. 67 | 68 | - `environments`: a dictionary of environment substitution rules 69 | ``{: , ...}``. The key 70 | `` is the name of the environment, i.e., what goes as 71 | argument to ``\begin{...}`` and ``\end{...}``. 72 | 73 | The `` is a dictionary ``{'argspec': , 74 | 'repl': }`` where `` specifies the structure of the 75 | arguments accepted immediately after ``\begin{}`` (as for 76 | ``{ccrl}`` in ``\begin{tabular}{ccrl}``). The `` works exactly 77 | like for macros. 78 | 79 | The replacement string `` works exactly like for macros, with the 80 | additional substitution key ``%(body)s`` and that can be used to include 81 | the body of the environment in the replacement string. (The body is 82 | itself also preprocessed by latexpp.) 83 | 84 | You can also use ``%(environmentname)s`` in the `repl` string, which 85 | will expand to the name of the environment. 86 | 87 | .. note:: 88 | 89 | For a starred version of an environment (like ``\begin{align*}``), 90 | the star is part of the environment name and NOT part of the 91 | ``. I.e., you should specify ``environments={'align*': 92 | ...}`` and NOT ``environments={'align': {'argspec':'*',...}}``. This 93 | is because the `` represents the arguments parsed *after* 94 | the ``\begin{...}`` command. That is, if we used '*' in the 95 | ``, the syntax ``\begin{align}*`` would be recognized 96 | instead of ``\begin{align*}``. 97 | """ 98 | 99 | def __init__(self, *, macros={}, environments={}): 100 | super().__init__() 101 | self.helper = MacroSubstHelper(macros, environments) 102 | logger.debug("substitutions are macros=%r, environments=%r", 103 | macros, environments) 104 | 105 | def specs(self, **kwargs): 106 | return dict(**self.helper.get_specs()) 107 | 108 | def fix_node(self, n, **kwargs): 109 | 110 | c = self.helper.get_node_cfg(n) 111 | if c is not None: 112 | return self.helper.eval_subst( 113 | c, n, 114 | node_contents_latex=self.preprocess_contents_latex 115 | ) 116 | 117 | return None 118 | -------------------------------------------------------------------------------- /doc/_static/custom.css: -------------------------------------------------------------------------------- 1 | 2 | /*@import url('https://fonts.googleapis.com/css?family=Fira+Sans:400,400i,600,600i&display=swap&subset=latin-ext');*/ 3 | @import url('https://fonts.googleapis.com/css?family=IBM+Plex+Serif:400,400i,600,600i&display=swap&subset=latin-ext'); 4 | @import url('https://fonts.googleapis.com/css?family=IBM+Plex+Mono:400,400i,600,600i&display=swap&subset=latin-ext'); 5 | 6 | 7 | /* ************************************************************************** 8 | sidebar 9 | ************************************************************************** */ 10 | div.sphinxsidebar ul li.toctree-l1 { 11 | margin-bottom: 0.5em; 12 | list-style: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0nVVRGLTgnPz4KPCEtLSBUaGlzIGZpbGUgd2FzIGdlbmVyYXRlZCBieSBkdmlzdmdtIDIuMS4zIC0tPgo8c3ZnIGhlaWdodD0nNi45MjkyMXB0JyB2ZXJzaW9uPScxLjEnIHZpZXdCb3g9Jzc2LjcxMjMgNTUuODM1NCA3LjAxNTI5IDYuOTI5MjEnIHdpZHRoPSc3LjAxNTI5cHQnIHhtbG5zPSdodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZycgeG1sbnM6eGxpbms9J2h0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsnPgo8ZGVmcz4KPHBhdGggZD0nTTMuNTAwNDcgLTIuNDY3NTVMNS40NTE1NSAtMC4zMjk5NjNDNS40OTQ1OSAtMC4yODY5MjQgNS41Mzc2MyAtMC4yNTgyMzIgNS41OTUwMiAtMC4yNTgyMzJDNS43MjQxMyAtMC4yNTgyMzIgNS43MjQxMyAtMC4zNzMwMDEgNS43MjQxMyAtMC4zODczNDdDNS43MjQxMyAtMC40MzAzODYgNS42ODEwOSAtMC41MTY0NjMgNS42NTI0IC0wLjU1OTUwMkw0LjI0NjQ3IC0zLjAxMjdMNi44MTQ0NCAtNC4xNzQ3NEM2Ljk4NjYgLTQuMjQ2NDcgNy4wMTUyOSAtNC4yNjA4MiA3LjAxNTI5IC00LjM0NjlDNy4wMTUyOSAtNC40MTg2MyA2Ljk1NzkgLTQuNDc2MDEgNi44NzE4MyAtNC40NzYwMUwzLjk3MzkgLTMuODg3ODJMMy42NTgyOCAtNi42NTY2M0MzLjYyOTU5IC02Ljg1NzQ4IDMuNjI5NTkgLTYuOTI5MjEgMy41MTQ4MiAtNi45MjkyMUMzLjM4NTcgLTYuOTI5MjEgMy4zODU3IC02Ljg1NzQ4IDMuMzU3MDEgLTYuNjQyMjlMMy4wNDEzOSAtMy44ODc4MkwwLjE0MzQ2MiAtNC40NzYwMUMwLjA1NzM4NDggLTQuNDc2MDEgMCAtNC40MTg2MyAwIC00LjM0NjlDMCAtNC4yNjA4MiAwLjA3MTczMSAtNC4yMzIxMyAwLjE4NjUwMSAtNC4xNzQ3NEwyLjc2ODgyIC0zLjAxMjdMMS4zOTE1OCAtMC41ODgxOTRDMS4yOTExNiAtMC40MTYwNCAxLjI5MTE2IC0wLjQwMTY5MyAxLjI5MTE2IC0wLjM4NzM0N1MxLjI5MTE2IC0wLjI1ODIzMiAxLjQyMDI3IC0wLjI1ODIzMkMxLjQ0ODk3IC0wLjI1ODIzMiAxLjUwNjM1IC0wLjI1ODIzMiAxLjYzNTQ3IC0wLjQwMTY5M0wzLjUwMDQ3IC0yLjQ2NzU1WicgaWQ9J2cwLTYzJy8+CjwvZGVmcz4KPGcgaWQ9J3BhZ2UxJz4KPHVzZSB4PSc3Ni43MTIzJyB4bGluazpocmVmPScjZzAtNjMnIHk9JzYyLjc2NDYnLz4KPC9nPgo8L3N2Zz4=') 13 | } 14 | div.sphinxsidebar p.caption { 15 | display: none; 16 | } 17 | 18 | div.sphinxsidebar h1.logo a { 19 | /*display: block;*/ 20 | border: 0px none; 21 | font-style: italic; 22 | /*background-color: rgba(16, 90, 121, 0.11);*/ 23 | padding: 0px 5px; 24 | } 25 | 26 | 27 | 28 | /* ************************************************************************** 29 | headings 30 | ************************************************************************** */ 31 | div.body h1 { 32 | font-size: 200%; 33 | } 34 | div.body h2 { 35 | font-size: 160%; 36 | } 37 | 38 | 39 | 40 | /* ************************************************************************** 41 | body text settings 42 | ************************************************************************** */ 43 | p { 44 | margin-bottom: 0.8em; 45 | } 46 | div.body li { 47 | margin-bottom: 0.4em; 48 | padding-left: 0.4em; 49 | } 50 | 51 | div.body ul { 52 | 53 | } 54 | 55 | div.section { 56 | margin-top: 2em; 57 | } 58 | div.section:first-child { 59 | margin-top: 0em; 60 | } 61 | 62 | 63 | 64 | 65 | div.topic { 66 | border: none; 67 | background-color: transparent; 68 | } 69 | a.toc-backref { 70 | color: inherit; 71 | text-decoration: inherit; 72 | } 73 | 74 | div.admonition, div.note { 75 | border: 1px solid rgba(50,50,50,0.15); 76 | background-color: rgba(50,50,50,0.08); 77 | } 78 | 79 | 80 | /* ************************************************************************** 81 | code block settings 82 | ************************************************************************** */ 83 | div.body tt, 84 | div.body code, 85 | div.body a tt, 86 | div.body a code, 87 | div.body a:hover tt, 88 | div.body a:hover code, 89 | div.body tt.xref, 90 | div.body code.xref 91 | { 92 | background-color: rgba(0,0,0,0.06); 93 | border-bottom: inherit; 94 | } 95 | div.highlight { 96 | background-color: transparent; 97 | } 98 | code { 99 | padding: 1px; 100 | } 101 | pre { 102 | padding: 7px 20px; 103 | } 104 | dl pre, blockquote pre, li pre { 105 | margin-left: 0; 106 | padding-left: 18px; 107 | } 108 | div.body dl dt code { 109 | background-color: transparent; 110 | } 111 | 112 | 113 | /* ************************************************************************** 114 | lpp fix block 115 | ************************************************************************** */ 116 | 117 | #list-of-fixes dl.class { 118 | background-color: rgb(240, 255, 220); 119 | margin-bottom: 20px; 120 | padding: 0px; 121 | margin-top: 1.5em; 122 | } 123 | #list-of-fixes dl.class dt { 124 | padding: 10px 10px; 125 | background-color: rgb(224, 240, 203); 126 | } 127 | #list-of-fixes dl.class dd { 128 | padding-top: 10px; 129 | padding-bottom: 10px; 130 | padding-right: 20px; 131 | padding-left: 20px; 132 | margin: 0px; 133 | } 134 | 135 | 136 | 137 | 138 | /* ************************************************************************** 139 | search page 140 | ************************************************************************** */ 141 | ul.search { 142 | list-style: disclosure-closed; 143 | } 144 | ul.search li { 145 | background-image: none; 146 | } 147 | -------------------------------------------------------------------------------- /doc/customfix.rst: -------------------------------------------------------------------------------- 1 | 2 | .. _customfix: 3 | 4 | Writing a custom fix 5 | -------------------- 6 | 7 | It is easy to write new fixes to integrate them in your `latexpp` flow. A fix 8 | instance simply performs actions on the document structure in an internal 9 | representation with data nodes, and alters them, removes selected nodes or 10 | produces new nodes that change the LaTeX code of the document. 11 | 12 | Quick start 13 | ~~~~~~~~~~~ 14 | 15 | Say you have a LaTeX document that you'd like to process with `latexpp`, and say 16 | that you feel the need to write a particular fix for this document. Let's try 17 | to get you started in 30 seconds. 18 | 19 | In the document's folder, create a new folder which we'll call here ``myfixes`` 20 | (this is your fix python package folder, you can give it any valid python 21 | package name). In that folder, create an empty file called ``__init__.py``. 22 | Finally, create your fix python file, say ``mycustomfix.py``, in that folder and 23 | paste in there the following contents: 24 | 25 | .. literalinclude:: example_custom_fix/myfixes/mycustomfix.py 26 | 27 | You can then use your new fix by adding to your ``lppconfig.yml``: 28 | 29 | .. code-block:: yaml 30 | 31 | ... 32 | fixes: 33 | ... 34 | - name: 'myfixes.mycustomfix.MyGreetingFix' 35 | config: 36 | greeting: "I've been expecting you, %(name)s." 37 | 38 | In this way, whenever your document contains a macro instruction such as: 39 | 40 | .. code-block:: latex 41 | 42 | \greet{Mr. Bond} 43 | 44 | it gets replaced by: 45 | 46 | .. code-block:: latex 47 | 48 | \emph{I've been expecting you, Mr. Bond.} 49 | 50 | To complete your quick start, here are some key points. 51 | 52 | Key points 53 | ~~~~~~~~~~ 54 | 55 | - Any configuration items specified in ``config:`` in your ``lppconfig.yml`` 56 | file are passed directly as arguments to the fix class constructor. You can 57 | specify booleans, ints, strings, or even full data structures, all using 58 | standard YaML syntax. 59 | 60 | - Your fix class should inherit :py:class:`latexpp.fix.BaseFix`. You can check 61 | out the documentation of that class for various utilities you can make use of 62 | in your fix. (It can also inherit from 63 | :py:class:`latexpp.fix.BaseMultiStageFix`, see further below.) 64 | 65 | - Perform transformations in the document by reimplementing the 66 | :py:meth:`~latexpp.fix.BaseFix.fix_node()` method. The argument is a "node" 67 | in the document structure. The node is one of `pylatexenc`'s 68 | :py:class:`~pylatexenc.latexwalker.LatexNode` document node subclasses (e.g., 69 | :py:class:`~pylatexenc.latexwalker.LatexMacroNode`). 70 | (See also :ref:`implementation-notes-pylatexenc`.) 71 | 72 | - Make sure you always preprocess all child nodes such as macro arguments, the 73 | environment body, etc. so that fixes are also applied to them. As a general 74 | rule, whenever `fix_node()` returns something different than `None` then it is 75 | also responsible for applying the fix to all the child nodes of the current 76 | node as well. This can be done conveniently with 77 | :py:meth:`self.preprocess_contents_latex() 78 | ` and 79 | :py:meth:`self.preprocess_latex() ` 80 | which directly return LaTeX code that can be inserted in your new replacement 81 | LaTeX code. 82 | 83 | - The parser will assume that a macro does not take any arguments, unless the 84 | parser is told in advance about that macro. The parser already knows about a 85 | set of standard latex macros (e.g., ``\emph``, ``\textbf``, etc.). Specify 86 | futher macros with their argument signatures by reimplementing the 87 | :py:meth:`specs() ` method. (See the doc for 88 | :py:meth:`specs() ` for more info. Also, it never 89 | hurts to specify a macro, even if it was already defined.) 90 | 91 | - If your fix needs multiple passes through the document, you should inherit the 92 | class :py:class:`latexpp.fix.BaseMultiStageFix` instead of 93 | :py:class:`~latexpp.fix.BaseFix`. In this case you can subdivide your fix 94 | into "stages," which you define by subclassing 95 | :py:class:`latexpp.fix.BaseMultiStageFix.Stage` for each stage in your fix 96 | process. Each stage object is itself a fix (meaning it indirectly inherits 97 | from :py:class:`~latexpp.fix.BaseFix`) on which you can reimplement 98 | `fix_node()` etc. Each stage is run sequentially. The "parent" fix object 99 | then manages the stages and can store data that is accessed and modified by 100 | the different stages. 101 | 102 | See the documentation for :py:class:`~latexpp.fix.BaseMultiStageFix` for more 103 | details, and check the fix :py:class:`latexpp.fixes.labels.RenameLabels` for 104 | an example. 105 | 106 | - If you want your fix to work with latexpp pragmas, you should to subclass 107 | :py:class:`latexpp.pragma_fix.PragmaFix` instead. See the documentation 108 | for that class. 109 | 110 | - The preprocessor instance, available as ``self.lpp``, exposes some methods that 111 | cover some common fixes' special needs: 112 | 113 | + to copy a file to the output directory, use :py:meth:`self.lpp.copy_file() 114 | `; 115 | 116 | + to parse some LaTeX code into nodes, use 117 | :py:meth:`self.lpp.make_latex_walker() 118 | ` to create a 119 | LatexWalker instance that will polish the node classes as required by 120 | `latexpp` internals; 121 | 122 | + see also :py:meth:`~latexpp.preprocessor.LatexPreprocessor.open_file()`, 123 | :py:meth:`~latexpp.preprocessor.LatexPreprocessor.check_autofile_up_to_date()`, 124 | :py:meth:`~latexpp.preprocessor.LatexPreprocessor.register_output_file()`, 125 | and 126 | :py:meth:`~latexpp.preprocessor.LatexPreprocessor.create_subpreprocessor()`. 127 | -------------------------------------------------------------------------------- /doc/howtouse.rst: -------------------------------------------------------------------------------- 1 | .. _howtouse: 2 | 3 | How to use *latexpp* 4 | -------------------- 5 | 6 | The latex preprocessor ``latexpp`` reads your main latex document and copies it 7 | to an output directory while applying a series of "fixes" that you can 8 | configure. For instance, you can remove comments, you can include files that 9 | you input with ``\input`` macros, or you can replace custom macros by their 10 | LaTeX expansion. 11 | 12 | You run ``latexpp`` in a folder with a ``lppconfig.yml`` file that specifies the 13 | necessary information such as the main LaTeX document, the output directory, and 14 | which fixes to apply. 15 | 16 | 17 | .. contents:: Contents: 18 | :local: 19 | 20 | 21 | Sample ``lppconfig.yml`` 22 | ~~~~~~~~~~~~~~~~~~~~~~~~ 23 | 24 | .. code-block:: yaml 25 | 26 | # latexpp config for MyDocument.tex 27 | # 28 | # This is YAML syntax -- google "YAML tutorial" to get a quick intro. 29 | # Be careful with spaces since indentation is important. 30 | 31 | # the master LaTeX document -- this file will not be modified, all 32 | # output will be produced in the output_dir 33 | fname: 'MyDocument.tex' 34 | 35 | # output file(s) will be created in this directory, originals will 36 | # not be modified 37 | output_dir: 'latexpp_output' 38 | 39 | # main document file name in the output directory 40 | output_fname: 'paper.tex' 41 | 42 | # specify list of fixes to apply, in the given order 43 | fixes: 44 | 45 | # replace \input{...} directives by the contents of the included 46 | # file 47 | - 'latexpp.fixes.input.EvalInput' 48 | 49 | # remove all comments 50 | - 'latexpp.fixes.comments.RemoveComments' 51 | 52 | # copy any style files (.sty) that are used in the document and 53 | # that are present in the current directory to the output directory 54 | - 'latexpp.fixes.usepackage.CopyLocalPkgs' 55 | 56 | # copy figure files to the output directory and rename them 57 | # fig-01.xxx, fig-02.xxx, etc. 58 | - 'latexpp.fixes.figures.CopyAndRenameFigs' 59 | 60 | # Replace \bibliography{...} by \input{xxx.bbl} and copy the bbl 61 | # file to the output directory. Make sure you run (pdf)latex on 62 | # the main docuemnt before running latexpp 63 | - 'latexpp.fixes.bib.CopyAndInputBbl' 64 | 65 | # Expand some macros. Latexpp doesn't parse \newcommand's, so you 66 | # need to specify here the LaTeX code that the macro should be 67 | # expanded to. If the macro has arguments, specify the nature of 68 | # the arguments here in the 'argspec:' key (a '*' is an optional 69 | # * character, a '[' one optional square-bracket-delimited 70 | # argument, and a '{' is a mandatory argument). The argument values 71 | # are available via the placeholders %(1)s, %(2)s, etc. Make sure 72 | # to use single quotes for strings that contain \ backslashes. 73 | - name: 'latexpp.fixes.macro_subst.Subst' 74 | config: 75 | macros: 76 | # \tr --> \operatorname{tr} 77 | tr: '\operatorname{tr}' 78 | # \ket{\psi} --> \lvert{\psi}\rangle 79 | ket: 80 | argspec: '{' 81 | repl: '\lvert{%(1)s}\rangle' 82 | # \braket{\psi}{\phi} --> \langle{\psi}\vert{\phi}\rangle 83 | braket: 84 | argspec: '{{' 85 | repl: '\langle{%(1)s}\vert{%(2)s}\rangle' 86 | 87 | 88 | Config File Syntax 89 | ~~~~~~~~~~~~~~~~~~ 90 | 91 | The config file follows standard YAML syntax (if you're in doubt, google a YAML 92 | tutorial). 93 | 94 | See the ``latexpp/fixes/`` directory for the list of possible fixes. There 95 | isn't any good documentation at the moment (I wrote this preprocessor in the 96 | matter of a few days, and I won't have tons of time to devote to it). But the 97 | python source is pretty short and should be relatively decipherable. 98 | 99 | Each fix is specified by a qualified python class name. For instance, 100 | ``latexpp.fixes.comments.RemoveComments`` invokes class ``RemoveComments`` from 101 | the python module ``latexpp.fixes.comments``. You can specify custom arguments 102 | to the class constructor by using the syntax with the 'name:' and 'config:' keys 103 | as shown above. The keys in each 'config:' section are directly passed on to 104 | the class constructor as corresponding keyword arguments. 105 | 106 | The fixes in the ``latexpp/fixes/pkg/`` directory are those fixes that are 107 | supposed to apply all definitions of the corresponding package in order to 108 | remove a dependency on that package. 109 | 110 | It's also straightforward to write your own fix classes to do more complicated 111 | stuff. Create a python package (a new folder ``mypackage`` with an empty 112 | ``__init__.py`` file) and create a python module (e.g. ``myfixmodule.py``) in 113 | that package that defines your fix class (e.g. ``MyFix``). You can get 114 | inspiration from one of the simple examples in the ``latexpp/fixes/`` folder. 115 | Set up your ``$PYTHONPATH`` so that your python package is exposed to python. 116 | Then simply specify the pacakge/module your fix is located in in the YAML file, 117 | e.g., ``mypackage.myfixmodule.MyFix`` instead of 118 | ``latexpp.fixes.xxxxx.YYYY``. 119 | 120 | 121 | Common pitfalls 122 | ~~~~~~~~~~~~~~~ 123 | 124 | * **Errors in the document preamble:** 125 | 126 | Beacuse the LaTeX parser is not a full LaTeX engine and parses the document 127 | contents basically like a markup language, the parser may choke on preamble 128 | definitions that e.g. define new macros. These definitions are best placed 129 | in a separate custom package. Simply create a file called 'mymacros.sty' that 130 | starts with the line:: 131 | 132 | \ProvidesPackage{./mymacros} 133 | 134 | ... 135 | 136 | and then use this in the main document as:: 137 | 138 | \usepackage{./mymacros} 139 | 140 | Added benefit: You don't need ``\makeatletter`` in the `*.sty` file, because 141 | latex style files automatically ``\makeatletter`` enabled. 142 | 143 | * ...? 144 | -------------------------------------------------------------------------------- /latexpp/fixes/ifsimple.py: -------------------------------------------------------------------------------- 1 | import logging 2 | logger = logging.getLogger(__name__) 3 | 4 | from pylatexenc.macrospec import MacroSpec 5 | from pylatexenc.latexwalker import LatexMacroNode 6 | 7 | from latexpp.fix import BaseFix 8 | 9 | 10 | class ApplyIf(BaseFix): 11 | r""" 12 | Very simplistic parser for `\ifXXX...\else...\fi` structures in a document. 13 | 14 | .. note:: 15 | 16 | This "parser" is much more rudimentary than TeX's offering. Your 17 | document might compile fine but this fix might choke on it. 18 | 19 | The main difference is that here, all if-else-fi commands must occur 20 | within the same logical block (e.g., group or environment). The code 21 | ``\iftrue {\bfseries \else {\itshape \fi contents}`` will not work for 22 | instance, even if it works in TeX, because it interleaves braced groups 23 | with the if structure. 24 | 25 | This fix is aware of a few built-in ``\ifXXX`` commands (``\iftrue``, 26 | ``\iffalse``, etc.) and attempts to detect custom ifs declared with 27 | ``\newif``. Provide any additional ``\ifXXX`` command names using the 28 | `ifnames` argument. 29 | 30 | - `ifnames` — a dictionary of TeX if-command names and corresponding 31 | True/False values. 32 | 33 | E.g. ``{'ifsomething': True, 'ifsomethingelse': False}`` 34 | """ 35 | def __init__(self, ifnames=None): 36 | super().__init__() 37 | self.ifnames = {'iftrue': True, 'iffalse': False} 38 | if ifnames: 39 | self.ifnames.update(ifnames) 40 | self.ifswitchnames = {} 41 | 42 | def specs(self): 43 | return dict(macros=[ 44 | MacroSpec('newif', '{') 45 | ]) 46 | 47 | def fix_nodelist(self, nodelist, **kwargs): 48 | 49 | newnodelist = [] 50 | 51 | # walk through node list and apply and if-else-fi's 52 | pos = 0 53 | while pos < len(nodelist): 54 | 55 | n = nodelist[pos] 56 | if n.isNodeType(LatexMacroNode) and n.macroname == 'newif': 57 | # remember new if declaration 58 | ifbasename = get_newif_ifbasename(n) 59 | if ifbasename is not None: 60 | self.ifnames['if'+ifbasename] = False 61 | self.ifswitchnames[ifbasename+'true'] = ('if'+ifbasename, True) 62 | self.ifswitchnames[ifbasename+'false'] = ('if'+ifbasename, False) 63 | logger.debug(r"new conditional: ‘\if{}’".format(ifbasename)) 64 | 65 | # drop the 'newif' node itself. 66 | pos += 1 67 | continue 68 | 69 | if n.isNodeType(LatexMacroNode) and n.macroname in self.ifnames: 70 | # apply if! 71 | try: 72 | poselse, posfi = self.find_matching_elsefi(nodelist, pos+1) 73 | except ValueError as e: 74 | logger.warning(r"Can't find matching ‘\else’/‘\fi’ for ‘\{}’: {!r}: {}" 75 | .format(n.macroname, n, e)) 76 | continue 77 | 78 | if self.ifnames[n.macroname]: 79 | # keep "If" branch, recurse to apply any inner "if"'s 80 | posend = poselse if poselse is not None else posfi 81 | newnodelist += self.preprocess(nodelist[pos+1:posend]) 82 | elif poselse is not None: 83 | # keep "Else" branch, recurse to apply any inner "if"'s 84 | newnodelist += self.preprocess(nodelist[poselse+1:posfi]) 85 | #else: drop this entire if block. 86 | 87 | pos = posfi + 1 88 | continue 89 | 90 | if n.isNodeType(LatexMacroNode) and n.macroname in self.ifswitchnames: 91 | 92 | (ifname, value) = self.ifswitchnames[n.macroname] 93 | self.ifnames[ifname] = value 94 | 95 | pos += 1 96 | continue 97 | 98 | # copy any other node as is to the new node list. Make sure to 99 | # process its children. 100 | self.preprocess_child_nodes(nodelist[pos]) 101 | newnodelist.append(nodelist[pos]) 102 | pos += 1 103 | 104 | return newnodelist 105 | 106 | 107 | def find_matching_elsefi(self, nodelist, p): 108 | stack_if_counter = 0 109 | pos_else = None 110 | while p < len(nodelist): 111 | if nodelist[p].isNodeType(LatexMacroNode): 112 | if nodelist[p].macroname in self.ifnames: 113 | stack_if_counter += 1 114 | p += 1 115 | continue 116 | if nodelist[p].macroname == 'else': 117 | if stack_if_counter == 0: 118 | pos_else = p 119 | p += 1 120 | continue 121 | elif nodelist[p].macroname == 'fi': 122 | if stack_if_counter > 0: 123 | stack_if_counter -= 1 124 | p += 1 125 | continue 126 | return pos_else, p 127 | p += 1 128 | 129 | raise ValueError("No matching ‘\fi’ found") 130 | 131 | 132 | def get_newif_ifbasename(n): 133 | if not n.nodeargd or not n.nodeargd.argnlist or len(n.nodeargd.argnlist) < 1: 134 | logger.warning(r"Cannot parse ‘\newif’ declaration, no argument: {!r}".format(n)) 135 | return None 136 | 137 | narg = n.nodeargd.argnlist[0] 138 | if not narg.isNodeType(LatexMacroNode): 139 | logger.warning(r"Cannot parse ‘\newif’ declaration, expected single " 140 | r"macro argument: {!r}".format(n)) 141 | return None 142 | 143 | ifname = narg.macroname 144 | if not ifname.startswith('if'): 145 | logger.warning(r"Cannot parse ‘\newif’ declaration, new \"if\" name " 146 | r"does not begin with ‘if’: {!r}".format(n)) 147 | return None 148 | 149 | return ifname[2:] 150 | -------------------------------------------------------------------------------- /test/test_fixes_newcommand.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest 3 | 4 | import helpers 5 | 6 | from latexpp.fixes import newcommand 7 | 8 | 9 | class TestExpand(unittest.TestCase): 10 | 11 | maxDiff = None 12 | 13 | def test_simple(self): 14 | 15 | latex = r""" 16 | \documentclass[11pt]{article} 17 | 18 | \newcommand{\a}{Albert Einstein} 19 | \newcommand\max[1]{Max #1} 20 | 21 | \begin{document} 22 | \a{} and \max{Planck} both thought a lot about quantum mechanics. 23 | \end{document} 24 | """ 25 | 26 | lpp = helpers.MockLPP() 27 | fix = newcommand.Expand(leave_newcommand=True) 28 | lpp.install_fix( fix ) 29 | 30 | self.assertEqual( 31 | lpp.execute(latex), 32 | r""" 33 | \documentclass[11pt]{article} 34 | 35 | \newcommand{\a}{Albert Einstein} 36 | \newcommand\max[1]{Max #1} 37 | 38 | \begin{document} 39 | Albert Einstein{} and Max Planck both thought a lot about quantum mechanics. 40 | \end{document} 41 | """ 42 | ) 43 | 44 | def test_noleave(self): 45 | 46 | latex = r""" 47 | \documentclass[11pt]{article} 48 | 49 | \newcommand{\a}{Albert Einstein} 50 | \newcommand\max[1]{Max #1} 51 | \renewcommand\thepage{\roman{page}} 52 | 53 | \begin{document} 54 | \a{} and \max{Planck} both thought a lot about quantum mechanics. 55 | \end{document} 56 | """ 57 | 58 | lpp = helpers.MockLPP() 59 | fix = newcommand.Expand(leave_newcommand=False) 60 | lpp.install_fix( fix ) 61 | 62 | self.assertEqual( 63 | lpp.execute(latex), 64 | r""" 65 | \documentclass[11pt]{article} 66 | 67 | 68 | 69 | \renewcommand\thepage{\roman{page}} 70 | 71 | \begin{document} 72 | Albert Einstein{} and Max Planck both thought a lot about quantum mechanics. 73 | \end{document} 74 | """ 75 | ) 76 | 77 | 78 | def test_newcommand_cmds(self): 79 | latex = r""" 80 | \documentclass[11pt]{article} 81 | 82 | \newcommand{\a}{Albert Einstein} 83 | \renewcommand\thepage{$-$ \roman{page} $-$} 84 | \providecommand\max[1]{Max #1} 85 | 86 | \begin{document} 87 | \a{} and \max{Planck} both thought a lot about quantum mechanics. 88 | \end{document} 89 | """ 90 | 91 | lpp = helpers.MockLPP() 92 | fix = newcommand.Expand(newcommand_cmds=['newcommand', 'providecommand'], 93 | leave_newcommand=False) 94 | lpp.install_fix( fix ) 95 | 96 | self.assertEqual( 97 | lpp.execute(latex), 98 | r""" 99 | \documentclass[11pt]{article} 100 | 101 | 102 | \renewcommand\thepage{$-$ \roman{page} $-$} 103 | 104 | 105 | \begin{document} 106 | Albert Einstein{} and Max Planck both thought a lot about quantum mechanics. 107 | \end{document} 108 | """ 109 | ) 110 | 111 | 112 | 113 | def test_macro_blacklist(self): 114 | latex = r""" 115 | \documentclass[11pt]{article} 116 | 117 | \newcommand{\a}{Albert Einstein} 118 | \newcommand\b{Bbbb} 119 | \newcommand\bob{Bobbby} 120 | \newcommand\max[1]{Max #1} 121 | \renewcommand\thepage{\roman{page}} 122 | 123 | \begin{document} 124 | \a{} and \max{Planck} both thought a lot about quantum mechanics. Some B's by \bob: \b. 125 | \end{document} 126 | """ 127 | 128 | lpp = helpers.MockLPP() 129 | fix = newcommand.Expand(leave_newcommand=False, macro_blacklist_patterns=[r'b$', r'^the']) 130 | lpp.install_fix( fix ) 131 | 132 | self.assertEqual( 133 | lpp.execute(latex), 134 | r""" 135 | \documentclass[11pt]{article} 136 | 137 | 138 | \newcommand\b{Bbbb} 139 | \newcommand\bob{Bobbby} 140 | 141 | \renewcommand\thepage{\roman{page}} 142 | 143 | \begin{document} 144 | Albert Einstein{} and Max Planck both thought a lot about quantum mechanics. Some B's by \bob: \b. 145 | \end{document} 146 | """ 147 | ) 148 | 149 | 150 | 151 | def test_newenvironment(self): 152 | latex = r""" 153 | \documentclass[11pt]{article} 154 | 155 | \newcommand\Albert{Albert E.} 156 | \newenvironment{testenviron}[2][x]{\texttt{testenviron<#1>{#2}}}{\texttt{endtestenviron}} 157 | 158 | \begin{document} 159 | Hello. 160 | \begin{testenviron}\textasciitilde 161 | Environment \textbf{body}, with an equation: 162 | \begin{equation} 163 | x = y + z\ . 164 | \end{equation} 165 | (Not by \Albert.) 166 | \end{testenviron} 167 | \end{document} 168 | """ 169 | 170 | lpp = helpers.MockLPP() 171 | fix = newcommand.Expand(newcommand_cmds=['newenvironment']) 172 | lpp.install_fix( fix ) 173 | 174 | self.assertEqual( 175 | lpp.execute(latex), 176 | r""" 177 | \documentclass[11pt]{article} 178 | 179 | \newcommand\Albert{Albert E.} 180 | 181 | 182 | \begin{document} 183 | Hello. 184 | {\texttt{testenviron{\textasciitilde 185 | }}Environment \textbf{body}, with an equation: 186 | \begin{equation} 187 | x = y + z\ . 188 | \end{equation} 189 | (Not by \Albert.) 190 | \texttt{endtestenviron}} 191 | \end{document} 192 | """ 193 | ) 194 | 195 | def test_environment_blacklist(self): 196 | latex = r""" 197 | \documentclass[11pt]{article} 198 | 199 | \newenvironment{testenviron}[2][x]{\texttt{testenviron<#1>{#2}}}{\texttt{endtestenviron}} 200 | \newenvironment{minienviron}{begin}{end} 201 | 202 | \begin{document} 203 | Hello. 204 | \begin{testenviron}{Z} 205 | Environment \textbf{body}, with an inner environment: 206 | \begin{minienviron} 207 | Hi! 208 | \end{minienviron} 209 | \end{testenviron} 210 | \end{document} 211 | """ 212 | 213 | lpp = helpers.MockLPP() 214 | fix = newcommand.Expand(environment_blacklist_patterns=[r'm([a-z])n\1']) 215 | lpp.install_fix( fix ) 216 | 217 | self.assertEqual( 218 | lpp.execute(latex), 219 | r""" 220 | \documentclass[11pt]{article} 221 | 222 | 223 | \newenvironment{minienviron}{begin}{end} 224 | 225 | \begin{document} 226 | Hello. 227 | {\texttt{testenviron{Z}} 228 | Environment \textbf{body}, with an inner environment: 229 | \begin{minienviron} 230 | Hi! 231 | \end{minienviron} 232 | \texttt{endtestenviron}} 233 | \end{document} 234 | """ 235 | ) 236 | 237 | 238 | if __name__ == '__main__': 239 | import logging 240 | logging.basicConfig(level=logging.DEBUG) 241 | helpers.test_main() 242 | -------------------------------------------------------------------------------- /latexpp/fixes/input.py: -------------------------------------------------------------------------------- 1 | import re 2 | import os.path as os_path # allow tests to monkey-patch this 3 | 4 | import logging 5 | logger = logging.getLogger(__name__) 6 | 7 | from pylatexenc.latexwalker import LatexMacroNode 8 | 9 | from latexpp.fix import BaseFix 10 | 11 | 12 | input_exts = ['', '.tex', '.latex'] 13 | 14 | class EvalInput(BaseFix): 15 | r""" 16 | Evaluate ``\input`` and ``\include`` routines by replacing the corresponding 17 | instruction by the contents of the included file. 18 | 19 | The contents of the included file will be processed with the rules that are 20 | declared *after* the `EvalInput` rule. Any rules that have already been 21 | applied do not affect the contents pasted in place of the 22 | ``\input``/``\include`` directives. 23 | 24 | .. note:: 25 | 26 | You most likely want to have this rule first in your `lppconfig.yml` fix 27 | list. 28 | 29 | Arguments: 30 | 31 | - `usepackage` [optional] specify a list of package names (a ``\usepackage`` 32 | argument) that import a local package file that is assumed to exist in the 33 | current directory. The file contents will be included at the location of 34 | the ``\usepackage`` call. For example, set 35 | ``usepacakge=['./mymacros.sty']`` to replace a call to 36 | ``\usepackage{./mymacros.sty}`` by the contents of ``mymacros.sty`` 37 | (surrounded by ``\makeatletter ... \makeatother``). 38 | """ 39 | def __init__(self, *, usepackage=None): 40 | super().__init__() 41 | self.usepackage = usepackage 42 | 43 | def fix_node(self, n, **kwargs): 44 | 45 | if n.isNodeType(LatexMacroNode) and n.macroname in ('input', 'include'): 46 | 47 | if not n.nodeargd.argnlist: 48 | logger.warning(r"Invalid \input/\include directive: ‘%s’, skipping.", 49 | n.to_latex()) 50 | return None 51 | 52 | infname = self.preprocess_arg_latex(n, 0) 53 | 54 | return self.do_input(n, infname, input_exts) 55 | 56 | if (self.usepackage is not None 57 | and self.usepackage 58 | and n.isNodeType(LatexMacroNode) 59 | and n.macroname == 'usepackage'): 60 | # 61 | # pick up the usepackage argument 62 | pkgname = self.preprocess_arg_latex(n, 1) # remember, there's an optional arg 63 | 64 | if pkgname in self.usepackage: 65 | return self.do_input(n, pkgname, 66 | exts=['', '.sty']) 67 | 68 | return None 69 | 70 | 71 | def do_input(self, n, infname, exts): 72 | 73 | logger.info("Input ‘%s’", infname) 74 | 75 | for e in exts: 76 | # FIXME: resolve path relative to main document source 77 | if os_path.exists(infname+e): 78 | infname = infname+e 79 | break 80 | else: 81 | logger.warning("File not found: ‘%s’. Tried extensions %r", infname, exts) 82 | return None # keep the node as it is 83 | 84 | # open that file and go through it, too 85 | 86 | infdata = self._read_file_contents(infname) 87 | 88 | ## we add %\n to the end to avoid having two newlines one after 89 | ## the other (at end of input file and after \input{}) that could 90 | ## be misinterpreted as a new paragraph in some settings 91 | # ### but this is also unreliable. because 92 | # ### "\\input{foo}\n\\input{bar}" would still include a new 93 | # ### paragraph. 94 | #if self.delimit_with_percent: 95 | # infdata = '%\n' + infdata + '%\n' 96 | 97 | # for \include, we need to issue \clearpage. See 98 | # https://tex.stackexchange.com/a/32058/32188 99 | if n.macroname == 'include': 100 | infdata = r'\clearpage' + '\n' + infdata 101 | 102 | # for \usepackage, surround the contents with '\makeatletter 103 | # .. \makeatother' and remove '\ProvidesPackage' 104 | if n.macroname == 'usepackage': 105 | infdata = re.sub(r'\\ProvidesPackage\s*\{[^}]+\}\s*(\[(\{[^}]*\}|[^\]]*)\])?', 106 | '', infdata) 107 | infdata = r'\makeatletter' + '\n' + infdata + r'\makeatother' + '\n' 108 | 109 | # preprocess recursively contents 110 | 111 | try: 112 | lw = self.lpp.make_latex_walker(infdata) 113 | except latexwalker.LatexWalkerParseError as e: 114 | if not e.input_source: 115 | e.input_source = 'file ‘{}’'.format(infname) 116 | raise 117 | 118 | nodes = self.preprocess( lw.get_latex_nodes()[0] ) 119 | return nodes # replace the input node by the content of the input file 120 | 121 | #lw = self.lpp.make_latex_walker(infdata) 122 | #res = self.preprocess_latex( lw.get_latex_nodes()[0] ) 123 | #return res # replace the input node by the content of the input file 124 | 125 | 126 | def _read_file_contents(self, infname): 127 | with self.lpp.open_file(infname) as f: 128 | return f.read() 129 | 130 | 131 | 132 | class CopyInputDeps(BaseFix): 133 | r""" 134 | Copy files referred to by ``\input`` and ``\include`` routines to the output 135 | directory, and run the full collection of fixes on them. 136 | """ 137 | 138 | def fix_node(self, n, **kwargs): 139 | 140 | if n.isNodeType(LatexMacroNode) and n.macroname in ('input', 'include'): 141 | 142 | if not n.nodeargd.argnlist: 143 | logger.warning(r"Invalid \input/\include directive: ‘%s’, skipping.", 144 | n.to_latex()) 145 | return None 146 | 147 | infname = self.preprocess_arg_latex(n, 0) 148 | 149 | for e in input_exts: 150 | if os_path.exists(infname+e): 151 | infname = infname+e 152 | break 153 | else: 154 | logger.warning("File not found: ‘%s’. Tried extensions %r", infname, input_exts) 155 | return None # keep the node as it is 156 | 157 | logger.info("Preprocessing ‘%s’", infname) 158 | 159 | # copy file to output while running our whole selection of fixes on 160 | # it! Recurse into a full instantiation of lpp.execute_file(). 161 | self.lpp.execute_file(infname, output_fname=infname) 162 | 163 | return None # don't change the \input directive 164 | 165 | return None 166 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | latexpp 2 | ======= 3 | 4 | Latex preprocessor — apply macro definitions, remove comments, and more 5 | 6 | *Disclaimer: latexpp is still at an experimental development stage.* 7 | 8 | .. image:: https://img.shields.io/github/license/phfaist/latexpp.svg?style=flat 9 | :target: https://github.com/phfaist/latexpp/blob/master/LICENSE.txt 10 | 11 | 12 | Installation 13 | ------------ 14 | 15 | You can install `latexpp` using `pip`: 16 | 17 | .. code-block:: sh 18 | 19 | > pip install latexpp 20 | 21 | How it works 22 | ------------ 23 | 24 | The latex preprocessor ``latexpp`` reads your main latex document and copies it 25 | to an output directory while applying a series of "fixes" that you can 26 | configure. For instance, you can remove comments, you can include files that 27 | you input with ``\input`` macros, or you can replace custom macros by their 28 | LaTeX expansion. 29 | 30 | You run ``latexpp`` in a folder with a ``lppconfig.yml`` file that specifies the 31 | necessary information such as the main LaTeX document, the output directory, and 32 | which fixes to apply. 33 | 34 | Sample ``lppconfig.yml``: 35 | 36 | .. code-block:: yaml 37 | 38 | # latexpp config for MyDocument.tex 39 | # 40 | # This is YAML syntax -- google "YAML tutorial" to get a quick intro. Be 41 | # careful with spaces since indentation is important. 42 | 43 | # the master LaTeX document -- this file will not be modified, all output will 44 | # be produced in the output_dir 45 | fname: 'MyDocument.tex' 46 | 47 | # output file(s) will be created in this directory, originals will not be 48 | # modified 49 | output_dir: 'latexpp_output' 50 | 51 | # main document file name in the output directory 52 | output_fname: 'paper.tex' 53 | 54 | # specify list of fixes to apply, in the given order 55 | fixes: 56 | 57 | # replace \input{...} directives by the contents of the included file 58 | - 'latexpp.fixes.input.EvalInput' 59 | 60 | # remove all comments 61 | - 'latexpp.fixes.comments.RemoveComments' 62 | 63 | # copy any style files (.sty) that are used in the document and that 64 | # are present in the current directory to the output directory 65 | - 'latexpp.fixes.usepackage.CopyLocalPkgs' 66 | 67 | # copy figure files to the output directory and rename them fig-1.xxx, 68 | # fig-2.xxx, etc. 69 | - 'latexpp.fixes.figures.CopyAndRenameFigs' 70 | 71 | # Replace \bibliography{...} by \input{xxx.bbl} and copy the bbl file to the 72 | # output directory. Make sure you run (pdf)latex on the main docuemnt 73 | # before running latexpp 74 | - 'latexpp.fixes.bib.CopyAndInputBbl' 75 | 76 | # Expand some macros. Latexpp doesn't parse \newcommand's, so you need to 77 | # specify here the LaTeX code that the macro should be expanded to. If the 78 | # macro has arguments, specify the nature of the arguments here in the 79 | # 'argspec:' key (a '*' is an optional * character, a '[' one optional 80 | # square-bracket-delimited argument, and a '{' is a mandatory argument). The 81 | # argument values are available via the placeholders %(1)s, %(2)s, etc. Make 82 | # sure to use single quotes for strings that contain \ backslashes. 83 | - name: 'latexpp.fixes.macro_subst.Subst' 84 | config: 85 | macros: 86 | # \tr --> \operatorname{tr} 87 | tr: '\operatorname{tr}' 88 | # \ket{\psi} --> \lvert{\psi}\rangle 89 | ket: 90 | argspec: '{' 91 | repl: '\lvert{%(1)s}\rangle' 92 | # \braket{\psi}{\phi} --> \langle{\psi}\vert{\phi}\rangle 93 | braket: 94 | argspec: '{{' 95 | repl: '\langle{%(1)s}\vert{%(2)s}\rangle' 96 | 97 | The config file follows standard YAML syntax (if you're in doubt, google a YAML 98 | tutorial). 99 | 100 | Documentation is available at `latexpp.readthedocs.io 101 | `_. You can also explore the ``latexpp/fixes/`` 102 | directory for the list of possible fixes. The documentation is still a little 103 | sparse at the moment (I wrote this preprocessor in the 104 | matter of a few days, and I won't have tons of time to devote to it). But the 105 | python source is fairly short and should be relatively decipherable. 106 | 107 | Each fix is specified by a qualified python class name. For instance, 108 | ``latexpp.fixes.comments.RemoveComments`` invokes class ``RemoveComments`` from 109 | the python module ``latexpp.fixes.comments``. You can specify custom arguments 110 | to the class constructor by using the syntax with the 'name:' and 'config:' keys 111 | as shown above. The keys in each 'config:' section are directly passed on to 112 | the class constructor as corresponding keyword arguments. 113 | 114 | The fixes in the ``latexpp/fixes/pkg/`` directory are those fixes that are 115 | supposed to apply all definitions of the corresponding package in order to 116 | remove a dependency on that package. 117 | 118 | It's also straightforward to write your own fix classes to do more complicated 119 | stuff. Create a python package (a new folder ``mypackage`` with an empty 120 | ``__init__.py`` file) and create a python module (e.g. ``myfixmodule.py``) in 121 | that package that defines your fix class (e.g. ``MyFix``). You can get 122 | inspiration from one of the simple examples in the ``latexpp/fixes/`` folder. 123 | Set up your ``$PYTHONPATH`` so that your python package is exposed to python. 124 | Then simply specify the pacakge/module your fix is located in in the YAML file, 125 | e.g., ``mypackage.myfixmodule.MyFix`` instead of 126 | ``latexpp.fixes.xxxxx.YYYY``. 127 | 128 | How it actually works 129 | --------------------- 130 | 131 | The ``latexpp`` preprocessor relies on `pylatexenc 2.0 132 | `_ to parse the latex document into an 133 | internal node structure. For instance, the chunk of latex code:: 134 | 135 | Hello, \textit{world}! % show a greeting 136 | 137 | will be parsed into a list of four nodes, a ‘normal characters node’ ``"Hello, 138 | "``, a ‘macro node’ ``\textit`` with argument a ‘group node’ ``{world}`` which 139 | itself contains a ‘normal characters node’ ``world``, a ‘normal characters node’ 140 | ``"! "``, and a ‘latex comment node’ ``% show a greeting``. The structure is 141 | recursive, with e.g. macro arguments and environment contents themselves 142 | represented as nodes which can contain further macros and environments. See 143 | `pylatexenc.latexwalker 144 | `_ for more 145 | information. The `pylatexenc` library has a list of some known macros and 146 | environments, and knows how to parse their arguments. Some fixes in `latexpp` 147 | add their own macro and environment definitions. 148 | 149 | Once the latex document is parsed into the node structure, the document is 150 | processed by the given list of fixes. Each fix is called in turn. Each fix 151 | traverses the document node structure and applies any relevant changes. 152 | 153 | 154 | License 155 | ------- 156 | 157 | \ (C) 2019 Philippe Faist, philippe dot faist bluewin dot ch 158 | 159 | MIT Licence, see License.txt 160 | 161 | -------------------------------------------------------------------------------- /test/helpers.py: -------------------------------------------------------------------------------- 1 | import os 2 | import os.path 3 | 4 | import logging 5 | import unittest 6 | 7 | from pylatexenc import latexwalker, macrospec 8 | 9 | from pylatexenc.latexwalker import ParsingState 10 | 11 | try: 12 | from pylatexenc.latexnodes import LatexArgumentSpec 13 | from pylatexenc.latexnodes.nodes import LatexNodeList 14 | except ImportError: 15 | LatexArgumentSpec = type(None) 16 | LatexNodeList = list 17 | 18 | from latexpp import preprocessor 19 | 20 | 21 | class MockLPP(preprocessor.LatexPreprocessor): 22 | def __init__(self, mock_files={}): 23 | super().__init__( 24 | output_dir='TESTOUT', 25 | main_doc_fname='TESTDOC', 26 | main_doc_output_fname='TESTMAIN' 27 | ) 28 | self.output_dir = '/TESTOUT' # simulate output here 29 | 30 | self.mock_files = mock_files 31 | 32 | self.copied_files = [] 33 | self.wrote_executed_files = {} 34 | 35 | self.omit_processed_by = True 36 | 37 | def _warn_if_output_dir_nonempty(self): 38 | pass 39 | 40 | def execute(self, latex): 41 | self.initialize() 42 | s = self.execute_string(latex, input_source='[test string]', omit_processed_by=True) 43 | self.finalize() 44 | return s 45 | 46 | 47 | def execute_file(self, fname, *, output_fname): 48 | 49 | s = self.mock_files[fname] 50 | 51 | #logging.getLogger(__name__).debug("mock execute_file(): %s -> %s, s=%r", 52 | # fname, output_fname, s) 53 | 54 | outdata = self.execute_string( 55 | s, 56 | input_source='"file" {} if you know what I mean *wink wink nudge nudge*'.format(fname) 57 | ) 58 | 59 | self.register_output_file(output_fname) 60 | 61 | # "write" to the given file 62 | self.wrote_executed_files[output_fname] = outdata 63 | 64 | 65 | def _os_walk_output_dir(self): 66 | return [('/TESTOUT', [], [d for s, d in self.copied_files])] 67 | 68 | def _do_ensure_destdir(self, destdir, destdn): 69 | pass 70 | 71 | def _do_copy_file(self, source, dest): 72 | source, dest = map(os.path.normpath, (source, dest)) 73 | self.copied_files.append( (source, dest,) ) 74 | 75 | def open_file(self, fname): 76 | import io 77 | mocklpp = self 78 | class X: 79 | def __enter__(self): 80 | return io.StringIO(mocklpp.mock_files[fname]) 81 | def __exit__(self, exc_type, exc_value, exc_traceback): 82 | pass 83 | 84 | return X() 85 | 86 | 87 | def check_autofile_up_to_date(self, autotexfile): 88 | # skip checks 89 | return 90 | 91 | def create_subpreprocessor(self, *, lppconfig_fixes=None): 92 | """ 93 | Create a sub-preprocessor (or child preprocessor) of this preprocessor. 94 | """ 95 | pp = MockLPP(mock_files=self.mock_files) 96 | pp.parent_preprocessor = self 97 | if lppconfig_fixes: 98 | pp.install_fixes_from_config(lppconfig_fixes) 99 | return pp 100 | 101 | def finalize(self): 102 | if self.parent_preprocessor: 103 | self.parent_preprocessor.copied_files += self.copied_files 104 | super().finalize() 105 | 106 | 107 | 108 | def make_latex_walker(s, **kwargs): 109 | return preprocessor._LPPLatexWalker(s, lpp=None, **kwargs) 110 | 111 | 112 | 113 | class FakeOsPath: 114 | 115 | def __init__(self, existing_filenames): 116 | super().__init__() 117 | self.existing_filenames = [os.path.normpath(fn) for fn in existing_filenames] 118 | 119 | def basename(self, *args, **kwargs): 120 | return os.path.basename(*args, **kwargs) 121 | 122 | def join(self, *args, **kwargs): 123 | return os.path.join(*args, **kwargs) 124 | 125 | def dirname(self, *args, **kwargs): 126 | return os.path.dirname(*args, **kwargs) 127 | 128 | def exists(self, fn): 129 | return os.path.normpath(fn) in self.existing_filenames 130 | 131 | 132 | def nodelist_to_d(nodelist, use_line_numbers=False, use_detailed_position=False): 133 | 134 | def get_obj(x): 135 | if x is None: 136 | return None 137 | 138 | if isinstance(x, (list, tuple)): 139 | return [get_obj(y) for y in x] 140 | 141 | if isinstance(x, dict): 142 | return {k: get_obj(v) for k, v in x.items()} 143 | 144 | if isinstance(x, (str, int, bool)): 145 | return x 146 | 147 | 148 | def _add_pos(d, pos, len_): 149 | if use_line_numbers: 150 | lineno, colno = \ 151 | n.parsing_state.lpp_latex_walker.pos_to_lineno_colno(pos) 152 | 153 | d['lineno'] = lineno 154 | 155 | if use_detailed_position: 156 | d['colno'] = colno 157 | d['pos'] = pos 158 | d['len'] = len_ 159 | 160 | # we already tested for 'list', so this condition never evals to true 161 | # if we're running w/ pylatexenc 2 162 | if isinstance(x, LatexNodeList): 163 | d = { 164 | 'nodelist': x.nodelist, 165 | } 166 | _add_pos(d, x.pos, x.len) 167 | return d 168 | 169 | if isinstance(x, latexwalker.LatexNode): 170 | n = x 171 | d = { 172 | 'nodetype': n.__class__.__name__ 173 | } 174 | for fld in n._fields: 175 | # skip some fields that we choose not to compare in our tests 176 | if fld in ( 177 | 'latex_walker', 'spec', 'parsing_state', 178 | 'pos', 'len', 'pos_end', 179 | ): 180 | continue 181 | 182 | d[fld] = n.__dict__[fld] 183 | 184 | _add_pos(d, n.pos, n.len) 185 | 186 | return get_obj(d) 187 | 188 | if isinstance(x, macrospec.ParsedMacroArgs): 189 | d = x.to_json_object() 190 | if 'arguments_spec_list' in d: 191 | d.pop('arguments_spec_list') 192 | d['argspec'] = x.argspec 193 | return get_obj(d) 194 | 195 | if isinstance(x, ParsingState): 196 | d = x.to_json_object() 197 | return get_obj(d) 198 | 199 | if isinstance(x, (latexwalker.LatexWalker, 200 | macrospec.MacroSpec, 201 | macrospec.EnvironmentSpec, 202 | macrospec.SpecialsSpec, 203 | LatexArgumentSpec)): 204 | return { '$skip-serialization-type': x.__class__.__name__ } 205 | 206 | raise ValueError("Unknown value to serialize: {!r}".format(x)) 207 | 208 | return get_obj(nodelist) 209 | 210 | 211 | 212 | class LatexWalkerNodesComparer: 213 | def __init__(self, **kwargs): 214 | super().__init__(**kwargs) 215 | 216 | def assert_nodelists_equal(self, nodelist, d, **kwargs): 217 | 218 | newd = nodelist_to_d(nodelist, **kwargs) 219 | 220 | self.assertEqual(newd, d) 221 | 222 | 223 | 224 | def test_main(): 225 | logging.basicConfig(level=logging.DEBUG) 226 | unittest.main() 227 | 228 | 229 | -------------------------------------------------------------------------------- /latexpp/fixes/usepackage.py: -------------------------------------------------------------------------------- 1 | 2 | import os.path as os_path # allow tests to monkey-patch this 3 | 4 | import logging 5 | logger = logging.getLogger(__name__) 6 | 7 | from pylatexenc.latexwalker import LatexMacroNode, LatexWalkerParseError 8 | from pylatexenc.macrospec import std_macro 9 | 10 | from latexpp.fix import BaseFix 11 | 12 | 13 | def node_get_usepackage(n, fix): 14 | """ 15 | If `n` is a macro node that is a 'usepackage' directive, then this function 16 | returns a string with the package name. Otherwise we return `None`. 17 | """ 18 | if (n.isNodeType(LatexMacroNode) and n.macroname in ("usepackage", "RequirePackage") 19 | and n.nodeargd is not None and n.nodeargd.argnlist is not None): 20 | # usepackage has signature '[{' 21 | return fix.preprocess_arg_latex(n, 1).strip() 22 | return None 23 | 24 | 25 | class RemovePkgs(BaseFix): 26 | r""" 27 | Remove some instances of ``\usepackage[..]{...}`` for some selected pacage 28 | names. 29 | 30 | Arguments: 31 | 32 | - `pkglist`: List of package names for which we should remove any 33 | ``\usepackage`` directives. 34 | 35 | .. warning:: 36 | 37 | [FIXME]: This does not work if you have ``\usepackage`` directives with 38 | several packages. This should be easy to fix... 39 | """ 40 | def __init__(self, pkglist): 41 | super().__init__() 42 | self.pkglist = set(pkglist) 43 | 44 | def fix_node(self, n, **kwargs): 45 | 46 | pkgname = node_get_usepackage(n, self) 47 | if pkgname is not None and pkgname in self.pkglist: 48 | logger.debug(r"Removing instruction ‘%s’", n.to_latex()) 49 | return [] # kill entire node 50 | 51 | return None 52 | 53 | def specs(self): 54 | return { 55 | "macros": [std_macro("RequirePackage", True, 1)] 56 | } 57 | 58 | 59 | class CopyLocalPkgs(BaseFix): 60 | r""" 61 | Copy package style files that are present in the current directory and that 62 | are included with ``\usepackage{...}``. 63 | 64 | Package style files are copied to the output directory as-is (no fixes are 65 | applied within the style file). If the `recursive` flag is set, the style 66 | file is also parsed (but not preprocessed) to detect further package 67 | inclusions that we should copy to the output directory. 68 | 69 | .. warning:: 70 | 71 | [FIXME]: This does not work if you have ``\usepackage`` directives with 72 | several packages. This should be easy to fix... 73 | 74 | Arguments: 75 | 76 | - `blacklist`: a list of package names (without the '.sty' extension) for 77 | which we should *not* copy the style file, even if found in the current 78 | working directory. 79 | 80 | - `recursive`: If `True`, then this fix also recursively inspects the copied 81 | package sources to detect further packages to inlcude. 82 | """ 83 | def __init__(self, blacklist=None, recursive=True): 84 | super().__init__() 85 | self.blacklist = frozenset(blacklist) if blacklist else frozenset() 86 | self.initialized = False 87 | self.finalized = False 88 | self.recursive = recursive 89 | 90 | def initialize(self): 91 | if self.initialized: 92 | return 93 | self.initialized = True 94 | if self.recursive: 95 | self.subpp = self.lpp.create_subpreprocessor() 96 | self.subpp.install_fix(self) 97 | self.subpp.initialize() 98 | else: 99 | self.subpp = None 100 | 101 | def fix_node(self, n, **kwargs): 102 | 103 | pkgname = node_get_usepackage(n, self) 104 | if pkgname is not None and pkgname not in self.blacklist: 105 | pkgnamesty = pkgname + '.sty' 106 | if os_path.exists(pkgnamesty): 107 | self.lpp.copy_file(pkgnamesty, destfname=pkgnamesty) 108 | if self.recursive: 109 | with self.subpp.open_file(pkgnamesty) as f: 110 | pkgcontents = f.read() 111 | try: 112 | self.subpp.execute_string(pkgcontents, 113 | omit_processed_by=True, input_source=pkgnamesty) 114 | except LatexWalkerParseError as e: 115 | logger.warning("Couldn't parse file ‘%s’, cannot recursively " 116 | "check for packages to include in that file: %s", 117 | pkgnamesty, e) 118 | return None # keep node the same 119 | 120 | return None 121 | 122 | def specs(self): 123 | return { 124 | "macros": [std_macro("RequirePackage", True, 1)] 125 | } 126 | 127 | def finalize(self): 128 | if self.finalized: 129 | return 130 | self.finalized = True 131 | if self.subpp is not None: 132 | self.subpp.finalize() 133 | 134 | 135 | class InputLocalPkgs(BaseFix): 136 | r""" 137 | Include the contents of specified package style files that are present in 138 | the current directory and that are included with ``\usepackage{...}``. 139 | 140 | .. warning:: 141 | 142 | [FIXME]: This does not work if you have ``\usepackage`` directives with 143 | several packages. This should be easy to fix... 144 | 145 | .. warning:: 146 | 147 | [FIXME]: This will most probably not work if your package processes 148 | package options. 149 | 150 | The copied local packages can also have their own set of "fixes" applied, 151 | too, as if you had run a separate instance of LatexPP on the pacakge file. 152 | 153 | Arguments: 154 | 155 | - `packages`: a list of package names (without the '.sty' extension) for 156 | which we should include the style file contents into the file. The style 157 | file must reside in the current working directory. 158 | 159 | - `fixes`: a set of fixes to run on the local package files. There is no 160 | artificial limit to the recursion :) 161 | """ 162 | def __init__(self, packages=None, fixes=None): 163 | super().__init__() 164 | self.packages = frozenset(packages) if packages else frozenset() 165 | self.fixes = fixes if fixes else [] 166 | self.subpp = None 167 | 168 | def initialize(self): 169 | 170 | # local import to make sure we avoid cyclic imports at import-time 171 | import latexpp.fixes.macro_subst 172 | 173 | self.subpp = self.lpp.create_subpreprocessor() 174 | self.subpp.install_fix( 175 | latexpp.fixes.macro_subst.Subst( 176 | macros={ 177 | 'NeedsTeXFormat': {'argspec': '{[', 'repl': ''}, 178 | 'ProvidesPackage': {'argspec': '{[', 'repl': ''}, 179 | } 180 | ) 181 | ) 182 | if self.fixes: 183 | self.subpp.install_fixes_from_config(self.fixes) 184 | 185 | self.subpp.initialize() 186 | 187 | def fix_node(self, n, **kwargs): 188 | 189 | pkgname = node_get_usepackage(n, self) 190 | if pkgname is not None and pkgname in self.packages: 191 | pkgnamesty = pkgname + '.sty' 192 | if os_path.exists(pkgnamesty): 193 | logger.debug("Processing input package ‘%s’", pkgnamesty) 194 | with self.lpp.open_file(pkgnamesty) as f: 195 | pkgcontents = f.read() 196 | if self.subpp: 197 | pkgcontents = \ 198 | self.subpp.execute_string(pkgcontents, 199 | omit_processed_by=True, 200 | input_source=pkgnamesty) 201 | pkgcontents = r"\makeatletter " + pkgcontents + r"\makeatother " 202 | return pkgcontents 203 | 204 | return None 205 | 206 | def finalize(self): 207 | self.subpp.finalize() 208 | 209 | def specs(self): 210 | return { 211 | "macros": [std_macro("RequirePackage", True, 1)] 212 | } 213 | -------------------------------------------------------------------------------- /latexpp/fixes/pkg/phfparen.py: -------------------------------------------------------------------------------- 1 | 2 | import logging 3 | 4 | logger = logging.getLogger(__name__) 5 | 6 | from pylatexenc.macrospec import SpecialsSpec, ParsedMacroArgs, MacroStandardArgsParser 7 | from pylatexenc import latexwalker 8 | 9 | from latexpp.fix import BaseFix 10 | 11 | 12 | class Expand(BaseFix): 13 | r""" 14 | Expand expressions provided by the {phfparen} package, such as ```*(...)`` 15 | or ```{...}``, into equivalent LaTeX code that does not require the 16 | {phfparen} package. 17 | 18 | This fix removes the dependency on the 19 | {phfparen} package. (That is, unless you defined custom delimiters etc. via 20 | {phfparen} internals or if you did other weird stuff like that...) 21 | 22 | Arguments: 23 | 24 | - `wrap_in_latex_group`: If set to true (false by default), then the delimited 25 | math contents is wrapped in a ``{...}`` group. This prevents line breaks 26 | within the delimited expression, as is the case when you use the `phfparen` 27 | latex package. 28 | """ 29 | 30 | def __init__(self, wrap_in_latex_group=False): 31 | super().__init__() 32 | 33 | self.wrap_in_latex_group = wrap_in_latex_group 34 | 35 | 36 | def specs(self, **kwargs): 37 | return dict(specials=[ 38 | SpecialsSpec('`', args_parser=PhfParenSpecialsArgsParser()) 39 | ]) 40 | 41 | def fix_node(self, n, **kwargs): 42 | 43 | if n.isNodeType(latexwalker.LatexSpecialsNode) and n.specials_chars == '`': 44 | 45 | #print("*** `specials-paren node: ", n) 46 | if not n.nodeargd.in_math_mode: 47 | # not in math mode, leave as is 48 | return None 49 | 50 | if n.nodeargd.has_star: 51 | delims_pc = (r'\mathopen{}\left%s', r'\right%s\mathclose{}') 52 | elif n.nodeargd.size_arg_node is not None: 53 | sizemacro = '\\'+n.nodeargd.size_arg_node.macroname 54 | delims_pc = (sizemacro+r'l%s', sizemacro+r'r%s') 55 | else: 56 | delims_pc = ('%s', '%s') 57 | 58 | if n.nodeargd.contents_node is None: 59 | # this is normal, happens for ` not in math mode 60 | raise ValueError("`(special) construct does not have contents_node: {!r}" 61 | .format(n.to_latex())) 62 | 63 | delimchars = n.nodeargd.contents_node.delimiters 64 | 65 | if delimchars == ('{', '}'): 66 | # literal braces if given with curly braces 67 | delimchars = (r'\{', r'\}') 68 | 69 | inner_replaced_str = self.preprocess_latex(n.nodeargd.contents_node.nodelist) 70 | 71 | if self.wrap_in_latex_group: 72 | inner_replaced_str = '{' + inner_replaced_str + '}' 73 | 74 | replaced_str = delims_pc[0]%delimchars[0] \ 75 | + inner_replaced_str \ 76 | + delims_pc[1]%delimchars[1] 77 | 78 | return replaced_str 79 | 80 | return None 81 | 82 | 83 | 84 | 85 | 86 | # parse `(...) `[...] `{ ... } 87 | # `\big(...) `\big[...] ... 88 | # `*(...) ... 89 | 90 | class PhfParenSpecialsParsedArgs(ParsedMacroArgs): 91 | def __init__(self, check_math_mode_node, star_node, size_arg_node, contents_node, **kwargs): 92 | self.check_math_mode_node = check_math_mode_node 93 | self.in_math_mode = check_math_mode_node is not None 94 | self.has_star = star_node is not None 95 | self.star_node = star_node # or None 96 | self.size_arg_node = size_arg_node # or None 97 | self.contents_node = contents_node 98 | 99 | argnlist = [ 100 | self.check_math_mode_node, # simulate additional macro to remember 101 | # that we had originally detected math 102 | # mode 103 | self.star_node, 104 | self.size_arg_node, 105 | self.contents_node 106 | ] 107 | 108 | super(PhfParenSpecialsParsedArgs, self).__init__(argspec='[*[{', 109 | argnlist=argnlist, 110 | **kwargs) 111 | 112 | 113 | class PhfParenSpecialsArgsParser(MacroStandardArgsParser): 114 | def __init__(self): 115 | super(PhfParenSpecialsArgsParser, self).__init__(argspec='[*[{') 116 | 117 | def parse_args(self, w, pos, parsing_state=None): 118 | 119 | if parsing_state is None: 120 | parsing_state = w.make_parsing_state() 121 | 122 | # check for magic token that tells us that we are in fact, in math mode. 123 | # Needed for repeated text->nodes->text->nodes->... conversion where the 124 | # 'in_math_mode' of the parsing_state is not reliable when we are 125 | # parsing an inner snippet 126 | # 127 | # ### UPDATE IN PYLATEXENC: This should no longer be needed 128 | p = pos 129 | tok = w.get_token(p) 130 | force_math_mode = False 131 | if tok.tok == 'macro' and tok.arg == 'phfparenInMathMode': 132 | force_math_mode = True 133 | p = tok.pos + tok.len 134 | 135 | if not force_math_mode and not parsing_state.in_math_mode: 136 | logger.debug("Ignoring '`' not in math mode: line %d, col %d", 137 | *w.pos_to_lineno_colno(pos)) 138 | return (PhfParenSpecialsParsedArgs(None, None, None, None), pos, 0) 139 | 140 | #logger.debug("*** reading specials args at pos=%d", pos) 141 | 142 | include_brace_chars = [('[', ']'), ('(', ')'), ('<', '>')] 143 | 144 | # check for star 145 | tok = w.get_token(p, include_brace_chars=include_brace_chars) 146 | if tok.tok == 'char' and tok.arg.lstrip().startswith('*'): 147 | # has star 148 | star_node = w.make_node(latexwalker.LatexCharsNode, 149 | parsing_state=parsing_state, 150 | chars='*', pos=tok.pos, len=tok.len) 151 | p = tok.pos + 1 152 | tok = w.get_token(p, include_brace_chars=include_brace_chars) # prepare next token 153 | else: 154 | star_node = None 155 | 156 | # check for size macro 157 | #tok = w.get_token(p, include_brace_chars=include_brace_chars) # already in tok 158 | if tok.tok == 'macro': 159 | # has size macro 160 | size_arg_node = w.make_node(latexwalker.LatexMacroNode, 161 | parsing_state=parsing_state, 162 | macroname=tok.arg, nodeargd=None, 163 | pos=tok.pos, len=tok.len) 164 | p = tok.pos+tok.len 165 | tok = w.get_token(p, include_brace_chars=include_brace_chars) # prepare next token 166 | else: 167 | size_arg_node = None 168 | 169 | #logger.debug("\tp=%r, tok=%r", p, tok) 170 | 171 | 172 | #tok = w.get_token(p, include_brace_chars=include_brace_chars) # already in tok 173 | if tok.tok != 'brace_open': 174 | raise latexwalker.LatexWalkerParseError( 175 | s=w.s, 176 | pos=p, 177 | msg=r"Expecting opening brace after '`'" 178 | ) 179 | 180 | (contents_node, apos, alen) = w.get_latex_braced_group(tok.pos, brace_type=tok.arg, 181 | parsing_state=parsing_state) 182 | 183 | #logger.debug("*** got phfparen args: %r, %r, %r", star_node, size_arg_node, contents_node) 184 | 185 | check_math_mode_node = w.make_node(latexwalker.LatexMacroNode, 186 | parsing_state=parsing_state, 187 | macroname='phfparenInMathMode', 188 | nodeargd=None, 189 | pos=pos,len=0) 190 | 191 | return (PhfParenSpecialsParsedArgs(check_math_mode_node, star_node, 192 | size_arg_node, contents_node), 193 | pos, apos+alen-pos) 194 | 195 | -------------------------------------------------------------------------------- /latexpp/fixes/bib.py: -------------------------------------------------------------------------------- 1 | import re 2 | import logging 3 | 4 | logger = logging.getLogger(__name__) 5 | 6 | from pylatexenc.macrospec import MacroSpec 7 | from pylatexenc.latexwalker import LatexMacroNode 8 | 9 | from latexpp.fix import BaseFix 10 | 11 | 12 | class CopyAndInputBbl(BaseFix): 13 | r""" 14 | Copy the (latex-generated) BBL file from the current directory into the 15 | output directory, and replace a ``\bibliography`` call by 16 | ``\input{}``. 17 | 18 | .. note:: 19 | 20 | Multiple bibliographies are not supported. 21 | 22 | Arguments: 23 | 24 | - `bblname`: the name of the BBL file to include. If `None` or not 25 | provided, the bbl name is derived from the main latex file name. 26 | 27 | - `outbblname`: the bbl file is copied to the output directory and renamed 28 | to `outbblname`. By default this derived from the main output latex file 29 | name. 30 | 31 | - `eval_input`: Directly paste the BBL file contents into the TeX file 32 | rather than issuing a ``\input{XXX.bbl}`` directive. 33 | """ 34 | 35 | def __init__(self, bblname=None, outbblname=None, eval_input=False): 36 | super().__init__() 37 | self.bblname = bblname 38 | self.outbblname = outbblname 39 | self.eval_input = eval_input 40 | 41 | def specs(self, **kwargs): 42 | return dict(macros=[ 43 | MacroSpec('bibliographystyle', '{'), 44 | MacroSpec('bibliography', '{'), 45 | ]) 46 | 47 | def fix_node(self, n, **kwargs): 48 | 49 | if n.isNodeType(LatexMacroNode) and n.macroname == 'bibliographystyle': 50 | # remove \bibliographystyle{} command 51 | return '' 52 | 53 | if n.isNodeType(LatexMacroNode) and n.macroname == 'bibliography': 54 | 55 | if self.bblname: 56 | bblname = self.bblname 57 | else: 58 | bblname = re.sub(r'(\.(la)?tex)$', '', self.lpp.main_doc_fname) + '.bbl' 59 | if self.outbblname: 60 | outbblname = self.outbblname 61 | else: 62 | outbblname = re.sub(r'(\.(la)?tex)$', '', self.lpp.main_doc_output_fname) \ 63 | + '.bbl' 64 | 65 | self.lpp.check_autofile_up_to_date(bblname) 66 | 67 | # input BBL contents in any case at least to check for nonascii chars 68 | with self.lpp.open_file(bblname) as f: 69 | bbl_contents = f.read() 70 | # check for nonascii chars 71 | check_for_nonascii(bbl_contents, what='BBL file {}'.format(bblname)) 72 | 73 | if self.eval_input: 74 | return bbl_contents 75 | else: 76 | # copy BBL file 77 | self.lpp.copy_file(bblname, outbblname) 78 | return r'\input{%s}'%(outbblname) 79 | 80 | return None 81 | 82 | def check_for_nonascii(x, what): 83 | cna = next( (ord(c) for c in x if ord(c) >= 127), 84 | None ) 85 | if cna is None: 86 | # all ok 87 | return True 88 | logger.warning("Non-ascii character ‘%s’ (U+%04x) encountered in %s", chr(cna), cna, what) 89 | return False 90 | 91 | 92 | class ApplyAliases(BaseFix): 93 | r""" 94 | Scans the files `bibalias_def_search_files` for bibalias commands 95 | ``\bibalias{alias}{target}`` (or whatever macro is given to `bibaliascmd`), 96 | and applies the aliases to all known citation commands (from the natbib 97 | doc). Any bibalias commands are encountered in the input they are stored as 98 | aliases. Further manual aliases can be specified using the `aliases={...}` 99 | argument. 100 | """ 101 | def __init__(self, 102 | bibaliascmd='bibalias', 103 | bibalias_defs_search_files=[], 104 | aliases={}): 105 | super().__init__() 106 | self.bibaliascmd = bibaliascmd 107 | self.bibalias_defs_search_files = bibalias_defs_search_files 108 | 109 | # which argument has the keys is obtained from the argspec as the first 110 | # mandatory argument 111 | self.cite_macros = set(('cite', 'citet', 'citep', 'citealt', 'citealp', 112 | 'citeauthor', 'citefullauthor', 'citeyear', 'citeyearpar', 113 | 'Citet', 'Citep', 'Citealt', 'Citealp', 'Citeauthor', 114 | 'citenum',)) 115 | 116 | self._bibaliases = {} 117 | self._bibaliases.update(aliases) 118 | 119 | self.bibalias_defs_search_files = bibalias_defs_search_files 120 | 121 | 122 | def initialize(self): 123 | # right away, populate bib aliases with search through given tex files. 124 | # Hmmm, should we use a latexwalker here in any way? ...? Not sure it's 125 | # worth it 126 | rx_bibalias = re.compile( 127 | r'^([^%]|\\%)*?\\' + self.bibaliascmd 128 | + r'\s*\{(?P[^}]+)\}\s*\{(?P[^}]+)\}', 129 | flags=re.MULTILINE 130 | ) 131 | for bfn in self.bibalias_defs_search_files: 132 | with self.lpp.open_file(bfn) as ff: 133 | for m in rx_bibalias.finditer(ff.read()): 134 | alias = m.group('alias') 135 | target = m.group('target') 136 | logger.debug("Found bibalias %s -> %s", alias, target) 137 | self._bibaliases[alias] = target 138 | self._update_bibaliases() 139 | 140 | 141 | def specs(self, **kwargs): 142 | return dict(macros=[MacroSpec(self.bibaliascmd, '{{')]) 143 | 144 | def fix_node(self, n, **kwargs): 145 | if n.isNodeType(LatexMacroNode): 146 | if n.macroname == self.bibaliascmd: 147 | if not n.nodeargd or not n.nodeargd.argnlist \ 148 | or len(n.nodeargd.argnlist) != 2: 149 | logger.warning(r"No arguments or invalid arguments to \bibalias " 150 | "command: %s", 151 | n.to_latex()) 152 | return None 153 | 154 | alias = self.preprocess_arg_latex(n, 0).strip() 155 | target = self.preprocess_arg_latex(n, 1).strip() 156 | logger.debug("Defined bibalias %s -> %s", alias, target) 157 | self._bibaliases[alias] = target 158 | self._update_bibaliases() 159 | return [] # remove bibalias command from input 160 | 161 | if n.macroname in self.cite_macros: 162 | if n.nodeargd is None or n.nodeargd.argspec is None \ 163 | or n.nodeargd.argnlist is None: 164 | logger.warning(r"Ignoring invalid citation command: %s", n.to_latex()) 165 | return None 166 | 167 | citargno = n.nodeargd.argspec.find('{') 168 | ncitarg = n.nodeargd.argnlist[citargno] 169 | citargnew = self._replace_aliases( 170 | self._preprocess_citation_string(ncitarg) 171 | ) 172 | 173 | s = '\\'+n.macroname \ 174 | + ''.join(self.preprocess_latex(n.nodeargd.argnlist[:citargno])) \ 175 | + '{'+citargnew+'}' \ 176 | + ''.join(self.preprocess_latex(n.nodeargd.argnlist[citargno+1:])) 177 | 178 | #print("*** replaced in cite cmd: ", s) 179 | 180 | return s 181 | 182 | 183 | return None 184 | 185 | def _update_bibaliases(self): 186 | self._rx_pattern = re.compile( 187 | r"^(" + 188 | r"|".join( re.escape(k) for k in sorted(self._bibaliases, key=len, reverse=True) ) 189 | + r")$" 190 | ) 191 | 192 | def _preprocess_citation_string(self, s): 193 | s = self.preprocess_contents_latex(s) 194 | # remove all comments here 195 | s = re.sub(r'[%].*\n\s*', '', s) 196 | return s 197 | 198 | def _replace_aliases(self, s): 199 | # use multiple string replacements --> apparently we need a regex. 200 | # Cf. https://stackoverflow.com/a/36620263/1694896 201 | #print("*** rx_pattern = ", self._rx_pattern.pattern, "; aliases = ", 202 | # self._bibaliases) 203 | s2 = ",".join( 204 | self._rx_pattern.sub(lambda m: self._bibaliases[m.group()], citk.strip()) 205 | for citk in s.split(",") 206 | ) 207 | #logger.debug("bibalias: Replaced ‘%s’ -> ‘%s’", s, s2) 208 | return s2 209 | -------------------------------------------------------------------------------- /latexpp/fixes/figures.py: -------------------------------------------------------------------------------- 1 | import re 2 | import os.path as os_path # allow tests to monkey-patch this 3 | 4 | import logging 5 | logger = logging.getLogger(__name__) 6 | 7 | from pylatexenc.latexwalker import LatexMacroNode 8 | #from pylatexenc.latexencode import unicode_to_latex 9 | 10 | from latexpp.fix import BaseFix 11 | 12 | from .labels import RenameLabels 13 | 14 | 15 | _exts = ['', '.lplx', '.pdf', '.png', '.jpg', '.jpeg', '.eps'] 16 | 17 | 18 | 19 | class CopyAndRenameFigs(BaseFix): 20 | r""" 21 | Copy graphics files that are included by ``\includegraphics`` commands to 22 | the output directory. By default, they are renamed in figure order, 23 | 'fig-01.jpg', 'fig-02.pdf', etc. 24 | 25 | Arguments: 26 | 27 | - `fig_rename`: Template to use when renaming the graphics file in the 28 | output directory. The string is parsed by python's ``str.format()`` 29 | mechanism, and the following keys are provided: 30 | 31 | - '{fig_counter}', '{fig_counter:02}' -- the figure number. Use ':0n' 32 | to format the number with `n` digits and leading zeros. 33 | 34 | - '{fig_ext}' -- the file name extension, including the dot. 35 | 36 | - '{orig_fig_name}' -- the original figure file name 37 | 38 | - '{orig_fig_basename}' -- the original figure file name, w/o extension 39 | 40 | - '{orig_fig_ext}' -- the original figure file extension 41 | 42 | - `start_fig_counter`: Figure numbering starts at this number (by default, 43 | 1). 44 | 45 | - `graphicspath`: Filesystem path where to look for graphics files 46 | 47 | - `exts`: Extensions to search for when looking up graphics files. 48 | """ 49 | 50 | def __init__(self, fig_rename='fig-{fig_counter:02}{fig_ext}', 51 | start_fig_counter=1, graphicspath=".", exts=None): 52 | super().__init__() 53 | 54 | # By default we start at Fig #1 because journals like separate files 55 | # with numbered figures starting at 1 56 | self.fig_counter = start_fig_counter 57 | self.fig_rename = fig_rename 58 | self.graphicspath = graphicspath 59 | self.exts = exts if exts is not None else _exts 60 | 61 | self.post_processors = { 62 | '.lplx': self.do_postprocess_lplx, 63 | } 64 | 65 | self.lplx_files_to_finalize = [] 66 | 67 | 68 | def fix_node(self, n, **kwargs): 69 | 70 | if n.isNodeType(LatexMacroNode) and n.macroname == 'includegraphics': 71 | # note, argspec is '[{' 72 | 73 | # find file and copy it 74 | orig_fig_name = self.preprocess_arg_latex(n, 1) 75 | orig_fig_name = os_path.join(self.graphicspath, orig_fig_name) 76 | for e in self.exts: 77 | if os_path.exists(orig_fig_name+e): 78 | orig_fig_name = orig_fig_name+e 79 | break 80 | else: 81 | logger.warning("File not found: %s. Tried extensions %r", 82 | orig_fig_name, self.exts) 83 | return None # keep the node as it is 84 | 85 | if '.' in orig_fig_name: 86 | orig_fig_basename, orig_fig_ext = orig_fig_name.rsplit('.', maxsplit=1) 87 | orig_fig_basename = os_path.basename(orig_fig_basename) 88 | orig_fig_ext = '.'+orig_fig_ext 89 | else: 90 | orig_fig_basename, orig_fig_ext = os_path.basename(orig_fig_name), '' 91 | 92 | figoutname = self.fig_rename.format( 93 | fig_counter=self.fig_counter, 94 | fig_ext=orig_fig_ext, 95 | orig_fig_name=orig_fig_name, 96 | orig_fig_basename=orig_fig_basename, 97 | orig_fig_ext=orig_fig_ext 98 | ) 99 | 100 | self.lpp.copy_file(orig_fig_name, figoutname) 101 | 102 | if orig_fig_ext in self.post_processors: 103 | pp_fn = self.post_processors[orig_fig_ext] 104 | pp_fn( 105 | node=n, 106 | orig_fig_name=orig_fig_name, 107 | orig_fig_basename=orig_fig_basename, 108 | orig_fig_ext=orig_fig_ext, 109 | figoutname=figoutname, 110 | ) 111 | 112 | # increment fig counter 113 | self.fig_counter += 1 114 | 115 | # don't use unicode_to_latex(figoutname) because actually we would 116 | # like to keep the underscores as is, \includegraphics handles it I 117 | # think 118 | return ( 119 | r'\includegraphics' + self.preprocess_latex(self.node_get_arg(n, 0)) + \ 120 | '{' + figoutname + '}' 121 | ) 122 | 123 | return None 124 | 125 | 126 | def do_postprocess_lplx(self, node, orig_fig_name, figoutname, **kwargs): 127 | 128 | rx_lplx_read = re.compile( 129 | r'\\lplxGraphic\{(?P[^}]+)\}\{(?P[^}]+)\}' 130 | ) 131 | 132 | 133 | self.lplx_files_to_finalize.append(figoutname) 134 | 135 | f_contents = None 136 | with self.lpp.open_file(orig_fig_name, encoding='utf-8') as f: 137 | f_contents = f.read() 138 | 139 | m = rx_lplx_read.search(f_contents) 140 | 141 | if m is None: 142 | logger.error("Could not read dependent LPLX graphic file, your build " 143 | "might be incomplete!") 144 | return [] 145 | 146 | # find and copy the dependent file 147 | 148 | dep_basename = m.group('dep_file_basename') 149 | dep_basename = os_path.join(self.graphicspath, dep_basename) 150 | 151 | dep_ext = m.group('dep_file_ext') 152 | 153 | dep_name = dep_basename + dep_ext 154 | 155 | dep_figoutname = self.fig_rename.format( 156 | fig_counter=self.fig_counter, 157 | fig_ext=dep_ext, 158 | orig_fig_name=dep_name, 159 | orig_fig_basename=dep_basename, 160 | orig_fig_ext=dep_ext, 161 | ) 162 | 163 | node.lpp_graphics_lplx_is_lplx_file = True 164 | node.lpp_graphics_lplx_output_file = figoutname 165 | node.lpp_graphics_lplx_dependent_output_file = dep_figoutname 166 | 167 | # copy to destination 168 | 169 | logger.debug(f"Detected LPLX dependent file {dep_name}") 170 | 171 | self.lpp.copy_file(dep_name, dep_figoutname) 172 | 173 | # patch output lplx file to find the correct dependent file 174 | 175 | if '.' in dep_figoutname: 176 | dep_figoutname_basename, dep_figoutname_mext = \ 177 | dep_figoutname.rsplit('.', maxsplit=1) 178 | dep_figoutname_ext = '.'+dep_figoutname_mext 179 | else: 180 | dep_figoutname_basename, dep_figoutname_ext = dep_figoutname, '' 181 | 182 | patched_content = "".join([ 183 | f_contents[:m.start()], 184 | r'\lplxGraphic{' + dep_figoutname_basename + '}{' + dep_figoutname_ext + '}', 185 | f_contents[m.end():], 186 | ]) 187 | 188 | file_to_patch = os_path.join(self.lpp.output_dir, figoutname) 189 | with open(file_to_patch, 'w', encoding='utf-8') as fw: 190 | fw.write(patched_content) 191 | 192 | logger.debug(f"patched file {file_to_patch}") 193 | 194 | 195 | 196 | 197 | def finalize_lplx(self): 198 | 199 | # see if we have a rename-labels fix installed 200 | labels_fixes = [] 201 | for fix in self.lpp.fixes: 202 | if isinstance(fix, RenameLabels): 203 | labels_fixes.append(fix) 204 | 205 | for labels_fix in labels_fixes: 206 | for lplxfigoutname in self.lplx_files_to_finalize: 207 | self.replace_labels_in_lplx_file(lplxfigoutname, labels_fix) 208 | 209 | 210 | def replace_labels_in_lplx_file(self, lplx_output_file, labels_fix): 211 | f_content = None 212 | full_output_file = os_path.join(self.lpp.output_dir, 213 | lplx_output_file) 214 | 215 | logger.debug(f"Patching labels in LPLX file {lplx_output_file} ...") 216 | 217 | with open(full_output_file, 'r', encoding='utf-8') as f: 218 | f_content = f.read() 219 | 220 | def get_new_label(lbl): 221 | return labels_fix.renamed_labels.get(lbl, lbl) 222 | 223 | # replace labels brutally, using a regex 224 | 225 | # rx_hr_uri = \ 226 | # re.compile(r'(?P
\\href\{)(?P[^}]+)(?P\})')
227 |         rx_hr_ref = \
228 |             re.compile(r'(?P
\\hyperref\[\{)(?P[^}]+)(?P\}\])')
229 |         rx_hr_cite = \
230 |             re.compile(r'(?P
\\hyperlink\{cite.)(?P[^}]+)(?P\})')
231 | 
232 |         f_content = rx_hr_ref.sub(
233 |             lambda m: "".join([m.group('pre'),
234 |                                get_new_label(m.group('target')),
235 |                                m.group('post')]),
236 |             f_content
237 |         )
238 | 
239 |         f_content = rx_hr_cite.sub(
240 |             lambda m: "".join([m.group('pre'),
241 |                                get_new_label(m.group('target')),
242 |                                m.group('post')]),
243 |             f_content
244 |         )
245 | 
246 |         with open(full_output_file, 'w', encoding='utf-8') as fw:
247 |             fw.write(f_content)
248 | 
249 | 
250 | 
251 | 
252 |     def finalize(self):
253 |         super().finalize()
254 |         self.finalize_lplx()
255 | 


--------------------------------------------------------------------------------
/latexpp/macro_subst_helper.py:
--------------------------------------------------------------------------------
  1 | # The MIT License (MIT)
  2 | #
  3 | # Copyright (c) 2019 Philippe Faist
  4 | #
  5 | # Permission is hereby granted, free of charge, to any person obtaining a copy
  6 | # of this software and associated documentation files (the "Software"), to deal
  7 | # in the Software without restriction, including without limitation the rights
  8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  9 | # copies of the Software, and to permit persons to whom the Software is
 10 | # furnished to do so, subject to the following conditions:
 11 | #
 12 | # The above copyright notice and this permission notice shall be included in all
 13 | # copies or substantial portions of the Software.
 14 | #
 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 21 | # SOFTWARE.
 22 | #
 23 | 
 24 | 
 25 | r"""
 26 | Module that provides a helper for writing fixes that perform macro
 27 | substitutions with custom replacement strings.
 28 | """
 29 | 
 30 | 
 31 | import logging
 32 | logger = logging.getLogger(__name__)
 33 | 
 34 | from pylatexenc.macrospec import MacroSpec, EnvironmentSpec, MacroStandardArgsParser
 35 | from pylatexenc.latexwalker import LatexMacroNode, LatexEnvironmentNode
 36 | 
 37 | from latexpp import fixes
 38 | 
 39 | 
 40 | class MacroSubstHelper:
 41 |     r"""
 42 |     Helper class that provides common functionality for fixes that replace
 43 |     certain macro invocations by a custom replacement string.
 44 | 
 45 |     TODO: Document me. ....
 46 |     """
 47 |     def __init__(self,
 48 |                  macros={},
 49 |                  environments={},
 50 |                  argspecfldname='argspec',
 51 |                  args_parser_class=MacroStandardArgsParser,
 52 |                  context={}):
 53 |         self.macros = macros
 54 |         self.environments = environments
 55 | 
 56 |         self.argspecfldname = argspecfldname
 57 |         self.args_parser_class = args_parser_class
 58 | 
 59 |         self.context = context # additional fields provided to repl text
 60 | 
 61 |     def get_specs(self):
 62 |         r"""
 63 |         Return the specs that we need to declare to the latex walker
 64 |         """
 65 |         macros = [
 66 |             MacroSpec(m, args_parser=self.args_parser_class(
 67 |                 self._cfg_argspec_repl(mconfig)[0]
 68 |             ))
 69 |             for m, mconfig in self.macros.items()
 70 |         ]
 71 |         return dict(
 72 |             macros=macros,
 73 |             environments=[
 74 |                 EnvironmentSpec(e, args_parser=self.args_parser_class(
 75 |                     self._cfg_argspec_repl(econfig)[0]
 76 |                 ))
 77 |                 for e, econfig in self.environments.items()
 78 |             ]
 79 |         )
 80 | 
 81 |     def _cfg_argspec_repl(self, meinfo):
 82 |         if isinstance(meinfo, str):
 83 |             return '', meinfo
 84 |         return meinfo.get(self.argspecfldname, ''), meinfo.get('repl', '')
 85 | 
 86 |     def get_macro_cfg(self, macroname):
 87 |         r"""
 88 |         Return the config associated with the macro named `macroname`, or `None`.
 89 | 
 90 |         You can use this function to test whether a given macro can be
 91 |         handled by us, testing the return value for non-`None` and using the
 92 |         return value directly as the relevant config for
 93 |         :py:meth:`eval_subst()`.
 94 |         """
 95 |         if macroname not in self.macros:
 96 |             return None
 97 |         return dict(zip([self.argspecfldname, 'repl'],
 98 |                         self._cfg_argspec_repl(self.macros[macroname])))
 99 | 
100 |     def get_environment_cfg(self, environmentname):
101 |         r"""
102 |         Return the config associated with the environment named `environmentname`,
103 |         or `None`.
104 | 
105 |         You can use this function to test whether a given environment can be
106 |         handled by us, testing the return value for non-`None` and using the
107 |         return value directly as the relevant config for
108 |         :py:meth:`eval_subst()`.
109 |         """
110 |         if environmentname not in self.environments:
111 |             return None
112 |         return dict(zip([self.argspecfldname, 'repl'],
113 |                         self._cfg_argspec_repl(self.environments[environmentname])))
114 | 
115 |     def get_node_cfg(self, n):
116 |         r"""
117 |         Return the config associated with the macro/environment of the given node
118 |         `n`, or `None`.
119 | 
120 |         You can use this function to test whether a given node can be handled by
121 |         us, testing the return value for non-`None` and using the return value
122 |         directly as the relevant config for :py:meth:`eval_subst()`.
123 |         """
124 |         if n is None:
125 |             return None
126 |         if n.isNodeType(LatexMacroNode):
127 |             return self.get_macro_cfg(n.macroname)
128 |         if n.isNodeType(LatexEnvironmentNode):
129 |             return self.get_environment_cfg(n.environmentname)
130 |         return None
131 | 
132 | 
133 |     def eval_subst(self, c, n, *, node_contents_latex, argoffset=0, context={},
134 |                    arg_filters=None):
135 |         r"""
136 |         Return the replacement string for the given node `n`, where the
137 |         macro/environment config (argspec/repl) is provided in `c` (return value
138 |         of `get_node_cfg()`).
139 | 
140 |         You need to specify as `node_contents_latex` a callable that will be
141 |         used to transform child nodes (argument nodes) to LaTeX code.  If you're
142 |         calling this from a fix class (:py:class:`latexpp.fixes.BaseFix`
143 |         subclass) then you should most probably specify
144 |         ``node_contents_latex=self.preprocess_contents_latex`` here.
145 | 
146 |         If `argoffset` is nonzero, then the first `argoffset` arguments are skipped
147 |         and the arguments `argoffset+1, argoffset+2, ...` are exposed to the
148 |         replacement string as `%(1)s, %(2)s, ...`.
149 | 
150 |         You can specify a dictionary `context` of additional key/value
151 |         replacement strings in the formatting of the `repl` string.  For
152 |         instance, if ``context={'delimsize': r'\big'}``, then ``%(delimsize)s``
153 |         in the replacement string `repl` is expanded to ``\big``.  This is all
154 |         in addition to the argument placeholders ``%(1)s`` etc., to the
155 |         environment body ``%(body)s``, and to
156 |         ``%(macroname)s``/``%(environmentname)s``.
157 | 
158 |         If `arg_filters` is not None and set to a dictionary, then additional
159 |         placeholders of the type ``%(1.xyz)`` are also recognized, where `xyz`
160 |         are keys in the `arg_filters` dictionary.  The values in the
161 |         `arg_filters` dictionary are functions that take the keyword arguments
162 |         `argspec`, `node`, `arg_index` (0-based index), `arg_number`
163 |         (placeholder number), and `arg_contents` (string representing the
164 |         contents of the argument, as would be used if no filter were invoked),
165 |         and return the replacement string should this placeholder and filter be
166 |         used.
167 |         """
168 | 
169 |         argspec, repl = self._cfg_argspec_repl(c)
170 | 
171 |         # TODO: use a lazy dictionary that will only evaluate the values if the
172 |         # placeholder is actually used.
173 |             
174 |         q = _LazySubstDict(self.context)
175 | 
176 |         if argspec and (n.nodeargd is None or n.nodeargd.argnlist is None):
177 |             logger.debug("Node arguments were not set, skipping replacement: %r", n)
178 |             raise fixes.DontFixThisNode
179 | 
180 |         if n.nodeargd and n.nodeargd.argnlist:
181 |             for k, nn in enumerate(n.nodeargd.argnlist[argoffset:]):
182 |                 if nn is None:
183 |                     arg_contents = ''
184 |                 else:
185 |                     arg_contents = node_contents_latex(nn)
186 | 
187 |                 q[str(1+k)] = arg_contents
188 | 
189 |                 if arg_filters:
190 |                     for filterkey, filterfn in arg_filters.items():
191 |                         q.register_fn(
192 |                             str(1+k)+'.'+filterkey,
193 |                             filterfn,
194 |                             dict(
195 |                                 argspec=argspec[k],
196 |                                 arg_index=k,
197 |                                 arg_number=1+k,
198 |                                 node=nn,
199 |                                 arg_contents=arg_contents,
200 |                             ),
201 |                             allow_args=True,
202 |                         )
203 |                             
204 | 
205 |         if n.isNodeType(LatexMacroNode):
206 |             q.update(macroname=n.macroname)
207 |         if n.isNodeType(LatexEnvironmentNode):
208 |             q.update(environmentname=n.environmentname,
209 |                      body=node_contents_latex(n.nodelist))
210 |         
211 |         q.update(context)
212 | 
213 |         try:
214 |             text = repl % q
215 |         except KeyError as e:
216 |             logger.error(
217 |                 ("Substitution failed (KeyError {}):\n"
218 |                  "    {} -> {}  (with keys {!r})\n"
219 |                  "node = {!r}").format(
220 |                 str(e),
221 |                 n.to_latex(),
222 |                 repl,
223 |                 q,
224 |                 n)
225 |             )
226 |             raise
227 |                 
228 | 
229 |         #logger.debug(" -- Performing substitution {} -> {}".format(n.to_latex(), text))
230 |         return text
231 | 
232 | 
233 | 
234 | class _LazySubstDict:
235 |     def __init__(self, d):
236 |         self.d = dict(d)
237 |         self.fns = []
238 | 
239 |     def update(self, *args, **kwargs):
240 |         self.d.update(*args, **kwargs)
241 | 
242 |     def register_fn(self, keyfn, filterfn, argsfn, allow_args=True):
243 |         self.fns.append( (keyfn, filterfn, argsfn, allow_args) )
244 | 
245 |     def __setitem__(self, key, value):
246 |         self.d[key] = value
247 | 
248 |     def __getitem__(self, key):
249 |         if key in self.d:
250 |             return self.d[key]
251 | 
252 |         for keyfn, filterfn, argsfn, allow_args in self.fns:
253 |             if key == keyfn:
254 |                 substarg = None
255 |             elif allow_args and key.startswith(keyfn+':'):
256 |                 substarg = key[len(keyfn+':'):]
257 |             else:
258 |                 continue
259 | 
260 |             kwargs = dict(argsfn)
261 |             if substarg is not None:
262 |                 kwargs['substarg'] = substarg
263 |             return filterfn(**kwargs)
264 |             
265 |         
266 | 


--------------------------------------------------------------------------------
/latexpp/__main__.py:
--------------------------------------------------------------------------------
  1 | import os.path
  2 | import sys
  3 | import argparse
  4 | import logging
  5 | 
  6 | import colorlog
  7 | 
  8 | logger = logging.getLogger('latexpp.__main__')
  9 | 
 10 | import yaml
 11 | 
 12 | from . import __version__ as version_str
 13 | 
 14 | from pylatexenc import latexwalker # catch latexwalker.LatexWalkerParseError
 15 | 
 16 | _LPPCONFIG_DOC_URL = 'https://latexpp.readthedocs.io/'
 17 | _LATEXPP_QUICKSTART_DOC_URL = 'https://git.io/JerVr' #'https://github.com/phfaist/latexpp/blob/master/README.rst'
 18 | _LATEXPP_FIXES_DOC_URL = 'https://latexpp.readthedocs.io/en/latest/fixes/'
 19 | 
 20 | 
 21 | from .preprocessor import LatexPreprocessor
 22 | 
 23 | 
 24 | 
 25 | def setup_logging(level):
 26 |     # You should use colorlog >= 6.0.0a4
 27 |     handler = colorlog.StreamHandler()
 28 |     dbg_modinfo_add = ''
 29 |     if level < logging.DEBUG:
 30 |         dbg_modinfo_add = '  [%(name)s]'
 31 |     handler.setFormatter( colorlog.LevelFormatter(
 32 |         log_colors={
 33 |             "DEBUG": "white",
 34 |             "INFO": "green",
 35 |             "WARNING": "yellow",
 36 |             "ERROR": "bold_red",
 37 |             "CRITICAL": "bold_red",
 38 |         },
 39 |         fmt={
 40 |             # emojis we can use: 🐞 🐜 🚨 🚦 ⚙️ 🧨 🧹 ❗️❓‼️ ⁉️ ⚠️ ℹ️ ➡️ ✔️ 〰️
 41 |             # 🎶 💭 📣 🔔 ⏳ 🔧 🔩 ✨ 💥 🔥 🐢 👉
 42 |             "DEBUG":    "%(log_color)s〰️    %(message)s"+dbg_modinfo_add, #'  [%(name)s]'
 43 |             "INFO":     "%(log_color)s✔️   %(message)s",
 44 |             "WARNING":  "%(log_color)s❗  %(message)s", # (%(module)s:%(lineno)d)",
 45 |             "ERROR":    "%(log_color)s🚨  %(message)s", # (%(module)s:%(lineno)d)",
 46 |             "CRITICAL": "%(log_color)s🧨  %(message)s", # (%(module)s:%(lineno)d)",
 47 |         },
 48 |         stream=sys.stderr
 49 |     ) )
 50 | 
 51 |     root = colorlog.getLogger()
 52 |     root.addHandler(handler)
 53 | 
 54 |     root.setLevel(level)
 55 | 
 56 | 
 57 | # def setup_logging(level):
 58 | #     handler = colorlog.StreamHandler()
 59 | #     handler.setFormatter(colorlog.TTYColoredFormatter(
 60 | #         stream=sys.stderr,
 61 | #         fmt='%(log_color)s%(levelname)-8s: %(message)s' #'  [%(name)s]'
 62 | #     ))
 63 | #     root = colorlog.getLogger()
 64 | #     root.addHandler(handler)
 65 | #     root.setLevel(level)
 66 | 
 67 | 
 68 | 
 69 | _lppconfig_template = r"""
 70 | # latexpp config for MyDocument.tex
 71 | #
 72 | # This is YAML syntax -- google "YAML tutorial" to get a quick intro.
 73 | # Be careful with spaces since indentation is important.
 74 | 
 75 | # the master LaTeX document -- this file will not be modified, all
 76 | # output will be produced in the output_dir
 77 | fname: 'MyDocument.tex'
 78 | 
 79 | # output file(s) will be created in this directory, originals will
 80 | # not be modified
 81 | output_dir: 'latexpp_output'
 82 | 
 83 | # main document file name in the output directory
 84 | output_fname: 'main.tex'
 85 | 
 86 | # specify list of fixes to apply, in the given order
 87 | fixes:
 88 | 
 89 |   # replace \input{...} directives by the contents of the included
 90 |   # file
 91 |   - 'latexpp.fixes.input.EvalInput'
 92 | 
 93 |   # remove all comments
 94 |   - 'latexpp.fixes.comments.RemoveComments'
 95 | 
 96 |   # copy any style files (.sty) that are used in the document and
 97 |   # that are present in the current directory to the output directory
 98 |   - 'latexpp.fixes.usepackage.CopyLocalPkgs'
 99 | 
100 |   # copy figure files to the output directory and rename them
101 |   # fig-01.xxx, fig-02.xxx, etc.
102 |   - 'latexpp.fixes.figures.CopyAndRenameFigs'
103 | 
104 |   # Replace \bibliography{...} by \input{xxx.bbl} and copy the bbl
105 |   # file to the output directory.  Make sure you run (pdf)latex on
106 |   # the main docuemnt before running latexpp
107 |   - 'latexpp.fixes.bib.CopyAndInputBbl'
108 | 
109 |   # Expand some macros. Latexpp doesn't parse \newcommand's, so you
110 |   # need to specify here the LaTeX code that the macro should be
111 |   # expanded to. If the macro has arguments, specify the nature of
112 |   # the arguments here in the 'argspec:' key (a '*' is an optional
113 |   # * character, a '[' one optional square-bracket-delimited
114 |   # argument, and a '{' is a mandatory argument). The argument values
115 |   # are available via the placeholders %(1)s, %(2)s, etc. Make sure
116 |   # to use single quotes for strings that contain \ backslashes.
117 |   - name: 'latexpp.fixes.macro_subst.Subst'
118 |     config:
119 |       macros:
120 |         # \tr         -->  \operatorname{tr}
121 |         tr: '\operatorname{tr}'
122 |         # \ket{\psi}  -->  \lvert{\psi}\rangle
123 |         ket:
124 |           argspec: '{'
125 |           repl: '\lvert{%(1)s}\rangle'
126 |         # \braket{\psi}{\phi}  -->  \langle{\psi}\vert{\phi}\rangle
127 |         braket:
128 |           argspec: '{{'
129 |           repl: '\langle{%(1)s}\vert{%(2)s}\rangle'
130 | """.lstrip() # strip leading '\n'
131 | 
132 | 
133 | class NewLppconfigTemplate(argparse.Action):
134 |     def __init__(self, **kwargs):
135 |         super().__init__(help='create a new template lppconfig.yml file and exit',
136 |                          nargs=0,
137 |                          **kwargs)
138 | 
139 |     def __call__(self, parser, namespace, values, option_string=None):
140 |         # create new YaML template and exit
141 |         cfgfile = 'lppconfig.yml'
142 |         if os.path.exists(cfgfile):
143 |             raise ValueError("The file {} already exists. I won't overwrite it."
144 |                              .format(cfgfile))
145 |         with open(cfgfile, 'w') as f:
146 |             f.write(_lppconfig_template)
147 |         # logger hasn't been set up yet.
148 |         sys.stderr.write(
149 |             ("Wrote template config file {}.  Please edit to your "
150 |              "liking and then run latexpp.\n").format(cfgfile)
151 |         )
152 |         sys.exit(0)
153 | 
154 | 
155 | def main(argv=None, omit_processed_by=False):
156 |     if argv is None:
157 |         argv = sys.argv[1:]
158 | 
159 |     parser = argparse.ArgumentParser(
160 |         prog='latexpp',
161 |         epilog=('See {} for a quick introduction on how to use latexpp '
162 |                 'and {} for a list of available fix classes.').format(
163 |             _LATEXPP_QUICKSTART_DOC_URL,
164 |             _LATEXPP_FIXES_DOC_URL
165 |         ),
166 |         add_help=False # custom help option
167 |         )
168 | 
169 |     # this is an optional argument, fname can be specified in lppconfig.yml
170 |     parser.add_argument('fname', metavar='file', nargs='?',
171 |                         help='input file name, master LaTeX document file')
172 | 
173 |     parser.add_argument('-p', '--profile', dest='lppconfig_profile',
174 |                         action='store', default='',
175 |                         help='look for config file lppconfig-.yml '
176 |                         'instead of lppconfig.yml')
177 | 
178 |     parser.add_argument('-c', '--lppconfig', dest='lppconfig',
179 |                         action='store', default='',
180 |                         help='lpp config file (YAML) to use instead of lppconfig.yml. '
181 |                         'Overrides -p option.')
182 | 
183 |     parser.add_argument('-o', '--output-dir',
184 |                         dest='output_dir',
185 |                         default=None,
186 |                         help='output directory (overrides setting from config file)')
187 | 
188 |     parser.add_argument('-f', '--output-fname', dest='output_fname',
189 |                         default=None,
190 |                         help='output file name in output directory (overrides '
191 |                         'setting from config file)')
192 | 
193 |     parser.add_argument('-v', '--verbose', dest='verbosity', default=logging.INFO,
194 |                         action='store_const', const=logging.DEBUG,
195 |                         help='verbose mode, see what\'s going on in more detail')
196 |     parser.add_argument('--superverbose', dest='verbosity',
197 |                         action='store_const', const=(logging.DEBUG - 1),
198 |                         help='*really* verbose, probably too much so for your needs')
199 |     parser.add_argument('--megaverbose', dest='verbosity',
200 |                         action='store_const', const=(logging.DEBUG - 2),
201 |                         help='most likely way way way too verbose for your needs')
202 | 
203 |     parser.add_argument('--new', action=NewLppconfigTemplate)
204 | 
205 |     parser.add_argument('--version', action='version',
206 |                         version='%(prog)s {}'.format(version_str))
207 |     parser.add_argument('--help', action='help',
208 |                         help='show this help message and exit')
209 | 
210 |     args = parser.parse_args(argv)
211 | 
212 | 
213 |     setup_logging(level=args.verbosity)
214 |     if args.verbosity >= logging.DEBUG:
215 |         # pylatexenc verbosity is just way tooo verbose
216 |         logging.getLogger('pylatexenc').setLevel(logging.INFO)
217 |     if args.verbosity >= logging.DEBUG-1:
218 |         # filter out those components of pylatexenc that are simply so
219 |         # excessively verbose
220 |         for mod in ['pylatexenc.latexnodes._nodescollector',
221 |                     'pylatexenc.latexnodes._tokenreader']:
222 |             logging.getLogger(mod).setLevel(logging.INFO)
223 | 
224 | 
225 |     if args.lppconfig:
226 |         lppconfigyml = args.lppconfig
227 |     elif args.lppconfig_profile:
228 |         lppconfigyml = 'lppconfig-{}.yml'.format(args.lppconfig_profile)
229 |     else:
230 |         lppconfigyml = 'lppconfig.yml'
231 | 
232 |     config_dir = os.path.dirname(os.path.abspath(lppconfigyml))
233 | 
234 |     try:
235 |         with open(lppconfigyml) as f:
236 |             lppconfig = yaml.load(f, Loader=yaml.FullLoader)
237 |     except FileNotFoundError:
238 |         logger.error("Cannot find configuration file ‘%s’.  "
239 |                      "See %s for instructions to create a lppconfig file.",
240 |                      lppconfigyml, _LPPCONFIG_DOC_URL)
241 |         sys.exit(1)
242 | 
243 |     output_dir = lppconfig.get('output_dir', '_latexpp_output')
244 |     if args.output_dir:
245 |         output_dir = args.output_dir
246 | 
247 |     fname = lppconfig.get('fname', None)
248 |     if args.fname:
249 |         fname = args.fname
250 | 
251 |     output_fname = lppconfig.get('output_fname', 'main.tex')
252 |     if args.output_fname:
253 |         output_fname = args.output_fname
254 | 
255 |     pp = LatexPreprocessor(
256 |         output_dir=output_dir,
257 |         main_doc_fname=fname,
258 |         main_doc_output_fname=output_fname,
259 |         config_dir=config_dir
260 |     )
261 | 
262 |     # for tests
263 |     if omit_processed_by:
264 |         pp.omit_processed_by = omit_processed_by
265 | 
266 |     pp.install_fixes_from_config(lppconfig['fixes'])
267 | 
268 |     try:
269 | 
270 |         pp.initialize()
271 | 
272 |         pp.execute_main()
273 | 
274 |         pp.finalize()
275 | 
276 |     except latexwalker.LatexWalkerParseError as e:
277 |         logger.error("Parse error! %s", e)
278 |         #sys.exit(1)
279 |         raise # will cause error code exit
280 | 
281 | 
282 | 
283 | def run_main():
284 | 
285 |     try:
286 | 
287 |         main()
288 | 
289 |     except Exception as e:
290 |         import traceback
291 |         traceback.print_exc()
292 |         import pdb
293 |         pdb.post_mortem()
294 |         sys.exit(255)
295 | 
296 | 
297 | if __name__ == "__main__":
298 | 
299 |     run_main() # easier to debug
300 | 
301 |     # main()
302 | 


--------------------------------------------------------------------------------
/latexpp/pragma_fix.py:
--------------------------------------------------------------------------------
  1 | import re
  2 | import shlex
  3 | import logging
  4 | 
  5 | logger = logging.getLogger(__name__)
  6 | 
  7 | 
  8 | from pylatexenc import latexwalker
  9 | 
 10 | from .fix import BaseFix
 11 | 
 12 | 
 13 | # Regex to detect LPP pragma instructions.
 14 | #
 15 | # - Only match a single percent sign because the attribute `node.comment` does
 16 | #   not contain the initial '%' comment char
 17 | #
 18 | # - The pragma must start with the exact string '%%!lpp'. We capture similar
 19 | #   strings here so that we can give more informative error messages (as opposed
 20 | #   to the instruction being silently ignored)
 21 | #
 22 | # - A scope is opened if the pragma instruction ends with '{', separated from
 23 | #   the rest with whitespace.  Arguments are parsed after detecting a possible
 24 | #   scope open.
 25 | #
 26 | rx_lpp_pragma = re.compile(
 27 |     r'^%!(?P\s*[lL][pP][pP])(?P\s*)'
 28 | )
 29 | rx_lpp_scope_open = re.compile(
 30 |     r'\s+\{\s*$'
 31 | )
 32 | rx_lpp_instruction_rest = re.compile(
 33 |     r'^(?P(?:\}|[a-zA-Z0-9_-]+))\s*(?P.*?)\s*$'
 34 | )
 35 | 
 36 | 
 37 | class PragmaFix(BaseFix):
 38 |     r"""
 39 |     A special kind of fix that processes latexpp pragma instructions.
 40 | 
 41 |     A `PragmaFix` differs from other :py:class:`~latexpp.fix.BaseFix`-based
 42 |     classes in how they process LaTeX nodes.  A `PragmaFix` subclass
 43 |     reimplements :py:func:`fix_pragma_scope()` and/or
 44 |     :py:func:`fix_pragma_simple()`, which are called upon encountering
 45 |     ``%%!lpp  [] [{ ... %%!lpp }]`` constructs.  The fix
 46 |     may then choose to process these pragma instructions, and their
 47 |     surrounding node lists, as it wishes.
 48 | 
 49 |     Pragmas are fixes, and they reimplement :py:class:`~latexpp.fix.BaseFix`.
 50 |     Some built-in pragmas are always loaded and processed (e.g. the
 51 |     :py:class:`~latexpp.pragma_fix.SkipPragma` pragma).  Others can be loaded
 52 |     into your list of fixes like normal fixes (e.g.,
 53 |     :py:class:`latexpp.fixes.regional_fix.Apply`).
 54 |     """
 55 | 
 56 |     def __init__(self):
 57 |         super().__init__()
 58 |     
 59 |     def fix_nodelist(self, nodelist):
 60 |         r"""
 61 |         Reimplemented from :py:class:`latexpp.fix.BaseFix`.  Subclasses should
 62 |         generally not reimplement this.
 63 |         """
 64 |         newnodelist = list(nodelist)
 65 | 
 66 |         for n in newnodelist:
 67 |             self.preprocess_child_nodes(n)
 68 | 
 69 |         self._do_pragmas(newnodelist)
 70 | 
 71 |         return newnodelist
 72 |     
 73 |     # override these to implement interesting functionality
 74 | 
 75 |     def fix_pragma_scope(self, nodelist, jstart, jend, instruction, args):
 76 |         r"""
 77 |         Called when a scoped pragma is encountered.  A scoped pragma is one with an
 78 |         opening brace at the end of the ``%%!lpp`` instruction, and is matched
 79 |         by a corresponding closing pragma instruction ``%%!lpp }``.
 80 | 
 81 |         This function may modify `nodelist` in place (including
 82 |         inserting/deleting elements). This function must return an index in
 83 |         `nodelist` where to continue processing of further pragmas after the
 84 |         current scope pragma, or an integer larger or equal to `nodelist`'s
 85 |         length to indicate the end of the list was reached.
 86 | 
 87 |         Child nodes are already and automatically parsed for pragmas, so do NOT
 88 |         do this again when you reimplement `fix_pragma_scope()`.
 89 | 
 90 |         Scope pragmas are parsed and reported inner first, then the outer
 91 |         scopes.  Nested scopes are allowed.  A pragma scope must be opened and
 92 |         closed within the same LaTeX scope (you cannot open a scope and close it
 93 |         in a different LaTeX environment, for instance).
 94 | 
 95 |         For instance, the :py:class:`~latexpp.skip.SkipPragma` fix removes the
 96 |         entire scope pragma and its contents with ``nodelist[jstart:jend] = []``
 97 |         and then returns `jstart` to continue processing after the removed block
 98 |         (which has new index `jstart`).  It is OK for this function to return an
 99 |         index that is larger than or equal to `len(nodelist)`; this is
100 |         interpreted as there is no further content to process in `nodelist`.
101 | 
102 |         Arguments:
103 | 
104 |           - `nodelist` is the full nodelist that is currently being processed.
105 | 
106 |           - `jstart` and `jend` are the indices in `nodelist` that point to the
107 |             opening lpp pragma comment node and *one past* the closing lpp
108 |             pragma comment node. This is like a Python range; for instance, you
109 |             can remove the entire pragma block with
110 |             ``nodelist[jstart:jend] = []``.
111 | 
112 |           - `instruction` is the pragma instruction name (the word after
113 |             ``%%!lpp``).
114 | 
115 |           - `args` is a list of any remaining arguments after the instruction
116 |             (excluding the opening brace).
117 | 
118 |         The default implementation does not do anything and returns `jend` to
119 |         continue after the current pragma scope.
120 |         """
121 |         return jend
122 | 
123 |     def fix_pragma_simple(self, nodelist, j, instruction, args):
124 |         r"""
125 |         Called when a simple pragma is encountered.
126 | 
127 |         This function may modify `nodelist[j]` directly.  It can also modify the
128 |         `nodelist` in place, including inserting/deleting elements if required.
129 | 
130 |         This function must return an index in `nodelist` where to continue
131 |         processing of further pragmas after the current pragma.  (It is OK for
132 |         this function to return an index that is larger than or equal to
133 |         `len(nodelist)`; this is interpreted as there is no further content to
134 |         process in `nodelist`.)
135 | 
136 |         Arguments:
137 | 
138 |           - `nodelist` is the full nodelist that is currently being processed.
139 | 
140 |           - `j` is the index in `nodelist` that points to the encountered lpp
141 |             pragma comment node that this function might want to handle.
142 | 
143 |           - `instruction` is the pragma instruction name (the word after
144 |             ``%%!lpp``).
145 | 
146 |           - `args` is a list of any remaining arguments after the instruction.
147 | 
148 |         Simple pragmas are parsed & reported in linear order for each LaTeX
149 |         scope (inner LaTeX scopes first).
150 | 
151 |         The default implementation does not do anything and returns `j+1` to
152 |         continue processing after the current pragma.
153 |         """
154 |         return j+1
155 | 
156 | 
157 |     # internal
158 | 
159 |     def _do_pragmas(self, nodelist, jstart=0, stop_at_close_scope=False):
160 | 
161 |         j = jstart
162 | 
163 |         # we can modify nodelist in place.
164 |         while j < len(nodelist):
165 |             n = nodelist[j]
166 |             md = self._parse_pragma(n)
167 |             if md is None:
168 |                 j += 1
169 |                 continue
170 | 
171 |             instruction, args, is_scope = md
172 | 
173 |             if instruction == '}':
174 |                 if stop_at_close_scope:
175 |                     return j
176 |                 raise ValueError("Invalid closing pragma ‘%%!lpp }}’ encountered "
177 |                                  "at line {}, col {}"
178 |                                  .format(*n.parsing_state.lpp_latex_walker
179 |                                          .pos_to_lineno_colno(n.pos)))
180 |             if is_scope:
181 |                 # this is a scope pragma
182 |                 j = self._do_scope_pragma(nodelist, j, instruction, args)
183 |                 continue
184 |             else:
185 |                 # this is a single simple pragma
186 |                 j = self._do_simple_pragma(nodelist, j, instruction, args)
187 |                 continue
188 | 
189 |             raise RuntimeError("Should never reach here") # lgtm[py/unreachable-statement]
190 | 
191 |         if stop_at_close_scope:
192 |             raise ValueError(
193 |                 "Cannot find closing ‘%%!lpp }’ to match ‘%%!lpp {}’ on line {}, col {}"
194 |                 .format(instruction,
195 |                         *nodelist[jstart].parsing_state.lpp_latex_walker
196 |                         .pos_to_lineno_colno(nodelist[jstart].pos))
197 |             )
198 | 
199 |         return
200 | 
201 | 
202 |     def _parse_pragma(self, node):
203 |         if not node:
204 |             return None
205 |         if not node.isNodeType(latexwalker.LatexCommentNode):
206 |             return None
207 |         m = rx_lpp_pragma.match(node.comment)
208 |         if not m:
209 |             return None
210 |         if m.group('lppcheck') != 'lpp':
211 |             raise ValueError("LPP Pragmas should start with the exact string ‘%%!lpp’: "
212 |                              "‘{}’ @ line {}, col {}".format(
213 |                                  n.to_latex(),
214 |                                  *n.parsing_state.lpp_latex_walker
215 |                                  .pos_to_lineno_colno(n.pos)
216 |                              ))
217 |         if len(m.group('needspace')) == 0:
218 |             raise ValueError("Expected space after ‘%%!lpp’: "
219 |                              "‘{}’ @ line {}, col {}".format(
220 |                                  n.to_latex(),
221 |                                  *n.parsing_state.lpp_latex_walker
222 |                                  .pos_to_lineno_colno(n.pos + 1 + m.group('needspace').start())
223 |                              ))
224 | 
225 |         pragma_str = node.comment[m.end():]
226 | 
227 |         is_scope = False
228 |         mscope = rx_lpp_scope_open.search(pragma_str)
229 |         if mscope is not None:
230 |             is_scope = True
231 |             pragma_str = pragma_str[:mscope.start()]
232 | 
233 |         m = rx_lpp_instruction_rest.match(pragma_str)
234 |         if m is None:
235 |             raise ValueError("Expected ‘instruction [arguments]’ after ‘%%!lpp’: "
236 |                              "‘{}’ @ line {}, col {}".format(
237 |                                  n.to_latex(),
238 |                                  *n.parsing_state.lpp_latex_walker
239 |                                  .pos_to_lineno_colno(n.pos + 1 + m.end())
240 |                              ))
241 | 
242 |         instruction = m.group('instruction')
243 |         rest = m.group('rest').strip()
244 | 
245 |         if instruction == '}' and (rest or is_scope):
246 |             raise ValueError("Encountered stuff after closing scope %%!lpp }: ‘%s’"
247 |                              .format(rest if rest else '{'))
248 | 
249 |         args = []
250 |         if rest:
251 |             shlexer = shlex.shlex(rest,
252 |                                   infile='Arguments to %%!lpp {}'.format(instruction),
253 |                                   posix=True)
254 |             shlexer.whitespace_split = True
255 |             args = list(shlexer)
256 | 
257 |         logger.debug("Parsed %%%%!lpp pragma instruction: instruction=%r args=%r is_scope=%r",
258 |                      instruction, args, is_scope)
259 | 
260 |         return instruction, args, is_scope
261 |         
262 |     def _do_scope_pragma(self, nodelist, j, instruction, args):
263 |         
264 |         # scan for closing pragma, parsing other pragmas on the way.
265 |         jclose = self._do_pragmas(nodelist, j+1, stop_at_close_scope=True)
266 | 
267 |         # then fix this scope pragma
268 |         jnew = self.fix_pragma_scope(nodelist, j, jclose+1, instruction, args)
269 | 
270 |         if jnew is None:
271 |             raise RuntimeError(
272 |                 "Fix dealing with scope pragma ‘%%!lpp {}’ did not report new j position"
273 |                 .format( instruction )
274 |             )
275 | 
276 |         return jnew
277 | 
278 |     def _do_simple_pragma(self, nodelist, j, instruction, args):
279 | 
280 |         jnew = self.fix_pragma_simple(nodelist, j, instruction, args)
281 | 
282 |         if jnew is None:
283 |             raise RuntimeError(
284 |                 "Fix dealing with simple pragma ‘%%!lpp {}’ did not report new j position"
285 |                 .format( instruction )
286 |             )
287 | 
288 |         return jnew
289 | 
290 | 


--------------------------------------------------------------------------------