1# -*- coding: utf-8 -*-2# Copyright 2011 Takeshi KOMIYA3#4# Licensed under the Apache License, Version 2.0 (the "License");5# you may not use this file except in compliance with the License.6# You may obtain a copy of the License at7#8# http://www.apache.org/licenses/LICENSE-2.09#10# Unless required by applicable law or agreed to in writing, software11# distributed under the License is distributed on an "AS IS" BASIS,12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.13# See the License for the specific language governing permissions and14# limitations under the License.1516import io17import os18from collections import namedtuple19from functools import wraps20from hashlib import sha12122from docutils import nodes23from docutils.parsers import rst24from docutils.parsers.rst.roles import set_classes25from docutils.statemachine import ViewList2627from blockdiag.utils.bootstrap import Application, create_fontmap28from blockdiag.utils.rst.nodes import blockdiag as blockdiag_node2930directive_options_default = dict(format='PNG',31 antialias=False,32 fontpath=None,33 outputdir=None,34 nodoctype=False,35 noviewbox=False,36 inline_svg=False)37directive_options = {}383940def relfn2path(env, filename):41 if filename.startswith('/') or filename.startswith(os.sep):42 relfn = filename[1:]43 else:44 path = env.doc2path(env.docname, base=None)45 relfn = os.path.join(os.path.dirname(path), filename)4647 return relfn, os.path.join(env.srcdir, relfn)484950def with_blockdiag(fn):5152 def decorator(*args):53 with Application():54 return fn(*args)5556 return decorator575859def align(argument):60 align_values = ('left', 'center', 'right')61 return rst.directives.choice(argument, align_values)626364def figwidth_value(argument):65 if argument.lower() == 'image':66 return 'image'67 else:68 return rst.directives.length_or_percentage_or_unitless(argument, 'px')697071class BlockdiagDirectiveBase(rst.Directive):72 """ Directive to insert arbitrary dot markup. """73 name = "blockdiag"74 node_class = blockdiag_node7576 has_content = True77 required_arguments = 078 optional_arguments = 179 final_argument_whitespace = False80 option_spec = {81 'alt': rst.directives.unchanged,82 'height': rst.directives.length_or_unitless,83 'width': rst.directives.length_or_percentage_or_unitless,84 'scale': rst.directives.percentage,85 'align': align,86 'caption': rst.directives.unchanged,87 'desctable': rst.directives.flag,88 'maxwidth': rst.directives.nonnegative_int, # deprecated89 'name': rst.directives.unchanged,90 'class': rst.directives.class_option,91 'figwidth': figwidth_value,92 'figclass': rst.directives.class_option,93 }9495 def run(self):96 if self.arguments:97 document = self.state.document98 if self.content:99 msg = ('%s directive cannot have both content and '100 'a filename argument' % self.name)101 return [document.reporter.warning(msg, line=self.lineno)]102103 try:104 filename = self.source_filename(self.arguments[0])105 fp = io.open(filename, 'r', encoding='utf-8-sig')106 try:107 dotcode = fp.read()108 finally:109 fp.close()110 except (IOError, OSError):111 msg = 'External %s file %r not found or reading it failed' % \112 (self.name, filename)113 return [document.reporter.warning(msg, line=self.lineno)]114 else:115 dotcode = '\n'.join(self.content).strip()116 if not dotcode:117 msg = 'Ignoring "%s" directive without content.' % self.name118 return [self.state_machine.reporter.warning(msg,119 line=self.lineno)]120121 set_classes(self.options)122 node = self.node_class()123 results = [node]124125 node['code'] = dotcode126 node['caption'] = self.options.pop('caption', None)127 node['options'] = self.options128129 # for sphinxcontrib.* module (backward compatibility)130 node['alt'] = self.options.get('alt')131132 # replace maxwidth option to width (backward compatibility)133 if 'maxwidth' in node['options']:134 node['options']['width'] = str(node['options']['maxwidth'])135136 msg = ':maxwidth: option is deprecated. Use :width: option.'137 warning = self.state_machine.reporter.warning(msg,138 line=self.lineno)139 results.append(warning)140141 return results142143 def source_filename(self, filename):144 if hasattr(self.state.document.settings, 'env'):145 env = self.state.document.settings.env146 rel_filename, filename = relfn2path(env, self.arguments[0])147 env.note_dependency(rel_filename)148149 return filename150151152class BlockdiagDirective(BlockdiagDirectiveBase):153 processor = None # backward compatibility for 1.4.0154155156 def run(self):157 figwidth = self.options.pop('figwidth', None)158 figclasses = self.options.pop('figclass', None)159 if self.options.get('caption'):160 align = self.options.pop('align', None)161 else:162 align = None163164 results = super(BlockdiagDirective, self).run()165166 node = results[0]167 if not isinstance(node, self.node_class):168 return results169170 try:171 diagram = self.node2diagram(node)172 except Exception as e:173 raise self.warning(str(e))174175 if 'desctable' in node['options']:176 results += self.description_tables(diagram)177178 results[0] = self.node2image(node, diagram)179 self.add_name(results[0])180181 if node.get('caption'):182 elem = nodes.Element()183 self.state.nested_parse(ViewList([node['caption']], source=''),184 self.content_offset, elem)185 caption_node = nodes.caption(elem[0].rawsource, '',186 *elem[0].children)187 caption_node.source = elem[0].source188 caption_node.line = elem[0].line189190 fig = nodes.figure()191 fig += results[0]192 fig += caption_node193194 if figwidth == 'image':195 width = self.get_actual_width(node, diagram)196 fig['width'] = str(width) + 'px'197 elif figwidth is not None:198 fig['width'] = figwidth199 if figclasses:200 fig['classes'] += figclasses201 if align:202 fig['align'] = align203204 results[0] = fig205206 return results207208209 def global_options(self):210 return directive_options211212 def node2diagram(self, node):213 if hasattr(node, 'to_diagram'):214 return node.to_diagram()215 else:216 try:217 tree = self.processor.parser.parse_string(node['code'])218 except Exception:219 code = '%s { %s }' % (self.name, node['code'])220 tree = self.processor.parser.parse_string(code)221 node['code'] = code # replace if succeeded222223 return self.processor.builder.ScreenNodeBuilder.build(tree)224225 def get_actual_width(self, node, diagram):226 fontmap = self.create_fontmap()227 if hasattr(node, 'to_drawer'):228 drawer = node.to_drawer('SVG', None, fontmap,229 **self.global_options)230 else:231 drawer = self.processor.drawer.DiagramDraw('SVG', diagram,232 None, fontmap=fontmap)233234 return drawer.pagesize()[0]235236 def node2image(self, node, diagram):237 _format = self.global_options['format'].lower()238 if _format == 'svg' and self.global_options['inline_svg'] is True:239 return self.node2image_inline_svg(node, diagram)240241 filename = self.image_filename(node)242 fontmap = self.create_fontmap()243 if hasattr(node, 'to_drawer'):244 drawer = node.to_drawer(_format, filename, fontmap,245 **self.global_options)246 else:247 drawer = self.processor.drawer.DiagramDraw(_format, diagram,248 filename,249 fontmap=fontmap,250 **self.global_options)251252 if not os.path.isfile(filename):253 drawer.draw()254 drawer.save()255256 return nodes.image(uri=filename, **node['options'])257258 def node2image_inline_svg(self, node, diagram):259 fontmap = self.create_fontmap()260 if hasattr(node, 'to_drawer'):261 drawer = node.to_drawer('SVG', None, fontmap,262 **self.global_options)263 else:264 drawer = self.processor.drawer.DiagramDraw('svg', diagram,265 None, fontmap=fontmap,266 **self.global_options)267 drawer.draw()268269 size = drawer.pagesize().resize(**node['options']).to_integer_point()270 content = drawer.save(size)271272 return nodes.raw('', content, format='html')273274 def create_fontmap(self):275 Options = namedtuple('Options', 'font fontmap')276 fontpath = self.global_options['fontpath']277 if isinstance(fontpath, (list, tuple)):278 options = Options(fontpath, None)279 elif isinstance(fontpath, str):280 options = Options([fontpath], None)281 else:282 options = Options([], None)283284 return create_fontmap(options)285286 def image_filename(self, node, prefix='', ext='png'):287 if hasattr(node, 'get_path'):288 return node.get_path(**self.global_options)289 else:290 options = dict(node['options'])291 options.update(font=self.global_options['fontpath'],292 antialias=self.global_options['antialias'])293 hashseed = (node['code'] + str(options)).encode('utf-8')294 hashed = sha1(hashseed).hexdigest()295296 _format = self.global_options['format'].lower()297 outputdir = self.global_options['outputdir']298 filename = "%s%s-%s.%s" % (self.name, prefix, hashed, _format)299 if outputdir:300 filename = os.path.join(outputdir, filename)301302 return filename303304 def description_tables(self, diagram):305 tables = []306 desctable = self.node_description_table(diagram)307 if desctable:308 tables.append(desctable)309310 desctable = self.edge_description_table(diagram)311 if desctable:312 tables.append(desctable)313314 return tables315316 def node_description_table(self, diagram):317 nodes = diagram.traverse_nodes()318 klass = diagram._DiagramNode319320 widths = [25] + [50] * (len(klass.desctable) - 1)321 headers = [klass.attrname[n] for n in klass.desctable]322323 def node_number(node):324 try:325 return int(node[0])326 except (TypeError, ValueError):327 return 65535328329 descriptions = [n.to_desctable() for n in nodes if n.drawable]330 descriptions.sort(key=node_number)331332 for i in reversed(range(len(headers))):333 if any(desc[i] for desc in descriptions):334 pass335 else:336 widths.pop(i)337 headers.pop(i)338 for desc in descriptions:339 desc.pop(i)340341 if len(headers) == 1:342 return None343 else:344 return self._description_table(descriptions, widths, headers)345346 def edge_description_table(self, diagram):347 edges = diagram.traverse_edges()348349 widths = [25, 50]350 headers = ['Name', 'Description']351 descriptions = [e.to_desctable() for e in edges if e.style != 'none']352353 if any(desc[1] for desc in descriptions):354 return self._description_table(descriptions, widths, headers)355 else:356 return None357358 def _description_table(self, descriptions, widths, headers):359 # generate table-root360 tgroup = nodes.tgroup(cols=len(widths))361 for width in widths:362 tgroup += nodes.colspec(colwidth=width)363 table = nodes.table()364 table += tgroup365366 # generate table-header367 thead = nodes.thead()368 row = nodes.row()369 for header in headers:370 entry = nodes.entry()371 entry += nodes.paragraph(text=header)372 row += entry373 thead += row374 tgroup += thead375376 # generate table-body377 tbody = nodes.tbody()378 for desc in descriptions:379 row = nodes.row()380 for attr in desc:381 entry = nodes.entry()382 if not isinstance(attr, str):383 attr = str(attr)384 self.state.nested_parse(ViewList([attr], source=attr),385 0, entry)386 row += entry387 tbody += row388 tgroup += tbody389390 return table391392393def setup(**kwargs):394 for key, value in directive_options_default.items():395 directive_options[key] = kwargs.get(key, value)396397 rst.directives.register_directive("blockdiag", BlockdiagDirective)