├── .gitattributes ├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE.md ├── MANIFEST.in ├── README.md ├── docs ├── .gitignore ├── docs │ ├── index.md │ └── options.md ├── mkdocs.yml └── theme-handler │ └── cinder.py ├── mkdocs_pdf_export_plugin ├── __init__.py ├── plugin.py ├── preprocessor │ ├── __init__.py │ ├── links │ │ ├── __init__.py │ │ ├── transform.py │ │ └── util.py │ └── prep.py ├── renderer.py └── themes │ ├── __init__.py │ ├── cinder.py │ ├── generic.py │ └── material.py ├── requirements.txt └── setup.py /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /venv/ 3 | __pycache__/ 4 | *.egg-info/ 5 | /build/ 6 | /dist/ 7 | 8 | !.gitkeep 9 | 10 | .DS_Store -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | matrix: 3 | include: 4 | - python: 3.5 5 | - python: 3.6 6 | - python: 3.7 7 | sudo: required 8 | dist: xenial 9 | 10 | script: true 11 | 12 | branches: 13 | only: 14 | - master 15 | - /^v\d+\.\d+(\.\d+)?(-\S*)?$/ # version tags 16 | 17 | notifications: 18 | email: 19 | on_success: never 20 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guidelines 2 | 3 | Thank you for considering to contribute to this project. These guidelines will help you get going with development and outline the most important rules to follow when submitting pull requests for this project. 4 | 5 | ## Submitting Changes 6 | 7 | To get changes merged, create a pull request. Here are a few things to pay attention to when doing so: 8 | 9 | #### Commit Messages 10 | 11 | The summary of a commit should be concise and worded in an imperative mood. 12 | ...a *what* mood? This should clear things up: *[How to Write a Git Commit Message][git-commit-message]* 13 | 14 | #### Code Style 15 | 16 | Make sure your code follows [PEP-8](https://www.python.org/dev/peps/pep-0008/) and keeps things consistent with the rest of the code. 17 | 18 | [git-commit-message]: https://chris.beams.io/posts/git-commit/ 19 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2018 Stephan Hauser 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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE.md 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MkDocs PDF Export Plugin [![Build Status][travis-status]][travis-link] 2 | 3 | *An MkDocs plugin to export content pages as PDF files* 4 | 5 | The pdf-export plugin will export all markdown pages in your MkDocs repository as PDF files using [WeasyPrint](http://weasyprint.org/). The exported documents support many advanced features missing in most other PDF exports, such as a PDF Index and support for [CSS paged media module](https://drafts.csswg.org/css-page-3/). 6 | 7 | ## Requirements 8 | 9 | 1. This package requires MkDocs version 1.0 or higher (0.17 works as well) 10 | 2. Python ~3.4~ 3.5 or higher 11 | 3. WeasyPrint depends on cairo, Pango and GDK-PixBuf which need to be installed separately. Please follow the installation instructions for your platform carefully: 12 | - [Linux][weasyprint-linux] 13 | - [MacOS][weasyprint-macos] 14 | - [Windows][weasyprint-windows] 15 | 4. Explicit support for your mkdocs theme is probably required. As of now, the only supported theme is [mkdocs-material][mkdocs-material]. A generic version will just generate the PDF files and put the download link into a `` tag. 16 | 17 | If you want to add a new theme, see [adding support for new themes](#adding-support-for-new-themes) for more information. 18 | 19 | ## Installation 20 | 21 | Install the package with pip: 22 | 23 | ```bash 24 | pip install mkdocs-pdf-export-plugin 25 | ``` 26 | 27 | Enable the plugin in your `mkdocs.yml`: 28 | 29 | ```yaml 30 | plugins: 31 | - search 32 | - pdf-export 33 | ``` 34 | 35 | > **Note:** If you have no `plugins` entry in your config file yet, you'll likely also want to add the `search` plugin. MkDocs enables it by default if there is no `plugins` entry set, but now you have to enable it explicitly. 36 | 37 | More information about plugins in the [MkDocs documentation][mkdocs-plugins]. 38 | 39 | ## Testing 40 | 41 | When building your repository with `mkdocs build`, you should now see the following message at the end of your build output: 42 | 43 | > Converting 17 files to PDF took 15.6s 44 | 45 | In your `site_dir` you should now have a PDF file for every markdown page. 46 | 47 | ## Options 48 | 49 | You may customize the plugin by passing options in `mkdocs.yml`: 50 | 51 | ```yaml 52 | plugins: 53 | - pdf-export: 54 | verbose: true 55 | media_type: print 56 | enabled_if_env: ENABLE_PDF_EXPORT 57 | ``` 58 | 59 | ### `verbose` 60 | 61 | Setting this to `true` will show all WeasyPrint debug messages during the build. Default is `false`. 62 | 63 | ### `media_type` 64 | 65 | This option allows you to use a different CSS media type (or a custom one like `pdf-export`) for the PDF export. Default is `print`. 66 | 67 | ### `enabled_if_env` 68 | 69 | Setting this option will enable the build only if there is an environment variable set to 1. This is useful to disable building the PDF files during development, since it can take a long time to export all files. Default is not set. 70 | 71 | ### `combined` 72 | 73 | Setting this to `true` will combine all pages into a single PDF file. All download links will point to this file. Default is `false`. 74 | 75 | ### `combined_output_path` 76 | 77 | This option allows you to use a different destination for the combined PDF file. Has no effect when `combined` is set to `false`. Default is `pdf/combined.pdf`. 78 | 79 | ### `theme_handler_path` 80 | 81 | This option allows you to specify a custom theme handler module. This path must be **relative to your project root** (See example below). Default is not set. 82 | 83 | `mkdocs.yml`: 84 | ```yaml 85 | plugins: 86 | - pdf-export: 87 | theme_handler_path: theme-handler.py 88 | ``` 89 | ```bash 90 | project-root 91 | ├── theme-handler.py 92 | ├── docs 93 | ├── mkdocs.yml 94 | ├── site 95 | . 96 | . 97 | ``` 98 | 99 | ## Adjusting the output 100 | 101 | The resulting PDF can be customized easily by adding a custom stylesheet such as the following: 102 | 103 | ``` 104 | @page { 105 | size: a4 portrait; 106 | margin: 25mm 10mm 25mm 10mm; 107 | counter-increment: page; 108 | font-family: "Roboto","Helvetica Neue",Helvetica,Arial,sans-serif; 109 | white-space: pre; 110 | color: grey; 111 | @top-left { 112 | content: '© 2018 My Company'; 113 | } 114 | @top-center { 115 | content: string(chapter); 116 | } 117 | @top-right { 118 | content: 'Page ' counter(page); 119 | } 120 | } 121 | ``` 122 | For this to take effect, use the `extra_css` directive in mkdocs.yml, as described in the [MkDocs user guide][extra-css]. 123 | 124 | ## Adding support for new themes 125 | 126 | If you use a mkdocs theme which is currently not supported, check out the `themes/material.py` file and adjust it according to your requirements. You will have to implement two methods to support a theme: 127 | 128 | 1. `get_stylesheet` should return a CSS which gets applied to fix issues with weasyprint 129 | 2. `modify_html` should add a link to the PDF download before writing it to disk 130 | 131 | If there is no explicit support for your theme, the generic version will just add a `` tag in the head pointing to the generated PDF. 132 | 133 | ## Contributing 134 | 135 | From reporting a bug to submitting a pull request: every contribution is appreciated and welcome. Report bugs, ask questions and request features using [Github issues][github-issues]. 136 | If you want to contribute to the code of this project, please read the [Contribution Guidelines][contributing]. 137 | 138 | ## Special thanks 139 | 140 | Special thanks go to [Stephan Hauser][shauser] for the original development of this plugin. 141 | 142 | Special thanks go to [Lukas Geiter][lukasgeiter] for developing the [mkdocs-awesome-pages-plugin][awesome-pages-plugin] which was used as a base and for convincing [Stephan Hauser][shauser] to write a plugin for this. 143 | 144 | [travis-status]: https://travis-ci.org/zhaoterryy/mkdocs-pdf-export-plugin.svg?branch=master 145 | [travis-link]: https://travis-ci.org/zhaoterryy/mkdocs-pdf-export-plugin 146 | [weasyprint-linux]: https://weasyprint.readthedocs.io/en/latest/install.html#linux 147 | [weasyprint-macos]: https://weasyprint.readthedocs.io/en/latest/install.html#os-x 148 | [weasyprint-windows]: https://weasyprint.readthedocs.io/en/latest/install.html#windows 149 | [mkdocs-plugins]: http://www.mkdocs.org/user-guide/plugins/ 150 | [mkdocs-material]: https://github.com/squidfunk/mkdocs-material 151 | [github-issues]: https://github.com/zhaoterryy/mkdocs-pdf-export-plugin/issues 152 | [contributing]: CONTRIBUTING.md 153 | [lukasgeiter]: https://github.com/lukasgeiter 154 | [shauser]: https://github.com/shauser 155 | [awesome-pages-plugin]: https://github.com/lukasgeiter/mkdocs-awesome-pages-plugin 156 | [extra-css]: https://www.mkdocs.org/user-guide/configuration/#extra_css 157 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | site/ -------------------------------------------------------------------------------- /docs/docs/index.md: -------------------------------------------------------------------------------- 1 | # MkDocs PDF Export Plugin [![Build Status](https://travis-ci.org/zhaoterryy/mkdocs-pdf-export-plugin.svg?branch=master)](https://travis-ci.org/zhaoterryy/mkdocs-pdf-export-plugin) 2 | 3 | The pdf-export plugin will export all markdown pages in your MkDocs repository as PDF files using [WeasyPrint](http://weasyprint.org/). The exported documents support many advanced features missing in most other PDF exports, such as a PDF Index and support for [CSS paged media module](https://developer.mozilla.org/en-US/docs/Web/CSS/@page). 4 | 5 | - MkDocs >= 1.0 6 | - Python >= 3.4 7 | - WeasyPrint >= 44 8 | 9 | ## Installation 10 | 11 | Install the package with pip: 12 | 13 | ```bash 14 | pip install mkdocs-pdf-export-plugin 15 | ``` 16 | 17 | Enable the plugin in your `mkdocs.yml`: 18 | 19 | ```yaml 20 | plugins: 21 | - search 22 | - pdf-export 23 | ``` 24 | 25 | > **Note:** If you have no `plugins` entry in your config file yet, you'll likely also want to add the `search` plugin. MkDocs enables it by default if there is no `plugins` entry set, but now you have to enable it explicitly. 26 | 27 | More information about plugins in the [MkDocs documentation](http://www.mkdocs.org/user-guide/plugins/). 28 | 29 | ## Contributing 30 | 31 | From reporting a bug to submitting a pull request: every contribution is appreciated and welcome. Report bugs, ask questions and request features using [Github issues][github-issues]. 32 | 33 | If you want to contribute to the code of this project, please read the [Contribution Guidelines][contributing]. 34 | 35 | #### **Special thanks** 36 | 37 | Special thanks go to [Stephan Hauser][shauser] for the original development of this plugin. 38 | 39 | Special thanks go to [Lukas Geiter][lukasgeiter] for developing the [mkdocs-awesome-pages-plugin][awesome-pages-plugin] which was used as a base and for convincing [Stephan Hauser][shauser] to write a plugin for this. 40 | 41 | [github-issues]: https://github.com/zhaoterryy/mkdocs-pdf-export-plugin/issues 42 | [contributing]: https://github.com/zhaoterryy/mkdocs-pdf-export-plugin/blob/master/CONTRIBUTING.md 43 | [lukasgeiter]: https://github.com/lukasgeiter 44 | [shauser]: https://github.com/shauser 45 | [awesome-pages-plugin]: https://github.com/lukasgeiter/mkdocs-awesome-pages-plugin -------------------------------------------------------------------------------- /docs/docs/options.md: -------------------------------------------------------------------------------- 1 | # Options 2 | 3 | Pass in options through `mkdocs.yml`: 4 | 5 | ```yaml 6 | plugins: 7 | - pdf-export: 8 | verbose: true 9 | media_type: print 10 | enabled_if_env: ENABLE_PDF_EXPORT 11 | ``` 12 | 13 | ### `verbose` 14 | 15 | *default: false* 16 | 17 | Setting this to `true` will show all WeasyPrint debug messages during the build. 18 | 19 | ### `media_type` 20 | 21 | *default: print* 22 | 23 | This option allows you to use a different CSS media type (or a custom one like `pdf-export`) for the PDF export. 24 | 25 | ### `enabled_if_env` 26 | 27 | *default: not set* 28 | 29 | Setting this option will enable the build only if there is an environment variable set to 1. This is useful to disable building the PDF files during development, since it can take a long time to export all files. 30 | 31 | ### `combined` 32 | 33 | *default: false* 34 | 35 | Setting this to `true` will combine all pages into a single PDF file. All download links will point to this file. 36 | 37 | ### `combined_output_path` 38 | 39 | *default: pdf/combined.pdf* 40 | 41 | This option allows you to use a different destination for the combined PDF file. Has no effect when `combined` is set to `false`. 42 | 43 | ### `theme_handler_path` 44 | 45 | *default: not set* 46 | 47 | This option allows you to specify a custom theme handler module. This path must be ***relative to your project root*** (See example below). 48 | 49 | `mkdocs.yml`: 50 | ```yaml 51 | plugins: 52 | - pdf-export: 53 | theme_handler_path: theme-handler.py 54 | 55 | ``` 56 | 57 | ```bash 58 | 59 | project-root 60 | 61 | ├── theme-handler.py 62 | 63 | ├── docs 64 | 65 | ├── mkdocs.yml 66 | 67 | ├── site 68 | 69 | . 70 | 71 | . 72 | 73 | ``` -------------------------------------------------------------------------------- /docs/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: mkdocs-pdf-export-plugin 2 | theme: 3 | name: cinder 4 | use_directory_urls: false 5 | plugins: 6 | - search 7 | - pdf-export: 8 | combined: true 9 | theme_handler_path: theme-handler/cinder.py 10 | 11 | nav: 12 | - Intro: index.md 13 | - Options: options.md 14 | 15 | repo_url: https://github.com/zhaoterryy/mkdocs-pdf-export-plugin 16 | edit_uri: edit/master/docs/ 17 | remote_name: git@github.com:zhaoterryy/mkdocs-pdf-export-plugin.git -------------------------------------------------------------------------------- /docs/theme-handler/cinder.py: -------------------------------------------------------------------------------- 1 | from bs4 import BeautifulSoup 2 | 3 | def get_stylesheet() -> str: 4 | return """ 5 | body > .container { 6 | margin-top: -100px; 7 | } 8 | 9 | @page { 10 | @bottom-left { 11 | content: counter(__pgnum__); 12 | } 13 | size: letter; 14 | } 15 | 16 | @media print { 17 | .noprint { 18 | display: none; 19 | } 20 | } 21 | """ 22 | 23 | def modify_html(html: str, href: str) -> str: 24 | soup = BeautifulSoup(html, 'html.parser') 25 | sm_wrapper = soup.new_tag('small') 26 | 27 | a = soup.new_tag('a', href=href, title='PDF Export', download=None) 28 | a['class'] = 'pdf-download' 29 | a.string = 'Download PDF' 30 | 31 | sm_wrapper.append(a) 32 | soup.body.footer.insert(0, sm_wrapper) 33 | 34 | return str(soup) -------------------------------------------------------------------------------- /mkdocs_pdf_export_plugin/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhaoterryy/mkdocs-pdf-export-plugin/5d3d96cabe40f94b837060b7757a3790dfdde854/mkdocs_pdf_export_plugin/__init__.py -------------------------------------------------------------------------------- /mkdocs_pdf_export_plugin/plugin.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from timeit import default_timer as timer 4 | 5 | from mkdocs.config import config_options 6 | from mkdocs.plugins import BasePlugin 7 | 8 | class PdfExportPlugin(BasePlugin): 9 | 10 | DEFAULT_MEDIA_TYPE = 'print' 11 | 12 | config_scheme = ( 13 | ('media_type', config_options.Type(str, default=DEFAULT_MEDIA_TYPE)), 14 | ('verbose', config_options.Type(bool, default=False)), 15 | ('enabled_if_env', config_options.Type(str)), 16 | ('combined', config_options.Type(bool, default=False)), 17 | ('combined_output_path', config_options.Type(str, default="pdf/combined.pdf")), 18 | ('theme_handler_path', config_options.Type(str)) 19 | ) 20 | 21 | def __init__(self): 22 | self.renderer = None 23 | self.enabled = True 24 | self.combined = False 25 | self.num_files = 0 26 | self.num_errors = 0 27 | self.total_time = 0 28 | 29 | def on_config(self, config): 30 | if 'enabled_if_env' in self.config: 31 | env_name = self.config['enabled_if_env'] 32 | if env_name: 33 | self.enabled = os.environ.get(env_name) == '1' 34 | if not self.enabled: 35 | print('PDF export is disabled (set environment variable {} to 1 to enable)'.format(env_name)) 36 | return 37 | 38 | self.combined = self.config['combined'] 39 | if self.combined: 40 | print('Combined PDF export is enabled') 41 | 42 | from weasyprint.logger import LOGGER 43 | import logging 44 | 45 | if self.config['verbose']: 46 | LOGGER.setLevel(logging.DEBUG) 47 | else: 48 | LOGGER.setLevel(logging.ERROR) 49 | 50 | handler = logging.StreamHandler() 51 | handler.setFormatter(logging.Formatter('%(levelname)s: %(message)s')) 52 | LOGGER.addHandler(handler) 53 | return config 54 | 55 | def on_nav(self, nav, config, files): 56 | if not self.enabled: 57 | return nav 58 | 59 | from .renderer import Renderer 60 | self.renderer = Renderer(self.combined, config['theme'].name, self.config['theme_handler_path']) 61 | 62 | self.renderer.pages = [None] * len(nav.pages) 63 | for page in nav.pages: 64 | self.renderer.page_order.append(page.file.url) 65 | 66 | return nav 67 | 68 | def on_post_page(self, output_content, page, config): 69 | if not self.enabled: 70 | return output_content 71 | 72 | start = timer() 73 | 74 | self.num_files += 1 75 | 76 | try: 77 | abs_dest_path = page.file.abs_dest_path 78 | src_path = page.file.src_path 79 | except AttributeError: 80 | # Support for mkdocs <1.0 81 | abs_dest_path = page.abs_output_path 82 | src_path = page.input_path 83 | 84 | path = os.path.dirname(abs_dest_path) 85 | os.makedirs(path, exist_ok=True) 86 | 87 | filename = os.path.splitext(os.path.basename(src_path))[0] 88 | 89 | from weasyprint import urls 90 | base_url = urls.path2url(os.path.join(path, filename)) 91 | pdf_file = filename + '.pdf' 92 | 93 | try: 94 | if self.combined: 95 | self.renderer.add_doc(output_content, base_url, page.file.url) 96 | pdf_path = self.get_path_to_pdf_from(page.file.dest_path) 97 | output_content = self.renderer.add_link(output_content, pdf_path) 98 | else: 99 | self.renderer.write_pdf(output_content, base_url, os.path.join(path, pdf_file)) 100 | output_content = self.renderer.add_link(output_content, pdf_file) 101 | except Exception as e: 102 | print('Error converting {} to PDF: {}'.format(src_path, e), file=sys.stderr) 103 | self.num_errors += 1 104 | 105 | end = timer() 106 | self.total_time += (end - start) 107 | 108 | return output_content 109 | 110 | def on_post_build(self, config): 111 | if not self.enabled: 112 | return 113 | 114 | if self.combined: 115 | start = timer() 116 | 117 | abs_pdf_path = os.path.join(config['site_dir'], self.config['combined_output_path']) 118 | os.makedirs(os.path.dirname(abs_pdf_path), exist_ok=True) 119 | self.renderer.write_combined_pdf(abs_pdf_path) 120 | 121 | end = timer() 122 | self.total_time += (end - start) 123 | 124 | print('Converting {} files to PDF took {:.1f}s'.format(self.num_files, self.total_time)) 125 | if self.num_errors > 0: 126 | print('{} conversion errors occurred (see above)'.format(self.num_errors)) 127 | 128 | def get_path_to_pdf_from(self, start): 129 | pdf_split = os.path.split(self.config['combined_output_path']) 130 | start_dir = os.path.split(start)[0] 131 | pdf_dir = pdf_split[0] if pdf_split[0] else '.' 132 | return os.path.join(os.path.relpath(pdf_dir, start_dir), pdf_split[1]) 133 | -------------------------------------------------------------------------------- /mkdocs_pdf_export_plugin/preprocessor/__init__.py: -------------------------------------------------------------------------------- 1 | from .prep import get_combined, get_separate -------------------------------------------------------------------------------- /mkdocs_pdf_export_plugin/preprocessor/links/__init__.py: -------------------------------------------------------------------------------- 1 | from .transform import transform_href, transform_id 2 | from .util import get_body_id, replace_asset_hrefs, rel_pdf_href -------------------------------------------------------------------------------- /mkdocs_pdf_export_plugin/preprocessor/links/transform.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from .util import is_doc, normalize_href 4 | 5 | # normalize href to #foo/bar/section:id 6 | def transform_href(href: str, rel_url: str): 7 | head, tail = os.path.split(href) 8 | 9 | num_hashtags = tail.count('#') 10 | 11 | if tail.startswith('#'): 12 | head, section = os.path.split(rel_url) 13 | section = os.path.splitext(section)[0] 14 | id = tail[1:] 15 | elif num_hashtags is 1: 16 | section, ext = tuple(os.path.splitext(tail)) 17 | id = str.split(ext, '#')[1] 18 | 19 | if head == '..': 20 | href = normalize_href(href, rel_url) 21 | return '#{}:{}'.format(href, id) 22 | 23 | elif num_hashtags is 0: 24 | if not is_doc(href): 25 | return href 26 | 27 | href = normalize_href(href, rel_url) 28 | return '#{}:'.format(href) 29 | 30 | if head != '': 31 | head += '/' 32 | 33 | return '#{}{}:{}'.format(head, section, id) 34 | 35 | # normalize id to foo/bar/section:id 36 | def transform_id(id: str, rel_url: str): 37 | head, tail = os.path.split(rel_url) 38 | section, _ = os.path.splitext(tail) 39 | 40 | if len(head) > 0: 41 | head += '/' 42 | 43 | return '{}{}:{}'.format(head, section, id) -------------------------------------------------------------------------------- /mkdocs_pdf_export_plugin/preprocessor/links/util.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from weasyprint import urls 4 | from bs4 import BeautifulSoup 5 | 6 | # check if href is relative -- 7 | # if it is relative it *should* be an html that generates a PDF doc 8 | def is_doc(href: str): 9 | tail = os.path.basename(href) 10 | _, ext = os.path.splitext(tail) 11 | 12 | absurl = urls.url_is_absolute(href) 13 | abspath = os.path.isabs(href) 14 | htmlfile = ext.startswith('.html') 15 | if absurl or abspath or not htmlfile: 16 | return False 17 | 18 | return True 19 | 20 | def rel_pdf_href(href: str): 21 | head, tail = os.path.split(href) 22 | filename, _ = os.path.splitext(tail) 23 | 24 | internal = href.startswith('#') 25 | if not is_doc(href) or internal: 26 | return href 27 | 28 | return urls.iri_to_uri(os.path.join(head, filename + '.pdf')) 29 | 30 | def abs_asset_href(href: str, base_url: str): 31 | if urls.url_is_absolute(href) or os.path.isabs(href): 32 | return href 33 | 34 | return urls.iri_to_uri(urls.urljoin(base_url, href)) 35 | 36 | # makes all relative asset links absolute 37 | def replace_asset_hrefs(soup: BeautifulSoup, base_url: str): 38 | for link in soup.find_all('link', href=True): 39 | link['href'] = abs_asset_href(link['href'], base_url) 40 | 41 | for asset in soup.find_all(src=True): 42 | asset['src'] = abs_asset_href(asset['src'], base_url) 43 | 44 | return soup 45 | 46 | # normalize href to site root 47 | def normalize_href(href: str, rel_url: str): 48 | # foo/bar/baz/../../index.html -> foo/index.html 49 | def reduce_rel(x): 50 | try: 51 | i = x.index('..') 52 | if i is 0: 53 | return x 54 | 55 | del x[i] 56 | del x[i - 1] 57 | return reduce_rel(x) 58 | except ValueError: 59 | return x 60 | 61 | rel_dir = os.path.dirname(rel_url) 62 | href = str.split(os.path.join(rel_dir, href), '/') 63 | href = reduce_rel(href) 64 | href[-1], _ = os.path.splitext(href[-1]) 65 | 66 | return os.path.join(*href) 67 | 68 | def get_body_id(url: str): 69 | section, _ = os.path.splitext(url) 70 | return '{}:'.format(section) -------------------------------------------------------------------------------- /mkdocs_pdf_export_plugin/preprocessor/prep.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from .links import transform_href, transform_id, get_body_id, replace_asset_hrefs, rel_pdf_href 4 | 5 | from weasyprint import urls 6 | from bs4 import BeautifulSoup 7 | 8 | def get_combined(soup: BeautifulSoup, base_url: str, rel_url: str): 9 | for id in soup.find_all(id=True): 10 | id['id'] = transform_id(id['id'], rel_url) 11 | 12 | for a in soup.find_all('a', href=True): 13 | if urls.url_is_absolute(a['href']) or os.path.isabs(a['href']): 14 | continue 15 | 16 | a['href'] = transform_href(a['href'], rel_url) 17 | 18 | soup.body['id'] = get_body_id(rel_url) 19 | soup = replace_asset_hrefs(soup, base_url) 20 | return soup 21 | 22 | def get_separate(soup: BeautifulSoup, base_url: str): 23 | # transforms all relative hrefs pointing to other html docs 24 | # into relative pdf hrefs 25 | for a in soup.find_all('a', href=True): 26 | a['href'] = rel_pdf_href(a['href']) 27 | 28 | soup = replace_asset_hrefs(soup, base_url) 29 | return soup -------------------------------------------------------------------------------- /mkdocs_pdf_export_plugin/renderer.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | 4 | from importlib import import_module 5 | from importlib.util import spec_from_file_location, module_from_spec 6 | from weasyprint import HTML 7 | from bs4 import BeautifulSoup 8 | 9 | from .themes import generic as generic_theme 10 | from .preprocessor import get_separate as prep_separate, get_combined as prep_combined 11 | 12 | class Renderer(object): 13 | def __init__(self, combined: bool, theme: str, theme_handler_path: str=None): 14 | self.theme = self._load_theme_handler(theme, theme_handler_path) 15 | self.combined = combined 16 | self.page_order = [] 17 | self.pgnum = 0 18 | self.pages = [] 19 | 20 | def write_pdf(self, content: str, base_url: str, filename: str): 21 | self.render_doc(content, base_url).write_pdf(filename) 22 | 23 | def render_doc(self, content: str, base_url: str, rel_url: str = None): 24 | soup = BeautifulSoup(content, 'html.parser') 25 | 26 | self.inject_pgnum(soup) 27 | 28 | stylesheet = self.theme.get_stylesheet() 29 | if stylesheet: 30 | style_tag = soup.new_tag('style') 31 | style_tag.string = stylesheet 32 | 33 | soup.head.append(style_tag) 34 | 35 | 36 | if self.combined: 37 | soup = prep_combined(soup, base_url, rel_url) 38 | else: 39 | soup = prep_separate(soup, base_url) 40 | 41 | html = HTML(string=str(soup)) 42 | return html.render() 43 | 44 | def add_doc(self, content: str, base_url: str, rel_url: str): 45 | pos = self.page_order.index(rel_url) 46 | self.pages[pos] = (content, base_url, rel_url) 47 | 48 | def write_combined_pdf(self, output_path: str): 49 | rendered_pages = [] 50 | for p in self.pages: 51 | if p is None: 52 | print('Unexpected error - not all pages were rendered properly') 53 | continue 54 | 55 | render = self.render_doc(p[0], p[1], p[2]) 56 | self.pgnum += len(render.pages) 57 | rendered_pages.append(render) 58 | 59 | flatten = lambda l: [item for sublist in l for item in sublist] 60 | all_pages = flatten([p.pages for p in rendered_pages if p != None]) 61 | 62 | rendered_pages[0].copy(all_pages).write_pdf(output_path) 63 | 64 | def add_link(self, content: str, filename: str): 65 | return self.theme.modify_html(content, filename) 66 | 67 | def inject_pgnum(self, soup): 68 | pgnum_counter = soup.new_tag('style') 69 | pgnum_counter.string = ''' 70 | @page :first {{ 71 | counter-reset: __pgnum__ {}; 72 | }} 73 | @page {{ 74 | counter-increment: __pgnum__; 75 | }} 76 | '''.format(self.pgnum) 77 | 78 | soup.head.append(pgnum_counter) 79 | 80 | @staticmethod 81 | def _load_theme_handler(theme: str, custom_handler_path: str = None): 82 | module_name = '.' + (theme or 'generic').replace('-', '_') 83 | 84 | if custom_handler_path: 85 | try: 86 | spec = spec_from_file_location(module_name, os.path.join(os.getcwd(), custom_handler_path)) 87 | mod = module_from_spec(spec) 88 | spec.loader.exec_module(mod) 89 | return mod 90 | except FileNotFoundError as e: 91 | print('Could not load theme handler {} from custom directory "{}": {}'.format(theme, custom_handler_path, e), file=sys.stderr) 92 | pass 93 | 94 | try: 95 | return import_module(module_name, 'mkdocs_pdf_export_plugin.themes') 96 | except ImportError as e: 97 | print('Could not load theme handler {}: {}'.format(theme, e), file=sys.stderr) 98 | return generic_theme -------------------------------------------------------------------------------- /mkdocs_pdf_export_plugin/themes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhaoterryy/mkdocs-pdf-export-plugin/5d3d96cabe40f94b837060b7757a3790dfdde854/mkdocs_pdf_export_plugin/themes/__init__.py -------------------------------------------------------------------------------- /mkdocs_pdf_export_plugin/themes/cinder.py: -------------------------------------------------------------------------------- 1 | from bs4 import BeautifulSoup 2 | 3 | def get_stylesheet() -> str: 4 | return None 5 | 6 | 7 | def modify_html(html: str, href: str) -> str: 8 | soup = BeautifulSoup(html, 'html.parser') 9 | 10 | sm_wrapper = soup.new_tag('small') 11 | 12 | a = soup.new_tag('a', href=href, title='PDF Export', download=None) 13 | a['class'] = 'pdf-download' 14 | a.string = 'Download PDF' 15 | 16 | sm_wrapper.append(a) 17 | soup.body.footer.insert(0, sm_wrapper) 18 | 19 | return str(soup) 20 | -------------------------------------------------------------------------------- /mkdocs_pdf_export_plugin/themes/generic.py: -------------------------------------------------------------------------------- 1 | from bs4 import BeautifulSoup 2 | 3 | 4 | def get_stylesheet() -> str: 5 | return None 6 | 7 | 8 | def modify_html(html: str, href: str) -> str: 9 | soup = BeautifulSoup(html, 'html.parser') 10 | 11 | if soup.head: 12 | link = soup.new_tag('link', href=href, rel='alternate', title='PDF Export', type='application/pdf') 13 | soup.head.append(link) 14 | 15 | return str(soup) 16 | -------------------------------------------------------------------------------- /mkdocs_pdf_export_plugin/themes/material.py: -------------------------------------------------------------------------------- 1 | from bs4 import BeautifulSoup 2 | 3 | 4 | def get_stylesheet() -> str: 5 | return """ 6 | h1, h2, h3 { 7 | string-set: chapter content(); 8 | } 9 | 10 | .md-container { 11 | display: block; 12 | padding-top: 0; 13 | } 14 | 15 | .md-main { 16 | display: block; 17 | height: inherit; 18 | } 19 | 20 | .md-main__inner { 21 | height: inherit; 22 | padding-top: 0; 23 | } 24 | 25 | .md-typeset .codehilitetable .linenos { 26 | display: none; 27 | } 28 | 29 | .md-typeset .footnote-ref { 30 | display: inline-block; 31 | } 32 | 33 | .md-typeset a.footnote-backref { 34 | transform: translateX(0); 35 | opacity: 1; 36 | } 37 | 38 | .md-typeset .admonition { 39 | display: block; 40 | border-top: .1rem solid rgba(0,0,0,.07); 41 | border-right: .1rem solid rgba(0,0,0,.07); 42 | border-bottom: .1rem solid rgba(0,0,0,.07); 43 | page-break-inside: avoid; 44 | } 45 | 46 | .md-typeset a::after { 47 | color: inherit; 48 | content: none; 49 | } 50 | 51 | .md-typeset table:not([class]) th { 52 | min-width: 0; 53 | } 54 | 55 | .md-typeset table { 56 | border: .1rem solid rgba(0,0,0,.07); 57 | } 58 | """ 59 | 60 | 61 | def modify_html(html: str, href: str) -> str: 62 | 63 | # SVG 'file-download' size 2x from fontawesome: https://fontawesome.com/icons/file-download?style=solid 64 | # resized to 1.2rem width * height 65 | a_tag = "" % href 66 | icon = '' 67 | button_tag = a_tag + icon + "" 68 | 69 | # insert into HTML 70 | insert_point = "
" 71 | html = html.replace(insert_point, insert_point + button_tag) 72 | 73 | return html 74 | 75 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | beautifulsoup4==4.6.3 2 | cairocffi==0.9.0 3 | CairoSVG==2.1.3 4 | cffi==1.11.5 5 | click==6.7 6 | cssselect2==0.2.1 7 | defusedxml==0.5.0 8 | html5lib==1.0.1 9 | Jinja2==2.11.2 10 | livereload==2.5.2 11 | Markdown==2.6.11 12 | MarkupSafe==1.1.1 13 | mkdocs==1.0.3 14 | pdfrw==0.4 15 | Pillow==6.2.2 16 | pycparser==2.18 17 | Pyphen==0.9.5 18 | PyYAML>=4.2b1 19 | six==1.11.0 20 | tinycss2==0.6.1 21 | tornado==5.1 22 | WeasyPrint==44 23 | webencodings==0.5.1 24 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | 4 | setup( 5 | name='mkdocs-pdf-export-plugin', 6 | version='0.5.10', 7 | description='An MkDocs plugin to export content pages as PDF files', 8 | long_description='The pdf-export plugin will export all markdown pages in your MkDocs repository as PDF files' 9 | 'using WeasyPrint. The exported documents support many advanced features missing in most other' 10 | 'PDF exports, such as a PDF Index and support for CSS paged media module.', 11 | keywords='mkdocs pdf export weasyprint', 12 | url='https://github.com/zhaoterryy/mkdocs-pdf-export-plugin/', 13 | author='Terry Zhao', 14 | author_email='zhao.terryy@gmail.com', 15 | license='MIT', 16 | python_requires='>=3.5', 17 | install_requires=[ 18 | 'mkdocs>=0.17', 19 | 'weasyprint>=0.44', 20 | 'beautifulsoup4>=4.6.3' 21 | ], 22 | classifiers=[ 23 | 'Development Status :: 4 - Beta', 24 | 'Intended Audience :: Developers', 25 | 'Intended Audience :: Information Technology', 26 | 'License :: OSI Approved :: MIT License', 27 | 'Programming Language :: Python', 28 | 'Programming Language :: Python :: 3 :: Only', 29 | 'Programming Language :: Python :: 3.4', 30 | 'Programming Language :: Python :: 3.5', 31 | 'Programming Language :: Python :: 3.6', 32 | 'Programming Language :: Python :: 3.7' 33 | ], 34 | packages=find_packages(), 35 | entry_points={ 36 | 'mkdocs.plugins': [ 37 | 'pdf-export = mkdocs_pdf_export_plugin.plugin:PdfExportPlugin' 38 | ] 39 | } 40 | ) 41 | --------------------------------------------------------------------------------