1# $Id: __init__.py 9542 2024-02-17 10:37:23Z milde $
2# Authors: Chris Liechti <cliechti@gmx.net>;
3# David Goodger <goodger@python.org>
4# Copyright: This module has been placed in the public domain.
5
6"""
7S5/HTML Slideshow Writer.
8"""
9
10__docformat__ = 'reStructuredText'
11
12import sys
13import os
14import re
15import docutils
16from docutils import frontend, nodes, utils
17from docutils.writers import html4css1
18
19themes_dir_path = utils.relative_path(
20 os.path.join(os.getcwd(), 'dummy'),
21 os.path.join(os.path.dirname(__file__), 'themes'))
22
23
24def find_theme(name):
25 # Where else to look for a theme?
26 # Check working dir? Destination dir? Config dir? Plugins dir?
27 path = os.path.join(themes_dir_path, name)
28 if not os.path.isdir(path):
29 raise docutils.ApplicationError(
30 'Theme directory not found: %r (path: %r)' % (name, path))
31 return path
32
33
34class Writer(html4css1.Writer):
35
36 settings_spec = html4css1.Writer.settings_spec + (
37 'S5 Slideshow Specific Options',
38 'For the S5/HTML writer, the --no-toc-backlinks option '
39 '(defined in General Docutils Options above) is the default, '
40 'and should not be changed.',
41 (('Specify an installed S5 theme by name. Overrides --theme-url. '
42 'The default theme name is "default". The theme files will be '
43 'copied into a "ui/<theme>" directory, in the same directory as the '
44 'destination file (output HTML). Note that existing theme files '
45 'will not be overwritten (unless --overwrite-theme-files is used).',
46 ['--theme'],
47 {'default': 'default', 'metavar': '<name>',
48 'overrides': 'theme_url'}),
49 ('Specify an S5 theme URL. The destination file (output HTML) will '
50 'link to this theme; nothing will be copied. Overrides --theme.',
51 ['--theme-url'],
52 {'metavar': '<URL>', 'overrides': 'theme'}),
53 ('Allow existing theme files in the ``ui/<theme>`` directory to be '
54 'overwritten. The default is not to overwrite theme files.',
55 ['--overwrite-theme-files'],
56 {'action': 'store_true', 'validator': frontend.validate_boolean}),
57 ('Keep existing theme files in the ``ui/<theme>`` directory; do not '
58 'overwrite any. This is the default.',
59 ['--keep-theme-files'],
60 {'dest': 'overwrite_theme_files', 'action': 'store_false'}),
61 ('Set the initial view mode to "slideshow" [default] or "outline".',
62 ['--view-mode'],
63 {'choices': ['slideshow', 'outline'], 'default': 'slideshow',
64 'metavar': '<mode>'}),
65 ('Normally hide the presentation controls in slideshow mode. '
66 'This is the default.',
67 ['--hidden-controls'],
68 {'action': 'store_true', 'default': True,
69 'validator': frontend.validate_boolean}),
70 ('Always show the presentation controls in slideshow mode. '
71 'The default is to hide the controls.',
72 ['--visible-controls'],
73 {'dest': 'hidden_controls', 'action': 'store_false'}),
74 ('Enable the current slide indicator ("1 / 15"). '
75 'The default is to disable it.',
76 ['--current-slide'],
77 {'action': 'store_true', 'validator': frontend.validate_boolean}),
78 ('Disable the current slide indicator. This is the default.',
79 ['--no-current-slide'],
80 {'dest': 'current_slide', 'action': 'store_false'}),))
81
82 settings_default_overrides = {'toc_backlinks': 0}
83
84 config_section = 's5_html writer'
85 config_section_dependencies = ('writers', 'html writers',
86 'html4css1 writer')
87
88 def __init__(self):
89 html4css1.Writer.__init__(self)
90 self.translator_class = S5HTMLTranslator
91
92
93class S5HTMLTranslator(html4css1.HTMLTranslator):
94
95 s5_stylesheet_template = """\
96<!-- configuration parameters -->
97<meta name="defaultView" content="%(view_mode)s" />
98<meta name="controlVis" content="%(control_visibility)s" />
99<!-- style sheet links -->
100<script src="%(path)s/slides.js" type="text/javascript"></script>
101<link rel="stylesheet" href="%(path)s/slides.css"
102 type="text/css" media="projection" id="slideProj" />
103<link rel="stylesheet" href="%(path)s/outline.css"
104 type="text/css" media="screen" id="outlineStyle" />
105<link rel="stylesheet" href="%(path)s/print.css"
106 type="text/css" media="print" id="slidePrint" />
107<link rel="stylesheet" href="%(path)s/opera.css"
108 type="text/css" media="projection" id="operaFix" />\n"""
109 # The script element must go in front of the link elements to
110 # avoid a flash of unstyled content (FOUC), reproducible with
111 # Firefox.
112
113 disable_current_slide = """
114<style type="text/css">
115#currentSlide {display: none;}
116</style>\n"""
117
118 layout_template = """\
119<div class="layout">
120<div id="controls"></div>
121<div id="currentSlide"></div>
122<div id="header">
123%(header)s
124</div>
125<div id="footer">
126%(title)s%(footer)s
127</div>
128</div>\n"""
129# <div class="topleft"></div>
130# <div class="topright"></div>
131# <div class="bottomleft"></div>
132# <div class="bottomright"></div>
133
134 default_theme = 'default'
135 """Name of the default theme."""
136
137 base_theme_file = '__base__'
138 """Name of the file containing the name of the base theme."""
139
140 direct_theme_files = (
141 'slides.css', 'outline.css', 'print.css', 'opera.css', 'slides.js')
142 """Names of theme files directly linked to in the output HTML"""
143
144 indirect_theme_files = (
145 's5-core.css', 'framing.css', 'pretty.css')
146 """Names of files used indirectly; imported or used by files in
147 `direct_theme_files`."""
148
149 required_theme_files = indirect_theme_files + direct_theme_files
150 """Names of mandatory theme files."""
151
152 def __init__(self, *args):
153 html4css1.HTMLTranslator.__init__(self, *args)
154 # insert S5-specific stylesheet and script stuff:
155 self.theme_file_path = None
156 try:
157 self.setup_theme()
158 except docutils.ApplicationError as e:
159 self.document.reporter.warning(e)
160 view_mode = self.document.settings.view_mode
161 control_visibility = ('visible', 'hidden')[self.document.settings
162 .hidden_controls]
163 self.stylesheet.append(self.s5_stylesheet_template
164 % {'path': self.theme_file_path,
165 'view_mode': view_mode,
166 'control_visibility': control_visibility})
167 if not self.document.settings.current_slide:
168 self.stylesheet.append(self.disable_current_slide)
169 self.meta.append('<meta name="version" content="S5 1.1" />\n')
170 self.s5_footer = []
171 self.s5_header = []
172 self.section_count = 0
173 self.theme_files_copied = None
174
175 def setup_theme(self):
176 if self.document.settings.theme:
177 self.copy_theme()
178 elif self.document.settings.theme_url:
179 self.theme_file_path = self.document.settings.theme_url
180 else:
181 raise docutils.ApplicationError(
182 'No theme specified for S5/HTML writer.')
183
184 def copy_theme(self):
185 """
186 Locate & copy theme files.
187
188 A theme may be explicitly based on another theme via a '__base__'
189 file. The default base theme is 'default'. Files are accumulated
190 from the specified theme, any base themes, and 'default'.
191 """
192 settings = self.document.settings
193 path = find_theme(settings.theme)
194 theme_paths = [path]
195 self.theme_files_copied = {}
196 required_files_copied = {}
197 # This is a link (URL) in HTML, so we use "/", not os.sep:
198 self.theme_file_path = 'ui/%s' % settings.theme
199 if not settings.output:
200 raise docutils.ApplicationError(
201 'Output path not specified, you may need to copy'
202 ' the S5 theme files "by hand" or set the "--output" option.')
203 dest = os.path.join(
204 os.path.dirname(settings.output), 'ui', settings.theme)
205 if not os.path.isdir(dest):
206 os.makedirs(dest)
207 default = False
208 while path:
209 for f in os.listdir(path): # copy all files from each theme
210 if f == self.base_theme_file:
211 continue # ... except the "__base__" file
212 if (self.copy_file(f, path, dest)
213 and f in self.required_theme_files):
214 required_files_copied[f] = True
215 if default:
216 break # "default" theme has no base theme
217 # Find the "__base__" file in theme directory:
218 base_theme_file = os.path.join(path, self.base_theme_file)
219 # If it exists, read it and record the theme path:
220 if os.path.isfile(base_theme_file):
221 with open(base_theme_file, encoding='utf-8') as f:
222 lines = f.readlines()
223 for line in lines:
224 line = line.strip()
225 if line and not line.startswith('#'):
226 path = find_theme(line)
227 if path in theme_paths: # check for duplicates/cycles
228 path = None # if found, use default base
229 else:
230 theme_paths.append(path)
231 break
232 else: # no theme name found
233 path = None # use default base
234 else: # no base theme file found
235 path = None # use default base
236 if not path:
237 path = find_theme(self.default_theme)
238 theme_paths.append(path)
239 default = True
240 if len(required_files_copied) != len(self.required_theme_files):
241 # Some required files weren't found & couldn't be copied.
242 required = list(self.required_theme_files)
243 for f in required_files_copied.keys():
244 required.remove(f)
245 raise docutils.ApplicationError(
246 'Theme files not found: %s'
247 % ', '.join('%r' % f for f in required))
248
249 files_to_skip_pattern = re.compile(r'~$|\.bak$|#$|\.cvsignore$')
250
251 def copy_file(self, name, source_dir, dest_dir):
252 """
253 Copy file `name` from `source_dir` to `dest_dir`.
254 Return True if the file exists in either `source_dir` or `dest_dir`.
255 """
256 source = os.path.join(source_dir, name)
257 dest = os.path.join(dest_dir, name)
258 if dest in self.theme_files_copied:
259 return True
260 else:
261 self.theme_files_copied[dest] = True
262 if os.path.isfile(source):
263 if self.files_to_skip_pattern.search(source):
264 return None
265 settings = self.document.settings
266 if os.path.exists(dest) and not settings.overwrite_theme_files:
267 settings.record_dependencies.add(dest)
268 else:
269 with open(source, 'rb') as src_file:
270 src_data = src_file.read()
271 with open(dest, 'wb') as dest_file:
272 dest_dir = dest_dir.replace(os.sep, '/')
273 dest_file.write(src_data.replace(
274 b'ui/default',
275 dest_dir[dest_dir.rfind('ui/'):].encode(
276 sys.getfilesystemencoding())))
277 settings.record_dependencies.add(source)
278 return True
279 if os.path.isfile(dest):
280 return True
281
282 def depart_document(self, node):
283 self.head_prefix.extend([self.doctype,
284 self.head_prefix_template %
285 {'lang': self.settings.language_code}])
286 self.html_prolog.append(self.doctype)
287 self.head = self.meta[:] + self.head
288 if self.math_header:
289 if self.math_output == 'mathjax':
290 self.head.extend(self.math_header)
291 else:
292 self.stylesheet.extend(self.math_header)
293 # skip content-type meta tag with interpolated charset value:
294 self.html_head.extend(self.head[1:])
295 self.fragment.extend(self.body)
296 # special S5 code up to the next comment line
297 header = ''.join(self.s5_header)
298 footer = ''.join(self.s5_footer)
299 title = ''.join(self.html_title).replace('<h1 class="title">', '<h1>')
300 layout = self.layout_template % {'header': header,
301 'title': title,
302 'footer': footer}
303 self.body_prefix.extend(layout)
304 self.body_prefix.append('<div class="presentation">\n')
305 self.body_prefix.append(
306 self.starttag({'classes': ['slide'], 'ids': ['slide0']}, 'div'))
307 if not self.section_count:
308 self.body.append('</div>\n')
309 #
310 self.body_suffix.insert(0, '</div>\n')
311 self.html_body.extend(self.body_prefix[1:] + self.body_pre_docinfo
312 + self.docinfo + self.body
313 + self.body_suffix[:-1])
314
315 def depart_footer(self, node):
316 start = self.context.pop()
317 self.s5_footer.append('<h2>')
318 self.s5_footer.extend(self.body[start:])
319 self.s5_footer.append('</h2>')
320 del self.body[start:]
321
322 def depart_header(self, node):
323 start = self.context.pop()
324 header = ['<div id="header">\n']
325 header.extend(self.body[start:])
326 header.append('\n</div>\n')
327 del self.body[start:]
328 self.s5_header.extend(header)
329
330 def visit_section(self, node):
331 if not self.section_count:
332 self.body.append('\n</div>\n')
333 self.section_count += 1
334 self.section_level += 1
335 if self.section_level > 1:
336 # dummy for matching div's
337 self.body.append(self.starttag(node, 'div', CLASS='section'))
338 else:
339 self.body.append(self.starttag(node, 'div', CLASS='slide'))
340
341 def visit_subtitle(self, node):
342 if isinstance(node.parent, nodes.section):
343 level = self.section_level + self.initial_header_level - 1
344 if level == 1:
345 level = 2
346 tag = 'h%s' % level
347 self.body.append(self.starttag(node, tag, ''))
348 self.context.append('</%s>\n' % tag)
349 else:
350 html4css1.HTMLTranslator.visit_subtitle(self, node)
351
352 def visit_title(self, node):
353 html4css1.HTMLTranslator.visit_title(self, node)