using annotation
158 | # (Should probably make these update when the plot is redrawn...)
159 | xlabel, ylabel = map(str, [centerx, centery])
160 | if centerx != 0 or centery != 0:
161 | annotation = '(%s, %s)' % (xlabel, ylabel)
162 | else:
163 | annotation = xlabel
164 | ax.annotate(annotation, (centerx, centery),
165 | xytext=(-4, -4), textcoords='offset points',
166 | ha='right', va='top')
167 |
168 | # Add arrows
169 | if 'x' in axes:
170 | ax.annotate("", xytext=(0, 0), xy=(1, 0),
171 | xycoords=ax.get_yaxis_transform(),
172 | arrowprops=dict(arrowstyle='->'))
173 | if 'y' in axes:
174 | ax.annotate("", xytext=(0, 0), xy=(0, 1),
175 | xycoords=ax.get_xaxis_transform(),
176 | arrowprops=dict(arrowstyle='->'))
177 |
178 |
179 | class CenteredFormatter(mpl.ticker.ScalarFormatter):
180 | """Acts exactly like the default Scalar Formatter, but yields an empty
181 | label for ticks at "center"."""
182 | center = 0
183 | def __call__(self, value, pos=None):
184 | if value == self.center:
185 | return ''
186 | else:
187 | return mpl.ticker.ScalarFormatter.__call__(self, value, pos)
188 |
189 |
190 | def settle_axes(xmin, xmax, ymin, ymax,
191 | xlabel="x",
192 | ylabel="y",
193 | ax=None,
194 | axlabelshift=1, axlabelshift_h=None, axlabelshift_v=None):
195 |
196 | if ax is None:
197 | ax = plt.gca()
198 | ax.set_ylim(ymin, ymax)
199 | ax.set_xlim(xmin, xmax)
200 |
201 | xwidth = xmax - xmin
202 | ywidth = ymax - ymin
203 |
204 | if xlabel:
205 | ax.set_xlabel(f'${xlabel}$', x=1)
206 |
207 | ticklab = next(iter(ax.xaxis.get_ticklabels()), None)
208 | if ticklab:
209 | trans = ticklab.get_transform()
210 | ax.xaxis.set_label_coords(xmax, 0, transform=trans)
211 | if ylabel:
212 | ax.set_ylabel(f'${ylabel}$', y=1, rotation=0)
213 |
214 | ticklab = next(iter(ax.yaxis.get_ticklabels()), None)
215 | if ticklab:
216 | trans = ticklab.get_transform()
217 | axlabelshift_h = axlabelshift if axlabelshift_h is None else axlabelshift_h
218 | axlabelshift_v = axlabelshift if axlabelshift_v is None else axlabelshift_v
219 |
220 | ax.yaxis.set_label_coords(xwidth * 0.04 * axlabelshift_h,
221 | ymax - ywidth * 0.05 * axlabelshift_v, transform=trans)
222 |
223 |
224 | def eulersplot(f, xa, xb, ya, n = 500, toolarge = 1E10, **kw):
225 | """plots numerical solution y'=f
226 |
227 | args
228 | ====
229 |
230 | - f(x,y): a function in rhs
231 | - xa: initial value of independent variable
232 | - xb: final value of independent variable
233 | - ya: initial value of dependent variable
234 | - n : number of steps (higher the better)
235 | """
236 | h = (xb - xa) / float(n)
237 | x = [xa]
238 | y = [ya]
239 | for i in range(1,n+1):
240 | newy = y[-1] + h * f(x[-1], y[-1])
241 | if abs(newy) > toolarge:
242 | break
243 | y.append(newy)
244 | x.append(x[-1] + h)
245 | plt.plot(x,y, **kw)
246 |
247 |
248 | def normvectorfield(xs,ys,fs,**kw):
249 | """
250 | plot normalized vector field
251 |
252 | kwargs
253 | ======
254 |
255 | - length is a desired length of the lines (default: 1)
256 | - the rest of kwards are passed to plot
257 | """
258 | length = kw.pop('length') if 'length' in kw else 1
259 | x, y = np.meshgrid(xs, ys)
260 | # calculate vector field
261 | vx,vy = fs(x,y)
262 | # plot vector field
263 | norm = length /np.sqrt(vx**2+vy**2)
264 | plt.quiver(x, y, vx * norm, vy * norm, angles='xy',**kw)
265 |
266 |
267 | def vectorfield(xs,ys,fs,**kw):
268 | """
269 | plot vector field (no normalization!)
270 |
271 | args
272 | ====
273 | fs is a function that returns tuple (vx,vy)
274 |
275 | kwargs
276 | ======
277 |
278 | - length is a desired length of the lines (default: 1)
279 | - the rest of kwards are passed to plot
280 | """
281 | length = kw.pop('length') if 'length' in kw else 1
282 | x, y = np.meshgrid(xs, ys)
283 | # calculate vector field
284 | vx,vy=fs(x,y)
285 | # plot vecor field
286 | norm = length
287 | plt.quiver(x, y, vx * norm, vy * norm, angles='xy',**kw)
288 |
289 |
290 | def plottrajectories(fs, x0, t=np.linspace(1,400,10000), **kw):
291 | """
292 | plots trajectory of the solution
293 |
294 | f -- must accept an array of X and t=0, and return a 2D array of \dot y and \dot x
295 | x0 -- vector
296 |
297 | Example
298 | =======
299 |
300 | plottrajectories(lambda X,t=0:array([ X[0] - X[0]*X[1] ,
301 | -X[1] + X[0]*X[1] ]), [ 5,5], color='red')
302 | """
303 | x0 = np.array(x0)
304 | #f = lambda X,t=0: array(fs(X[0],X[1]))
305 | #fa = lambda X,t=0:array(fs(X[0],X[1]))
306 | X = integrate.odeint( fs, x0, t)
307 | plt.plot(X[:,0], X[:,1], **kw)
308 |
309 |
310 | def phaseportrait(fs, inits, t=(-5, 5), n=100, firstint=None, arrow=True,
311 | xmin=None, ymin=None, xmax=None, ymax=None, gridstep=200,
312 | head_width = 0.13,
313 | head_length=0.3, arrow_size=1, singpoint_size=0,
314 | singcolor='steelblue', contourcolor='steelblue', **kw):
315 | """
316 | plots phase portrait of the differential equation (\dot x,\dot y)=fs(x,y)
317 |
318 | fs -- must accept an array X=(x, y), and return a 2D array of \dot y and \dot x
319 | firstint -- first integral function, must accept an array X=(x, y) and
320 | return real number. If specified, no integration of equation will be
321 | performed. Instead, contours of firstint will be drawn. fs will be used
322 | to draw vectors. xmin, xmax, ymin, ymax should be specified
323 | inits -- list of vectors representing inital conditions
324 | t -- is either a tuple (tmin, tmax), where tmin <= 0 and tmax >= 0,
325 | or scalar; in the latter case, tmin = 0, tmax = t
326 | n -- number of points
327 |
328 | Example
329 | =======
330 |
331 | from itertools import product
332 | phaseportrait(lambda X: array([X[0],2*X[1]]), product(linspace(-4,4,15),linspace(-4,4,15)), [-2,0.3], n=20)
333 | """
334 | try:
335 | tmin = t[0]
336 | tmax = t[1]
337 | assert tmin <= 0 and tmax >= 0
338 | except TypeError:
339 | tmin = 0
340 | tmax = t
341 | head_width *= arrow_size
342 | head_length *= arrow_size
343 |
344 | points = []
345 | inits = np.array(inits)
346 | integrator = integrate.ode(lambda t, X: fs(X)).set_integrator('vode')
347 | if firstint is None:
348 | for x0 in inits:
349 | if tmin < 0:
350 | segments=[(tmin, tmin/n), (tmax, tmax/n)]
351 | else:
352 | segments=[(tmax, tmax/n)]
353 |
354 | for T, delta_t in segments:
355 | integrator.set_initial_value(x0)
356 | points.append(x0)
357 | sign = np.sign(delta_t)
358 |
359 | while (sign * integrator.t < sign * T):
360 | point = integrator.integrate(integrator.t + delta_t)
361 | if not integrator.successful():
362 | break
363 | if ((xmin is not None and point[0] < xmin) or
364 | (xmax is not None and point[0] > xmax) or
365 | (ymin is not None and point[1] < ymin) or
366 | (ymax is not None and point[1] > ymax)):
367 | point = [None, None]
368 | points.append(point)
369 | points.append([None, None])
370 | points = np.array(points)
371 | plt.plot(points[:, 0], points[:, 1],**kw)
372 | else:
373 | assert None not in [xmin, xmax, ymin, ymax], \
374 | ("Please, specify xmin, xmax, ymin, ymax and gridstep"
375 | "if you use first integral")
376 | X = np.linspace(xmin, xmax, gridstep)
377 | Y = np.linspace(xmin, xmax, gridstep)
378 | # Z = np.array([[firstint(np.array([x, y])) for x in X] for y in Y])
379 | try:
380 | Z = firstint(np.meshgrid(X, Y))
381 | # fast version for ufunc-compatible firstint
382 | except:
383 | print("Can't use vectorized first integral,"
384 | " falling back to loops")
385 | Z = np.array([[firstint(np.array([x, y])) for x in X] for y in Y])
386 | # fallback if something goes wrong
387 | levels = sorted({firstint(x0) for x0 in inits})
388 | plt.contour(X, Y, Z, levels=levels, colors=contourcolor)
389 |
390 | for x0 in inits:
391 | vector = np.array(fs(x0))
392 | if arrow:
393 | if scipy.linalg.norm(vector) > 1E-5:
394 | direction = vector / scipy.linalg.norm(vector) * 0.01
395 | else:
396 | direction = None
397 | if 'color' in kw:
398 | arrow_params = dict(fc=kw['color'],
399 | ec=kw['color'])
400 | else:
401 | arrow_params = {}
402 | if direction is not None:
403 | plt.arrow(x0[0] - direction[0],
404 | x0[1] - direction[1],
405 | direction[0],
406 | direction[1],
407 | head_width=head_width,
408 | head_length=head_length,
409 | lw=0.0, **arrow_params)
410 | else:
411 | plt.plot([x0[0]], [x0[1]],
412 | marker='o', mew=2 * singpoint_size,
413 | lw=0, markersize=5 * singpoint_size,
414 | markerfacecolor='white', markeredgecolor=singcolor)
415 |
416 |
417 | def mcontour(xs, ys, fs, levels=None, **kw):
418 | """
419 | wrapper function for contour
420 |
421 | example
422 | ======
423 | mcontour(linspace(-4,4),linspace(-4,4),lambda x,y: x*y)
424 | """
425 | x,y=np.meshgrid(xs,ys)
426 | z=fs(x,y)
427 | if levels!=None:
428 | plt.contour(x,y,z,sorted(set(levels)),**kw)
429 | else:
430 | plt.contour(x,y,z,**kw)
431 |
432 |
433 | def get_default(from_, **kwargs):
434 | return {k:from_.get(k, v) for k, v in kwargs.items()}
435 |
436 |
437 | def onedim_phasecurves(left, right, singpoints, directions,
438 | orientation='vertical', shift=0,
439 | delta=0.05, **kwargs):
440 | """
441 | Draws phase curves of one-directional vector field;
442 | left and right are borders
443 | singpoints is a list of singular points (equilibria)
444 | directions is a list of +1 and -1 that gives a direction
445 |
446 | Example:
447 |
448 |
449 | plt.ylim(-4, 4)
450 | plt.xlim(-4, 4)
451 | onedim_phasecurves(-4, 4, [-1, 1], [1, -1, 1], orientation='horizontal',
452 | shift=1)
453 |
454 | """
455 | assert len(directions) == len(singpoints) + 1
456 | assert orientation in ['vertical', 'horizontal']
457 | n = len(singpoints)
458 | defaultcolor = 'Teal'
459 | plot_params = get_default(kwargs, color=defaultcolor, marker='o',
460 | fillstyle='none', mew=5, lw=0, markersize=2)
461 | quiver_params = dict(angles='xy',
462 | scale_units='xy', scale=1, units='inches')
463 | quiver_params.update(get_default(kwargs, width=0.03,
464 | color=defaultcolor))
465 |
466 | baseline = np.zeros(n) + shift
467 | if orientation == 'vertical':
468 | plt.plot(baseline, singpoints, **plot_params)
469 | else:
470 | plt.plot(singpoints, baseline, **plot_params)
471 |
472 | # We have to process special case when left or right border is singular
473 | # move them to special list lonesingpoints to process later
474 | if singpoints:
475 | if singpoints[0] == left:
476 | singpoints.pop(0)
477 | directions.pop(0)
478 | n -= 1
479 | if singpoints:
480 | if singpoints[-1] == right:
481 | singpoints.pop()
482 | directions.pop()
483 | n -= 1
484 |
485 | xs = np.zeros(n + 1) + shift
486 | ys = []
487 | us = np.zeros(n + 1)
488 | vs = []
489 |
490 | endpoints = [left] + list(singpoints) + [right]
491 | for i, direction in enumerate(directions):
492 | if direction > 0:
493 | beginning = endpoints[i]
494 | ending = endpoints[i+1]
495 | elif direction < 0:
496 | beginning = endpoints[i+1]
497 | ending = endpoints[i]
498 | else:
499 | raise Exception("direction should be >0 or <0")
500 | ys.append(beginning + np.sign(direction) * delta)
501 | vs.append(ending - beginning - np.sign(direction)*2*delta)
502 | if orientation == 'vertical':
503 | plt.quiver(xs, ys, us, vs, **quiver_params, **kwargs)
504 | else:
505 | plt.quiver(ys, xs, vs, us, **quiver_params, **kwargs)
506 |
507 |
508 | ### FROM: https://gist.github.com/WetHat/1d6cd0f7309535311a539b42cccca89c
509 | class Arrow3D(FancyArrowPatch):
510 | def __init__(self, x, y, z, dx, dy, dz, *args, **kwargs):
511 | super().__init__((0, 0), (0, 0), *args, **kwargs)
512 | self._xyz = (x, y, z)
513 | self._dxdydz = (dx, dy, dz)
514 |
515 | def draw(self, renderer):
516 | x1, y1, z1 = self._xyz
517 | dx, dy, dz = self._dxdydz
518 | x2, y2, z2 = (x1 + dx, y1 + dy, z1 + dz)
519 |
520 | xs, ys, zs = proj_transform((x1, x2), (y1, y2), (z1, z2), self.axes.M)
521 | self.set_positions((xs[0], ys[0]), (xs[1], ys[1]))
522 | super().draw(renderer)
523 |
524 | def do_3d_projection(self, renderer=None):
525 | x1, y1, z1 = self._xyz
526 | dx, dy, dz = self._dxdydz
527 | x2, y2, z2 = (x1 + dx, y1 + dy, z1 + dz)
528 |
529 | xs, ys, zs = proj_transform((x1, x2), (y1, y2), (z1, z2),
530 | self.axes.M)
531 | self.set_positions((xs[0], ys[0]), (xs[1], ys[1]))
532 |
533 | return np.min(zs)
534 |
535 |
536 | def arrow3D(ax, x, y, z, dx, dy, dz, *args, **kwargs):
537 | """Add an 3d arrow to an `Axes3D` instance."""
538 |
539 | arrow = Arrow3D(x, y, z, dx, dy, dz, *args, **kwargs)
540 | ax.add_artist(arrow)
541 |
542 | ### END FROM
543 |
--------------------------------------------------------------------------------
/qqmbr/qqhtml.py:
--------------------------------------------------------------------------------
1 | # (c) Ilya V. Schurov, 2016
2 | # Available under MIT license (see LICENSE file in the root folder)
3 |
4 | from indentml.parser import QqTag
5 | from yattag import Doc
6 | import re
7 | import inspect
8 | import hashlib
9 | import os
10 | import urllib.parse
11 | from mako.template import Template
12 | from fuzzywuzzy import process
13 | from html import escape as html_escape
14 | from typing import Optional, List
15 | from typing import (
16 | NamedTuple,
17 | TypeVar,
18 | Sequence,
19 | Tuple,
20 | Dict,
21 | Iterator,
22 | Any,
23 | )
24 | from textwrap import indent, dedent
25 | import contextlib
26 | import sys
27 | from io import StringIO
28 |
29 | import matplotlib
30 |
31 | matplotlib.use("Agg")
32 |
33 | import matplotlib.pyplot as plt
34 | from celluloid import Camera
35 |
36 |
37 | def mk_safe_css_ident(s):
38 | # see http://stackoverflow.com/a/449000/3025981 for details
39 | s = re.sub(r"[^a-zA-Z\d_-]", "_", s)
40 | if re.match(r"([^a-zA-Z]+)", s):
41 | m = re.match(r"([^a-zA-Z]+)", s)
42 | first = m.group(1)
43 | s = s[len(first) :] + "__" + first
44 | return s
45 |
46 |
47 | # FROM http://stackoverflow.com/a/14364249/3025981
48 | def make_sure_path_exists(path):
49 | try:
50 | os.makedirs(path)
51 | except OSError:
52 | if not os.path.isdir(path):
53 | raise
54 |
55 |
56 | # END FROM
57 |
58 | # FROM: http://stackoverflow.com/a/3906390/3025981
59 | @contextlib.contextmanager
60 | def stdout_io(stdout=None):
61 | old = sys.stdout
62 | if stdout is None:
63 | stdout = StringIO()
64 | sys.stdout = stdout
65 | yield stdout
66 | sys.stdout = old
67 |
68 |
69 | # END FROM
70 |
71 |
72 | T = TypeVar("T")
73 |
74 |
75 | # BASED ON: https://stackoverflow.com/a/15358005/3025981
76 | def split_by_predicate(
77 | seq: Sequence[T], predicate, zero_delim: Optional[T] = None
78 | ) -> Iterator[Sequence[T]]:
79 | """
80 | Splits a sequence by delimeters that satisfy predicate,
81 | keeping the delimeters
82 |
83 | split_by_predicate([0, "One", 1, 2, 3,
84 | "Two", 4, 5, 6, 7, "Three", "Four"],
85 | predicate=lambda x: isinstance(x, str),
86 | zero_delim="Nothing")
87 |
88 | [["Nothing", 0], ["One", 1, 2, 3], ["Two", 4, 5, 6, 7],
89 | ["Three"], ["Four"]]
90 |
91 | :param seq: sequence to proceed
92 | :param predicate: checks whether element is delimeter
93 | :param zero_delim: pseudo-delimter prepended to the sequence
94 | :return: sequence of sequences
95 | """
96 | g = [zero_delim]
97 | for el in seq:
98 | if predicate(el):
99 | yield g
100 | g = []
101 | g.append(el)
102 | yield g
103 |
104 |
105 | # END BASED
106 |
107 |
108 | class Counter(object):
109 | """
110 | Very simple class that support latex-style counters with subcounters.
111 | For example, if new section begins, the enumeration of subsections
112 | resets.
113 | If `showparents` option is set, str(counter) contains numbers of all
114 | its parents
115 | That's all.
116 | """
117 |
118 | def __init__(self, showparents=True):
119 | self.value = 0
120 | self.children: List["Counter"] = []
121 | self.parent: Counter = None
122 | self.showparents = showparents
123 |
124 | def reset(self):
125 | self.value = 0
126 | for child in self.children:
127 | child.reset()
128 |
129 | def increase(self):
130 | self.value += 1
131 | for child in self.children:
132 | child.reset()
133 |
134 | def spawn_child(self) -> "Counter":
135 | newcounter = Counter()
136 | newcounter.parent = self
137 | self.children.append(newcounter)
138 | return newcounter
139 |
140 | def __str__(self):
141 | my_str = str(self.value)
142 | if self.parent and self.showparents:
143 | my_str = str(self.parent) + "." + my_str
144 | return my_str
145 |
146 |
147 | def join_nonempty(*args, sep=" "):
148 | return sep.join(x for x in args if x)
149 |
150 |
151 | Chapter = NamedTuple("Chapter", (("heading", QqTag), ("content", List)))
152 |
153 |
154 | class TOCItem(object):
155 | def __init__(
156 | self, tag: QqTag = None, parent: "TOCItem" = None, level=0
157 | ) -> None:
158 | self.tag = tag
159 | self.children: List["TOCItem"] = []
160 | self.parent = parent
161 | self.level = level
162 |
163 | def __str__(self):
164 | return "{}: {}\n".format(
165 | self.tag and self.tag.name, self.tag and self.tag.text_content
166 | ) + indent("".join(str(child) for child in self.children), " " * 4)
167 |
168 | def as_tuple(self):
169 | return (
170 | self.tag and self.tag.name,
171 | [child.as_tuple() for child in self.children],
172 | )
173 |
174 | def spawn_child(self, tag: QqTag = None):
175 | new = TOCItem(tag, parent=self, level=self.level + 1)
176 | self.children.append(new)
177 | return new
178 |
179 |
180 | class FormattedTOCItem(object):
181 | def __init__(
182 | self,
183 | tag: QqTag = None,
184 | parent: "FormattedTOCItem" = None,
185 | string: str = None,
186 | href: str = None,
187 | iscurchapter=False,
188 | ) -> None:
189 | self.tag = tag
190 | self.parent = parent
191 | self.children: List["FormattedTOCItem"] = []
192 | self.string = string
193 | self.href = href
194 |
195 | self.iscurchapter = iscurchapter
196 | # item is a chapter that is currently shown
197 |
198 | def append_child(self, child: "FormattedTOCItem"):
199 | child.parent = self
200 | self.children.append(child)
201 |
202 |
203 | plotly = None
204 |
205 |
206 | class PlotlyPlotter(object):
207 | _first = True
208 |
209 | def __init__(self):
210 | self.buffer = []
211 |
212 | def plot(self, figure_or_data) -> None:
213 | self.buffer.append(
214 | plotly.offline.plot(
215 | figure_or_data,
216 | show_link=False,
217 | validate=True,
218 | output_type="div",
219 | include_plotlyjs=False,
220 | )
221 | )
222 | PlotlyPlotter._first = False
223 |
224 | def get_data(self) -> str:
225 | ret = "".join(self.buffer)
226 | self.buffer.clear()
227 | return ret
228 |
229 |
230 | def rstrip_p(s: str) -> str:
231 | return re.sub(r"(\s*?p>\s*)+$", "", s)
232 |
233 |
234 | def spawn_or_create_counter(parent: Optional[Counter]):
235 | if parent is not None:
236 | return parent.spawn_child()
237 | else:
238 | return Counter()
239 |
240 |
241 | def process_only(tag: QqTag) -> Tuple[QqTag, QqTag]:
242 | long_tag = QqTag(tag.name, adopt=True)
243 | splitted_tag = QqTag(tag.name, adopt=True)
244 | for child in tag:
245 | if isinstance(child, str) or child.name not in [
246 | "splonly",
247 | "longonly",
248 | ]:
249 | long_tag.append_child(child)
250 | splitted_tag.append_child(child)
251 | continue
252 | if child.name == "splonly":
253 | for grandchild in child:
254 | splitted_tag.append_child(grandchild)
255 | if child.name == "longonly":
256 | for grandchild in child:
257 | long_tag.append_child(grandchild)
258 |
259 | return long_tag, splitted_tag
260 |
261 |
262 | def extract_splitted_items(tag: QqTag) -> Tuple[QqTag, QqTag]:
263 | curitem = None
264 | long_tag = QqTag(tag.name, adopt=True)
265 | splitted_tag = QqTag(tag.name, adopt=True)
266 | for child in tag.children_tags():
267 | long_child, splitted_child = process_only(child)
268 | if child.name == "item":
269 | long_tag.append_child(long_child)
270 | splitted_tag.append_child(splitted_child)
271 | curitem = long_tag[-1]
272 | elif child.name == "splitem":
273 |
274 | splitted_tag.append_child(
275 | QqTag("item", splitted_child, adopt=True)
276 | )
277 | for grandchild in long_child:
278 | curitem.append_child(grandchild)
279 | return long_tag, splitted_tag
280 |
281 |
282 | class QqHTMLFormatter(object):
283 | def __init__(
284 | self,
285 | root: QqTag = QqTag("_root"),
286 | with_chapters=True,
287 | eq_preview_by_labels=False,
288 | ) -> None:
289 |
290 | self.templates_dir = os.path.join(
291 | os.path.dirname(os.path.realpath(__file__)), "templates"
292 | )
293 |
294 | self.with_chapters = with_chapters
295 | self.eq_preview_by_labels = eq_preview_by_labels
296 |
297 | self.label_to_number: Dict[str, str] = {}
298 | self.label_to_title: Dict[str, str] = {}
299 | self.label_to_tag: Dict[str, QqTag] = {}
300 | self.label_to_chapter: Dict[str, int] = {}
301 | self.flabel_to_tag: Dict[str, QqTag] = {}
302 | self.root: QqTag = root
303 | self.counters = {}
304 | self.chapters: List[Chapter] = []
305 | self.heading_to_level = {
306 | "chapter": 1,
307 | "section": 2,
308 | "subsection": 3,
309 | "subsubsection": 4,
310 | }
311 |
312 | self.mode = "wholedoc"
313 | #: how to render the doc? the following options are available:
314 | #: - 'wholedoc' - the whole document on one page
315 | #: - 'bychapters' - every chapter on its own page
316 |
317 | chapters_counter = None
318 | if with_chapters:
319 | chapters_counter = Counter()
320 | self.counters["chapter"] = chapters_counter
321 |
322 | self.counters["section"] = spawn_or_create_counter(
323 | chapters_counter
324 | )
325 |
326 | self.counters["subsection"] = self.counters[
327 | "section"
328 | ].spawn_child()
329 | self.counters["subsubsection"] = self.counters[
330 | "subsection"
331 | ].spawn_child()
332 |
333 | self.counters["equation"] = spawn_or_create_counter(
334 | chapters_counter
335 | )
336 | self.counters["equation"].showparents = True
337 |
338 | self.counters["item"] = {
339 | "align": self.counters["equation"],
340 | "gather": self.counters["equation"],
341 | "multline": self.counters["equation"],
342 | }
343 |
344 | self.counters["figure"] = spawn_or_create_counter(chapters_counter)
345 |
346 | self.enumerateable_envs = {
347 | name: name.capitalize()
348 | for name in [
349 | "remark",
350 | "theorem",
351 | "example",
352 | "exercise",
353 | "definition",
354 | "quasidefinition",
355 | "proposition",
356 | "lemma",
357 | "question",
358 | "corollary",
359 | ]
360 | }
361 |
362 | self.metatags = {
363 | "meta",
364 | "author",
365 | "translator",
366 | "editor",
367 | "affiliation",
368 | "link",
369 | "license",
370 | "title",
371 | "url",
372 | "lang",
373 | "role",
374 | "codepreamble",
375 | "preamble",
376 | }
377 |
378 | # You can make self.localnames = {} to use
379 | # plain English localization
380 |
381 | self.localizations = {
382 | "ru": {
383 | "Remark": "Замечание",
384 | "Theorem": "Теорема",
385 | "Example": "Пример",
386 | "Exercise": "Упражнение",
387 | "Definition": "Определение",
388 | "Proposition": "Утверждение",
389 | "Lemma": "Лемма",
390 | "Proof": "Доказательство",
391 | "Proof outline": "Набросок доказательства",
392 | "Figure": "Рисунок",
393 | "Fig.": "Рис.",
394 | "Question": "Вопрос",
395 | "Corollary": "Следствие",
396 | "Quasidefinition": "Как бы определение",
397 | "More details": "Подробнее",
398 | },
399 | "uk": {
400 | "Remark": "Зауваження",
401 | "Theorem": "Теорема",
402 | "Example": "Приклад",
403 | "Exercise": "Вправа",
404 | "Definition": "Визначення",
405 | "Proposition": "Твердження",
406 | "Lemma": "Лема",
407 | "Proof": "Доказ",
408 | "Proof outline": "Схема доведення",
409 | "Figure": "Малюнок",
410 | "Fig.": "Мал.",
411 | "Question": "Питання",
412 | "Corollary": "Наслідок",
413 | "Quasidefinition": "Квазівизначення",
414 | # "More details": "TODO"
415 | }
416 | }
417 |
418 | self.localnames: Dict[str, str] = None
419 |
420 | self.formulaenvs = {"eq", "equation", "align", "gather"}
421 |
422 | for env in self.enumerateable_envs:
423 | self.counters[env] = spawn_or_create_counter(chapters_counter)
424 | self.counters[env].showparents = False
425 |
426 | self.figures_dir = None
427 |
428 | self.default_figname = "fig"
429 |
430 | plt.rcParams["figure.figsize"] = (6, 4)
431 | plt.rcParams["animation.frame_format"] = "svg"
432 |
433 | self.pythonfigure_globals = {"plt": plt, "Camera": Camera}
434 | self.code_prefixes = dict(
435 | pythonfigure="import matplotlib.pyplot as plt\n",
436 | plotly=(
437 | "import plotly\n"
438 | "import plotly.graph_objs as go\n"
439 | "from plotly.offline import iplot "
440 | "as plot\n"
441 | "from plotly.offline import "
442 | "init_notebook_mode\n\n"
443 | "init_notebook_mode()\n\n"
444 | ),
445 | rawhtml="",
446 | pythoncode="",
447 | )
448 |
449 | self.plotly_plotter = PlotlyPlotter()
450 |
451 | self.plotly_globals: Dict[str, Any] = {}
452 |
453 | self.python_globals: Dict[str, Any] = {}
454 |
455 | self.css: Dict[str, str] = {}
456 | self.js_top: Dict[str, str] = {}
457 | self.js_bottom: Dict[str, str] = {}
458 | self.js_onload: Dict[str, str] = {}
459 |
460 | self.safe_tags = (
461 | set(self.enumerateable_envs)
462 | | set(self.formulaenvs)
463 | | {
464 | "item",
465 | "figure",
466 | "label",
467 | "number",
468 | "ref",
469 | "nonumber",
470 | "snref",
471 | "snippet",
472 | "flabel",
473 | "name",
474 | "proof",
475 | "outline",
476 | "of",
477 | "caption",
478 | "showcode",
479 | "collapsed",
480 | "hidden",
481 | "backref",
482 | "label",
483 | "em",
484 | "emph",
485 | "tt",
486 | "quiz",
487 | "choice",
488 | "correct",
489 | "comment",
490 | }
491 | | set(self.heading_to_level)
492 | | self.metatags
493 | )
494 |
495 | def url_for_figure(self, s: str):
496 | """
497 | Override it to use flask.url_for
498 | :param s:
499 | :return:
500 | """
501 | return "/fig/" + s
502 |
503 | def url_for_img(self, s: str):
504 | return "/img/" + s
505 |
506 | def make_python_fig(
507 | self,
508 | code: str,
509 | exts: Tuple[str, ...] = ("pdf", "svg"),
510 | tight_layout=True,
511 | video=False,
512 | ) -> str:
513 | hashsum = hashlib.md5(code.encode("utf8")).hexdigest()
514 | prefix = hashsum[:2]
515 | path = os.path.join(self.figures_dir, prefix, hashsum)
516 | needfigure = False
517 | for ext in exts:
518 | if not os.path.isfile(
519 | os.path.join(path, self.default_figname + "." + ext)
520 | ):
521 | needfigure = True
522 | break
523 |
524 | if needfigure:
525 | make_sure_path_exists(path)
526 | gl = self.pythonfigure_globals
527 | plt.close()
528 | exec(code, gl)
529 | if video:
530 | animation = gl["animation"]
531 | for ext in exts:
532 | animation.save(
533 | os.path.join(
534 | path, self.default_figname + "." + ext,
535 | ), bitrate=2000
536 | )
537 |
538 | else:
539 | if tight_layout:
540 | plt.tight_layout()
541 | for ext in exts:
542 | plt.savefig(
543 | os.path.join(
544 | path, self.default_figname + "." + ext
545 | )
546 | )
547 |
548 | return os.path.join(prefix, hashsum)
549 |
550 | def make_python_jsanimate(self, code: str):
551 | gl = self.pythonfigure_globals
552 | plt.close()
553 | exec(code, gl)
554 | animation = gl["animation"]
555 | anim_html = (animation.to_jshtml(default_mode="once")
556 | .replace(" "
580 | )
581 | return self.plotly_plotter.get_data()
582 |
583 | def uses_tags(self) -> set:
584 | members = inspect.getmembers(self, predicate=inspect.ismethod)
585 | handles = [
586 | member
587 | for member in members
588 | if (
589 | member[0].startswith("handle_")
590 | or member[0] == "make_numbers"
591 | )
592 | ]
593 | alltags = set([])
594 | for handle in handles:
595 | if handle[0].startswith("handle_"):
596 | alltags.add(handle[0][len("handle_") :])
597 | doc = handle[1].__doc__
598 | if not doc:
599 | continue
600 | for line in doc.splitlines():
601 | m = re.search(r"Uses tags:(.+)", line)
602 | if m:
603 | tags = m.group(1).split(",")
604 | tags = [tag.strip() for tag in tags]
605 | alltags.update(tags)
606 | alltags.update(self.enumerateable_envs.keys())
607 | alltags.update(self.metatags)
608 | return alltags
609 |
610 | def localize(self, s: str) -> str:
611 | if not self.localnames:
612 | self.localnames = self.localizations.get(
613 | self.root.meta_.get("lang"), {}
614 | )
615 | return self.localnames.get(s, s)
616 |
617 | def handle(self, tag: QqTag) -> str:
618 | name = tag.name
619 | default_handler = "handle_" + name
620 | if name in self.heading_to_level:
621 | return self.handle_heading(tag)
622 | elif name in self.enumerateable_envs:
623 | return self.handle_enumerateable(tag)
624 | elif hasattr(self, default_handler):
625 | return getattr(self, default_handler)(tag)
626 | else:
627 | return ""
628 |
629 | @staticmethod
630 | def blanks_to_pars(s: str, keep_end_pars=True) -> str:
631 | if not keep_end_pars:
632 | s = s.rstrip()
633 |
634 | return re.sub(r"\n\s*\n", "\n\n\n", s)
635 |
636 | @staticmethod
637 | def label2id(label: str) -> str:
638 | return "label_" + mk_safe_css_ident(label.strip())
639 |
640 | def format(
641 | self,
642 | content: Optional[QqTag],
643 | blanks_to_pars=True,
644 | keep_end_pars=True,
645 | ) -> str:
646 | """
647 | :param content: could be QqTag or any iterable of QqTags
648 | :param blanks_to_pars: use blanks_to_pars (True or False)
649 | :param keep_end_pars: keep end paragraphs
650 | :return: str: text of tag
651 | """
652 | if content is None:
653 | return ""
654 |
655 | out = []
656 |
657 | for child in content:
658 | if isinstance(child, str):
659 | if blanks_to_pars:
660 | out.append(
661 | self.blanks_to_pars(
662 | html_escape(child, keep_end_pars)
663 | )
664 | )
665 | else:
666 | out.append(html_escape(child))
667 | else:
668 | out.append(self.handle(child))
669 | return "".join(out)
670 |
671 | def handle_heading(self, tag: QqTag) -> str:
672 | """
673 | Uses tags: chapter, section, subsection, subsubsection
674 | Uses tags: label, number
675 |
676 | Example:
677 |
678 | \chapter This is first heading
679 |
680 | \section This is the second heading \label{sec:second}
681 |
682 | :param tag:
683 | :return:
684 | """
685 | tag_to_hx = {
686 | "chapter": "h1",
687 | "section": "h2",
688 | "subsection": "h3",
689 | "subsubsection": "h4",
690 | }
691 |
692 | doc, html, text = Doc().tagtext()
693 | with html(tag_to_hx[tag.name]):
694 | doc.attr(id=self.tag_id(tag))
695 | if tag.find("number"):
696 | with html("span", klass="section__number"):
697 | with html(
698 | "a",
699 | href="#" + self.tag_id(tag),
700 | klass="section__number",
701 | ):
702 | text(tag.number_.value)
703 | doc.asis(self.format(tag, blanks_to_pars=False))
704 | ret = doc.getvalue()
705 | if tag.next() and isinstance(tag.next(), str):
706 | ret += "
"
707 | return doc.getvalue()
708 |
709 | def handle_eq(self, tag: QqTag) -> str:
710 | """
711 | eq tag corresponds to \[ \] or $$ $$ display formula
712 | without number.
713 |
714 | Example:
715 |
716 | \eq
717 | x^2 + y^2 = z^2
718 |
719 | :param tag:
720 | :return:
721 | """
722 | doc, html, text = Doc().tagtext()
723 | with html("div", klass="latex_eq"):
724 | text("\\[\n")
725 | doc.asis(self.format(tag, blanks_to_pars=False))
726 | text("\\]\n")
727 | return doc.getvalue()
728 |
729 | def handle_equation(self, tag: QqTag) -> str:
730 | """
731 | Uses tags: equation, number, label
732 |
733 | Example:
734 |
735 | \equation \label eq:first
736 | x^2 + y^2 = z^2
737 |
738 | :param tag:
739 | :return:
740 | """
741 | doc, html, text = Doc().tagtext()
742 | with html("div", klass="latex_equation"):
743 | text("\\[\n")
744 | text("\\begin{equation}\n")
745 | if tag.find("number"):
746 | text("\\tag{{{}}}\n".format(tag.number_.value))
747 | if tag.find("label"):
748 | doc.attr(id=self.label2id(tag.label_.value))
749 | doc.asis(self.format(tag, blanks_to_pars=False))
750 | text("\\end{equation}\n")
751 | text("\\]\n")
752 | return doc.getvalue()
753 |
754 | def multieq_template(self, name: str, tag: QqTag) -> str:
755 | template = Template(
756 | dedent(
757 | r"""
758 | \[
759 | \begin{${name}}
760 | <% items = tag("item") %>
761 | % if items:
762 | % for i, item in enumerate(items):
763 | ${formatter.format(item, blanks_to_pars=False)}
764 | % if item.exists("number"):
765 | \tag{${item.number_.value}}
766 | % endif
767 | % if i != len(items) - 1:
768 | \\\
769 |
770 | % endif
771 | % endfor
772 | % endif
773 | \end{${name}}
774 | \]
775 | """
776 | )
777 | )
778 | if tag.exists("splitem"):
779 | long_tag, splitted_tag = extract_splitted_items(tag)
780 | long_name = tag.get("longenv", name)
781 | return dedent(
782 | f"""
783 |
784 | { template.render(formatter=self, tag=long_tag, name=long_name) }
785 |
786 |
787 | { template.render(formatter=self,
788 | tag=splitted_tag,
789 | name=name) }
790 |
791 | """
792 | )
793 | return template.render(formatter=self, tag=tag, name=name)
794 |
795 | def handle_align(self, tag: QqTag) -> str:
796 | """
797 | Uses tags: align, number, label, item, splitem, splonly, longonly
798 | Uses tags: longenv
799 |
800 | Example:
801 | \\align
802 | \\item c^2 &= a^2 + b^2 \\label eq:one
803 | \\item c &= \\sqrt{a^2 + b^2} \\label eq:two
804 |
805 | :param tag:
806 | :return:
807 | """
808 | return self.multieq_template(name="align", tag=tag)
809 |
810 | def handle_gather(self, tag: QqTag) -> str:
811 | """
812 | Uses tags: gather, number, label, item, splitem, splonly, longonly
813 | Uses tags: longenv
814 |
815 | Example:
816 | \\gather
817 | \\item c^2 &= a^2 + b^2 \\label eq:one
818 | \\item c &= \\sqrt{a^2 + b^2} \\label eq:two
819 |
820 | :param tag:
821 | :return:
822 | """
823 | return self.multieq_template(name="gather", tag=tag)
824 |
825 | def handle_multline(self, tag: QqTag) -> str:
826 | """
827 | Uses tags: multline, number, label, item, splitem, splonly, longonly, longenv
828 | Uses tags: longenv
829 |
830 | Example:
831 | \\multline
832 | \\item c^2 &= a^2 + b^2 \\label eq:one
833 | \\item c &= \\sqrt{a^2 + b^2} \\label eq:two
834 |
835 | :param tag:
836 | :return:
837 | """
838 | return self.multieq_template(name="multline", tag=tag)
839 |
840 | def handle_ref(self, tag: QqTag):
841 | """
842 | Examples:
843 |
844 | See Theorem \ref{thm:existence}
845 |
846 | Other way:
847 |
848 | See \ref[Theorem|thm:existence]
849 |
850 | In this case word ``Theorem'' will be part of a reference: e.g.
851 | in HTML it will look like
852 |
853 | See Theorem 1
854 |
855 | If you want to omit number, just use \nonumber tag like so:
856 |
857 | See \ref[Theorem\nonumber|thm:existence]
858 |
859 | This will produce HTML like
860 | See Theorem
861 |
862 |
863 | Uses tags: ref, nonumber
864 |
865 | :param tag:
866 | :return:
867 | """
868 | doc, html, text = Doc().tagtext()
869 | if len(tag) == 1:
870 | tag = tag.unitemized()
871 |
872 | if tag.is_simple:
873 | prefix = None
874 | label = tag.value
875 | else:
876 | if len(tag) != 2:
877 | raise Exception(
878 | "Incorrect number of arguments in "
879 | + str(tag)
880 | + ": 2 arguments expected"
881 | )
882 |
883 | prefix, label = tag.children_values(not_simple="keep")
884 |
885 | number = self.label_to_number.get(label, "[" + label + "]")
886 | target = self.label_to_tag.get(label)
887 |
888 | href = ""
889 | if self.mode == "bychapters":
890 | if "snippet" not in [t.name for t in tag.ancestor_path()]:
891 | # check that we're not inside snippet now
892 | fromindex = self.tag2chapter(tag)
893 | else:
894 | fromindex = None
895 | href = (
896 | self.url_for_chapter(
897 | self.tag2chapter(target), fromindex=fromindex
898 | )
899 | if target
900 | else ""
901 | )
902 |
903 | eqref = target and (
904 | target.name in self.formulaenvs
905 | or target.name == "item"
906 | and target.parent.name in self.formulaenvs
907 | )
908 |
909 | if eqref:
910 | href += "#mjx-eqn-" + str(number)
911 | else:
912 | href += "#" + self.label2id(label)
913 |
914 | with html("span", klass="ref"):
915 | with html(
916 | "a",
917 | klass="a-ref",
918 | href=href,
919 | title=self.label_to_title.get(label, ""),
920 | ):
921 | if prefix:
922 | doc.asis(self.format(prefix, blanks_to_pars=False))
923 | if eqref:
924 | eq_id = label if self.eq_preview_by_labels else number
925 | try:
926 | doc.attr(
927 | ("data-url", self.url_for_eq_snippet(eq_id))
928 | )
929 | except NotImplementedError:
930 | pass
931 | if not isinstance(prefix, QqTag) or not prefix.exists(
932 | "nonumber"
933 | ):
934 | if prefix:
935 | doc.asis(" ")
936 | if eqref:
937 | text("(" + number + ")")
938 | else:
939 | text(number)
940 | return doc.getvalue()
941 |
942 | def handle_snref(self, tag: QqTag) -> str:
943 | """
944 | Makes snippet ref.
945 |
946 | Example:
947 |
948 | Consider \snref[Initial Value Problem][sn:IVP].
949 |
950 | Here sn:IVP -- label of snippet.
951 |
952 | If no separator present, fuzzy search will be performed over
953 | flabels
954 |
955 | Example:
956 |
957 | \snippet \label sn:IVP \flabel Initial Value Problem
958 | Initial Value Problem is a problem with initial value
959 |
960 | Consider \snref[initial value problem].
961 |
962 |
963 | :param tag:
964 | :return:
965 | """
966 |
967 | doc, html, text = Doc().tagtext()
968 |
969 | if len(tag) == 1:
970 | tag = tag.unitemized()
971 | if tag.is_simple:
972 | title = tag.value.replace("\n", " ")
973 | target = self.find_tag_by_flabel(title)
974 | label = target.label_.value
975 | else:
976 | if len(tag) != 2:
977 | raise Exception(
978 | "Incorrect number of arguments in "
979 | + str(tag)
980 | + (": one or two arguments " "expected")
981 | )
982 | title, label = tag.children_values(not_simple="keep")
983 | # TODO: testme
984 |
985 | data_url = self.url_for_snippet(label)
986 | with html("a", ("data-url", data_url), klass="snippet-ref"):
987 | doc.asis(self.format(title, blanks_to_pars=True))
988 | return doc.getvalue()
989 |
990 | def handle_href(self, tag: QqTag) -> str:
991 | """
992 | Example:
993 |
994 | Content from \href[Wikipedia|http://wikipedia.org]
995 |
996 | Uses tags: href
997 |
998 | :param tag: tag to proceed
999 | :return:
1000 | """
1001 | a, url = tag.children_values(not_simple="keep")
1002 | doc, html, text = Doc().tagtext()
1003 | with html("a", klass="href", href=url.strip()):
1004 | doc.asis(self.format(a.strip(), blanks_to_pars=False))
1005 | return doc.getvalue()
1006 |
1007 | def url_for_snippet(self, label: str) -> str:
1008 | """
1009 | Returns url for snippet by label.
1010 |
1011 | Override this method to use Flask's url_for
1012 |
1013 | :param label:
1014 | :return:
1015 | """
1016 | return "/snippet/" + label
1017 |
1018 | def url_for_eq_snippet(self, label: str) -> Optional[str]:
1019 | """
1020 | Returns url for equation snippet by label
1021 | Should be implemented in subclass
1022 |
1023 | :param label:
1024 | :return:
1025 | """
1026 | raise NotImplementedError
1027 |
1028 | def handle_eqref(self, tag: QqTag) -> str:
1029 | """
1030 | Alias to handle_ref
1031 |
1032 | Refs to formulas are ALWAYS in parenthesis
1033 |
1034 | :param tag:
1035 | :return:
1036 | """
1037 | return self.handle_ref(tag)
1038 |
1039 | def handle_enumerateable(self, tag: QqTag) -> str:
1040 | """
1041 | Uses tags: label, number
1042 | Add tags used manually from enumerateable_envs
1043 |
1044 | :param tag:
1045 | :return:
1046 | """
1047 | doc, html, text = Doc().tagtext()
1048 | name = tag.name
1049 | env_localname = self.localize(self.enumerateable_envs[name])
1050 | with html("div", klass="env env__" + name):
1051 | if tag.find("label"):
1052 | doc.attr(id=self.label2id(tag.label_.value))
1053 |
1054 | number = tag.get("number", "")
1055 | with html("span", klass="env-title env-title__" + name):
1056 | if tag.find("label"):
1057 | with html(
1058 | "a",
1059 | klass="env-title env-title__" + name,
1060 | href="#" + self.label2id(tag.label_.value),
1061 | ):
1062 | text(join_nonempty(env_localname, number) + ".")
1063 | else:
1064 | text(join_nonempty(env_localname, number) + ".")
1065 |
1066 | doc.asis(" " + self.format(tag, blanks_to_pars=True))
1067 | return "" + doc.getvalue() + "
\n"
1068 |
1069 | def handle_proof(self, tag: QqTag) -> str:
1070 | """
1071 | Uses tags: proof, label, outline, of
1072 |
1073 | Examples:
1074 |
1075 | \proof
1076 | Here is the proof
1077 |
1078 | \proof \of theorem \ref{thm:1}
1079 | Now we pass to proof of theorem \ref{thm:1}
1080 |
1081 | :param tag:
1082 | :return: HTML of proof
1083 | """
1084 | doc, html, text = Doc().tagtext()
1085 | with html("div", klass="env env__proof"):
1086 | if tag.find("label"):
1087 | doc.attr(id=self.label2id(tag.label_.value))
1088 | with html("span", klass="env-title env-title__proof"):
1089 | if tag.exists("outline"):
1090 | proofline = "Proof outline"
1091 | else:
1092 | proofline = "Proof"
1093 | doc.asis(
1094 | join_nonempty(
1095 | self.localize(proofline),
1096 | self.format(tag.find("of"), blanks_to_pars=False),
1097 | ).rstrip()
1098 | + "."
1099 | )
1100 | doc.asis(rstrip_p(" " + self.format(tag, blanks_to_pars=True)))
1101 | doc.asis("∎ ")
1102 | return doc.getvalue() + "\n
"
1103 |
1104 | def handle_paragraph(self, tag: QqTag) -> str:
1105 | """
1106 | :param tag:
1107 | :return:
1108 | """
1109 | doc, html, text = Doc().tagtext()
1110 | with html("span", klass="paragraph"):
1111 | doc.asis(self.format(tag, blanks_to_pars=False).strip() + ".")
1112 | # TODO: make paragraphs clickable?
1113 | anchor = ""
1114 | if tag.exists("label"):
1115 | anchor = " ".format(
1116 | self.label2id(tag.label_.value)
1117 | )
1118 | return anchor + "
" + doc.getvalue() + " "
1119 |
1120 | def handle_list(self, tag: QqTag, type_: str) -> str:
1121 | doc, html, text = Doc().tagtext()
1122 | with html(type_):
1123 | for item in tag("item"):
1124 | with html("li"):
1125 | doc.asis(self.format(item))
1126 | return doc.getvalue()
1127 |
1128 | def handle_enumerate(self, tag: QqTag) -> str:
1129 | """
1130 | Uses tags: item
1131 | :param tag:
1132 | :return:
1133 | """
1134 | return self.handle_list(tag, "ol")
1135 |
1136 | def handle_itemize(self, tag: QqTag) -> str:
1137 | """
1138 | Uses tags: item
1139 | :param tag:
1140 | :return:
1141 | """
1142 | return self.handle_list(tag, "ul")
1143 |
1144 | def handle_figure(self, tag: QqTag) -> str:
1145 | """
1146 | Currently, only python-generated figures, plotly figures and
1147 | tags are supported. Also one can use \rawhtml to embed
1148 | arbitrary HTML code (e.g. use D3.js).
1149 |
1150 | Example:
1151 |
1152 | \figure \label fig:figure
1153 | \pythonfigure
1154 | plt.plot([1, 2, 3], [1, 4, 9])
1155 | \caption
1156 | Some figure
1157 |
1158 | Uses tags: figure, label, caption, number, showcode, collapsed, img
1159 | Uses tags: center, nocenter
1160 |
1161 | :param tag: QqTag
1162 | :return: HTML of figure
1163 | """
1164 | doc, html, text = Doc().tagtext()
1165 | subtags = [
1166 | "pythonfigure",
1167 | "plotly",
1168 | "rawhtml",
1169 | "img",
1170 | "pythonvideo",
1171 | ]
1172 | langs = {
1173 | "pythonfigure": "python",
1174 | "plotly": "python",
1175 | "rawhtml": "html",
1176 | "pythonvideo": "python",
1177 | }
1178 | with html("div", klass="figure"):
1179 | if tag.find("label"):
1180 | doc.attr(id=self.label2id(tag.label_.value))
1181 | label = tag.label_.value
1182 | else:
1183 | label = None
1184 | for child in tag.children_tags():
1185 | if child.name in subtags:
1186 | if tag.exists("showcode"):
1187 | doc.asis(
1188 | self.showcode(
1189 | child,
1190 | collapsed=tag.exists("collapsed"),
1191 | lang=langs.get(child.name),
1192 | )
1193 | )
1194 | doc.asis(self.handle(child))
1195 | elif child.name == "caption":
1196 | caption_content = self.format(
1197 | child, blanks_to_pars=True
1198 | )
1199 | with html("div"):
1200 | klass = "figure_caption"
1201 | if child.exists("center"):
1202 | klass += " figure_caption_center"
1203 | elif (
1204 | child.exists("nocenter")
1205 | or len(child.text_content) > 90
1206 | ):
1207 | klass += " figure_caption_nocenter"
1208 | doc.attr(klass=klass)
1209 | if label is not None:
1210 | with html(
1211 | "a",
1212 | klass="figure_caption_anchor",
1213 | href="#" + self.label2id(label),
1214 | ):
1215 | text(
1216 | join_nonempty(
1217 | self.localize("Fig."),
1218 | tag.get("number"),
1219 | )
1220 | )
1221 | text(": ")
1222 | else:
1223 | text(
1224 | join_nonempty(
1225 | self.localize("Fig."),
1226 | tag.get("number"),
1227 | )
1228 | + ": "
1229 | )
1230 | doc.asis(caption_content)
1231 | return doc.getvalue()
1232 |
1233 | def handle_pythonvideo(self, tag: QqTag) -> str:
1234 | """
1235 | Uses tags: pythonvideo, style, jsanimate
1236 |
1237 | :param tag:
1238 | :return:
1239 | """
1240 |
1241 | if tag.exists("jsanimate"):
1242 | return self.make_python_jsanimate(tag.text_content)
1243 |
1244 | path = self.make_python_fig(
1245 | tag.text_content, exts=("mp4",), video=True
1246 | )
1247 | doc, html, text = Doc().tagtext()
1248 | with html(
1249 | "video",
1250 | klass="figure img-responsive",
1251 | controls="",
1252 | ):
1253 | if tag.exists("style"):
1254 | doc.attr(style=tag.style_.value)
1255 | doc.stag(
1256 | "source",
1257 | src=self.url_for_figure(
1258 | path + "/" + self.default_figname + ".mp4"
1259 | ),
1260 | type="video/mp4",
1261 | )
1262 | return doc.getvalue()
1263 |
1264 | def handle_pythonfigure(self, tag: QqTag) -> str:
1265 | """
1266 | Uses tags: pythonfigure, style, imgformat
1267 |
1268 | :param tag:
1269 | :return:
1270 | """
1271 | format = tag.get("imgformat", "svg")
1272 | path = self.make_python_fig(tag.text_content, exts=(format,))
1273 | doc, html, text = Doc().tagtext()
1274 | with html(
1275 | "img",
1276 | klass="figure img-responsive",
1277 | src=self.url_for_figure(
1278 | path + "/" + self.default_figname + "." + format
1279 | ),
1280 | ):
1281 | if tag.exists("style"):
1282 | doc.attr(style=tag.style_.value)
1283 | return doc.getvalue()
1284 |
1285 | def handle_pythoncode(self, tag: QqTag) -> str:
1286 | """
1287 | Uses tags: pythoncode, clearglobals, donotrun
1288 |
1289 | :param tag:
1290 | :return:
1291 | """
1292 | doc, html, text = Doc().tagtext()
1293 |
1294 | if tag.exists("showcode"):
1295 | doc.asis(
1296 | self.showcode(
1297 | tag,
1298 | collapsed=tag.exists("collapsed"),
1299 | lang="python",
1300 | )
1301 | )
1302 |
1303 | if tag.exists("clearglobals"):
1304 | self.python_globals.clear()
1305 |
1306 | if not tag.exists("donotrun"):
1307 | with stdout_io() as s:
1308 | try:
1309 | exec(tag.text_content, self.python_globals)
1310 | except Exception as e:
1311 | print(
1312 | "Exception: {}\n{}".format(e.__class__.__name__, e)
1313 | )
1314 | with html("pre"):
1315 | with html("code", klass="lang-python"):
1316 | doc.asis(s.getvalue())
1317 | return doc.getvalue()
1318 |
1319 | def handle_plotly(self, tag: QqTag) -> str:
1320 | return "".join(self.make_plotly_fig(tag.text_content))
1321 |
1322 | def handle_img(self, tag: QqTag) -> str:
1323 | """
1324 | Uses tags: src, style, alt
1325 |
1326 | :param tag:
1327 | :return:
1328 | """
1329 | src = tag.src_.value
1330 | doc, html, text = Doc().tagtext()
1331 | if src.startswith(("http://", "https://")):
1332 | url = src
1333 | else:
1334 | url = self.url_for_img(src)
1335 | with html(
1336 | "img", klass="figure img-responsive", src=url
1337 | ):
1338 | if tag.exists("style"):
1339 | doc.attr(style=tag.style_.value)
1340 | if tag.exists("alt"):
1341 | doc.attr(alt=tag.alt_.value)
1342 | return doc.getvalue()
1343 |
1344 | def showcode(
1345 | self, tag: QqTag, collapsed=False, lang: str = None
1346 | ) -> str:
1347 | """
1348 |
1349 |
1350 |
1351 |
1352 | :param tag:
1353 | :param collapsed: show code in collapsed mode by default
1354 | :param lang: language to use
1355 | :return:
1356 | """
1357 |
1358 | self.css["highlightjs"] = (
1359 | ' \n'
1362 | '\n"
1365 | )
1366 | self.js_top["highlightjs"] = (
1367 | '\n'
1369 | "\n"
1370 | '\n'
1372 | )
1373 | self.js_onload[
1374 | "highlightjs"
1375 | ] = """
1376 | function toggle_block(obj, show) {
1377 | var span = obj.find('span');
1378 | if(show === true){
1379 | span.removeClass('glyphicon-chevron-up')
1380 | .addClass('glyphicon-chevron-down');
1381 | obj.next('pre').slideDown();
1382 | }
1383 | else {
1384 | span.removeClass('glyphicon-chevron-down')
1385 | .addClass('glyphicon-chevron-up');
1386 | obj.next('pre').slideUp();
1387 | }
1388 | }
1389 |
1390 | /* onclick toggle next code block */
1391 | $('.toggle').click(function() {
1392 | var span = $(this).find('span');
1393 | toggle_block($(this), !span.hasClass('glyphicon-chevron-down'));
1394 | return false
1395 | })
1396 | """
1397 | button = """
1398 |
1399 |
1400 |
1401 | """
1402 |
1403 | if not collapsed:
1404 | button = button.replace(
1405 | "glyphicon-chevron-up", "glyphicon-chevron-down"
1406 | )
1407 |
1408 | doc, html, text = Doc().tagtext()
1409 | with html("pre"):
1410 | if collapsed:
1411 | doc.attr(style="display:none")
1412 | with html("code"):
1413 | if lang:
1414 | doc.attr(klass="lang-" + lang)
1415 |
1416 | doc.asis(self.code_prefixes.get(tag.name, ""))
1417 | # add a prefix if exists
1418 |
1419 | text(tag.text_content)
1420 |
1421 | return (
1422 | "
"
1423 | + button
1424 | + doc.getvalue()
1425 | + "
"
1426 | )
1427 |
1428 | def handle_snippet(self, tag: QqTag) -> str:
1429 | """
1430 | Uses tags: hidden, backref, label, nobackref
1431 |
1432 | :param tag:
1433 | :return:
1434 | """
1435 | anchor = ""
1436 | if not tag.exists("backref") and tag.exists("label"):
1437 | anchor = " ".format(
1438 | self.label2id(tag.label_.value)
1439 | )
1440 | if tag.exists("hidden"):
1441 | return anchor
1442 | return anchor + self.format(tag, blanks_to_pars=True)
1443 |
1444 | def handle_alert(self, tag: QqTag) -> str:
1445 | """
1446 | Uses tags: alert, type
1447 |
1448 | :param tag:
1449 | :return:
1450 | """
1451 |
1452 | type = tag.get("type", "warning")
1453 |
1454 | return dedent(
1455 | f"""
1456 | {self.format(tag)}
1457 |
"""
1458 | )
1459 |
1460 | def handle_hide(self, tag: QqTag) -> str:
1461 | """
1462 | :param tag:
1463 | :return: str
1464 | """
1465 | return ""
1466 |
1467 | def handle_em(self, tag: QqTag) -> str:
1468 | """
1469 | Example:
1470 | Let us define \em{differential equation}.
1471 |
1472 | :param tag:
1473 | :return:
1474 | """
1475 | return "" + self.format(tag) + " "
1476 |
1477 | def handle_tt(self, tag: QqTag) -> str:
1478 | """
1479 | Example:
1480 | In Python it corresponds to \tt{bool} type.
1481 | :param tag:
1482 | :return:
1483 | """
1484 | return "" + self.format(tag) + ""
1485 |
1486 | def handle_emph(self, tag: QqTag) -> str:
1487 | """
1488 | Alias for em
1489 | :param tag:
1490 | :return:
1491 | """
1492 | return self.handle_em(tag)
1493 |
1494 | def handle_strong(self, tag: QqTag) -> str:
1495 | """
1496 | Makes text strong (i.e. bold face)
1497 | :param tag:
1498 | :return:
1499 | """
1500 | return "" + self.format(tag) + " "
1501 |
1502 | def handle_preformatted(self, tag: QqTag) -> str:
1503 | r"""
1504 | preformatted tag, like in HTML or \verbatim in LaTeX
1505 | Uses tags: collapsed, lang
1506 |
1507 | :param tag:
1508 | :return:
1509 | """
1510 | if not tag.exists("lang"):
1511 | return "" + tag.text_content + " "
1512 | else:
1513 | return self.showcode(
1514 | tag,
1515 | collapsed=tag.exists("collapsed"),
1516 | lang=tag.get("lang"),
1517 | )
1518 |
1519 | def get_counter_for_tag(self, tag: QqTag) -> Optional[Counter]:
1520 | name = tag.name
1521 | counters = self.counters
1522 | while True:
1523 | if tag.exists("nonumber"):
1524 | return None
1525 | current = counters.get(name)
1526 | if current is None:
1527 | return None
1528 | if isinstance(current, Counter):
1529 | return current
1530 | if isinstance(current, dict):
1531 | counters = current
1532 | tag = tag.parent
1533 | name = tag.name
1534 | continue
1535 | return None
1536 |
1537 | def make_numbers(self, tag: QqTag) -> None:
1538 | """
1539 | Uses tags: number, label, nonumber, flabel
1540 |
1541 | :return:
1542 | """
1543 | for child in tag.children_tags():
1544 | name = child.name
1545 | if name == 'hide':
1546 | continue
1547 | if (
1548 | name in self.counters or name in self.enumerateable_envs
1549 | ) and not (child.find("number") or child.exists("nonumber")):
1550 | counter = self.get_counter_for_tag(child)
1551 | if counter is not None:
1552 | counter.increase()
1553 | child.append_child(QqTag({"number": str(counter)}))
1554 | if child.find("label"):
1555 | label = child.label_.value
1556 | self.label_to_number[label] = str(counter)
1557 | # self.label_to_title[label] = child.text_content
1558 | if child.find("label") and child.find("number"):
1559 | self.label_to_number[
1560 | child.label_.value
1561 | ] = child.number_.value
1562 | if child.find("label"):
1563 | self.label_to_tag[child.label_.value] = child
1564 | if child.find("flabel"):
1565 | self.flabel_to_tag[child.flabel_.value.lower()] = child
1566 | self.make_numbers(child)
1567 |
1568 | def find_tag_by_flabel(self, s: str) -> QqTag:
1569 | flabel = process.extractOne(s.lower(), self.flabel_to_tag.keys())[
1570 | 0
1571 | ]
1572 | return self.flabel_to_tag.get(flabel)
1573 |
1574 | def make_chapters(self):
1575 | for heading, *contents in split_by_predicate(
1576 | self.root,
1577 | predicate=lambda tag: (
1578 | isinstance(tag, QqTag) and tag.name == "chapter"
1579 | ),
1580 | zero_delim=QqTag("_zero_chapter"),
1581 | ):
1582 | self.add_chapter(Chapter(heading, [heading] + contents))
1583 |
1584 | def tag2chapter(self, tag) -> int:
1585 | """
1586 | Returns the number of chapter to which tag belongs.
1587 |
1588 | Chapters are separated by `chapter` tag.
1589 | Chapter before the first `chapter`
1590 | tag has number zero.
1591 |
1592 | :param tag:
1593 | :return:
1594 | """
1595 |
1596 | eve = tag.get_eve()
1597 | chapters = self.root("chapter")
1598 |
1599 | return next(
1600 | (
1601 | i - 1
1602 | for i, chapter in enumerate(chapters, 1)
1603 | if eve.idx < chapter.idx
1604 | ),
1605 | len(chapters),
1606 | )
1607 |
1608 | def url_for_chapter(
1609 | self, index=None, label=None, fromindex=None
1610 | ) -> str:
1611 | """
1612 | Returns url for chapter. Either index or label of
1613 | the target chapter have to be provided.
1614 | Optionally, fromindex can be provided. In this case
1615 | function will return empty string if
1616 | target chapter coincides with current one.
1617 |
1618 | You can inherit from QqHTMLFormatter and override
1619 | url_for_chapter_by_index and url_for_chapter_by_label too
1620 | use e.g. Flask's url_for.
1621 | """
1622 | assert index is not None or label is not None
1623 | if index is None:
1624 | index = self.label_to_chapter[label]
1625 | if fromindex is not None and fromindex == index:
1626 | # we are already on the right page
1627 | return ""
1628 | if label is None:
1629 | label = self.chapters[index].heading.find("label")
1630 | if not label:
1631 | return self.url_for_chapter_by_index(index)
1632 | return self.url_for_chapter_by_label(label.value)
1633 |
1634 | def url_for_chapter_by_index(self, index):
1635 | return "/chapter/index/" + urllib.parse.quote(str(index))
1636 |
1637 | def url_for_chapter_by_label(self, label):
1638 | return "/chapter/label/" + urllib.parse.quote(label)
1639 |
1640 | def add_chapter(self, chapter: Chapter) -> None:
1641 | if chapter.heading.find("label"):
1642 | self.label_to_chapter[chapter.heading.label_.value] = len(
1643 | self.chapters
1644 | )
1645 | self.chapters.append(chapter)
1646 |
1647 | def do_format(self) -> str:
1648 | self.make_numbers(self.root)
1649 | return self.format(self.root, blanks_to_pars=True)
1650 |
1651 | def tag_id(self, tag: QqTag) -> str:
1652 | """
1653 | Returns id of tag:
1654 | - If it has label, it is label-based
1655 | - If it does not have label, but have number, it is number-based
1656 | :param tag:
1657 | :return: str id
1658 | """
1659 | if tag.find("label"):
1660 | return self.label2id(tag.label_.value)
1661 | elif tag.find("number"):
1662 | return self.label2id(
1663 | tag.name + "_number_" + str(tag.number_.value)
1664 | )
1665 | else:
1666 | return ""
1667 |
1668 | def mk_toc(self, maxlevel=2, chapter=None) -> str:
1669 | """
1670 | Makes TOC (Table Of Contents)
1671 |
1672 | :param maxlevel: maximum heading level to include to TOC
1673 | (default: 2)
1674 | :param chapter: if None, we assume to have whole document on
1675 | the same page and TOC contains only local links.
1676 | If present, it is equal to index of current chapter
1677 | :return: str with HTML content of TOC
1678 | """
1679 | doc, html, text = Doc().tagtext()
1680 | curlevel = 1
1681 |
1682 | curchapter = 0
1683 | # chapter before first `chapter` has index 0
1684 |
1685 | with html("ul", klass="nav"):
1686 | for child in self.root.children_tags():
1687 | chunk = []
1688 | if child.name in self.heading_to_level:
1689 | hlevel = self.heading_to_level[child.name]
1690 |
1691 | # `chapter` heading marks new chapter, so increase
1692 | # curchapter counter
1693 | if hlevel == 1:
1694 | curchapter += 1
1695 |
1696 | if hlevel > maxlevel:
1697 | continue
1698 | while hlevel > curlevel:
1699 | chunk.append(" \n")
1700 | curlevel += 1
1701 | while hlevel < curlevel:
1702 | chunk.append(" \n")
1703 | curlevel -= 1
1704 |
1705 | targetpage = self.url_for_chapter(
1706 | index=curchapter, fromindex=chapter
1707 | )
1708 |
1709 | item_doc, item_html, item_text = Doc().tagtext()
1710 | with item_html(
1711 | "li",
1712 | klass=("toc_item toc_item_level_%i" % curlevel),
1713 | ):
1714 | with item_html(
1715 | "a",
1716 | href=(targetpage + "#" + self.tag_id(child)),
1717 | ):
1718 | item_text(
1719 | self.format(child, blanks_to_pars=False)
1720 | )
1721 | chunk.append(item_doc.getvalue())
1722 | doc.asis("".join(chunk))
1723 | return doc.getvalue()
1724 |
1725 | def extract_toc(self, maxlevel=2) -> TOCItem:
1726 | """
1727 | \chapter Hello
1728 | \section World
1729 | \section This
1730 | \subsection Haha
1731 | \section Hoho
1732 | \chapter Another
1733 | \section Dada
1734 |
1735 | --->
1736 | TOCItem(None,
1737 | [
1738 | TOCItem(None, []),
1739 | TOCItem("chap:Hello", [
1740 | TOCItem("sec:World", []),
1741 | TOCItem("sec:This", [
1742 | TOCItem("ssec:Haha", [])
1743 | ],
1744 | TOCItem("sec:Hoho", [])
1745 | ],
1746 | TOCItem("chap:Another",[
1747 | TOCItem("sec:Dada", [])
1748 | ])
1749 | ])
1750 |
1751 | \section This
1752 | \chapter Hello
1753 | \subsection Haha
1754 | \section Hoho
1755 |
1756 | TOCItem(None,
1757 | [
1758 | TOCItem(None, [
1759 | TOCItem("sec:This")
1760 | ]),
1761 | TOCItem("chap:Hello"), [
1762 | TOCItem(None, [
1763 | TOCItem("ssec:Haha", [])
1764 | ]),
1765 | TOCItem("Hoho", [])
1766 | ]
1767 | ])
1768 |
1769 | :param maxlevel: maximal level of headings to include
1770 | (headings numeration starts with 1)
1771 | :return:
1772 | """
1773 |
1774 | toc = TOCItem(None)
1775 | curitem = toc.spawn_child(None)
1776 |
1777 | curlevel = 1
1778 |
1779 | # loop invariant:
1780 | # 1. all heading processed so far are added to toc
1781 | # 2. curlevel == level of the last processed heading
1782 | # 3. curitem is a TOCItem for the last processed heading
1783 | # 4. depth of curitem == curlevel (depth of root is 0)
1784 |
1785 | for tag in self.root.children_tags():
1786 | if tag.name in self.heading_to_level:
1787 | hlevel = self.heading_to_level[tag.name]
1788 |
1789 | if hlevel > maxlevel:
1790 | continue
1791 |
1792 | # want to make curlevel == hlevel - 1
1793 | # by going up and down on the tree
1794 |
1795 | # if curlevel < hlevel - 1: add fake levels
1796 | for curlevel in range(curlevel + 1, hlevel):
1797 | curitem = curitem.spawn_child(None)
1798 |
1799 | # if curlevel >= hlevel, go to parent TOCItem
1800 | # (curlevel - hlevel + 1) times
1801 | for curlevel in range(curlevel - 1, hlevel - 2, -1):
1802 | curitem = curitem.parent
1803 |
1804 | assert curlevel == hlevel - 1
1805 |
1806 | curitem = curitem.spawn_child(tag)
1807 | curlevel = hlevel
1808 | return toc
1809 |
1810 | def format_toc(
1811 | self, toc: TOCItem, fromchapter: int = None, tochapter: int = None
1812 | ) -> FormattedTOCItem:
1813 | """
1814 | Formats toc obtained with extract_toc()
1815 |
1816 | :param toc:
1817 | :param fromchapter: current chapter, if None,
1818 | assuming all chapters are on one page
1819 | and only local links presented
1820 | :param tochapter: chapter we render now,
1821 | None if we render contents
1822 | for the whole book
1823 | :return:
1824 | """
1825 |
1826 | ftoc = FormattedTOCItem()
1827 | if toc.tag:
1828 | ftoc.string = self.format(toc.tag, blanks_to_pars=False)
1829 | targetpage = self.url_for_chapter(
1830 | index=tochapter, fromindex=fromchapter
1831 | )
1832 | ftoc.href = targetpage + "#" + self.tag_id(toc.tag)
1833 | ftoc.tag = toc.tag
1834 | ftoc.iscurchapter = toc.level == 1 and fromchapter == tochapter
1835 |
1836 | for i, heading in enumerate(toc.children):
1837 | if toc.level == 0:
1838 | tochapter = i
1839 |
1840 | ftoc.append_child(
1841 | self.format_toc(
1842 | heading, fromchapter=fromchapter, tochapter=tochapter
1843 | )
1844 | )
1845 | return ftoc
1846 |
1847 | @staticmethod
1848 | def tag_hash_id(tag: QqTag) -> str:
1849 | """
1850 | Returns autogenerated tag id based on tag's contents.
1851 | It's first 5 characters of MD5-hashsum of tag's content
1852 | :return:
1853 | """
1854 | return hashlib.md5(
1855 | repr(tag.as_list()).encode("utf-8")
1856 | ).hexdigest()[:5]
1857 |
1858 | def handle_quiz(self, tag: QqTag) -> str:
1859 | """
1860 | Uses tags: choice, correct, comment
1861 |
1862 | Example:
1863 |
1864 | \question
1865 | Do you like qqmbr?
1866 | \quiz
1867 | \choice
1868 | No.
1869 | \comment You didn't even try!
1870 | \choice \correct
1871 | Yes, i like it very much!
1872 | \comment And so do I!
1873 |
1874 | :param tag:
1875 | :return:
1876 | """
1877 | if not tag.exists("md5id"):
1878 | tag.append_child(QqTag("md5id", [self.tag_hash_id(tag)]))
1879 | template = Template(
1880 | filename=os.path.join(self.templates_dir, "quiz.html")
1881 | )
1882 | return template.render(formatter=self, tag=tag)
1883 |
1884 | def handle_rawhtml(self, tag: QqTag) -> str:
1885 | return tag.text_content
1886 |
1887 | def handle_meta(self, tag: QqTag) -> str:
1888 | return self.format(tag, blanks_to_pars=False) + "\n "
1889 |
1890 | def handle_author(self, tag: QqTag) -> str:
1891 | doc, html, text = Doc().tagtext()
1892 | with html("p", klass="meta meta-author"):
1893 | doc.asis(self.format(tag, blanks_to_pars=False))
1894 | return doc.getvalue()
1895 |
1896 | def handle_affiliation(self, tag: QqTag) -> str:
1897 | doc, html, text = Doc().tagtext()
1898 | with html(
1899 | "span", klass="meta meta-author meta-author-affiliation"
1900 | ):
1901 | doc.asis(" (" + self.format(tag, blanks_to_pars=False) + ")")
1902 | return doc.getvalue()
1903 |
1904 | def handle_link(self, tag: QqTag) -> str:
1905 | doc, html, text = Doc().tagtext()
1906 | with html("p", klass="meta meta-link"):
1907 | if tag.exists("role"):
1908 | doc.add_class("meta-link-" + tag.role_.value)
1909 | if tag.exists("url"):
1910 | with html("a", href=tag.url_.value):
1911 | doc.asis(self.format(tag, blanks_to_pars=False))
1912 | else:
1913 | doc.asis(self.format(tag, blanks_to_pars=False))
1914 | return doc.getvalue()
1915 |
1916 | def handle_title(self, tag: QqTag) -> str:
1917 | doc, html, text = Doc().tagtext()
1918 | with html("h1", klass="meta meta-title"):
1919 | doc.asis(self.format(tag, blanks_to_pars=False))
1920 | return doc.getvalue()
1921 |
1922 | def handle_subtitle(self, tag: QqTag) -> str:
1923 | doc, html, text = Doc().tagtext()
1924 | with html("h2", klass="meta meta-subtitle"):
1925 | doc.asis(self.format(tag, blanks_to_pars=False))
1926 | return doc.getvalue()
1927 |
1928 | def handle_blockquote(self, tag: QqTag) -> str:
1929 | return "" + self.format(tag, blanks_to_pars=True) + " "
1930 |
--------------------------------------------------------------------------------