| 1 | # -*- coding: utf-8 -*- |
|---|
| 2 | # |
|---|
| 3 | # Copyright (C) 2010 Polar Technologies - www.polartech.es |
|---|
| 4 | # Copyright (C) 2010 Alvaro J Iradier <alvaro.iradier@polartech.es> |
|---|
| 5 | # Copyright (C) 2012-2017 Ryan J Ollos <ryan.j.ollos@gmail.com> |
|---|
| 6 | # All rights reserved. |
|---|
| 7 | # |
|---|
| 8 | # This software is licensed as described in the file COPYING, which |
|---|
| 9 | # you should have received as part of this distribution. |
|---|
| 10 | |
|---|
| 11 | import hashlib |
|---|
| 12 | import os |
|---|
| 13 | import subprocess |
|---|
| 14 | import re |
|---|
| 15 | |
|---|
| 16 | from trac.config import Option |
|---|
| 17 | from trac.core import implements |
|---|
| 18 | from trac.util.html import Markup, html as tag |
|---|
| 19 | from trac.util.translation import _ |
|---|
| 20 | from trac.versioncontrol.api import NoSuchNode, RepositoryManager |
|---|
| 21 | from trac.web import IRequestHandler |
|---|
| 22 | from trac.wiki.formatter import system_message |
|---|
| 23 | from trac.wiki.macros import WikiMacroBase |
|---|
| 24 | |
|---|
| 25 | img_dir = 'cache/plantuml' |
|---|
| 26 | |
|---|
| 27 | |
|---|
| 28 | class PlantUmlMacro(WikiMacroBase): |
|---|
| 29 | """ |
|---|
| 30 | A wiki processor that renders PlantUML diagrams in wiki text. |
|---|
| 31 | |
|---|
| 32 | Example: |
|---|
| 33 | {{{ |
|---|
| 34 | {{{ |
|---|
| 35 | #!PlantUML |
|---|
| 36 | @startuml |
|---|
| 37 | Alice -> Bob: Authentication Reque |
|---|
| 38 | st |
|---|
| 39 | Bob --> Alice: Authentication Response |
|---|
| 40 | Alice -> Bob: Another authentication Request |
|---|
| 41 | Alice <-- Bob: another authentication Response |
|---|
| 42 | @enduml |
|---|
| 43 | }}} |
|---|
| 44 | }}} |
|---|
| 45 | |
|---|
| 46 | Results in: |
|---|
| 47 | {{{ |
|---|
| 48 | #!PlantUML |
|---|
| 49 | @startuml |
|---|
| 50 | Alice -> Bob: Authentication Request |
|---|
| 51 | Bob --> Alice: Authentication Response |
|---|
| 52 | Alice -> Bob: Another authentication Request |
|---|
| 53 | Alice <-- Bob: another authentication Response |
|---|
| 54 | @enduml |
|---|
| 55 | }}} |
|---|
| 56 | """ |
|---|
| 57 | |
|---|
| 58 | implements(IRequestHandler) |
|---|
| 59 | |
|---|
| 60 | plantuml_jar = Option('plantuml', 'plantuml_jar', '', |
|---|
| 61 | """Path to PlantUML jar file. The jar file can be downloaded from the |
|---|
| 62 | [http://plantuml.sourceforge.net/download.html PlantUML] site.""") |
|---|
| 63 | |
|---|
| 64 | java_bin = Option('plantuml', 'java_bin', 'java', |
|---|
| 65 | """Path to the Java binary file. The default is `java`, which and |
|---|
| 66 | assumes that the Java binary is on the search path.""") |
|---|
| 67 | |
|---|
| 68 | def __init__(self): |
|---|
| 69 | self.abs_img_dir = os.path.join(os.path.abspath(self.env.path), |
|---|
| 70 | img_dir) |
|---|
| 71 | if not os.path.isdir(self.abs_img_dir): |
|---|
| 72 | os.makedirs(self.abs_img_dir) |
|---|
| 73 | |
|---|
| 74 | # WikiMacroBase methods |
|---|
| 75 | |
|---|
| 76 | def expand_macro(self, formatter, name, content, args=None): |
|---|
| 77 | if not self.plantuml_jar: |
|---|
| 78 | return system_message(_("Installation error: plantuml_jar " |
|---|
| 79 | "option not defined in trac.ini")) |
|---|
| 80 | if not os.path.exists(self.plantuml_jar): |
|---|
| 81 | return system_message(_("Installation error: plantuml.jar not " |
|---|
| 82 | "found at '%(path)s'", |
|---|
| 83 | path=self.plantuml_jar)) |
|---|
| 84 | filename = os.path.basename(self.plantuml_jar) |
|---|
| 85 | if not os.path.splitext(filename)[1] == '.jar': |
|---|
| 86 | return system_message(_("'%(path)s' is not the path of a JAR " |
|---|
| 87 | "file.", path=self.plantuml_jar)) |
|---|
| 88 | |
|---|
| 89 | # Trac 0.12 supports expand_macro(self, formatter, name, content, |
|---|
| 90 | # args) which allows us to readily differentiate between a WikiProcess |
|---|
| 91 | # and WikiMacro call. To support Trac 0.11, some additional work is |
|---|
| 92 | # required. |
|---|
| 93 | try: |
|---|
| 94 | args = formatter.code_processor.args |
|---|
| 95 | except AttributeError: |
|---|
| 96 | args = None |
|---|
| 97 | args = args or {} |
|---|
| 98 | |
|---|
| 99 | path = None |
|---|
| 100 | if 'path' not in args: # Could be WikiProcessor or WikiMacro call |
|---|
| 101 | if content.strip().startswith("@startuml"): |
|---|
| 102 | path = None |
|---|
| 103 | else: |
|---|
| 104 | path = content |
|---|
| 105 | if not path: |
|---|
| 106 | return system_message(_("Path not specified")) |
|---|
| 107 | elif args: # WikiProcessor with args |
|---|
| 108 | path = args.get('path') |
|---|
| 109 | if not path: |
|---|
| 110 | return system_message(_("Path not specified")) |
|---|
| 111 | |
|---|
| 112 | if path: |
|---|
| 113 | try: |
|---|
| 114 | markup = self._read_source_from_repos(formatter, path) |
|---|
| 115 | except NoSuchNode, e: |
|---|
| 116 | return system_message(e) |
|---|
| 117 | else: |
|---|
| 118 | if not content: |
|---|
| 119 | return system_message(_("No UML text defined")) |
|---|
| 120 | markup = content.encode('utf-8').strip() |
|---|
| 121 | |
|---|
| 122 | img_id = hashlib.sha1(markup).hexdigest() |
|---|
| 123 | if not self._is_img_existing(img_id): |
|---|
| 124 | self._write_markup_to_file(img_id, markup) |
|---|
| 125 | cmd = '%s -jar -Djava.awt.headless=true "%s" ' \ |
|---|
| 126 | '-charset UTF-8 "%s"' % (self.java_bin, self.plantuml_jar, |
|---|
| 127 | self._get_markup_path(img_id)) |
|---|
| 128 | p = subprocess.Popen(cmd, shell=True) |
|---|
| 129 | stderr = p.wait() |
|---|
| 130 | if p.returncode != 0: |
|---|
| 131 | return system_message(_("Error running plantuml: '%(error)s'", |
|---|
| 132 | error=stderr)) |
|---|
| 133 | img_link = formatter.href('plantuml', id=img_id) |
|---|
| 134 | cmap = self._is_cmapx_existing(img_id) and \ |
|---|
| 135 | self._read_cmapx_from_file(img_id) or '' |
|---|
| 136 | return Markup(cmap) + tag.img(src=img_link, usemap='#%s_map' % img_id) |
|---|
| 137 | |
|---|
| 138 | def get_macros(self): |
|---|
| 139 | yield 'PlantUml' # WikiMacros syntax |
|---|
| 140 | yield 'plantuml' # WikiProcessor syntax |
|---|
| 141 | yield 'PlantUML' # deprecated, retained for backward compatibility |
|---|
| 142 | |
|---|
| 143 | # IRequestHandler methods |
|---|
| 144 | |
|---|
| 145 | def match_request(self, req): |
|---|
| 146 | return re.match(r'/plantuml?$', req.path_info) |
|---|
| 147 | |
|---|
| 148 | def process_request(self, req): |
|---|
| 149 | img_id = req.args.get('id') |
|---|
| 150 | img_data = self._read_img_from_file(img_id) |
|---|
| 151 | req.send(img_data, 'image/png', status=200) |
|---|
| 152 | |
|---|
| 153 | # Internal methods |
|---|
| 154 | |
|---|
| 155 | def _get_markup_path(self, img_id): |
|---|
| 156 | img_path = os.path.join(self.abs_img_dir, img_id) |
|---|
| 157 | img_path += '.txt' |
|---|
| 158 | return img_path |
|---|
| 159 | |
|---|
| 160 | def _get_img_path(self, img_id): |
|---|
| 161 | img_path = os.path.join(self.abs_img_dir, img_id) |
|---|
| 162 | img_path += '.png' |
|---|
| 163 | return img_path |
|---|
| 164 | |
|---|
| 165 | def _get_cmapx_path(self, img_id): |
|---|
| 166 | img_path = os.path.join(self.abs_img_dir, img_id) |
|---|
| 167 | img_path += '.cmapx' |
|---|
| 168 | return img_path |
|---|
| 169 | |
|---|
| 170 | def _is_img_existing(self, img_id): |
|---|
| 171 | img_path = self._get_img_path(img_id) |
|---|
| 172 | return os.path.isfile(img_path) |
|---|
| 173 | |
|---|
| 174 | def _is_cmapx_existing(self, img_id): |
|---|
| 175 | img_path = self._get_cmapx_path(img_id) |
|---|
| 176 | return os.path.isfile(img_path) |
|---|
| 177 | |
|---|
| 178 | def _write_markup_to_file(self, img_id, markup): |
|---|
| 179 | img_path = self._get_markup_path(img_id) |
|---|
| 180 | open(img_path, 'wb').write(markup) |
|---|
| 181 | |
|---|
| 182 | def _read_cmapx_from_file(self, img_id): |
|---|
| 183 | img_path = self._get_cmapx_path(img_id) |
|---|
| 184 | img_data = open(img_path, 'r').read() |
|---|
| 185 | return img_data |
|---|
| 186 | |
|---|
| 187 | def _read_img_from_file(self, img_id): |
|---|
| 188 | img_path = self._get_img_path(img_id) |
|---|
| 189 | img_data = open(img_path, 'rb').read() |
|---|
| 190 | return img_data |
|---|
| 191 | |
|---|
| 192 | def _read_source_from_repos(self, formatter, src_path): |
|---|
| 193 | repos_mgr = RepositoryManager(self.env) |
|---|
| 194 | try: # 0.12+ |
|---|
| 195 | repos_name, repos, repos_rel_path = \ |
|---|
| 196 | repos_mgr.get_repository_by_path(src_path) |
|---|
| 197 | except AttributeError: # 0.11 |
|---|
| 198 | repos = repos_mgr.get_repository(formatter.req.authname) |
|---|
| 199 | path, rev = _split_path(repos_rel_path) |
|---|
| 200 | if repos and repos.has_node(path, rev): |
|---|
| 201 | node = repos.get_node(path, rev) |
|---|
| 202 | content = node.get_content().read() |
|---|
| 203 | else: |
|---|
| 204 | if rev is None and repos: |
|---|
| 205 | rev = repos.get_youngest_rev() |
|---|
| 206 | raise NoSuchNode(path, rev) |
|---|
| 207 | |
|---|
| 208 | return content |
|---|
| 209 | |
|---|
| 210 | |
|---|
| 211 | def _split_path(fqpath): |
|---|
| 212 | if '@' in fqpath: |
|---|
| 213 | path, rev = fqpath.split('@', 1) |
|---|
| 214 | else: |
|---|
| 215 | path, rev = fqpath, None |
|---|
| 216 | return path, rev |
|---|