Skip to content

Instantly share code, notes, and snippets.

@shimizukawa
Last active November 11, 2016 15:53
Show Gist options
  • Save shimizukawa/069818f5633d7e51ca0ff93541ed958b to your computer and use it in GitHub Desktop.
Save shimizukawa/069818f5633d7e51ca0ff93541ed958b to your computer and use it in GitHub Desktop.
sphinx拡張: shuwa builder
文頭は、一文字下げ
○章扉リード文(タグ不要)
○第1レベル見出し(節見出し) ■■■■
○第2レベル見出し ■■■
○第3レベル見出し ■■
○第4レベル見出し ■
▽●コラム
△●
▽●●Tips
△●●
▽●●●
表 表(Tabアキ)
表 表(Tabアキ)
表 表(Tabアキ)
△●●●
★[リスト番号○-○]――ソースファイル名★
▽●●●●
(ソースコードやコマンドラインの全部・一部)
△●●●●
▽●●●●●
構文・書式
△●●●●●
▲引き出し線キャプション▲
(画面キャプチャの一部分を説明する場合などに利用)
☆一語・一行・短文強調☆
(例)
 ここで☆ラムダ式☆が定義されています。
 ☆http://www.example.com/☆
▽◎
① 操作手順
② 操作手順
③ 操作手順
△◎
▽◆
箇条書き
箇条書き
△◆
# -*- coding: utf8 -*-
"""
docutils shuwa-system writer.
:copyright: Copyright 2014 by Takayuki SHIMIZUKAWA.
:license: MIT.
"""
__docformat__ = 'reStructuredText'
import posixpath
import os
import re
import textwrap
import unicodedata
import codecs
from itertools import izip_longest
from six import iteritems, text_type
from docutils import nodes
from docutils.io import StringOutput
from sphinx import addnodes
from sphinx.builders.text import TextBuilder
from sphinx.writers.text import TextWriter, TextTranslator
from sphinx.util.osutil import relative_uri, ensuredir, copyfile, os_path
from sphinx.util.console import bold, darkgreen, brown
from sphinx.util.nodes import inline_all_toctrees
admonition_labels = {
'note': (u'▽●●', u'△●●'),
'column': (u'▽●', u'△●'),
}
def node_prev(node):
if node.parent is not None:
idx = node.parent.children.index(node)
if idx:
return node.parent[idx-1]
return None
def node_next(node):
if node.parent is not None:
idx = node.parent.children.index(node)
if idx + 1 < len(node.parent):
return node.parent[idx+1]
return None
def get_width(text):
return sum((unicodedata.east_asian_width(x)=='W' and 2 or 1) for x in text)
def normalize_to_wide_parenthesis(text):
"""
If text include half parenthesis within wide text, emit warning.
"""
orig = text
text = text.replace(u'(', u'(').replace(u')', u')')
#if orig != text:
# print orig, ' -> ', text
return text
def convert_double_parenthesis(text):
"""
replace ((1)) -> ①
"""
circle_nums = re.findall(ur'([((]{2}(\d{1,2})[))]{2})', text)
for t, n in (x for x in circle_nums if 0<int(x[1])<=20):
text = text.replace(t, unichr(ord(u'①') - 1 + int(n)))
return text
def convert_for_rst(text, builder=None):
"""don't apply this to literal block"""
text = convert_double_parenthesis(text)
text = normalize_to_wide_parenthesis(text)
return text
def format_section_number(numbers):
"""
convert numbers from ['2', '3', '1'] to '02-03-01'
"""
numbers = map(text_type, numbers)
_ = (('%02d' % int(x) if x.isdigit() else x) for x in numbers)
secnum = '-'.join(_)
return secnum
def setup(app):
app.add_builder(ShuwaBuilder)
class ShuwaBuilder(TextBuilder):
name = 'shuwa'
format = 'text'
out_suffix = '.txt'
def init(self):
TextBuilder.init(self)
self.assembled_fignumbers = {}
self.assembled_secnumbers = {}
def get_docname(self, node):
docname = self.current_docname
p = node
while p:
if p.get('docname'):
docname = p.get('docname')
break
p = p.parent
return docname
def assemble_doctree(self, docname):
tree = self.env.get_doctree(docname)
docnameset = set()
tree = inline_all_toctrees(self, docnameset, docname, tree, darkgreen)
tree['docname'] = docname
self.env.resolve_references(tree, docname, self)
return tree, docnameset
def assemble_toc_secnumbers(self, root_docname, child_docnames):
# Assemble toc_secnumbers to resolve section numbers on Merged File.
# Merge all secnumbers to single secnumber.
new_secnumbers = self.assembled_secnumbers[root_docname] = {}
for docname in set([root_docname]) | child_docnames:
secnums = self.env.toc_secnumbers.get(docname, {})
for id, secnum in iteritems(secnums):
new_secnumbers[(docname, id)] = secnum
def assemble_toc_fignumbers(self, root_docname, child_docnames):
# Assemble toc_fignumbers to resolve section numbers on Merged File.
# Merge all fignumbers to single fignumber.
new_fignumbers = self.assembled_fignumbers[root_docname] = {}
for docname in set([root_docname]) | child_docnames:
fignums = self.env.toc_fignumbers.get(docname, {})
for figtype, figids in iteritems(fignums):
for id, fignum in iteritems(figids):
new_fignumbers.setdefault(figtype, {})[(docname, id)] = fignum
def get_docnames_in_doc(self, docname):
doctree = self.env.get_doctree(docname)
docnameset = set()
for toctreenode in doctree.traverse(addnodes.toctree):
includefiles = map(text_type, toctreenode['includefiles'])
for includefile in includefiles:
try:
docnameset.add(includefile)
except Exception:
self.warn('toctree contains ref to nonexisting '
'file %r' % includefile,
self.env.doc2path(docname))
return docnameset
def get_outfilename(self, docname):
secnumbers = self.assembled_secnumbers.get(docname)
if secnumbers:
secnum = secnumbers[(docname, '')][0]
outfilename = '%02d%s' % (secnum, self.out_suffix)
elif '/' in docname:
outfilename = docname.split('/')[0] + self.out_suffix
else:
outfilename = os_path(docname) + self.out_suffix
outfilename = os.path.join(self.outdir, outfilename)
return outfilename
def get_target_uri(self, docname, typ=None):
return docname
def get_relative_uri(self, from_, to, typ=None):
# ignore source
return self.get_target_uri(to, typ)
def write(self, *ignored):
self.info(bold('preparing documents... '), nonl=True)
self.prepare_writing(self.env.all_docs)
self.info('done')
master_doc = self.config.master_doc
master_doctree = self.env.get_and_resolve_doctree(master_doc, self)
self.write_doc_serialized(master_doc, master_doctree)
self.write_doc(master_doc, master_doctree)
docnames = self.get_docnames_in_doc(master_doc)
for docname in self.app.status_iterator(
docnames, 'writing output... ', darkgreen, len(docnames)):
doctree, child_docnames = self.assemble_doctree(docname)
self.assemble_toc_secnumbers(docname, child_docnames)
self.assemble_toc_fignumbers(docname, child_docnames)
self.write_doc_serialized(docname, doctree)
self.write_doc(docname, doctree)
self.info('done')
def prepare_writing(self, docnames):
self.writer = ShuwaWriter(self)
def write_doc(self, docname, doctree):
self.current_docname = docname
self.secnumbers = self.assembled_secnumbers.get(docname, {})
self.fignumbers = self.assembled_fignumbers.get(docname, {})
self.imgpath = relative_uri(self.get_target_uri(docname), '_images')
self.post_process_images(doctree)
destination = StringOutput(encoding='utf-8')
self.writer.write(doctree, destination)
outfilename = self.get_outfilename(docname)
ensuredir(os.path.dirname(outfilename))
try:
with codecs.open(outfilename, 'w', 'utf-8') as f:
f.write(self.writer.output)
except (IOError, OSError) as err:
self.warn("error writing file %s: %s" % (outfilename, err))
def post_process_images(self, doctree):
for node in doctree.traverse(nodes.image):
candidate = node['uri']
if candidate not in self.env.images:
# non-existing URI; let it alone
continue
numbers = []
if isinstance(node.parent, nodes.figure):
docname = self.get_docname(node)
anchor = node.parent['ids'][0]
key = (docname, anchor)
if key in self.fignumbers.get('figure', {}):
numbers = self.fignumbers['figure'][key]
numbers = ['%02d' % d for d in numbers]
if len(numbers) < 2:
numbers.insert(0, re.sub('[/.]', '_', docname))
dest = self.env.images[candidate][1]
if numbers:
fignum = '-'.join(numbers)
dest = fignum + '_' + dest
self.images[candidate] = dest
def finish(self):
self.copy_image_files()
return TextBuilder.finish(self)
def copy_image_files(self):
# copy image files
if self.images:
ensuredir(os.path.join(self.outdir, '_images'))
for src in self.status_iterator(self.images, 'copying images... ',
brown, len(self.images)):
dest = self.images[src]
try:
copyfile(os.path.join(self.srcdir, src),
os.path.join(self.outdir, '_images', dest))
except Exception, err:
self.warn('cannot copy image file %r: %s' %
(os.path.join(self.srcdir, src), err))
class TextWrapper(textwrap.TextWrapper):
"""Custom subclass that uses a different word separator regex."""
wordsep_re = re.compile(
r'(\s+|' # any whitespace
r'(?<=\s)(?::[a-z-]+:)?`\S+|' # interpreted text start
r'[^\s\w]*\w+[a-zA-Z]-(?=\w+[a-zA-Z])|' # hyphenated words
r'(?<=[\w\!\"\'\&\.\,\?])-{2,}(?=\w))') # em-dash
MAXWIDTH = 70
STDINDENT = 3
def my_wrap(text, width=MAXWIDTH, **kwargs):
w = TextWrapper(width=width, **kwargs)
return w.wrap(text)
class ShuwaWriter(TextWriter):
supported = ('text',)
settings_spec = ('No options here.', '', ())
settings_defaults = {}
output = None
def translate(self):
visitor = ShuwaTranslator(self.document, self.builder)
self.document.walkabout(visitor)
self.output = visitor.body
class ShuwaTranslator(TextTranslator):
def add_line(self, text):
if isinstance(text, basestring):
text = [text]
self.states[-1].append((0, text))
def end_state(self, wrap=True, end=[''], first=None):
content = self.states.pop()
maxindent = sum(self.stateindent)
indent = self.stateindent.pop()
result = []
toformat = []
def do_format():
if not toformat:
return
if wrap:
pass
#res = my_wrap(''.join(toformat), width=MAXWIDTH-maxindent)
#res = convert_for_rst(''.join(toformat), self.builder).splitlines() #NOTE: no-wrap and convert non-literals for shuwa
res = ''.join(toformat).splitlines()
else:
res = ''.join(toformat).splitlines()
if end:
res += end
result.append((indent, res))
for itemindent, item in content:
if itemindent == -1:
toformat.append(item)
else:
do_format()
result.append((indent + itemindent, item))
toformat = []
do_format()
if first is not None and result:
itemindent, item = result[0]
if item:
result.insert(0, (itemindent - indent, [first + item[0]]))
result[1] = (itemindent, item[1:])
self.states[-1].extend(result)
def get_target_secnumber(self, docname, anchorname):
secnumbers = self.builder.env.toc_secnumbers.get(docname, {})
if anchorname not in secnumbers:
anchorname = '' # try first heading which has no anchor
if secnumbers.get(anchorname):
return secnumbers[anchorname]
return None
def get_secnumber(self, node):
if node.get('secnumber'):
return '.'.join(map(text_type, node['secnumber']))
docname = self.builder.get_docname(node)
anchorname = '#' + node.parent['ids'][0]
id = (docname, anchorname)
if id not in self.builder.secnumbers:
id = (docname, '') # try first heading which has no anchor
if self.builder.secnumbers.get(id):
numbers = self.builder.secnumbers[id]
return '.'.join(map(text_type, numbers))
return ''
def get_fignumber(self, node):
def append_fignumber(figtype, figure_id):
docname = self.builder.get_docname(node)
key = (docname, figure_id)
if key in self.builder.fignumbers.get(figtype, {}):
prefix = self.builder.config.numfig_format.get(figtype, '')
numbers = self.builder.fignumbers[figtype][key]
return prefix % '.'.join(map(text_type, numbers)) + ' '
return ''
if isinstance(node.parent, nodes.figure):
return append_fignumber('figure', node.parent['ids'][0])
elif isinstance(node.parent, nodes.table):
return append_fignumber('table', node.parent['ids'][0])
elif isinstance(node.parent, nodes.container):
return append_fignumber('code-block', node.parent['ids'][0])
return ''
def visit_start_of_file(self, node):
pass
def depart_start_of_file(self, node):
pass
def depart_document(self, node):
self.end_state()
self.body = '\n'.join(line and (' '*indent + line)
for indent, lines in self.states[0]
for line in lines)
self.body = re.sub('\n{3,10}', '\n\n', self.body)
# XXX header/footer?
def visit_topic(self, node):
if 'contents' in node['classes']:
raise nodes.SkipChildren
self.new_state(0)
def depart_topic(self, node):
if 'contents' not in node['classes']:
self.end_state()
def visit_title(self, node):
if isinstance(node.parent, (nodes.Admonition, nodes.table)):
text = node.astext()
text = convert_for_rst(text, self.builder)
self.add_text(text)
raise nodes.SkipNode
self.new_state(0)
def depart_title(self, node):
headings = {1:u'■■■■■', 2:u'■■■■', 3:u'■■■', 4:u'■■', 5:u'■'}
heading = headings.get(self.sectionlevel, '')
text = ''.join(x[1] for x in self.states.pop() if x[0] == -1)
# split "2.3.1 hogehoge" into ("2.3.1", "hogehoge")
secnum = self.get_secnumber(node)
if secnum:
# 1-15章
title = text
numbers = secnum.strip()
chapter = 'Chapter'
match = 1 #dummy flag
else:
match = re.match(ur'(Appendix\s+)?([A-Z\d\.][\d\.]*)[:\s]+(.*)$', text)
if match:
# Appendix章
_, numbers, title = match.groups()
chapter = 'Appendix'
if match:
# convert numbers from '2.3.1' to ['2', '3', '1']
numbers = [x for x in re.split(r'\.', numbers)]
# convert chapter title
if self.sectionlevel == 1:
title = title.lstrip(u'章').lstrip(':').strip()
title = '%(chapter)s %(secnum)s\t' + title
secnum = numbers[0]
else:
title = '%(secnum)s\t' + title.strip()
secnum = format_section_number(numbers)
#re concatinate
text = title % locals()
# 前の行が空行でないなら
if self.states[-1] and self.states[-1][-1] != (0, ['']):
self.add_line('')
self.stateindent.pop()
self.add_line(heading + text)
def visit_figure(self, node):
self.new_state(0)
def depart_figure(self, node):
self.end_state()
def visit_caption(self, node):
if isinstance(node.parent, nodes.figure):
self.add_text(u'▲' + self.get_fignumber(node))
elif isinstance(node.parent, nodes.container):
try:
next_node = node.parent[node.parent.index(node)+1]
except IndexError:
next_node = None
if isinstance(next_node, nodes.literal_block):
next_node['caption'] = self.get_fignumber(node) + node.next_node().astext()
raise nodes.SkipChildren
def depart_caption(self, node):
if isinstance(node.parent, nodes.figure):
self.add_text(u'▲')
def visit_seealso(self, node):
if isinstance(node[0], nodes.title):
del node[0]
self.new_state(0)
self.add_line(u'▽●●●●●')
def depart_seealso(self, node):
self.add_line([u'△●●●●●', ''])
self.end_state(first=u'\n', end=[])
def visit_option_list(self, node):
self.add_line([''])
self.new_state(0)
self.add_line(u'▽●●●●●●')
def depart_option_list(self, node):
self.add_line([u'△●●●●●●'])
self.end_state(end=[])
self.add_line([''])
def visit_option_list_item(self, node):
self.new_state(0)
def depart_option_list_item(self, node):
self.end_state(end=[], first=u'□')
def visit_option_group(self, node):
self._firstoption = True
def depart_option_group(self, node):
pass
def visit_option(self, node):
if self._firstoption:
self._firstoption = False
else:
self.add_text(', ')
def depart_option(self, node):
pass
def visit_option_string(self, node):
self.add_text(u'☆')
def depart_option_string(self, node):
self.add_text(u'☆')
def visit_table(self, node):
if self.table:
raise NotImplementedError('Nested tables are not supported.')
self.add_line([''])
self.new_state(0)
self.add_text(u'▽●●●')
self.table = [[]]
def depart_table(self, node):
lines = self.table[1:]
prev_line = None
for line in lines:
if line == 'sep':
if prev_line and prev_line != line:
self.add_line('')
else:
# line = ['col1', 'col2-line1\ncol2-line2', 'col3']
# add line as:
# 'col1\tcol2-line1\tcol3\n'
# '\tcol2-line1\t\n'
for cols in izip_longest(*[l.split('\n') for l in line], fillvalue=''):
value = '\t'.join(c.strip() for c in cols)
if value.strip(): # skip empty/tab-onlye line
self.add_line(value)
prev_line = line
self.table = None
self.add_line(u'△●●●')
self.end_state(wrap=False, end=None)
self.add_line([''])
def visit_image(self, node):
olduri = node['uri']
# rewrite the URI if the environment knows about it
if olduri in self.builder.images:
node['uri'] = posixpath.join(self.builder.imgpath,
self.builder.images[olduri])
newuri = node['uri']
if newuri.startswith(self.builder.outdir):
newuri = newuri[len(self.builder.outdir):]
else:
newuri = posixpath.join('_images', os.path.basename(olduri))
self.add_line(['', ('[image: %s]' % newuri)])
raise nodes.SkipNode
def visit_bullet_list(self, node):
self.list_counter.append(-1)
# 前の行が空行でないなら
if self.states[-1] and self.states[-1][-1] != (0, ['']):
self.add_line('')
self.add_line(u'▽◆')
def depart_bullet_list(self, node):
self.add_line([u'△◆', ''])
self.list_counter.pop()
def visit_enumerated_list(self, node):
self.list_counter.append(0)
self.add_line(u'▽◆')
def depart_enumerated_list(self, node):
self.add_text(u'△◆')
self.list_counter.pop()
def visit_definition_list(self, node):
self.add_line([''])
self.new_state(0)
self.add_line(u'▽●●●●●●')
def depart_definition_list(self, node):
self.add_line([u'△●●●●●●'])
self.end_state(end=[])
self.add_line([''])
def visit_list_item(self, node):
if self.list_counter[-1] == -1:
# bullet list
self.new_state(0)
elif self.list_counter[-1] == -2:
# definition list
pass
else:
# enumerated list
self.list_counter[-1] += 1
#self.new_state(len(text_type(self.list_counter[-1])) + 2)
self.new_state(0)
def depart_list_item(self, node):
if self.list_counter[-1] == -1:
self.end_state(first=u'・', end=None)
elif self.list_counter[-1] == -2:
pass
else:
level = len(self.list_counter) - 1
if level == 0:
n = unichr(ord(u'①') - 1 + self.list_counter[-1])
elif level > 0:
n = u'(%d)' % self.list_counter[-1]
padding = '\t' * level
self.end_state(first=u'%s%s ' % (padding, n), end=None)
def visit_term(self, node):
self.new_state(0)
def depart_term(self, node):
if not self._li_has_classifier:
self.end_state(end=[], first=u'□')
def visit_classifier(self, node):
self.add_text(u'(')
def depart_classifier(self, node):
self.add_text(u')')
self.end_state(end=None, first=u'□')
def visit_definition(self, node):
self.new_state(0)
def depart_definition(self, node):
self.end_state()
def visit_admonition(self, node):
self.add_line([''])
self.new_state(0)
if node.tagname == 'todo_node':
node.children[:] = [] #NOTE: remove todo block
elif 'name' in node.attributes:
# column, note
name = node.attributes['name']
labels = admonition_labels.get(name, ('', ''))
self.add_text(labels[0])
def depart_admonition(self, node):
if 'name' in node.attributes:
name = node.attributes['name']
labels = admonition_labels.get(name, ('', ''))
self.add_text(labels[1])
self.end_state(end=[])
self.add_line([''])
def _make_admonition(name):
#name = admonitionlabels.get(name, name)
def visit_admonition(self, node):
self.new_state(0)
self.add_line(['', u'▽●●' + name])
def depart_admonition(self, node):
self.add_text(u'△●●')
self.end_state()
return visit_admonition, depart_admonition
visit_hint, depart_hint = _make_admonition(u'note')
visit_tip, depart_tip = _make_admonition(u'note')
visit_note, depart_note = _make_admonition(u'note')
def visit_literal_block(self, node):
if 'caption' in node:
line = node['caption']
else:
line = ''
# 前の行が空行でないなら
if self.states[-1] and self.states[-1][-1] != (0, ['']):
self.add_line('')
self.new_state(0)
self.add_line(u'▽●●●●' + line)
def depart_literal_block(self, node):
self.add_line([u'△●●●●', ''])
self.end_state(wrap=False, end=[])
def visit_block_quote(self, node):
raise NotImplementedError('blockquote')
def depart_block_quote(self, node):
raise NotImplementedError('blockquote')
def visit_paragraph(self, node):
self.new_state(0)
def depart_paragraph(self, node):
if isinstance(node.parent, nodes.list_item):
if node.parent.next_node() == node:
self.end_state(end=[], first=u'')
else:
self.end_state(end=[], first=u' ')
elif isinstance(node.parent, nodes.Admonition):
self.end_state(end=[], first=u' ')
elif isinstance(node.parent, nodes.entry):
self.end_state(end=[], first=u'')
else:
self.end_state(end=[], first=u' ')
def visit_reference(self, node):
if node.get('secnumber'):
#self.add_text('.'.join(map(text_type, node['secnumber'])) + ' ')
pass
elif node.get('internal') and node.get('refuri'):
# pass # 他のページに繋がってるノードはここに来ない
refuri = node['refuri']
if '#' in refuri:
docname, anchorname = refuri.split('#', 1)
anchorname = '#' + anchorname
else:
docname = refuri
anchorname = ''
if docname:
node['secnumber'] = self.get_target_secnumber(docname, anchorname)
def depart_reference(self, node):
pass
def visit_number_reference(self, node):
self.visit_reference(node)
def depart_number_reference(self, node):
self.depart_reference(node)
def visit_emphasis(self, node):
self.add_text(u'☆') # :ref:`lets-start-python` -> ☆第1章...☆
if isinstance(node.parent, nodes.reference):
secnum = None
if node.parent.get('secnumber'):
secnum = node.parent['secnumber']
elif node.parent.get('refid'):
secnum = self.get_target_secnumber(self.document['docname'],
'#'+node.parent['refid'])
if secnum and not isinstance(node.parent, addnodes.number_reference):
if len(secnum) == 1:
self.add_text(u'第%s章 ' % secnum)
else: # len(secnum) > 1
self.add_text(format_section_number(secnum) + u' ')
def depart_emphasis(self, node):
self.add_text(u'☆')
def visit_literal_emphasis(self, node):
self.add_text(u'※')
def depart_literal_emphasis(self, node):
self.add_text(u'※')
def visit_strong(self, node):
self.add_text(u'★') # **foo** -> ★foo★
def depart_strong(self, node):
self.add_text(u'★')
def visit_literal_strong(self, node):
self.add_text(u'※')
def depart_literal_strong(self, node):
self.add_text(u'※')
def visit_abbreviation(self, node):
#self.add_text('')
pass
def depart_abbreviation(self, node):
if node.hasattr('explanation'):
self.add_text(u'(%s)' % node['explanation'])
def visit_title_reference(self, node):
self.add_text(u'☆') # `foo` -> ☆foo☆
def depart_title_reference(self, node):
self.add_text(u'☆')
def visit_literal(self, node):
self.add_text(u'※') # ``foo`` -> ※foo※
def depart_literal(self, node):
self.add_text(u'※')
def visit_Text(self, node):
text = node.astext().strip()
if (isinstance(node.parent, nodes.reference) # 親ノードがref
and not node.parent.get('internal') # refはページ内リンクではない
and re.match('^https?://', node.parent.get('refuri', '')) # refはURL
):
if node.parent['refuri'] != text: # textはURL以外の文字列
# URLを脚注にする
text = u'%s◆◆%s◆◆' % (text, node.parent['refuri'])
else:
# URLを埋め込みリンクにする
text = u'◆%s◆' % text
if not isinstance(node.parent, (nodes.literal, nodes.literal_block)):
#NOTE: no-wrap and convert non-literals for shuwa
text = convert_for_rst(text, self.builder)
self.add_text(text)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment