Compare commits

..

No commits in common. "master" and "f3309d65692e4ab4e4b1f16fab1814ba67b721bf" have entirely different histories.

50 changed files with 1424 additions and 3387 deletions

View file

@ -1,7 +0,0 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
indent_style = tab

5
.gitignore vendored
View file

@ -10,8 +10,3 @@ public/
*.jpeg
*.svg
!test/1px.png
**/.mypy_cache
**/*.egg-info
build/
dist/
**/node_modules

3
.gitmodules vendored
View file

@ -1,6 +1,3 @@
[submodule "ucwmac"]
path = ucwmac
url = git://git.ucw.cz/ucwmac.git
[submodule "src/formatitko/katex-server"]
path = src/formatitko/katex-server
url = https://gitea.ks.matfyz.cz:/KSP/formatitko-katex-server

213
README.md
View file

@ -16,9 +16,7 @@ Inkscape are used for image processing. Nodejs is used for KaTeX.
## Usage
```
usage: formatitko [-h] [-l IMG_LOOKUP_DIRS [IMG_LOOKUP_DIRS ...]] [-p IMG_PUBLIC_DIR] [-c IMG_CACHE_DIR] [-i IMG_WEB_PATH] [-w OUTPUT_HTML] [-t OUTPUT_TEX] [-m OUTPUT_MD]
[-j OUTPUT_JSON] [--katex-server] [-k KATEX_SOCKET] [--debug]
input_filename
usage: formatitko.py [-h] [-l IMG_LOOKUP_DIRS [IMG_LOOKUP_DIRS ...]] [-p IMG_PUBLIC_DIR] [-i IMG_WEB_PATH] [-w OUTPUT_HTML] [-t OUTPUT_TEX] input_filename
positional arguments:
input_filename The markdown file to process.
@ -26,27 +24,16 @@ positional arguments:
options:
-h, --help show this help message and exit
-l IMG_LOOKUP_DIRS [IMG_LOOKUP_DIRS ...], --img-lookup-dirs IMG_LOOKUP_DIRS [IMG_LOOKUP_DIRS ...]
Image lookup directories. When processing images, the program will try to find the image in them first. Always looks for images in the same folder
as the markdown file. (default: [])
Image lookup directories. When processing images, the program will try to find the image in them first. Always looks for images in the same folder as the markdown
file. (default: [])
-p IMG_PUBLIC_DIR, --img-public-dir IMG_PUBLIC_DIR
Directory to put processed images into. The program will overwrite images, whose dependencies are newer. (default: public)
-c IMG_CACHE_DIR, --img-cache-dir IMG_CACHE_DIR
Directory to cache processed images and intermediate products. The program will overwrite files, whose dependencies are newer. (default: cache)
Directory to put processed images into. The program will not overwrite existing images. (default: public)
-i IMG_WEB_PATH, --img-web-path IMG_WEB_PATH
Path where the processed images are available on the website. (default: /)
-w OUTPUT_HTML, --output-html OUTPUT_HTML
The HTML file (for Web) to write into. (default: None)
The HTML file (for Web) to write into. (default: output.html)
-t OUTPUT_TEX, --output-tex OUTPUT_TEX
The TEX file to write into. (default: None)
-m OUTPUT_MD, --output-md OUTPUT_MD
The Markdown file to write into. (Uses pandoc to generate markdown) (default: None)
-j OUTPUT_JSON, --output-json OUTPUT_JSON
The JSON file to dump the pandoc-compatible AST into. (default: None)
--katex-server Starts a KaTeX server and prints the socket filename onto stdout. Useful for running formatitko many times without starting the KaTeX server each
time. (default: False)
-k KATEX_SOCKET, --katex-socket KATEX_SOCKET
The KaTeX server socket filename obtained by running with `--katex-server`. (default: None)
--debug
The TEX file to write into. (default: output.tex)
```
## Format
@ -59,7 +46,7 @@ definition lists and citations. It also adds its own custom features.
Flags can be set in the Front Matter or with python code. Then, elements with
the `if` attribute will only be shown if the flag is set to True and elements
with the `ifnot` attribute will only be show if the flag is not set to True.
with the `ifn` attribute will only be show if the flag is not set to True.
**Example:**
@ -72,7 +59,7 @@ flags:
[This will not be shown]{if=bar}
[This will be shown]{ifnot=bar}
[This will be shown]{ifn=bar}
```
### Including other files
@ -82,28 +69,12 @@ There are two ways of including files.
#### Importing
The first is importing, which only takes the state (defined commands, metadata,
etc.) from the file and any content is omitted. This is useful for creating
libraries of commands.
libraries of commands. The syntax is as follows:
There are three types of imports:
[#test/empty.md]{}
##### Python Module (the default)
```markdown
[#ksp_formatitko as ksp]{}
```
or
```markdown
[#ksp_formatitko]{}
```
with an optional `type=module` in the curly brackets, tries to import a python
module as a set of formatitko commands. See below for more details about
commands.
##### JSON Metadata
[#test/test.json]{type=metadata key=orgs}
This will import metadata from a JSON file. THe optional `key` argument sets the
key under which the whole JSON file will be placed. Dictionaries are merged,
others overwritten.
The curly braces are required for pandoc to parse the import properly and should
be left empty.
#### Partials
Partials are the very opposite of imports, they have their own context, which
@ -124,19 +95,12 @@ partial to `tex` or `html`.
### Groups
Groups are pieces of markdown with their own sandboxed context, in other words,
inline partials. Syntax-wise they are pandoc Divs with the `.group` class. All
attributes of the Div will be passed down as metadata to the group.
::: {.group lang=cs}
OOOoo český mód
:::
If you want to have more fancy metadata, that can only be specified in a front
matter, you can use the following syntax:
inline partials. They function exactly the same as partials, namely can have
their own front matter.
```markdown {.group}
---
lang: cs
language: cs
---
OOOoo český mód
```
@ -150,9 +114,6 @@ fmt.Pritln("owo")
```
````
Note however, that when this syntax is used, pandoc is executed for each of
these blocks which could get slow. Using divs is preferred.
Groups and partials are also enclosed in `\begingroup` and `\endgroup` in the
output TeX.
@ -177,38 +138,15 @@ pandoc feature.]
### Running python code
Formátítko allows you to run Python code directly from your MD file. Any
`python` code block with the class `run` will be executed.
`python` code block with the class `run` will be executed:
#### Command environment
#### Context
The commands will be executed as functions with the following signature:
```python
def command(element: Command, context: Context) -> list[Element]:
```
some global variables may be available, and are defined in `command_env.py`:
```python
import panflute as pf
import formatitko.elements as fe
from formatitko.util import import_md_list
from formatitko.util import parse_string
from formatitko.context import Context
from formatitko.command import Command
from panflute import Element
```
##### `element` parameter
The `element` parameter holds the element the command is currently being executed
on. In the case of running python blocks directly, it is probably not
interesting but will get interesting later.
##### `context` parameter
You can access the current context using the `context` parameter. The context
You can access the current context using the `ctx` variable. The context
provides read/write access to the FrontMatter metadata. The context has the
following methods:
`context.get_metadata(key: str, simple: bool=True, immediate: bool=False)`
`ctx.get_metadata(key: str, simple: bool=True, immediate: bool=False)`
- `key`: The key of the metadatum you want to get. Separate child keys with
dots: `ctx.get_metadata("flags.foo")`
@ -218,13 +156,13 @@ following methods:
- `immediate`: Only get metadatum from the current context, not from its
parents.
`context.set_metadata(key: str, value)`
`ctx.set_metadata(key: str, value)`
- `key`: The key of the metadatum you want to get. Separate child keys with
dots: `ctx.get_metadata("flags.foo")`
- `value`: Any value you want to assign to the metadatum
`context.unset_metadata(key: str)`
`ctx.unset_metadata(key: str)`
Delete the metadatum in the current context and allow it to inherit the value
from the parent context.
@ -234,31 +172,26 @@ from the parent context.
Helper functions for flags exist which work the same as for metadata:
`context.is_flag_set(flag: str) -> bool`
`ctx.is_flag_set(flag: str) -> bool`
`context.set_flag(flag: str, val: bool)`
`ctx.set_flag(flag: str, val: bool)`
`context.unset_flag(flag: str)`
`ctx.unset_flag(flag: str)`
There are also other useful functions, which you can see for yourself in
`context.py`.
#### Writing output
> **WARNING**: Writing to metadata should **only** be done **at the beginning**
> of the document or a group (before any printable content). Writing to metadata
> in other places in the document might cause undefined behaviour (mostly some
> elements might behave as if the metadata was set elsewhere).
There are two modes of writing output, plaintext and element-based.
##### Return value
The function **must** return a list of valid Elements. This list may be empty.
These elements will be placed in the document in the location where the command
was invoked.
Plaintext mode uses the `print(text: str)` and `println(text: str)` functions,
that append text to a buffer which is then interpreted as markdown input.
The `parse_string` function might be useful, it turns a simple string into a
list of panflute's `Str`s and `Space`s (without any formatting). If you want to
use markdown in your function output, you have to convert it yourself using
`import_md` but beware this calls pandoc, is potentially slow and is
discouraged.
Element-based mode uses the `appendChild(element: pf.Element)` and
`appendChildren(*elements: List[pf.Element])` functions which allow you to
append `panflute` elements to a list which is then again interpreted as input.
The `panflute` library is available as `pf`.
When one of these functions is called, the mode is set and functions from the
other mode cannot be called within the same block of code.
**Examples:**
@ -267,15 +200,14 @@ discouraged.
title: Foo
---
```python {.run}
return [
pf.Para(pf.Emph(pf.Str("wooo"))),
pf.Para(*parse_string("The title of this file is: " + context.get_metadata("title")))
]
println("*wooo*")
println()
println("The title of this file is: " + ctx.get_metadata("title"))
```
````
```python {.run}
return [pf.Strong(*parse_string("Hello world!"))]
appendChild(pf.Para(pf.Strong(pf.Str("foo"))))
```
### Defining and running commands
@ -286,7 +218,7 @@ Code blocks can be also saved and executed later. Defining is done using the
**Example:**
```python {define=commandname}
return [pf.Str("foo")]
print("foo")
```
If you try to define the same command twice, you will get an error. To redefine
@ -298,7 +230,7 @@ There are multiple ways of running commands. There is the shorthand way:
[!commandname]{}
Or using the `c` attribute on a span or a div (new: or a codeblock!):
Or using the `c` attribute on a span or a div:
[Some content]{c=commandname}
@ -306,16 +238,6 @@ Or using the `c` attribute on a span or a div (new: or a codeblock!):
Some content
:::
```python {define=bash}
import subprocess
c = subprocess.run(["bash", "-c", element.text], stdout=subprocess.PIPE, check=True, encoding="utf-8")
return [pf.Para(pf.Str(c.stdout))]
```
```bash {c=bash}
cat /etc/hostname
```
To access the content or attributes of the div or span the command has been
called on, the `element` variable is available, which contains the `panflute`
representation of the element.
@ -323,7 +245,7 @@ representation of the element.
**Example:**
```python {define=index}
return [element.content[int(element.attributes["i"])]]
appendChild(element.content[int(element.attributes["i"])])
```
[Pick the third element from this span]{c=index i=2}
@ -346,24 +268,23 @@ blocks. To turn it off for a single block, don't specify a language or set the
`highlight` attribute to `False`. You can also set the metadatum `highlight` to
`false` in the FrontMatter to disable it in a given Group. To change the [highlighting
style](https://pygments.org/styles/), you have to set the `highlight-style`
metadatum or the `style` attribute directly on the element.
metadatum in the **top-level document** this is to prevent the need for many
inline style definitions.
**Examples:**
```python
print("cool")
```
```python {style=manni}
print("freezing")
```
```zsh {highlight=False}
./formatitko.py README.md
```
### Language awareness
Formátítko is language aware, this means that the `lang` metadatum is
somewhat special. (It is also special for pandoc)
Formátítko is language aware, this means that the `language` metadatum is
somewhat special. When set using the front matter, it is also popped out to TeX
as a `\languagexx` macro. Currently supported values are `cs` and `en` for
internal uses but can be set to anything.
### NBSP
Formátítko automatically inserts no-break spaces according to its sorta smart
@ -382,9 +303,12 @@ language.
**Examples:**
::: {.group lang=cs}
```markdown {.group}
---
language: cs
---
"Uvozovky se v českém testu píší 'jinak' než v angličtině."
:::
```
"In Czech texts, quotes are written 'differently' than in English"
@ -415,9 +339,6 @@ Images are automatically searched for in the directory where each markdown file
command line parameter. After processing, they're all put into the folder
specified with `--public-dir`.
Formátítko also does dependency management, which means that all images will be
regenerated only when their dependencies are newer.
#### Image processing
Images are automatically processed so that they can be successfully used in both
output formats. This includes generating multiple sizes and providing a
@ -427,22 +348,12 @@ To customize this, the `file-width`, `file-height`, `file-dpi`, `file-quality`
and `no-srcset` attributes are available. All but the last one should be
integers.
Processing also includes Asymptote images -- you can simply include an asymptote
program as an image and formátítko handles the rest for you.
#### Content headers and footers
If you want formatitko to generate fully formed html files for you, you might
want to add a HTML partial with the starting tags and `<head>`. This would
normally not work, because the entire document is wrapped with `<main>`. Using
the special `.header_content` and `.footer_content` classes of divs, you can
append content to a header and footer, which are popped to the output before and
after the document.
:::: {.header_content}
::: {partial="test/test-top.html" type="html"}
:::
::::
Keep in mind that the processing tries to be as lazy as possible, so it never
overwrites any files and if it finds the right format or resolution (only
judging by the filenames) in the lookup directories it will just copy that. This
means that any automatic attempts at conversion can be overridden by converting
the file yourself, naming it accordingly and placing it either in the public or
one of the lookup directories.
## Working with the produced output
@ -455,15 +366,11 @@ your `<head>`^[This is taken directly from [KaTeX's docs](https://katex.org/docs
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/katex@0.16.4/dist/katex.min.css' integrity='sha384-vKruj+a13U8yHIkAyGgK1J3ArTLzrFGBbBc0tDp4ad/EyewESeXE/Iv67Aj8gKZ0' crossorigin='anonymous'>
```
You can see how this is done in `test/test.md`
Also the output HTML is not intended as a standalone file but should be included
as part of a larger template. (That includes a doctype, other css, etc.)
### TeX
The TeX output is not usable as is. Many of the elements are just converted to
macros, which you have to define yourself. There is an example implementation in
`formatitko.tex`, which uses LuaTeX and the ucwmac package, but you should
customize it to your needs (and to the context in which the output is used).
## More examples
More usage examples can be found (even though a bit chaotically) in the test
directory.

95
command.py Normal file
View file

@ -0,0 +1,95 @@
from panflute import Div,Span,Para
from typing import List
# Import local files
from util import *
from context import Context
from mj_show import show
class Command:
pass
# This distinction is needed because while transforming the tree, inline
# elements cannot be replaced with block ones
class InlineCommand(Span, Command):
def replaceSelf(self, content: List[Element]) -> Span:
try:
return Span(*content)
except TypeError:
if len(content) == 1 and isinstance(content[0], Para):
return Span(*content[0].content)
else:
raise SyntaxError(f"The command {self.attributes['c']} returned multiple Paragraphs and must be executed using `::: {{c={self.attributes['c']}}}\\n:::`.")
pass
class BlockCommand(Div, Command):
def replaceSelf(self, content: List[Element]) -> Div:
return Div(*content)
pass
# This function is called in trasform.py, defining a command which can be
# called later using the function below
def handle_command_define(e: Element, c: Context):
if "define" in e.attributes:
if not c.get_command(e.attributes["define"]):
c.set_command(e.attributes["define"], compile(e.text, '<string>', 'exec'))
return nullify(e)
else:
raise NameError(f"Command already defined: '{e.attributes['define']}'")
if "redefine" in e.attributes:
c.set_command(e.attributes["redefine"], compile(e.text, '<string>', 'exec'))
return nullify(e)
return e
# This function executes commands and inline runnable code blocks (see
# transform.py for their syntax). Context can be accessed using `ctx` and there
# are four functions available to create output from these commands and the
# element the command has been called on (including its .content) can be
# accessed using `element`. Arguments can be passed down to the comand using
# the element's attributes.
#
# print and println append text to a buffer which is then interpreted as
# markdown with the current context.
#
# appendChild and appendChildren append panflute elements to a list which is
# then transformed. A command which does nothing looks like this:
# ```python {define=nop}
# appendChildren(element.content)
# ```
#
# These two types, appending and printing, cannot be mixed.
def executeCommand(source, element: Element, ctx: Context) -> List[Element]:
mode = 'empty'
text = ""
content = []
def print(s: str=""):
nonlocal mode, text
if mode == 'elements':
raise SyntaxError("Cannot use `print` and `appendChild` in one command at the same time.")
mode = 'text'
text += str(s)
def println(s: str=""):
print(str(s)+"\n")
def appendChild(e: Element):
nonlocal mode, content
if mode == 'text':
raise SyntaxError("Cannot use `print` and `appendChild` in one command at the same time.")
mode = 'elements'
content.append(e)
def appendChildren(l: List[Element]):
for e in l:
appendChild(e)
import panflute as pf
exec(source)
if mode == 'text':
return import_md(text, standalone=False)
if mode == 'elements':
return content
return []

94
context.py Normal file
View file

@ -0,0 +1,94 @@
from panflute import Doc, Div
from typing import Dict
import os
# This class is used to keep state while transforming the document using
# transform.py. For the context to be available to the html and TeX generators,
# individual keys must be manually assigned to the individual elements. This is
# done in transform.py.
#
# The context is also aware of its parent contexts and relevant data (such as
# metadata and commands) can be read from the closest parent context. Writing
# only happens to the current one.
#
# This class is basically an extension to panflute's doc, this is why metadata
# is read directly from it.
class Context:
def __init__(self, doc: Doc, path: str, parent: 'Context'=None, trusted: bool=True):
self.parent = parent
self._commands = {}
self.doc = doc
self.trusted = trusted
self.path = path
self.dir = os.path.dirname(path) if os.path.dirname(path) != "" else "."
self.filename = os.path.basename(path)
if self.get_metadata("flags", immediate=True) is None:
self.set_metadata("flags", {})
def get_command(self, command: str):
if command in self._commands:
return self._commands[command]
elif self.parent:
return self.parent.get_command(command)
else:
return None
def set_command(self, command: str, val):
self._commands[command] = val
def unset_command(self, command: str):
del self._commands[command]
def is_flag_set(self, flag: str):
if self.get_metadata("flags."+flag):
if self.get_metadata("flags."+flag):
return True
else:
return False
elif self.parent:
return self.parent.is_flag_set(flag)
else:
return False
def set_flag(self, flag: str, val: bool):
self.set_metadata("flags."+flag, val)
def unset_flag(self, flag: str):
self.unset_metadata("flags."+flag)
def get_metadata(self, key: str, simple: bool=True, immediate: bool=False):
value = self.doc.get_metadata(key, None, simple)
if value is not None:
return value
elif self.parent and not immediate:
return self.parent.get_metadata(key)
else:
return None
def set_metadata(self, key: str, value):
if key == "language":
print("WARN: Setting language this way doesn't propagate to TeX. Either use the Front Matter or specify it additionally using the \\languagexx macro.")
meta = self.doc.metadata
key = key.split(".")
for k in key[:-1]:
meta = meta[k]
meta[key[-1]] = value
def unset_metadata(self, key: str):
meta = self.doc.metadata
key = key.split(".")
for k in key[:-1]:
meta = meta[k]
del meta.content[key[-1]] # A hack because MetaMap doesn't have a __delitem__
# This is a custom element which creates \begingroup \endgroup groups in TeX
# and also causes KaTeX math blocks to be isolated in a similar way.
#
# Whenever a new context is created, its content should be eclosed in a group and vice-versa.
class Group(Div):
def __init__(self, *args, metadata={}, **kwargs):
self.metadata = metadata
super().__init__(*args, **kwargs)

62
formatitko.py Executable file
View file

@ -0,0 +1,62 @@
#!/usr/bin/env python3
import argparse
import re
import sys
from typing import List
import os
# Import local files
from transform import transform
from util import *
from context import Context, Group
from katex import KatexClient
from html import html
from tex import tex
from images import ImageProcessor
from mj_show import show
# Initialize command line arguments
parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("-l", "--img-lookup-dirs", help="Image lookup directories. When processing images, the program will try to find the image in them first. Always looks for images in the same folder as the markdown file.", nargs="+", default=[])
parser.add_argument("-p", "--img-public-dir", help="Directory to put processed images into. The program will not overwrite existing images.", default="public")
parser.add_argument("-i", "--img-web-path", help="Path where the processed images are available on the website.", default="/")
parser.add_argument("-w", "--output-html", help="The HTML file (for Web) to write into.", default="output.html")
parser.add_argument("-t", "--output-tex", help="The TEX file to write into.", default="output.tex")
parser.add_argument("input_filename", help="The markdown file to process.")
parser.add_argument("--debug", action='store_true')
args = parser.parse_args()
# TODO: Accept path to unix socket for katexClient, then don't init our own,
# just connect to an existing one. For formátíking many files in a row.
# Use panflute to parse the input MD file
doc = import_md(open(args.input_filename, "r").read())
if args.debug:
print(show(doc))
# The language metadatum is important, so it's read before transformation and
# then attached to a group inside the Doc
language = doc.get_metadata("language", None, True)
context = Context(doc, args.input_filename)
# Transform the document. This includes all the fancy formatting this software does.
doc = doc.walk(transform, context)
# Now wrap the document contents in a group, which is able to pop its language
# setting out to TeX
doc.content = [Group(*doc.content, metadata={"language":language})]
# Initialize the image processor (this just keeps some basic state)
imageProcessor = ImageProcessor(args.img_public_dir, args.img_web_path, *args.img_lookup_dirs)
# Initialize KaTeX client (this runs the node app and connects to a unix socket)
with KatexClient() as katexClient:
# Generate HTML and TeX out of the transformed document
open(args.output_html, "w").write(html(doc, katexClient, imageProcessor))
open(args.output_tex, "w").write(tex(doc, imageProcessor))
if args.debug:
print(show(doc))

View file

@ -1,6 +1,9 @@
\input luatex85.sty
\input ucwmac2.tex
\parskip=5pt plus 3pt minus 2pt
\ucwmodule{luaofs}
\ucwmodule{link}
\ucwmodule{verb}
\parskip=3pt plus 2pt minus 1pt
\parindent=0sp
\def\strong#1{{%
@ -14,17 +17,23 @@
\def\superscript#1{\leavevmode\raise3pt\hbox{\fiverm#1}}
\def\subscript#1{\leavevmode\lower1pt\hbox{\fiverm#1}}
\newcount\fncount
\fncount=1
\def\fnmark{\superscript{\the\fncount}}
\def\fn#1{\footnote\fnmark{#1}\advance\fncount by 1}
\def\section#1{{\parskip1em\settextsize{18}\bf #1}}
\def\subsection#1{{\parskip1em\settextsize{16}\bf #1}}
\def\subsubsection#1{{\parskip1em\settextsize{14}\bf #1}}
\def\subsubsubsection#1{{\parskip1em\settextsize{12}\bf #1}}
\def\subsubsubsubsection#1{{\parskip1em\settextsize{10}\bf #1}}
\def\subsubsubsubsubsection#1{{\parskip1em\settextsize{10}\bi #1}}
\def\hA#1{{\parskip1em\settextsize{14}\bf #1}}
\def\hB#1{{\parskip1em\settextsize{12}\bf #1}}
\def\hC#1{{\parskip1em\settextsize{10}\bf #1}}
\def\hD#1{{\parskip1em\settextsize{10}\bi #1}}
\def\hr{{\vskip5pt\hrule\vskip5pt}}
\long\def\blockquote#1{\vskip\lineskip\vskip\parskip\hbox{\vrule\hskip5pt\vbox{#1}}}
\let\code\verbatim
\let\codeblock\verbatim
\def\subscript#1{\leavevmode\lower1pt\hbox{\fiverm#1}}
\def\strikeout#1{FIXME: Strikeout not implemented}
\def\underline#1{FIXME: Underline not implemented}
\def\figure#1#2{\vskip5pt\centerline{#1}\centerline{#2}\vskip5pt}
\def\figcaption#1{{\it #1}}
\let\image\putimage
\def\languagecs{} % KSP should define this to \cze probably
\def\languageen{} % KSP should define this to \eng probably

305
html.py Normal file
View file

@ -0,0 +1,305 @@
from panflute import *
from pygments import highlight
from pygments.lexers import get_lexer_by_name
from pygments.formatters import HtmlFormatter
from pygments.util import ClassNotFound
import os
from whitespace import NBSP
from transform import FQuoted
from katex import KatexClient
from util import inlinify
from context import Group
from images import ImageProcessor
def html(e: Element, k: KatexClient, i: ImageProcessor, indent_level: int=0, indent_str: str="\t") -> str:
# `only` attribute which makes transformed elements appear only in tex
# output or html output
if hasattr(e, "attributes") and "only" in e.attributes and e.attributes["only"] != "html":
return ""
if isinstance(e, ListContainer):
return ''.join([html(child, k, i, indent_level, indent_str) for child in e])
# Bits from which the final element output is built at the end of this
# function. Most elements override this by returning their own output.
tag = e.tag.lower()
attributes = ""
content_foot = ""
content_head = ""
if isinstance(e, Str):
return e.text.replace(" ", "&nbsp;")
# Most elements fit the general template at the end of the function, just
# need their html tag specified.
tags = {
BulletList: "ul",
Doc: "main",
Emph: "em",
Caption: "figcaption",
Para: "p",
Header: "h"+str(e.level) if hasattr(e, "level") else "",
LineBlock: "p",
ListItem: "li",
SmallCaps: "span",
Strikeout: "strike",
Subscript: "sub",
Superscript: "sup",
Underline: "u",
TableBody: "tbody",
TableHead: "thead",
TableFoot: "tfoot",
TableRow: "tr",
TableCell: "td",
}
if type(e) in tags:
tag = tags[type(e)]
# These are also disabled in pandoc so they shouldn't appear in the AST at all.
not_implemented = {
Citation: True,
Cite: True,
Definition: True,
DefinitionItem: True,
DefinitionList: True
}
if type(e) in not_implemented:
return f'<!-- FIXME: {type(e)}s not implemented -->'
# Elements which can be represented by a simple string
simple_string = {
NBSP: "&nbsp;",
Space: " ",
Null: "",
LineBreak: f"\n{indent_level*indent_str}<br>\n{indent_level*indent_str}",
SoftBreak: f" ",
HorizontalRule: f"{indent_level*indent_str}<hr>\n"
}
if type(e) in simple_string:
return simple_string[type(e)]
if hasattr(e, "identifier") and e.identifier != "":
attributes += f' id="{e.identifier}"'
if hasattr(e, "classes") and len(e.classes) != 0:
attributes += f' class="{" ".join(e.classes)}"'
# Attributes are only passed down manually, because we use them internally.
# Maybe this should be a blocklist instead of an allowlist?
# Overriding elements with their own returns
if isinstance(e, CodeBlock):
if len(e.classes) > 0 and (e.attributes["highlight"] == True or e.attributes["highlight"] == 'True'):
# Syntax highlighting using pygments
for cl in e.classes:
try:
lexer = get_lexer_by_name(cl)
except ClassNotFound:
continue
break
else:
print(f"WARN: Syntax highligher does not have lexer for element with these classes: {e.classes}")
formatter = HtmlFormatter(style=e.attributes["style"])
result = highlight(e.text, lexer, formatter)
return f'{result}'
else:
return f'<pre>{e.text}</pre>'
if isinstance(e, Doc):
formatter = HtmlFormatter(style=e.get_metadata("highlight-style") if e.get_metadata("highlight-style") is not None else "default")
content_head = f'<style>{formatter.get_style_defs(".highlight")}</style>'
if isinstance(e, Image):
url = e.url
# Attributes → image processor args
additional_args = {}
if "file-width" in e.attributes:
additional_args["width"] = int(e.attributes["file-width"])
if "file-height" in e.attributes:
additional_args["height"] = int(e.attributes["file-height"])
if "file-quality" in e.attributes:
additional_args["quality"] = int(e.attributes["file-quality"])
if "file-dpi" in e.attributes:
additional_args["dpi"] = int(e.attributes["file-dpi"])
# The directory of the current file, will also look for images there.
source_dir = e.attributes["source_dir"]
_, ext = os.path.splitext(url)
ext = ext[1:]
# Conversions between various formats.
if ext in ["svg", "png", "jpeg", "gif"]:
# Even supported elements have to be 'converted' because the
# processing contains finding and moving them to the output
# directory.
url = i.process_image(url, ext, source_dir, **additional_args)
elif ext in ["pdf", "epdf"]:
if not "dpi" in additional_args:
additional_args["dpi"] = 300
url = i.process_image(url, "png", source_dir, **additional_args)
elif ext in ["jpg"]:
url = i.process_image(url, "jpeg", source_dir, **additional_args)
else:
url = i.process_image(url, "png", source_dir, **additional_args)
# Srcset generation - multiple alternative sizes of images browsers can
# choose from.
_, ext = os.path.splitext(url)
ext = ext[1:]
srcset = []
if ext in ["png", "jpeg"] and (not "no-srcset" in e.attributes or e.attributes["no-srcset"] == False or e.attributes["no-srcset"] == 'False'):
# This is inspired by @vojta001's blogPhoto shortcode he made for
# patek.cz:
# https://gitlab.com/patek-devs/patek.cz/-/blob/master/themes/patek/layouts/shortcodes/blogPhoto.html
width, height = i.get_image_size(url, [i.public_dir])
sizes = [(640, 360, 85), (1280, 720, 85), (1920, 1080, 90)] # (widht, height, quality)
for size in sizes:
if width <= size[0] and height <= size[1]:
srcset.append((f'{i.web_path}/{url}', f'{width}w'))
break
quality = size[2] if ext == "jpeg" else None
srcset.append((f'{i.web_path}/{i.process_image(url, ext, i.public_dir, width=size[0], height=size[1], quality=quality)}', f'{size[0]}w'))
url = i.web_path + "/" + url
attributes = f'{" style=width:"+e.attributes["width"] if "width" in e.attributes else ""} alt="{e.title or html(e.content, k, i, 0, "")}"'
if len(srcset) != 0:
return f'<a href="{url}"><img src="{srcset[-1][0]}" srcset="{", ".join([" ".join(src) for src in srcset])}"{attributes}></a>'
else:
return f'<img src="{url}"{attributes}>'
# See https://pandoc.org/MANUAL.html#line-blocks
if isinstance(e, LineItem):
return indent_level*indent_str + html(e.content, k, i) + "<br>\n"
# Footnotes are placed into parentheses. (And not footnotes (This is how KSP did it before me))
if isinstance(e, Note):
content_head = "("
content_foot = ")"
if inlinify(e) is not None:
return f' <note>({html(inlinify(e), k, i, 0, "")})</note>'
if isinstance(e, FQuoted):
if e.style == "cs":
if e.quote_type == "SingleQuote":
return f'{html(e.content, k, i, 0, "")}'
elif e.quote_type == "DoubleQuote":
return f'{html(e.content, k, i, 0, "")}'
elif e.style == "en":
if e.quote_type == "SingleQuote":
return f'{html(e.content, k, i, 0, "")}'
elif e.quote_type == "DoubleQuote":
return f'{html(e.content, k, i, 0, "")}'
else:
if e.quote_type == "SingleQuote":
return f'\'{html(e.content, k, i, 0, "")}\''
elif e.quote_type == "DoubleQuote":
return f'"{html(e.content, k, i, 0, "")}"'
else:
return f'"{html(e.content, k, i, 0, "")}"'
if isinstance(e, Group):
k.begingroup()
ret = html(e.content, k, i, indent_level, indent_str)
k.endgroup()
return ret
if isinstance(e, Math):
formats = {
"DisplayMath": True,
"InlineMath": False
}
return indent_level*indent_str + k.render(e.text, {"displayMode": formats[e.format]})
if isinstance(e, RawInline):
if e.format == "html":
return e.text
else:
return ""
if isinstance(e, RawBlock):
if e.format == "html":
return f'{e.text}\n'
else:
return ""
# Non-overriding elements, they get generated using the template at the end
# of this function
if isinstance(e, Header):
tag = "h"+str(e.level)
if isinstance(e, Figure):
content_foot = html(e.caption, k, i, indent_level+1, indent_str)
if isinstance(e, Caption):
tag = "figcaption"
if isinstance(e, Link):
tag = "a"
attributes += f' href="{e.url}"'
if e.title:
attributes += f' title="{e.title}"'
if isinstance(e, OrderedList):
tag = "ol"
if e.start and e.start != 1:
attributes += f' start="{e.start}"'
html_styles = {
"Decimal": "1",
"LowerRoman": "i",
"UpperRoman:": "I",
"LowerAlpha": "a",
"UpperAlpha": "A"
}
if e.style and e.style != "DefaultStyle":
attributes += f' type="{html_styles[e.style]}"'
# FIXME: Delimeter styles
if isinstance(e, Table):
content_head = html(e.head, k, i, indent_level+1, indent_str)
content_foot = html(e.foot, k, i, indent_level+1, indent_str)
# FIXME: Fancy pandoc tables, using colspec
if isinstance(e, TableCell):
tag = "td"
if e.colspan != 1:
attributes += f' colspan="{e.colspan}"'
if e.rowspan != 1:
attributes += f' rowspan="{e.rowspan}"'
aligns = {
"AlignLeft": "left",
"AlignRight": "right",
"AlignCenter": "center"
}
if e.alignment and e.alignment != "AlignDefault":
attributes += f' style="text-align: {aligns[e.alignment]}"'
# The default which all non-overriding elements get generated by. This
# includes elements, which were not explicitly mentioned in this function,
# e. g. Strong
if isinstance(e, Inline):
return f'<{tag}{attributes}>{content_head}{html(e.content, k, i, 0, "") if hasattr(e, "_content") else ""}{e.text if hasattr(e, "text") else ""}{content_foot}</{tag}>'
out_str = ""
if not isinstance(e, Plain):
out_str += f"{indent_level*indent_str}<{tag}{attributes}>\n"
out_str += content_head
if hasattr(e, "_content"):
if len(e.content) > 0 and isinstance(e.content[0], Inline):
out_str += (indent_level+1)*indent_str
out_str += html(e.content, k, i, indent_level+1, indent_str)
if hasattr(e, "text"):
out_str += e.text
out_str += f"{content_foot}\n"
if not isinstance(e, Plain):
out_str += f"{indent_level*indent_str}</{tag}>\n"
return out_str

80
images.py Normal file
View file

@ -0,0 +1,80 @@
from typing import List
import os
import shutil
import subprocess
from PIL import Image
class ImageProcessor:
def __init__(self, public_dir: str, web_path: str, *lookup_dirs: List[str]):
self.public_dir = public_dir
self.lookup_dirs = lookup_dirs
self.web_path = web_path if web_path[-1] != "/" else web_path[:-1]
if not os.path.exists(self.public_dir):
os.mkdir(self.public_dir)
def process_image(self, input_filename: str, format: str, source_dir: str, relative: bool=True, width: int=None, height:int=None, quality: int=None, dpi: int=None, fit: bool=True) -> str:
name = os.path.basename(input_filename)
base, ext = os.path.splitext(name)
ext = ext[1:]
full_path = self.find_image(input_filename, [source_dir])
if full_path is None:
raise FileNotFoundError(f'Image {input_filename} not found.')
# Generate filename from arguments
suffix = ""
geometry = None
if width is not None or height is not None:
geometry = f'{width if width is not None else ""}x{height if height is not None else ""}{"" if fit else "!"}'
suffix += "_"+geometry
if quality is not None:
suffix += f'_q{quality}'
target_name = base+suffix+"."+format
target_path = self.public_dir + "/" + target_name
# Only regenerate if the file doesn't already exist.
if not os.path.isfile(target_path):
# If the format is the same or it is just a different extension for
# the same format, just copy it.
if (((ext == format)
or (ext == "epdf" and format == "pdf")
or (ext == "jpg" and format == "jpeg"))
and width is None and height is None and quality is None and dpi is None):
shutil.copyfile(full_path, target_path)
# Try to find the converted filename in lookup_dirs, if you find
# it, don't convert, just copy.
elif self.find_image(target_name, [source_dir]):
shutil.copyfile(self.find_image(target_name, [source_dir]), target_path)
# Convert SVGs using inkscape
elif ext == "svg":
width_arg = ['--export-width', str(width)] if width is not None else []
height_arg = ['--export-height', str(height)] if height is not None else []
dpi_arg = ['--export-dpi', str(dpi)] if dpi is not None else []
if subprocess.run(['inkscape', full_path, '-o', target_path, *width_arg, *height_arg, *dpi_arg]).returncode != 0:
raise Exception(f"Could not convert '{full_path}' to '{format}'")
# Convert everything else using ImageMagick.
else:
resize_arg = ['-resize', str(geometry)] if geometry is not None else []
density_arg = ['-density', str(dpi)] if dpi is not None else []
quality_arg = ['-quality', str(quality)] if quality is not None else []
if subprocess.run(['convert', *density_arg, full_path, *resize_arg, *quality_arg, target_path]).returncode != 0:
raise Exception(f"Could not convert '{full_path}' to '{format}'")
return target_name if relative else target_path
def get_image_size(self, input_filename: str, additional_dirs: List[str]=[]) -> (int, int):
full_path = self.find_image(input_filename, additional_dirs)
if full_path is None:
raise FileNotFoundError(f'Image {input_filename} not found.')
# Getting image size using ImageMagick is slow. VERY
return Image.open(full_path).size
def find_image(self, input_filename: str, additional_dirs: List[str]=[]) -> str:
for dir in [*self.lookup_dirs, *additional_dirs]:
if os.path.isfile(dir + "/" + input_filename):
return dir + "/" + input_filename

1
katex-server/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
node_modules

1
katex-server/README.md Normal file
View file

@ -0,0 +1 @@
This was made by Standa Lukeš @exyi

1
katex-server/index.js Normal file
View file

@ -0,0 +1 @@
console.log(require('katex').renderToString('\\frac{2a}{b}'))

127
katex-server/index.mjs Normal file
View file

@ -0,0 +1,127 @@
// KaTeX rendering server
// Listens on unix socket, path is provided as first argument
// Expects JSON lines, each line is a query with the following schema:
// {
// formulas: [
// {
// tex: string,
// options?: object
// }
// ],
// options?: object
// }
// see https://katex.org/docs/options.html for list of available options
// If options formulas[].options field is used, the global options field is ignored.
// For each line, returns one JSON line with the following schema:
// {
// results: [
// { html?: string } | { error?: string }
// ]
// } | { error?: string }
// If one formula is invalid, the error in results is used
// If the entire query is invalid (couldn't parse JSON, for example), the outer error field is used
import katex from 'katex'
import net from 'net'
import * as readline from 'readline'
const myArgs = process.argv.slice(2)
const unixSocketPath = myArgs[0]
if (!unixSocketPath) {
console.error('you must specify socket path')
process.exit(1)
}
// This server listens on a Unix socket at /var/run/mysocket
var unixServer = net.createServer(handleClient);
unixServer.listen(unixSocketPath);
function handleExit(signal) {
// unixServer.emit('close')
unixServer.close(function () {
});
process.exit(0); // put this into the callback to avoid closing open connections
}
process.on('SIGINT', handleExit);
process.on('SIGQUIT', handleExit);
process.on('SIGTERM', handleExit);
process.on('exit', handleExit);
const defaultOptions = {}
/**
* @param {net.Socket} socket
* @returns {Promise<void>}
* */
function socketWrite(socket, data) {
return new Promise((resolve, reject) => {
socket.write(data, (err) => {
if (err) {
reject(err)
} else {
resolve()
}
})
})
}
/**
* @param {net.Socket} client
* */
async function handleClient(client) {
const rl = readline.createInterface({ input: client })
/* Added by GS: A stack of katex's `macros` objects, each group inherits
* the one from the parent group and can add its own stuff without
* affecting the parent.
*/
const macroStack = [{}]
for await (const line of rl) {
try {
// The custom commands for pushing and popping the macro stack.
if (line === "begingroup") {
// Copy the current state of macros and push it onto the stack.
macroStack.push({...macroStack.slice(-1)[0]})
continue
} else if (line === "endgroup") {
macroStack.pop()
continue
}
const query = JSON.parse(line)
const results = []
for (const input of query.formulas) {
const options = input.options ?? query.options ?? defaultOptions
// Add macros from the macros option
if (options.macros) {
for (const macro of Object.keys(options.macros)) {
macroStack.slice(-1)[macro] = options.macros[macro]
}
}
options.macros = macroStack.slice(-1)[0]
// Enforce globalGroup option, katex then saves created macros
// into the options.macros object.
options.globalGroup = true
try {
const html = katex.renderToString(input.tex, options)
results.push({ html })
} catch (e) {
results.push({ error: String(e) })
}
}
await socketWrite(client, JSON.stringify({ results }, null, query.debug ? ' ' : undefined))
await socketWrite(client, '\n')
} catch (e) {
console.error(e)
await socketWrite(client, JSON.stringify({ error: String(e) }))
await socketWrite(client, '\n')
}
}
}

21
katex-server/package-lock.json generated Normal file
View file

@ -0,0 +1,21 @@
{
"name": "ksp-katex-server",
"version": "1.0.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="
},
"katex": {
"version": "0.16.3",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.3.tgz",
"integrity": "sha512-3EykQddareoRmbtNiNEDgl3IGjryyrp2eg/25fHDEnlHymIDi33bptkMv6K4EOC2LZCybLW/ZkEo6Le+EM9pmA==",
"requires": {
"commander": "^8.0.0"
}
}
}
}

14
katex-server/package.json Normal file
View file

@ -0,0 +1,14 @@
{
"name": "ksp-katex-server",
"version": "1.0.0",
"description": "",
"main": "index.mjs",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"katex": "^0.16.3"
}
}

64
katex.py Normal file
View file

@ -0,0 +1,64 @@
import socket
import subprocess
import tempfile
import json
import os
from typing import Dict
import time
class KatexError(Exception):
pass
class KatexClient:
def __init__(self):
# Create temporary directory for socket
self._temp_dir = tempfile.TemporaryDirectory(prefix='formatitko')
self._socket_file = self._temp_dir.name + "/katex-socket"
self._server_process = subprocess.Popen(["node", os.path.dirname(os.path.realpath(__file__)) + "/katex-server/index.mjs", self._socket_file])
self._client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
# Wait for the node program to create the socket file
while not os.path.exists(self._socket_file):
time.sleep(0.01)
# Wait for the node program to start accepting connections
while True:
try:
self._client.connect(self._socket_file)
time.sleep(0.01)
except ConnectionRefusedError:
continue
break
def render(self, tex: str, options: Dict={}):
# Send formulas to translate
self._client.sendall((json.dumps({"formulas":[{"tex":tex}], "options":options})+"\n").encode("utf-8"))
# Receive response
data = self._client.recv(4096)
while data[-1] != 0x0a:
data += self._client.recv(128)
response = json.loads(data)
if "error" in response:
raise Exception(response["error"])
if "error" in response["results"][0]:
raise KatexError(response["results"][0]["error"])
else:
return response["results"][0]["html"]
# Special commands implemented in the JS file for grouping defs together.
def begingroup(self):
self._client.sendall("begingroup\n".encode("utf-8"))
def endgroup(self):
self._client.sendall("endgroup\n".encode("utf-8"))
def __enter__(self):
return self
def __exit__(self, type, value, tb):
self._server_process.terminate()

View file

@ -1,65 +0,0 @@
#!/usr/bin/env python3
import argparse
import sys
# Import local files
from formatitko.transform import transform
from formatitko.util import import_md
from formatitko.context import Context, Group
from formatitko.katex import KatexClient
from formatitko.images import ImageProcessor
from ksp_html_generator import KSPHTMLGenerator
from formatitko.mj_show import show
def main():
# Initialize command line arguments
parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("-l", "--img-lookup-dirs", help="Image lookup directories. When processing images, the program will try to find the image in them first. Always looks for images in the same folder as the markdown file.", nargs="+", default=[])
parser.add_argument("-p", "--img-public-dir", help="Directory to put processed images into. The program will overwrite images, whose dependencies are newer.", default="public")
parser.add_argument("-c", "--img-cache-dir", help="Directory to cache processed images and intermediate products. The program will overwrite files, whose dependencies are newer.", default="cache")
parser.add_argument("-i", "--img-web-path", help="Path where the processed images are available on the website.", default="/")
parser.add_argument("-w", "--output-html", help="The HTML file (for Web) to write into.", default="output.html")
parser.add_argument("-t", "--output-tex", help="The TEX file to write into.", default="output.tex")
parser.add_argument("input_filename", help="The markdown file to process.")
parser.add_argument("--debug", action='store_true')
args = parser.parse_args()
# TODO: Accept path to unix socket for katexClient, then don't init our own,
# just connect to an existing one. For formátíking many files in a row.
# Use panflute to parse the input MD file
doc = import_md(open(args.input_filename, "r").read())
if args.debug:
print(show(doc))
# The language metadatum is important, so it's read before transformation and
# then attached to a group inside the Doc
language = doc.get_metadata("language", None, True)
context = Context(doc, args.input_filename)
# Transform the document. This includes all the fancy formatting this software does.
doc = doc.walk(transform, context)
# Now wrap the document contents in a group, which is able to pop its language
# setting out to TeX
doc.content = [Group(*doc.content, metadata={"language":language})]
# Initialize the image processor (this just keeps some basic state)
imageProcessor = ImageProcessor(args.img_public_dir, args.img_web_path, args.img_cache_dir, *args.img_lookup_dirs)
# Initialize KaTeX client (this runs the node app and connects to a unix socket)
with KatexClient() as katexClient:
# Generate HTML and TeX out of the transformed document
#open(args.output_html, "w").write(html(doc, katexClient, imageProcessor))
#open(args.output_tex, "w").write(tex(doc, imageProcessor))
KSPHTMLGenerator(sys.stdout, katexClient, imageProcessor).generate(doc)
if args.debug:
print(show(doc))
if __name__ == "__main__":
main()

View file

@ -1,11 +0,0 @@
# A sample on how a custom html generator might look
from formatitko.html_generator import HTMLGenerator
from panflute import Link
class KSPHTMLGenerator(HTMLGenerator):
def generate_Link(self, e: Link):
if e.url.startswith("ksp://"):
e.url = "https://ksp.mff.cuni.cz/viz/" + e.url[len("ksp://"):]
return super().generate_Link(e)

View file

@ -3,7 +3,7 @@
###
import sys, re
from panflute import Str, Element, ListContainer, DictContainer
from panflute import *
avoid_keys = {
'dict',

View file

@ -1,43 +0,0 @@
[build-system]
requires = ["setuptools>=61.0","setuptools_scm[toml]>=6.2"]
build-backend = "setuptools.build_meta"
[project]
name = "formatitko"
#version = "0.0.1"
dynamic = ["version"]
authors = [
{ name="Greenscreener", email="gs@grsc.cz" },
{ name="Organizers of KSP", email="ksp@mff.cuni.cz" }
]
description = "A python program based on pandoc and its python library panflute for converting from markdown to TeX and HTML with added fancy features like image processing, python-based macros and much more."
readme = "README.md"
requires-python = ">=3.9"
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: Linux",
]
dependencies = [
"Pygments>=2.14.0",
"panflute>=2.3.0",
"fontTools>=4.38.0",
"Pillow>=9.4.0",
]
[project.urls]
"Homepage" = "https://gitea.ks.matfyz.cz/KSP/formatitko"
"Bug Tracker" = "https://gitea.ks.matfyz.cz/KSP/formatitko/issues"
[project.scripts]
formatitko = "formatitko.formatitko:main"
[tool.setuptools_scm]
[tool.setuptools.packages.find]
where = ["src"]
exclude = ["src/formatitko/katex-server/node_modules"]
[tool.pyright]
strictParameterNoneValue = false

View file

@ -1,4 +0,0 @@
from formatitko.formatitko import main
if __name__ == "__main__":
main()

View file

@ -1,34 +0,0 @@
from panflute import Span, Div, Element, Plain, Para
class InlineError(Exception):
pass
class Command:
pass
# This distinction is needed because while transforming the tree, inline
# elements cannot be replaced with block ones
class InlineCommand(Span, Command):
def replaceSelf(self, *content: list[Element]) -> Span:
try:
return Span(*content)
except TypeError:
if len(content) == 1 and (isinstance(content[0], Para) or isinstance(content[0], Plain)):
return Span(*content[0].content)
else:
return Div(*content)
pass
class BlockCommand(Div, Command):
def replaceSelf(self, *content: list[Element]) -> Div:
try:
return Div(*content)
except TypeError:
return Div(Para(*content))
pass
class CodeCommand(BlockCommand):
test: str
def __init__(self, *args, **kwargs):
self.text = args[0]
super().__init__(**kwargs)

View file

@ -1,9 +0,0 @@
import panflute as pf
import formatitko.elements as fe
from formatitko.util import import_md_list
from formatitko.util import parse_string
from formatitko.context import Context
from formatitko.command import Command
from .nop_processor import NOPProcessor
from panflute import Element

View file

@ -1,34 +0,0 @@
from .context import Context, CommandCallable # This is there because of a wild circular import dependency between many functions and classes
from panflute import CodeBlock, Null
from . import command_env
from .util import nullify
def parse_command(code: str) -> CommandCallable:
code_lines = code.split("\n")
tabs = False
for line in code_lines:
if len(line) != 0 and line[0] == "\t":
tabs = True
break
indented_code_lines = []
for line in code_lines:
indented_code_lines.append(("\t" if tabs else " ")+line)
code = "def command(element: Command, context: Context, processor: NOPProcessor) -> list[Element]:\n"+"\n".join(indented_code_lines)
env = {**command_env.__dict__}
exec(code, env)
return env["command"]
# This function is called in trasform.py, defining a command which can be
# called later
def handle_command_define(e: CodeBlock, c: Context) -> Null:
command = parse_command(e.text)
if "define" in e.attributes:
if not c.get_command(e.attributes["define"]):
c.set_command(e.attributes["define"], command)
else:
raise NameError(f"Command already defined: '{e.attributes['define']}'")
if "redefine" in e.attributes:
c.set_command(e.attributes["redefine"], command)
return nullify(e)

View file

@ -1,188 +0,0 @@
from panflute import Doc, Element, Div, Span
from typing import Union, Callable
from types import ModuleType
import os
from .command import Command
CommandCallable = Callable[[Command, 'Context', 'NOPProcessor'], list[Element]] # This is here because of a wild circular import dependency between many functions and classes
# This class is used to keep state while transforming the document using
# transform.py. For the context to be available to the html and TeX generators,
# individual keys must be manually assigned to the individual elements. This is
# done in transform.py.
#
# The context is also aware of its parent contexts and relevant data (such as
# metadata and commands) can be read from the closest parent context. Writing
# only happens to the current one.
#
# This class is basically an extension to panflute's doc, this is why metadata
# is read directly from it.
class Context:
parent: Union["Context", None]
_commands: dict[str, Union[CommandCallable, None]]
_data: dict[str, object]
doc: Doc
trusted: bool
path: str
dir: str
filename: str
root_dir: str # Absolute path to the dir of the file formátítko was called on
rel_dir: str # Relative path to the current dir from the root dir
deps: set[str]
def __init__(self, doc: Doc, path: str, parent: Union['Context', None]=None, trusted: bool=True):
self.parent = parent
self._commands = {}
self._data = {}
self.doc = doc
self.trusted = trusted
self.path = path
self.dir = os.path.dirname(path) if os.path.dirname(path) != "" else "."
self.filename = os.path.basename(path)
self.root_dir = parent.root_dir if parent else os.path.abspath(self.dir)
self.rel_dir = os.path.relpath(self.dir, self.root_dir)
self.deps = set()
self.add_dep(path)
if self.get_metadata("flags", immediate=True) is None:
self.set_metadata("flags", {})
def get_command(self, command: str) -> Union[CommandCallable, None]:
if command in self._commands:
return self._commands[command]
elif self.parent:
return self.parent.get_command(command)
else:
return None
def set_command(self, command: str, val: CommandCallable):
self._commands[command] = val
def unset_command(self, command: str):
del self._commands[command]
def add_commands_from_module(self, module: Union[dict[str, CommandCallable], ModuleType], module_name: str=""):
if isinstance(module, ModuleType):
module = module.__dict__
prefix = module_name+"." if module_name else ""
for name, func in module.items():
if isinstance(func, Callable):
self.set_command(prefix+name, func)
def is_flag_set(self, flag: str):
if self.get_metadata("flags."+flag) is not None:
if self.get_metadata("flags."+flag):
return True
else:
return False
elif self.parent:
return self.parent.is_flag_set(flag)
else:
return False
def set_flag(self, flag: str, val: bool):
self.set_metadata("flags."+flag, val)
def unset_flag(self, flag: str):
self.unset_metadata("flags."+flag)
def get_metadata(self, key: str, simple: bool=True, immediate: bool=False):
value = self.doc.get_metadata(key, None, simple)
if value is not None:
return value
elif self.parent and not immediate:
return self.parent.get_metadata(key)
else:
return None
def set_metadata(self, key: str, value):
meta = self.doc.metadata
keys = key.split(".")
for k in keys[:-1]:
meta = meta[k]
meta[keys[-1]] = value
def unset_metadata(self, key: str):
meta = self.doc.metadata
keys = key.split(".")
for k in keys[:-1]:
meta = meta[k]
del meta.content[key[-1]] # A hack because MetaMap doesn't have a __delitem__
def import_metadata(self, data, key: str=""):
if isinstance(data, dict) and isinstance(self.get_metadata(key), dict):
for subkey, value in enumerate(data):
self.import_metadata(value, key+"."+subkey if key != "" else subkey)
else:
self.set_metadata(key, data)
def get_data(self, key: str, immediate: bool=False):
data = self._data
keys = key.split(".")
try:
for k in keys:
data = data[k]
return data
except KeyError:
if self.parent and not immediate:
return self.parent.get_data(key)
else:
return None
def set_data(self, key: str, value: object):
data = self._data
keys = key.split(".")
for k in keys[:-1]:
try:
data = data[k]
except KeyError:
data[k] = {}
data = data[k]
data[keys[-1]] = value
def unset_data(self, key: str):
if key == "":
self._data = {}
data = self._data
keys = key.split(".")
for k in keys[:-1]:
data = data[k]
del data[keys[-1]]
def get_deps(self) -> list[str]:
if self.parent is not None:
return self.parent.get_deps()
else:
return self.deps
def add_dep(self, dep: str):
self.get_deps().add(os.path.abspath(dep))
def add_deps(self, deps: list[str]):
self.get_deps().update([os.path.abspath(path) for path in deps])
def get_context_from_doc(doc: Doc) -> Context:
if len(doc.content) == 1 and isinstance(doc.content[0], Group):
return doc.content[0].context
else:
return None
# This is a custom element which creates \begingroup \endgroup groups in TeX
# and also causes KaTeX math blocks to be isolated in a similar way.
#
# Whenever a new context is created, its content should be eclosed in a group and vice-versa.
class Group(Element):
metadata: dict
context: Context
def __init__(self, *args, context:Context, metadata={}, **kwargs):
self.metadata = metadata # This is only here for backwards compatibility with old html.py, tex.py and transform.py. FIXME: Remove this when the time comes.
self.context = context
super().__init__(*args, **kwargs)
class BlockGroup(Group, Div):
pass
class InlineGroup(Group, Span):
pass

View file

@ -1,19 +0,0 @@
from panflute import Quoted, Link
from .command import Command, InlineCommand, BlockCommand, CodeCommand
from .context import Group, BlockGroup, InlineGroup
from .whitespace import Whitespace, NBSP
# This is a small extension to the Quoted panflute elements which allows to
# have language-aware quotation marks.
class FQuoted(Quoted):
style: str
def __init__(self, *args, **kwargs):
self.style = kwargs["style"]
del kwargs["style"]
super().__init__(*args, **kwargs)
class FileLink(Link):
pass

View file

@ -1,133 +0,0 @@
#!/usr/bin/env python3
import argparse
import sys
import tempfile
import subprocess
import shutil
# Import local files
from .util import import_md
from .katex import KatexClient
from .images import ImageProcessor, ImageProcessorNamespace
from .output_generator import OutputGenerator, FormatitkoRecursiveError
from .html_generator import HTMLGenerator, StandaloneHTMLGenerator
from .transform_processor import TransformProcessor
from .pandoc_processor import PandocProcessor
from .tex_generator import UCWTexGenerator
from .context import get_context_from_doc
from panflute import convert_text
def main():
# Initialize command line arguments
parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("-l", "--img-lookup-dirs", help="Image lookup directories. When processing images, the program will try to find the image in them first. Always looks for images in the same folder as the markdown file.", nargs="+", default=[])
parser.add_argument("-p", "--img-public-dir", help="Directory to put processed images into. The program will overwrite images, whose dependencies are newer.", default="public")
parser.add_argument("-c", "--img-cache-dir", help="Directory to cache processed images and intermediate products. The program will overwrite files, whose dependencies are newer.", default="cache")
parser.add_argument("-i", "--img-web-path", help="Path where the processed images are available on the website.", default="/")
parser.add_argument("-w", "--output-html", help="The HTML file (for Web) to write into.")
parser.add_argument("-s", "--output-standalone-html", help="The Standalone HTML file to write into. A full page is generated instead of just a fragment.")
parser.add_argument("-t", "--output-tex", help="The TEX file to write into.")
parser.add_argument("-m", "--output-md", help="The Markdown file to write into. (Uses pandoc to generate markdown)")
parser.add_argument("-j", "--output-json", help="The JSON file to dump the pandoc-compatible AST into.")
parser.add_argument("-P", "--output-pdf", help="The PDF file to save the PDF generated by TeX.")
parser.add_argument("--katex-server", action='store_true', help="Starts a KaTeX server and prints the socket filename onto stdout. Useful for running formatitko many times without starting the KaTeX server each time.")
parser.add_argument("-k", "--katex-socket", help="The KaTeX server socket filename obtained by running with `--katex-server`.")
parser.add_argument("input_filename", help="The markdown file to process.", nargs="?" if "--katex-server" in sys.argv else None)
parser.add_argument("--debug", action='store_true')
parser.add_argument("--traceback-limit", help="Traceback limit for when errors happen, defaults to 0, as it is only useful for internal debugging.", default=0)
parser.add_argument("--deps", help="File to write list of dependencies to. May depend on output formats used.")
args = parser.parse_args()
if args.katex_server:
with KatexClient(connect=False) as katexClient:
print(katexClient.get_socket())
try:
while True:
pass
except KeyboardInterrupt:
print("Exiting...")
return
# Use panflute to parse the input MD file
doc = import_md(open(args.input_filename, "r").read())
if args.debug:
try:
OutputGenerator(sys.stdout).generate(doc)
except FormatitkoRecursiveError as e:
e.pretty_print(tracebacklimit=args.traceback_limit)
try:
doc = TransformProcessor(args.input_filename).transform(doc)
except FormatitkoRecursiveError as e:
e.pretty_print(tracebacklimit=args.traceback_limit)
# Initialize the image processor (this just keeps some basic state)
imageProcessor = ImageProcessor({"": ImageProcessorNamespace(args.img_public_dir, args.img_web_path, args.img_cache_dir, args.img_lookup_dirs, True)})
if args.output_html is not None:
# Initialize KaTeX client (this runs the node app and connects to a unix socket)
with KatexClient(socket=args.katex_socket) as katexClient:
with open(args.output_html, "w") as file:
try:
HTMLGenerator(file, katexClient, imageProcessor).generate(doc)
except FormatitkoRecursiveError as e:
e.pretty_print(tracebacklimit=args.traceback_limit)
if args.output_standalone_html is not None:
# Initialize KaTeX client (this runs the node app and connects to a unix socket)
with KatexClient(socket=args.katex_socket) as katexClient:
with open(args.output_standalone_html, "w") as file:
try:
StandaloneHTMLGenerator(file, katexClient, imageProcessor).generate(doc)
except FormatitkoRecursiveError as e:
e.pretty_print(tracebacklimit=args.traceback_limit)
if args.output_tex is not None:
with open(args.output_tex, "w") as file:
try:
UCWTexGenerator(file, imageProcessor).generate(doc)
except FormatitkoRecursiveError as e:
e.pretty_print(tracebacklimit=args.traceback_limit)
if args.output_md is not None:
with open(args.output_md, "w") as file:
file.write(convert_text(PandocProcessor().transform(doc), input_format="panflute", output_format="markdown"))
if args.output_json is not None:
with open(args.output_json, "w") as file:
file.write(convert_text(PandocProcessor().transform(doc), input_format="panflute", output_format="json"))
if args.output_pdf is not None:
if args.output_tex is None:
fd = tempfile.NamedTemporaryFile(dir=".", suffix=".tex")
with open(fd.name, "w") as file:
try:
UCWTexGenerator(file, imageProcessor).generate(doc)
except FormatitkoRecursiveError as e:
e.pretty_print(tracebacklimit=args.traceback_limit)
filename = fd.name
else:
filename = args.output_tex
outdir = tempfile.TemporaryDirectory(prefix="formatitko")
subprocess.run(["pdfcsplain", "-halt-on-error", "-output-directory="+outdir.name, "-jobname=formatitko", filename], check=True)
shutil.move(outdir.name+"/formatitko.pdf", args.output_pdf)
if args.deps is not None:
with open(args.deps, "w") as file:
for dep in get_context_from_doc(doc).get_deps():
file.write(dep + "\n")
if args.debug:
print("-----------------------------------")
try:
OutputGenerator(sys.stdout).generate(doc)
except FormatitkoRecursiveError as e:
e.pretty_print(tracebacklimit=args.traceback_limit)
if __name__ == "__main__":
main()

View file

@ -1,376 +0,0 @@
from panflute import Cite, Code, Emph, Image, LineBreak, Link, Math, Note, RawInline, SmallCaps, Str, Strikeout, Subscript, Superscript, Underline
from panflute import BulletList, Citation, CodeBlock, Definition, DefinitionItem, DefinitionList, Header, HorizontalRule, LineBlock, LineItem, ListItem, Null, OrderedList, Para, Plain, RawBlock, TableBody, TableFoot, TableHead
from panflute import TableRow, TableCell, Caption, Doc
from panflute import ListContainer, Element
from typing import Union
import os
import io
import warnings
from pygments import highlight
from pygments.lexers import get_lexer_by_name
from pygments.formatters import HtmlFormatter
from pygments.util import ClassNotFound
from .whitespace import NBSP
from .context import Group, BlockGroup, InlineGroup
from .output_generator import OutputGenerator
from .katex import KatexClient
from .images import ImageProcessor, ImageProcessorNamespaceSearcher
from .util import inlinify
from .elements import FileLink
class HTMLGenerator(OutputGenerator):
imageProcessor: ImageProcessor
katexClient: KatexClient
def __init__(self, output_file, katexClient: KatexClient, imageProcessor: ImageProcessor, *args, **kwargs):
self.katexClient = katexClient
self.imageProcessor = imageProcessor
super().__init__(output_file, *args, **kwargs)
def generate(self, e: Union[Element, ListContainer]):
if hasattr(e, "attributes") and "only" in e.attributes and e.attributes["only"] != "html":
return
super().generate(e)
def escape_special_chars(self, text: str) -> str:
text = text.replace("&", "&amp;")
text = text.replace("<", "&lt;")
text = text.replace(">", "&gt;")
text = text.replace("\"", "&quot;")
text = text.replace("'", "&#39;")
# text = text.replace(" ", "&nbsp;") # Don't replace no-break spaces with HTML escapes, because we trust unicode?
return text
def start_tag(self, tag: str, attributes: dict[str,str]={}) -> str:
words = [tag]
for key, value in attributes.items():
if value is not None:
words.append(f"{key}=\"{self.escape_special_chars(value)}\"")
return "<" + " ".join(words) + ">"
def end_tag(self, tag: str, attributes: dict[str,str]={}) -> str:
return "</" + tag + ">"
def single_tag(self, tag: str, attributes: dict[str,str]={}) -> str:
return self.start_tag(tag, attributes)
def tagname(self, e) -> str:
if isinstance(e, Header):
return "h" + str(e.level)
try:
return {
BulletList: "ul",
Doc: "main",
Emph: "em",
Caption: "figcaption",
Para: "p",
LineBlock: "p",
LineBreak: "br",
Link: "a",
ListItem: "li",
HorizontalRule: "hr",
OrderedList: "ol",
SmallCaps: "span",
Strikeout: "strike",
Subscript: "sub",
Superscript: "sup",
Underline: "u",
TableBody: "tbody",
TableHead: "thead",
TableFoot: "tfoot",
TableRow: "tr",
TableCell: "td",
InlineGroup: "span",
BlockGroup: "div"
}[type(e)]
except KeyError:
return type(e).__name__.lower()
def common_attributes(self, e) -> dict[str,str]:
attributes = {}
if hasattr(e, "identifier") and e.identifier != "":
attributes["id"] = e.identifier
if hasattr(e, "classes") and len(e.classes) != 0:
attributes["class"] = " ".join(e.classes)
return attributes
def generate_NBSP(self, e: NBSP):
self.write(" ") # Unicode no-break space, because we trust unicode?
def generate_Null(self, e: Null):
pass
#def generate_Doc(self, e: Doc):
# formatter = HtmlFormatter(style=e.get_metadata("highlight-style") if e.get_metadata("highlight-style") is not None else "default")
# self.generate_simple_tag(tag="style", attributes={}, content=formatter.get_style_defs(".highlight"))
#self.generate_simple_tag(e, tag="main")
def generate_CodeBlock(self, e: CodeBlock):
lexer = None
if e.classes and len(e.classes) > 0 and (e.attributes["highlight"] == True or e.attributes["highlight"] == 'True'):
# Syntax highlighting using pygments
for cl in e.classes:
try:
lexer = get_lexer_by_name(cl)
except ClassNotFound:
continue
break
else:
warnings.warn(f"Syntax highligher does not have lexer for element with these classes: {e.classes}", UserWarning)
if lexer:
formatter = HtmlFormatter(style=e.attributes["style"], noclasses=True)
result = highlight(e.text, lexer, formatter)
self.writeraw(result)
else:
e.text = self.escape_special_chars(e.text)
self.generate_simple_tag(e, tag="pre")
def generate_Code(self, e: Code):
e.text = self.escape_special_chars(e.text)
self.generate_simple_tag(e)
def generate_Image(self, e: Image):
url = e.url
additional_args = self.get_image_processor_args(e.attributes)
additional_args["context"] = self.context
# The directory of the current file relative to the current working directory
source_dir = self.context.dir
# The directory of the current file relative to the md file we were called on
rel_dir = self.context.rel_dir
searcher = self.imageProcessor.get_searcher_by_path(url, rel_dir, source_dir)
url = self.imageProcessor.get_path_without_namespace(url)
_, ext = os.path.splitext(url)
ext = ext[1:]
# Conversions between various formats.
if ext in ["svg", "png", "jpeg", "gif"]:
# Even supported elements have to be 'converted' because the
# processing contains finding and moving them to the output
# directory.
url = self.imageProcessor.process_image(url, ext, searcher, **additional_args)
elif ext in ["pdf", "epdf","asy"]:
# Only relevant for when these were PNGs, leaving this here for future reference.
# if not "dpi" in additional_args:
# additional_args["dpi"] = 300
url = self.imageProcessor.process_image(url, "svg", searcher, **additional_args)
elif ext in ["jpg"]:
url = self.imageProcessor.process_image(url, "jpeg", searcher, **additional_args)
else:
url = self.imageProcessor.process_image(url, "png", searcher, **additional_args)
# Srcset generation - multiple alternative sizes of images browsers can
# choose from.
_, ext = os.path.splitext(url)
ext = ext[1:]
srcset = []
if ext in ["png", "jpeg"] and (not "no-srcset" in e.attributes or e.attributes["no-srcset"] == False or e.attributes["no-srcset"] == 'False'):
# This is inspired by @vojta001's blogPhoto shortcode he made for
# patek.cz:
# https://gitlab.com/patek-devs/patek.cz/-/blob/master/themes/patek/layouts/shortcodes/blogPhoto.html
width, height = self.imageProcessor.get_image_size(searcher.find_image_in_dir(url, searcher.get_cache_dir()))
sizes = [(640, 360, 85), (1280, 720, 85), (1920, 1080, 90)] # (widht, height, quality)
for size in sizes:
if width <= size[0] and height <= size[1]:
srcset.append((f'{searcher.get_web_path()}/{url}', f'{width}w'))
break
quality = size[2] if ext == "jpeg" else None
cache_img = self.imageProcessor.process_image(url, ext, searcher.get_cache_searcher(), width=size[0], height=size[1], quality=quality)
searcher.publish_image(cache_img)
srcset.append((f'{searcher.get_web_path()}/{cache_img}', f'{size[0]}w'))
searcher.publish_image(url)
url = searcher.get_web_path() + "/" + url
attributes = self.common_attributes(e)
if "width" in e.attributes:
attributes["width"] = e.attributes["width"]
if "height" in e.attributes:
attributes["height"] = e.attributes["height"]
if "title" in e.attributes:
attributes["title"] = e.attributes["title"]
if e.title:
attributes["alt"] = e.title
else:
fake_out = io.StringIO()
HTMLGenerator(fake_out, self.katexClient, self.imageProcessor).generate(e.content)
attributes["alt"] = fake_out.getvalue()
if len(srcset) > 1:
attributes["src"] = srcset[-1][0]
attributes["srcset"] = ", ".join([" ".join(src) for src in srcset])
else:
attributes["src"] = url
if e.attributes["no-img-link"]:
self.write(self.single_tag("img", attributes))
return
img = RawInline(self.single_tag("img", attributes))
link = Link(img, url=url)
self.generate(link)
def generate_FileLink(self, e: FileLink):
url = e.url
# The directory of the current file relative to the current working directory
source_dir = self.context.dir
# The directory of the current file relative to the md file we were called on
rel_dir = self.context.rel_dir
searcher = self.imageProcessor.get_searcher_by_path(url, rel_dir, source_dir)
url = self.imageProcessor.get_path_without_namespace(url)
url = self.imageProcessor.process_image(url, "", searcher, self.context)
searcher.publish_image(url)
url = searcher.get_web_path() + "/" + url
self.generate_Link(Link(*e.content, url=url))
def generate_InlineGroup(self, e: InlineGroup):
self.generate_Group(e)
def generate_BlockGroup(self, e: BlockGroup):
self.generate_Group(e)
def generate_Group(self, e: Group):
self.katexClient.begingroup()
self.generate_simple_tag(e, attributes=self.common_attributes(e) | {"lang":self.context.get_metadata("lang")})
self.katexClient.endgroup()
def generate_Plain(self, e: Plain):
self.generate(e.content)
def generate_LineItem(self, e: LineItem):
self.generate(e.content)
self.write("<br>")
self.endln()
# Footnotes are placed into parentheses. (And not footnotes (This is how KSP did it before))
def generate_Note(self, e: Note):
inline = inlinify(e)
tag = self.tagname(e)
if inline is not None:
self.write(self.start_tag(tag)+" (")
self.generate(inline)
self.write(") "+self.end_tag(tag))
else:
self.writeln(self.start_tag(tag) + "(")
self.indent_more()
self.generate(e.content)
self.indent_less()
self.writeln(self.end_tag(tag) + ")")
def generate_Math(self, e: Math):
formats = {
"DisplayMath": True,
"InlineMath": False
}
rawhtml = self.katexClient.render(e.text, {"displayMode": formats[e.format]})
if (e.format == "InlineMath"):
self.write(rawhtml)
else:
self.writeraw(rawhtml)
def generate_RawInline(self, e: RawInline):
if e.format == "html":
self.write(e.text)
def generate_RawBlock(self, e: RawBlock):
if e.format == "html":
self.writeraw(e.text)
def generate_Link(self, e: Link):
attributes = {}
attributes["href"] = e.url
if e.title:
attributes["title"] = e.title
self.generate_simple_tag(e, attributes=self.common_attributes(e) | attributes)
def generate_OrderedList(self, e: OrderedList):
attributes = {}
if e.start and e.start != 1:
attributes["start"] = str(e.start)
html_styles = {
"Decimal": "1",
"LowerRoman": "i",
"UpperRoman:": "I",
"LowerAlpha": "a",
"UpperAlpha": "A"
}
if e.style and e.style != "DefaultStyle":
attributes["type"] = html_styles[e.style]
# FIXME: Delimeter styles: 1. 1) (1)
self.generate_simple_tag(e, attributes=self.common_attributes(e) | attributes)
def generate_TableCell(self, e: TableCell):
attributes = self.common_attributes(e)
if e.colspan != 1:
attributes["colspan"] = str(e.colspan)
if e.rowspan != 1:
attributes["rowspan"] = str(e.rowspan)
aligns = {
"AlignLeft": "left",
"AlignRight": "right",
"AlignCenter": "center"
}
if e.alignment and e.alignment != "AlignDefault":
attributes["style"] = attributes.get("style", "")+f"text-align: {aligns[e.alignment]};"
self.generate_simple_tag(e, attributes=attributes)
def generate_Cite(self, e: Cite):
self.generate_simple_tag(e, tag="a", attributes=self.common_attributes(e) | {"href": f"#ref-{e.citations[0].id}"})
# These are also disabled in pandoc so they shouldn't appear in the AST at all.
def generate_Citation(self, e: Citation):
self.writeln("<!-- FIXME: Citations not implemented -->")
def generate_Definition(self, e: Definition):
self.writeln("<!-- FIXME: Definitions not implemented -->")
def generate_DefinitionItem(self, e: DefinitionItem):
self.writeln("<!-- FIXME: DefinitionItems not implemented -->")
def generate_DefinitionList(self, e: DefinitionList):
self.writeln("<!-- FIXME: DefinitionLists not implemented -->")
class StandaloneHTMLGenerator(HTMLGenerator):
def generate_Doc(self, e: Doc):
self.writeraw("<!DOCTYPE html>")
self.writeln(self.start_tag("html", attributes={"lang": e.get_metadata("lang", None, True)}))
self.writeln(self.start_tag("head"))
self.indent_more()
self.writeln(self.single_tag("meta", attributes={"charset": "utf-8"}))
self.writeln(self.single_tag("meta", attributes={"viewport": "width=device-width, initial-scale=1.0"}))
self.writeln(self.single_tag("link", attributes={"rel": "stylesheet", "href": "https://cdn.jsdelivr.net/npm/katex@0.16.4/dist/katex.min.css", "integrity":"sha384-vKruj+a13U8yHIkAyGgK1J3ArTLzrFGBbBc0tDp4ad/EyewESeXE/Iv67Aj8gKZ0", "crossorigin":"anonymous"}))
if "title" in e.metadata:
self.write(self.start_tag("title"))
self.generate(e.metadata["title"])
self.write(self.end_tag("title"))
self.endln()
if "html-head-includes" in e.metadata:
self.generate(e.metadata["html-head-includes"])
self.indent_less()
self.writeln(self.end_tag("head"))
self.writeln(self.start_tag("body"))
self.indent_more()
super().generate_Doc(e)
self.indent_less()
self.writeln(self.end_tag("body"))
self.writeln(self.end_tag("html"))

View file

@ -1,260 +0,0 @@
from typing import Union
import os
import shutil
import subprocess
from PIL import Image
from .context import Context
class FileInWrongDirError(Exception):
pass
class ConversionProgramError(Exception):
pass
class InkscapeError(ConversionProgramError):
pass
class ImageMagickError(ConversionProgramError):
pass
class AsyError(ConversionProgramError):
pass
class ImageProcessorNamespace:
public_dir: str
cache_dir: str
lookup_dirs: list[str]
web_path: str
include_src: bool
def __init__(self, public_dir: str, web_path: str, cache_dir: str, lookup_dirs: list[str], include_src: bool):
self.public_dir = public_dir
self.cache_dir = cache_dir
self.lookup_dirs = lookup_dirs
self.web_path = web_path if web_path[-1] != "/" else web_path[:-1]
self.include_src = include_src
class ImageProcessorSearcher:
def get_lookup_dirs(self) -> list[str]:
return []
def get_cache_dir(self) -> str:
return ""
def get_public_dir(self) -> str:
return ""
def get_web_path(self) -> str:
return ""
def find_image_in_dir(self, input_filename: str, dir: str) -> Union[str, None]:
if os.path.isfile(dir + "/" + input_filename):
return dir + "/" + input_filename
else:
return None
def find_image(self, input_filename: str) -> Union[str, None]:
for dir in self.get_lookup_dirs():
image = self.find_image_in_dir(input_filename, dir)
if image:
return image
return None
def publish_image(self, target_name, relative: bool=True) -> str:
cache_path = self.get_cache_dir() + "/" + target_name
if not os.path.isfile(cache_path):
raise FileNotFoundError(f'Image {target_name} not cached')
target_path = self.get_public_dir() + "/" + target_name
try:
if os.path.exists(target_path):
if os.path.getmtime(cache_path) > os.path.getmtime(target_path):
os.remove(target_path)
os.link(cache_path, target_path)
else:
os.link(cache_path, target_path)
except OSError as e:
if e.errno == 18: # Invalid cross-device link: cache and public dirs are on different devices, don't hardlink, copy
shutil.copyfile(cache_path, target_path)
else:
raise e
return target_name if relative else target_path
class ImageProcessorCacheSearcher(ImageProcessorSearcher):
cache_dir: str
def __init__(self, cache_dir: str):
self.cache_dir = cache_dir
if not os.path.exists(self.cache_dir):
os.makedirs(self.cache_dir, exist_ok=True)
def get_lookup_dirs(self) -> list[str]:
return [self.cache_dir]
def get_cache_dir(self) -> str:
return self.cache_dir
def get_public_dir(self) -> str:
return ""
def get_web_path(self) -> str:
return ""
def publish_image(self, target_name, relative: bool=True) -> str:
raise NotImplementedError();
class ImageProcessorNamespaceSearcher(ImageProcessorSearcher):
namespace: ImageProcessorNamespace
rel_dir: str
source_dir: str
def __init__(self, namespace: ImageProcessorNamespace, rel_dir: str, source_dir: str):
self.namespace = namespace
self.rel_dir = rel_dir
self.source_dir = source_dir
def get_lookup_dirs(self) -> list[str]:
return self.namespace.lookup_dirs + ([self.source_dir] if self.namespace.include_src else [])
def transform_path(self, path: str) -> str:
return path.replace("$dir", self.rel_dir)
def get_cache_dir(self) -> str:
cache_dir = self.transform_path(self.namespace.cache_dir)
if not os.path.exists(cache_dir):
os.makedirs(cache_dir, exist_ok=True)
return cache_dir
def get_public_dir(self) -> str:
public_dir = self.transform_path(self.namespace.public_dir)
if not os.path.exists(public_dir):
os.makedirs(public_dir, exist_ok=True)
return public_dir
def get_web_path(self) -> str:
return self.transform_path(self.namespace.web_path)
def get_cache_searcher(self) -> ImageProcessorCacheSearcher:
return ImageProcessorCacheSearcher(self.get_cache_dir())
class ImageProcessor:
namespaces: dict[str, ImageProcessorNamespace]
def __init__(self, namespaces: dict[str, ImageProcessorNamespace]):
self.namespaces = namespaces
def get_namespace_by_path(self, path: str) -> ImageProcessorNamespace:
return self.namespaces[path.split(":")[0] if ":" in path else ""]
def get_path_without_namespace(self, path: str) -> str:
if len(path.split(":")) <= 1:
return path
return ":".join(path.split(":")[1:])
def get_searcher_by_path(self, path: str, rel_dir: str, source_dir: str) -> ImageProcessorNamespaceSearcher:
return ImageProcessorNamespaceSearcher(self.get_namespace_by_path(path), rel_dir, source_dir)
def process_image(self, input_filename: str, format: str, searcher: ImageProcessorSearcher, context: Context=None, width: int=None, height:int=None, quality: int=None, dpi: int=None, fit: bool=True, deps: list[str]=[]) -> str:
name = os.path.basename(input_filename)
base, ext = os.path.splitext(name)
ext = ext[1:]
full_path = searcher.find_image(input_filename)
if full_path is None:
raise FileNotFoundError(f'Image {input_filename} not found in {searcher.get_lookup_dirs()}.')
if format == "jpg":
format = "jpeg"
if format == "":
format = ext
# Locate all dependencies
deps_full = [full_path]
for dep in deps:
dep_full_path = searcher.find_image(dep)
if dep_full_path is None:
raise FileNotFoundError(f'Image dependency {dep} not found.')
deps_full.append(dep_full_path)
# Generate filename from arguments
suffix = ""
geometry = None
if width is not None or height is not None:
geometry = f'{width if width is not None else ""}x{height if height is not None else ""}{"" if fit else "!"}'
suffix += "_"+geometry
if quality is not None:
suffix += f'_q{quality}'
target_name = base+suffix+"."+format
target_path = searcher.get_cache_dir() + "/" + target_name
# Only regenerate if the file doesn't already exist and no dependencies are newer
if not os.path.isfile(target_path) or self.is_outdated(target_path, deps_full):
# If the format is the same or it is just a different extension for
# the same format, just copy it.
if (((ext == format)
or (ext == "epdf" and format == "pdf")
or (ext == "jpg" and format == "jpeg"))
and width is None and height is None and quality is None and dpi is None):
shutil.copyfile(full_path, target_path)
# Try to find the converted filename in lookup_dirs, if you find
# it, don't convert, just copy.
elif searcher.find_image(target_name) is not None and not self.is_outdated(searcher.find_image(target_name), deps):
shutil.copyfile(searcher.find_image(target_name), target_path)
# Process asymptote
elif ext == "asy":
# Collect dependencies
deps_dir = searcher.get_cache_dir() + "/" + name + "_deps"
if not os.path.isdir(deps_dir):
os.mkdir(deps_dir)
for dep_full in deps_full:
dep = os.path.basename(dep_full)
if not os.path.isfile(deps_dir + "/" + dep) or os.path.getmtime(deps_dir + "/" + dep) < os.path.getmtime(dep_full):
shutil.copyfile(dep_full, deps_dir + "/" + dep)
dpi_arg = ['-render', str(dpi/72)] if dpi is not None else []
if subprocess.run(['asy', name, '-o', target_name, '-f', format, *dpi_arg], cwd=deps_dir).returncode != 0:
raise AsyError(f"Could not convert '{full_path}' to '{format}'")
shutil.move(deps_dir + "/" + target_name, searcher.get_cache_dir() + "/" + target_name)
# Convert SVGs using inkscape
elif ext == "svg":
width_arg = ['--export-width', str(width)] if width is not None else []
height_arg = ['--export-height', str(height)] if height is not None else []
dpi_arg = ['--export-dpi', str(dpi)] if dpi is not None else []
if subprocess.run(['inkscape', full_path, '-o', target_path, *width_arg, *height_arg, *dpi_arg]).returncode != 0:
raise InkscapeError(f"Could not convert '{full_path}' to '{format}'")
# Convert everything else using ImageMagick.
else:
resize_arg = ['-resize', str(geometry)] if geometry is not None else []
density_arg = ['-density', str(dpi)] if dpi is not None else []
quality_arg = ['-quality', str(quality)] if quality is not None else []
if subprocess.run(['convert', *density_arg, full_path, *resize_arg, *quality_arg, target_path]).returncode != 0:
raise ImageMagickError(f"Could not convert '{full_path}' to '{format}'")
if context is not None:
context.add_deps(deps_full)
return target_name
def is_outdated(self, target: str, deps: list[str]):
target_timestamp = os.path.getmtime(target)
for dep in deps:
dep_timestamp = os.path.getmtime(dep)
if dep_timestamp > target_timestamp:
return True
return False
def get_image_size(self, full_path: str) -> tuple[int, int]:
# Getting image size using ImageMagick is slow. VERY
return Image.open(full_path).size

@ -1 +0,0 @@
Subproject commit 211cb2010e23265be599819c5f79f66f0abd62d1

View file

@ -1,106 +0,0 @@
import socket
import subprocess
import tempfile
import json
import os
import shutil
class KatexError(Exception):
pass
class NPMNotFoundError(Exception):
pass
class KatexServerError(Exception):
pass
class KatexClient:
_client: socket.socket
_server_process: subprocess.Popen[bytes]
_socket_file: str
_temp_dir: tempfile.TemporaryDirectory[str]
_connected: bool
_katex_server_path: str
def __init__(self, socket: str=None, connect: bool=True, katex_server_path: str=None):
self._katex_server_path = katex_server_path
if socket is not None:
self._socket_file = socket
else:
self.open_socket()
if connect:
self.connect()
self._client.sendall("init\n".encode("utf-8")) # Reinitialize KaTeX Server in case it was reused.
self._connected = True
else:
self._connected = False
def open_socket(self):
# Create temporary directory for socket
self._temp_dir = tempfile.TemporaryDirectory(prefix='formatitko')
self._socket_file = self._temp_dir.name + "/katex-socket"
if self._katex_server_path is None:
srcdir = os.path.dirname(os.path.realpath(__file__))
# Test if `node_modules` directory exists and if not, run `npm install`
if not os.path.isdir(srcdir + "/katex-server/node_modules"):
print("Installing node dependencies for the first time...")
npm = shutil.which("npm") or shutil.which("yarnpkg")
if npm is None:
raise NPMNotFoundError("npm not found. Node.js is required to use KaTeX.")
subprocess.run([npm, "install"], cwd=srcdir+"/katex-server", check=True)
self._katex_server_path = srcdir + "/katex-server/index.mjs"
self._server_process = subprocess.Popen(["node", self._katex_server_path, self._socket_file], stdout=subprocess.PIPE)
ok = self._server_process.stdout.readline()
if ok != b"OK\n":
raise KatexServerError("Failed to connect to katex-server")
def connect(self):
self._client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self._client.connect(self._socket_file)
def get_socket(self):
return self._socket_file
def render(self, tex: str, options: dict={}):
if not self._connected:
raise KatexServerError("KatexClient not connected to Katex server. It should be initialized with connect=True.")
# Send formulas to translate
self._client.sendall((json.dumps({"formulas":[{"tex":tex}], "options":options})+"\n").encode("utf-8"))
# Receive response
data = self._client.recv(4096)
while data[-1] != 0x0a:
data += self._client.recv(128)
response = json.loads(data)
if "error" in response:
raise KatexServerError(response["error"])
if "error" in response["results"][0]:
raise KatexError(response["results"][0]["error"] + " in $" + tex + "$")
else:
return response["results"][0]["html"]
# Special commands implemented in the JS file for grouping defs together.
def begingroup(self):
if not self._connected:
raise KatexServerError("KatexClient not connected to Katex server. It should be initialized with connect=True.")
self._client.sendall("begingroup\n".encode("utf-8"))
def endgroup(self):
if not self._connected:
raise KatexServerError("KatexClient not connected to Katex server. It should be initialized with connect=True.")
self._client.sendall("endgroup\n".encode("utf-8"))
def __enter__(self):
return self
def __exit__(self, type, value, tb):
if hasattr(self, "_server_process") and self._server_process is not None:
self._server_process.terminate()

View file

@ -1,104 +0,0 @@
from panflute import Element, ListContainer, Inline, Block
from panflute import Cite, Code, Emph, Image, LineBreak, Link, Math, Note, Quoted, RawInline, SmallCaps, SoftBreak, Space, Span, Str, Strikeout, Strong, Subscript, Superscript, Underline
from panflute import BlockQuote, BulletList, Citation, CodeBlock, Definition, DefinitionItem, DefinitionList, Div, Figure, Header, HorizontalRule, LineBlock, LineItem, ListItem, MetaBlocks, MetaBool, MetaInlines, MetaList, MetaMap, MetaString, Null, OrderedList, Para, Plain, RawBlock, Table, TableBody, TableFoot, TableHead
from panflute import TableRow, TableCell, Caption, Doc
from typing import Union
from .whitespace import NBSP
from .elements import FQuoted
from .context import Group
from .output_generator import OutputGenerator
from .images import ImageProcessor
class LaTeXGenerator(OutputGenerator):
imageProcessor: ImageProcessor
def __init__(self, output_file, imageProcessor: ImageProcessor, *args, **kwargs):
self.imageProcessor = imageProcessor
super().__init__(output_file, *args, **kwargs)
def generate(self, e: Union[Element, ListContainer]):
if hasattr(e, "attributes") and "only" in e.attributes and e.attributes["only"] != "tex":
return
super().generate(e)
def escape_special_chars(self, text: str) -> str:
text = text.replace("&", "\\&")
text = text.replace("%", "\\%")
text = text.replace("$", "\\$")
text = text.replace("#", "\\#")
text = text.replace("_", "\\_")
text = text.replace("{", "\\{")
text = text.replace("}", "\\}")
text = text.replace("~", "\\textasciitilde{}")
text = text.replace("^", "\\textasciicircum{}")
text = text.replace("\\", "\\textbackslash{}")
text = text.replace(" ", "~") # We use unicode no-break spaces to force nbsp in output
return text
def start_tag(self, tag: str, attributes: dict[str,str]={}) -> str:
return "\\" + tag + "{"
def end_tag(self, tag: str, attributes: dict[str,str]={}) -> str:
return "}"
def single_tag(self, tag: str, attributes: dict[str,str]={}) -> str:
return "\\" + tag + "{}"
def generate_NBSP(self, e: NBSP):
self.write("~")
def generate_Null(self, e: Null):
pass
def generate_LineBreak(self, e: LineBreak):
self.write("\\\\")
self.endln()
def generate_Para(self, e: Para):
self.generate(e.content)
self.writeln("") # This ensures an empty line
def generate_Plain(self, e: Plain):
self.generate(e.content)
def generate_Span(self, e: Plain):
self.generate(e.content)
def generate_Header(self, e: Header):
tag = {
1: "section",
2: "subsection",
3: "subsubsection",
4: "paragraph",
5: "subparagraph",
6: "textbf"
}
self.generate_simple_tag(e, tag=tag[e.level])
def generate_HorizontalRule(self, e: HorizontalRule):
self.writeln("\\begin{center}\\rule{0.5\\linewidth}{0.5pt}\\end{center}")
# These are also disabled in pandoc so they shouldn't appear in the AST at all.
def generate_Citation(self, e: Citation):
self.writeln("% FIXME: Citations not implemented")
def generate_Cite(self, e: Cite):
self.writeln("% FIXME: Cites not implemented")
def generate_Definition(self, e: Definition):
self.writeln("% FIXME: Definitions not implemented")
def generate_DefinitionItem(self, e: DefinitionItem):
self.writeln("% FIXME: DefinitionItems not implemented")
def generate_DefinitionList(self, e: DefinitionList):
self.writeln("% FIXME: DefinitionLists not implemented")

View file

@ -1,391 +0,0 @@
from panflute import Element, ListContainer, Inline, Block
from panflute import Cite, Code, Emph, Image, LineBreak, Link, Math, Note, Quoted, RawInline, SmallCaps, SoftBreak, Space, Span, Str, Strikeout, Strong, Subscript, Superscript, Underline
from panflute import BlockQuote, BulletList, Citation, CodeBlock, Definition, DefinitionItem, DefinitionList, Div, Figure, Header, HorizontalRule, LineBlock, LineItem, ListItem, MetaBlocks, MetaBool, MetaInlines, MetaList, MetaMap, MetaString, Null, OrderedList, Para, Plain, RawBlock, Table, TableBody, TableFoot, TableHead
from panflute import TableRow, TableCell, Caption, Doc
from panflute import MetaValue
from typing import Union, Callable
from .whitespace import NBSP
from .elements import FQuoted, FileLink
from .context import Group, InlineGroup, BlockGroup, Context
from .whitespace import Whitespace
from .command import BlockCommand, InlineCommand, CodeCommand, Command
from .output_generator import FormatitkoRecursiveError
ELCl = Union[Element, ListContainer, list[Union[Element, ListContainer]]]
class DoubleDocError(Exception):
"TransformProcessor should only ever see a single Doc."
pass
class NOPProcessor:
TYPE_DICT: dict[type, Callable]
context: Union[Context, None] = None
class UnknownElementError(Exception):
f"An unknown Element has been passed to the NOPProcessor, probably because panflute introduced a new one."
pass
def __init__(self):
self.TYPE_DICT = {
TableRow: self.transform_TableRow,
TableCell: self.transform_TableCell,
Caption: self.transform_Caption,
Doc: self.transform_Doc,
LineItem: self.transform_LineItem,
ListItem: self.transform_ListItem,
BlockQuote: self.transform_BlockQuote,
BulletList: self.transform_BulletList,
Citation: self.transform_Citation,
CodeBlock: self.transform_CodeBlock,
Definition: self.transform_Definition,
DefinitionItem: self.transform_DefinitionItem,
DefinitionList: self.transform_DefinitionList,
Div: self.transform_Div,
Figure: self.transform_Figure,
Header: self.transform_Header,
HorizontalRule: self.transform_HorizontalRule,
LineBlock: self.transform_LineBlock,
MetaBlocks: self.transform_MetaBlocks,
MetaBool: self.transform_MetaBool,
MetaInlines: self.transform_MetaInlines,
MetaList: self.transform_MetaList,
MetaMap: self.transform_MetaMap,
MetaString: self.transform_MetaString,
Null: self.transform_Null,
OrderedList: self.transform_OrderedList,
Para: self.transform_Para,
Plain: self.transform_Plain,
RawBlock: self.transform_RawBlock,
Table: self.transform_Table,
TableBody: self.transform_TableBody,
TableFoot: self.transform_TableFoot,
TableHead: self.transform_TableHead,
Group: self.transform_Group,
InlineGroup: self.transform_InlineGroup,
BlockGroup: self.transform_BlockGroup,
Cite: self.transform_Cite,
Code: self.transform_Code,
Emph: self.transform_Emph,
Image: self.transform_Image,
LineBreak: self.transform_LineBreak,
Link: self.transform_Link,
Math: self.transform_Math,
Note: self.transform_Note,
Quoted: self.transform_Quoted,
RawInline: self.transform_RawInline,
SmallCaps: self.transform_SmallCaps,
SoftBreak: self.transform_SoftBreak,
Space: self.transform_Space,
Span: self.transform_Span,
Str: self.transform_Str,
Strikeout: self.transform_Strikeout,
Strong: self.transform_Strong,
Subscript: self.transform_Subscript,
Superscript: self.transform_Superscript,
Underline: self.transform_Underline,
NBSP: self.transform_NBSP,
FQuoted: self.transform_FQuoted,
FileLink: self.transform_FileLink,
InlineCommand: self.transform_InlineCommand,
BlockCommand: self.transform_BlockCommand,
CodeCommand: self.transform_CodeCommand
}
def get_pretransformers(self) -> list[Callable[[ELCl],ELCl]]:
return []
def get_posttransformers(self) -> list[Callable[[ELCl],ELCl]]:
return []
def transform(self, e: ELCl) -> ELCl:
try:
if isinstance(e, list):
return self.transform_list(e)
elif isinstance(e, ListContainer):
return self.transform_ListContainer(e)
for transformer in self.get_pretransformers():
e = transformer(e)
try:
method = self.TYPE_DICT[type(e)]
except KeyError:
raise self.UnknownElementError(type(e))
e = method(e)
for transformer in self.get_posttransformers():
e = transformer(e)
return e
except FormatitkoRecursiveError as err:
if not isinstance(e, ListContainer):
err.add_element(e)
raise err
except Exception as err:
raise FormatitkoRecursiveError(e, self.context) from err
def transform_list(self, e: list[Union[Element, ListContainer]]) -> list[Union[Element, ListContainer]]:
i = 0
while i < len(e): # The length of the list can change mid-transformation, so we need to check the length each time
e[i] = self.transform(e[i])
i-=-1
return e
def transform_ListContainer(self, e: ListContainer) -> ListContainer:
i = 0
while i < len(e): # The length of the list can change mid-transformation, so we need to check the length each time
e[i] = self.transform(e[i])
i-=-1
return e
def transform_TableRow(self, e: TableRow) -> TableRow:
e.content = self.transform(e.content)
return e
def transform_TableCell(self, e: TableCell) -> TableCell:
e.content = self.transform(e.content)
return e
def transform_Caption(self, e: Caption) -> Caption:
e.content = self.transform(e.content)
return e
def transform_LineItem(self, e: LineItem) -> LineItem:
e.content = self.transform(e.content)
return e
def transform_ListItem(self, e: ListItem) -> ListItem:
e.content = self.transform(e.content)
return e
def transform_BlockQuote(self, e: BlockQuote) -> BlockQuote:
e.content = self.transform(e.content)
return e
def transform_BulletList(self, e: BulletList) -> BulletList:
e.content = self.transform(e.content)
return e
def transform_Citation(self, e: Citation) -> Citation:
e.content = self.transform(e.content)
return e
def transform_Definition(self, e: Definition) -> Definition:
e.content = self.transform(e.content)
return e
def transform_DefinitionItem(self, e: DefinitionItem) -> DefinitionItem:
e.content = self.transform(e.content)
return e
def transform_DefinitionList(self, e: DefinitionList) -> DefinitionList:
e.content = self.transform(e.content)
return e
def transform_Header(self, e: Header) -> Header:
e.content = self.transform(e.content)
return e
def transform_LineBlock(self, e: LineBlock) -> LineBlock:
e.content = self.transform(e.content)
return e
def transform_MetaBlocks(self, e: MetaBlocks) -> MetaBlocks:
e.content = self.transform(e.content)
return e
def transform_MetaBool(self, e: MetaBool) -> MetaBool:
e.content = self.transform(e.content)
return e
def transform_MetaInlines(self, e: MetaInlines) -> MetaInlines:
e.content = self.transform(e.content)
return e
def transform_MetaList(self, e: MetaList) -> MetaList:
e.content = self.transform(e.content)
return e
def transform_MetaMap(self, e: MetaMap) -> MetaMap:
e.content = self.transform(e.content)
return e
def transform_MetaString(self, e: MetaString) -> MetaString:
e.content = self.transform(e.content)
return e
def transform_OrderedList(self, e: OrderedList) -> OrderedList:
e.content = self.transform(e.content)
return e
def transform_Para(self, e: Para) -> Para:
e.content = self.transform(e.content)
return e
def transform_Plain(self, e: Plain) -> Plain:
e.content = self.transform(e.content)
return e
def transform_TableBody(self, e: TableBody) -> TableBody:
e.content = self.transform(e.content)
return e
def transform_TableFoot(self, e: TableFoot) -> TableFoot:
e.content = self.transform(e.content)
return e
def transform_TableHead(self, e: TableHead) -> TableHead:
e.content = self.transform(e.content)
return e
def transform_Group(self, e: Group) -> Group:
e.content = self.transform(e.content)
return e
def transform_InlineGroup(self, e: InlineGroup) -> InlineGroup:
e.content = self.transform(e.content)
return e
def transform_BlockGroup(self, e: BlockGroup) -> BlockGroup:
e.content = self.transform(e.content)
return e
def transform_Cite(self, e: Cite) -> Cite:
e.content = self.transform(e.content)
return e
def transform_Emph(self, e: Emph) -> Emph:
e.content = self.transform(e.content)
return e
def transform_Link(self, e: Link) -> Link:
e.content = self.transform(e.content)
return e
def transform_Note(self, e: Note) -> Note:
e.content = self.transform(e.content)
return e
def transform_SmallCaps(self, e: SmallCaps) -> SmallCaps:
e.content = self.transform(e.content)
return e
def transform_Strikeout(self, e: Strikeout) -> Strikeout:
e.content = self.transform(e.content)
return e
def transform_Strong(self, e: Strong) -> Strong:
e.content = self.transform(e.content)
return e
def transform_Subscript(self, e: Subscript) -> Subscript:
e.content = self.transform(e.content)
return e
def transform_Superscript(self, e: Superscript) -> Superscript:
e.content = self.transform(e.content)
return e
def transform_Underline(self, e: Underline) -> Underline:
e.content = self.transform(e.content)
return e
def transform_FQuoted(self, e: FQuoted) -> FQuoted:
e.content = self.transform(e.content)
return e
def transform_FileLink(self, e: FileLink) -> FileLink:
e.content = self.transform(e.content)
return e
def transform_Figure(self, e: Figure) -> Figure:
e.content = self.transform(e.content)
e.caption = self.transform(e.caption)
return e
def transform_Table(self, e: Table) -> Table:
e.head = self.transform(e.head)
e.content = self.transform(e.content)
e.foot = self.transform(e.foot)
return e
def transform_Doc(self, e: Doc) -> Doc:
if self.context is not None:
raise DoubleDocError()
self.context = Context(e, self.root_file_path)
e.content = self.transform(e.content)
return e
def transform_Quoted(self, e: Quoted) -> Quoted:
e.content = self.transform(e.content)
return e
def transform_Image(self, e: Image) -> Image:
e.content = self.transform(e.content)
return e
def transform_Div(self, e: Div) -> Div:
e.content = self.transform(e.content)
return e
def transform_Span(self, e: Span) -> Span:
e.content = self.transform(e.content)
return e
def transform_CodeBlock(self, e: CodeBlock) -> CodeBlock:
return e
def transform_Command(self, e: Command) -> Command:
e.content = self.transform(e.content)
return e
def transform_InlineCommand(self, e: InlineCommand) -> Span:
return self.transform_Command(e)
def transform_BlockCommand(self, e: BlockCommand) -> Div:
return self.transform_Command(e)
def transform_CodeCommand(self, e: CodeCommand) -> Div:
return self.transform_Command(e)
def transform_Whitespace(self, e: Whitespace) -> Whitespace:
return e
def transform_SoftBreak(self, e: SoftBreak) -> Whitespace:
return self.transform_Whitespace(e)
def transform_Space(self, e: Space) -> Whitespace:
return self.transform_Whitespace(e)
def transform_NBSP(self, e: NBSP) -> NBSP:
return e
def transform_Str(self, e: Str) -> Str:
return e
def transform_RawInline(self, e: RawInline) -> RawInline:
return e
def transform_Math(self, e: Math) -> Math:
return e
def transform_LineBreak(self, e: LineBreak) -> LineBreak:
return e
def transform_Code(self, e: Code) -> Code:
return e
def transform_RawBlock(self, e: RawBlock) -> RawBlock:
return e
def transform_Null(self, e: Null) -> Null:
return e
def transform_HorizontalRule(self, e: HorizontalRule) -> HorizontalRule:
return e

View file

@ -1,550 +0,0 @@
from panflute import Element, ListContainer, Inline, Block
from panflute import Cite, Code, Emph, Image, LineBreak, Link, Math, Note, Quoted, RawInline, SmallCaps, SoftBreak, Space, Span, Str, Strikeout, Strong, Subscript, Superscript, Underline
from panflute import BlockQuote, BulletList, Citation, CodeBlock, Definition, DefinitionItem, DefinitionList, Div, Figure, Header, HorizontalRule, LineBlock, LineItem, ListItem, MetaBlocks, MetaBool, MetaInlines, MetaList, MetaMap, MetaString, Null, OrderedList, Para, Plain, RawBlock, Table, TableBody, TableFoot, TableHead
from panflute import TableRow, TableCell, Caption, Doc
from panflute import MetaValue
from panflute import stringify
from typing import Union, Callable
from .whitespace import NBSP
from .elements import FQuoted, FileLink
from .context import Group, InlineGroup, BlockGroup, Context
import sys
class UnknownElementError(Exception):
"An unknown Element has been passed to the OutputGenerator, probably because panflute introduced a new one."
pass
class FormatitkoRecursiveError(Exception):
"A generic exception which wraps other exceptions and adds element-based traceback"
elements: list[Union[Element, ListContainer, list[Union[Element, ListContainer]]]]
context: Context
def __init__(self, e: Union[Element, ListContainer, list[Union[Element, ListContainer]]], context: Context, *args):
self.elements = [e]
self.context = context
super().__init__(args)
def add_element(self, e: Union[Element, ListContainer, list[Union[Element, ListContainer]]]):
self.elements.append(e)
def pretty_print(self, tracebacklimit: int=0):
def eprint(*args, **kwargs):
print(*args, file=sys.stderr, **kwargs)
def print_filename_recursive(context: Context):
return context.path +\
((" (included from " + print_filename_recursive(context.parent) + ")") if context.parent else "")
eprint(f"Error occured in file {print_filename_recursive(self.context)} in ", end="")
line = None
for i in range(len(self.elements)-1, 0, -1):
if hasattr(self.elements[i], "content") and len(self.elements[i].content) > 0 and isinstance(self.elements[i].content[0], Inline) and line is None:
line = self.elements[i]
eprint(type(self.elements[i]).__name__ + "[" + (str(self.elements[i-1].index) if isinstance(self.elements[i-1].index, int) else "") + "]", end=": ")
if line:
eprint()
eprint('on line: "' + stringify(line).strip() + '"', end="")
eprint()
eprint("in element: " + str(self.elements[0]).replace("\n", "\\n"))
sys.tracebacklimit = tracebacklimit
raise self.__cause__ from None
class OutputGenerator:
_empty_lines: int
context: Union[Context, None]
indent_level: int
indent_str: str
output_file: ...
TYPE_DICT_BLOCK: dict[type, Callable]
TYPE_DICT_INLINE: dict[type, Callable]
TYPE_DICT_MISC: dict[type, Callable]
def __init__(self, output_file, indent_str: str="\t", initial_indent_level: int=0):
self.output_file = output_file
self.indent_str = indent_str
self.indent_level = initial_indent_level
self._empty_lines = 1
self.context = None
self.TYPE_DICT_MISC = {
TableRow: self.generate_TableRow,
TableCell: self.generate_TableCell,
Caption: self.generate_Caption,
Doc: self.generate_Doc,
LineItem: self.generate_LineItem,
ListItem: self.generate_ListItem
}
self.TYPE_DICT_BLOCK = {
BlockQuote: self.generate_BlockQuote,
BulletList: self.generate_BulletList,
Citation: self.generate_Citation,
CodeBlock: self.generate_CodeBlock,
Definition: self.generate_Definition,
DefinitionItem: self.generate_DefinitionItem,
DefinitionList: self.generate_DefinitionList,
Div: self.generate_Div,
Figure: self.generate_Figure,
Header: self.generate_Header,
HorizontalRule: self.generate_HorizontalRule,
LineBlock: self.generate_LineBlock,
Null: self.generate_Null,
OrderedList: self.generate_OrderedList,
Para: self.generate_Para,
Plain: self.generate_Plain,
RawBlock: self.generate_RawBlock,
Table: self.generate_Table,
TableBody: self.generate_TableBody,
TableFoot: self.generate_TableFoot,
TableHead: self.generate_TableHead,
BlockGroup: self.generate_BlockGroup
}
self.TYPE_DICT_INLINE = {
Cite: self.generate_Cite,
Code: self.generate_Code,
Emph: self.generate_Emph,
Image: self.generate_Image,
LineBreak: self.generate_LineBreak,
Link: self.generate_Link,
Math: self.generate_Math,
Note: self.generate_Note,
Quoted: self.generate_Quoted,
RawInline: self.generate_RawInline,
SmallCaps: self.generate_SmallCaps,
SoftBreak: self.generate_SoftBreak,
Space: self.generate_Space,
Span: self.generate_Span,
Str: self.generate_Str,
Strikeout: self.generate_Strikeout,
Strong: self.generate_Strong,
Subscript: self.generate_Subscript,
Superscript: self.generate_Superscript,
Underline: self.generate_Underline,
NBSP: self.generate_NBSP,
FQuoted: self.generate_FQuoted,
FileLink: self.generate_FileLink,
InlineGroup: self.generate_InlineGroup
}
self.TYPE_DICT_META = {
MetaBlocks: self.generate_MetaBlocks,
MetaBool: self.generate_MetaBool,
MetaInlines: self.generate_MetaInlines,
MetaMap: self.generate_MetaMap,
MetaString: self.generate_MetaString,
}
def generate(self, e: Union[Element, ListContainer, list[Union[Element, ListContainer]]]):
try:
if isinstance(e, Group):
old_context = self.context
self.context = e.context
if isinstance(e, list):
self.generate_list(e)
elif isinstance(e, ListContainer):
self.generate_ListContainer(e)
elif isinstance(e, Inline):
self.generate_Inline(e)
elif isinstance(e, Block):
self.generate_Block(e)
elif isinstance(e, MetaValue):
self.generate_MetaValue(e)
elif isinstance(e, MetaList):
self.generate_MetaList(e)
else:
try:
method = self.TYPE_DICT_MISC[type(e)]
except KeyError as err:
raise UnknownElementError(type(e)) from err
method(e)
if isinstance(e, Group):
self.context = old_context
except FormatitkoRecursiveError as err:
if not isinstance(e, ListContainer):
err.add_element(e)
raise err
except Exception as err:
raise FormatitkoRecursiveError(e, self.context) from err
def escape_special_chars(self, text: str) -> str:
return text
def indent(self) -> str:
return self.indent_str*self.indent_level
def indent_more(self):
self.indent_level += 1
def indent_less(self):
self.indent_level -= 1
def write(self, text: str=""):
if self._empty_lines > 0:
self.output_file.write(self.indent())
self.output_file.write(text)
self._empty_lines = 0
def writeln(self, text: str=""):
if self._empty_lines == 0:
self.output_file.write("\n")
self.output_file.write(self.indent())
self.output_file.write(text+"\n")
self._empty_lines = 1
def writeraw(self, text: str=""):
if self._empty_lines == 0:
self.output_file.write("\n")
self.output_file.write(text+"\n")
self._empty_lines = 1
def ensure_empty(self, n: int=1):
while self._empty_lines < n:
self.output_file.write("\n")
self._empty_lines+=1
def endln(self):
self.ensure_empty(1)
def start_tag(self, tag: str, attributes: dict[str,str]={}) -> str:
return tag
def end_tag(self, tag: str, attributes: dict[str,str]={}) -> str:
return "/" + tag
def single_tag(self, tag: str, attributes: dict[str,str]={}) -> str:
return "/" + tag + "/"
def tagname(self, e) -> str:
return type(e).__name__
def common_attributes(self, e: Element) -> dict[str,str]:
return {}
def generate_simple_tag(self, e: Union[Element, None]=None, tag: str="", attributes: Union[dict[str,str],None]=None, content: Union[ListContainer, Element, list[Union[Element, ListContainer]], str, None]=None, inline: Union[bool, None]=None):
if not tag and e:
tag = self.tagname(e)
if attributes is None:
if e:
attributes = self.common_attributes(e)
else:
attributes = {}
if content is None and e and hasattr(e, "content"):
content = e.content
if content is None and e and hasattr(e, "text"):
content = e.text
if inline is None and e:
inline = isinstance(e, Inline)
if content is None:
self.generate_empty_block_tag(tag, attributes)
return
if inline:
if isinstance(content, str):
self.generate_raw_inline_tag(tag, content, attributes)
else:
self.generate_simple_inline_tag(tag, content, attributes)
else:
if isinstance(content, str):
self.generate_raw_block_tag(tag, content, attributes)
else:
self.generate_simple_block_tag(tag, content, attributes)
def generate_simple_inline_tag(self, tag: str, content: Union[ListContainer, Element, list[Union[Element, ListContainer]]], attributes: dict[str,str]={}):
self.write(self.start_tag(tag, attributes))
self.generate(content)
self.write(self.end_tag(tag))
def generate_simple_block_tag(self, tag: str, content: Union[ListContainer, Element, list[Union[Element, ListContainer]]], attributes: dict[str,str]={}):
self.writeln(self.start_tag(tag, attributes))
self.indent_more()
self.generate(content)
self.indent_less()
self.writeln(self.end_tag(tag))
def generate_raw_inline_tag(self, tag: str, text: str, attributes: dict[str,str]={}):
self.write(self.start_tag(tag, attributes))
self.write(text)
self.write(self.end_tag(tag))
def generate_raw_block_tag(self, tag: str, text: str, attributes: dict[str,str]={}):
self.writeraw(self.start_tag(tag, attributes))
self.writeraw(text)
self.writeraw(self.end_tag(tag))
def generate_empty_block_tag(self, tag: str, attributes: dict[str,str]={}):
self.writeln(self.single_tag(tag, attributes))
def generate_ListContainer(self, e: ListContainer):
for child in e:
self.generate(child)
def generate_list(self, e: list):
for el in e:
self.generate(el)
def generate_MetaList(self, e: MetaList):
for child in e:
self.generate(child)
def generate_MetaValue(self, e: MetaValue):
try:
method = self.TYPE_DICT_META[type(e)]
except KeyError:
self.generate(e.content)
method(e)
def generate_MetaBlocks(self, e: MetaBlocks):
self.generate(e.content)
def generate_MetaInlines(self, e: MetaInlines):
self.generate(e.content)
def generate_MetaBool(self, e: MetaBool):
if e.boolean:
self.write("True")
else:
self.write("False")
def generate_MetaMap(self, e: MetaMap):
self.generate_simple_tag(e)
def generate_MetaString(self, e: MetaString):
self.write(e.text)
def generate_Inline(self, e: Inline):
try:
method = self.TYPE_DICT_INLINE[type(e)]
except KeyError as err:
raise UnknownElementError(type(e)) from err
method(e)
def generate_Str(self, e: Str):
self.write(self.escape_special_chars(e.text))
def generate_Space(self, e: Space):
self.write(" ")
def generate_NBSP(self, e: NBSP):
self.write("~")
def generate_SoftBreak(self, e: SoftBreak):
self.endln()
def generate_FQuoted(self, e: FQuoted):
if e.style == "cs":
if e.quote_type == "SingleQuote":
self.write("")
self.generate(e.content)
self.write("")
elif e.quote_type == "DoubleQuote":
self.write("")
self.generate(e.content)
self.write("")
elif e.style == "en":
if e.quote_type == "SingleQuote":
self.write("")
self.generate(e.content)
self.write("")
elif e.quote_type == "DoubleQuote":
self.write("")
self.generate(e.content)
self.write("")
else:
if e.quote_type == "SingleQuote":
self.write("'")
self.generate(e.content)
self.write("'")
elif e.quote_type == "DoubleQuote":
self.write("\"")
self.generate(e.content)
self.write("\"")
else:
self.write("\"")
self.generate(e.content)
self.write("\"")
def generate_FileLink(self, e: FileLink):
self.generate_simple_tag(e)
# Inline Elements
def generate_Cite(self, e: Cite):
self.generate_simple_tag(e)
def generate_Emph(self, e: Emph):
self.generate_simple_tag(e)
def generate_Image(self, e: Image):
self.generate_simple_tag(e)
def generate_Link(self, e: Link):
self.generate_simple_tag(e)
def generate_Note(self, e: Note):
self.generate_simple_tag(e)
def generate_Quoted(self, e: Quoted):
self.generate_simple_tag(e)
def generate_SmallCaps(self, e: SmallCaps):
self.generate_simple_tag(e)
def generate_Span(self, e: Span):
self.generate_simple_tag(e)
def generate_Strikeout(self, e: Strikeout):
self.generate_simple_tag(e)
def generate_Strong(self, e: Strong):
self.generate_simple_tag(e)
def generate_Subscript(self, e: Subscript):
self.generate_simple_tag(e)
def generate_Superscript(self, e: Superscript):
self.generate_simple_tag(e)
def generate_Underline(self, e: Underline):
self.generate_simple_tag(e)
# Raw Inline elements
def generate_Math(self, e: Math):
self.generate_simple_tag(e)
def generate_Code(self, e: Code):
self.generate_simple_tag(e)
def generate_RawInline(self, e: RawInline):
self.generate_simple_tag(e)
def generate_Block(self, e: Block):
try:
method = self.TYPE_DICT_BLOCK[type(e)]
except KeyError as err:
raise UnknownElementError(type(e)) from err
method(e)
# Block elements
def generate_BlockQuote(self, e: BlockQuote):
self.generate_simple_tag(e)
def generate_BulletList(self, e: BulletList):
self.generate_simple_tag(e)
def generate_Citation(self, e: Citation):
self.generate_simple_tag(e)
def generate_Definition(self, e: Definition):
self.generate_simple_tag(e)
def generate_DefinitionItem(self, e: DefinitionItem):
self.generate_simple_tag(e)
def generate_DefinitionList(self, e: DefinitionList):
self.generate_simple_tag(e)
def generate_Div(self, e: Div):
self.generate_simple_tag(e)
def generate_Header(self, e: Header):
self.generate_simple_tag(e)
def generate_LineBlock(self, e: LineBlock):
self.generate_simple_tag(e)
def generate_LineItem(self, e: LineItem):
self.generate_simple_tag(e)
def generate_ListItem(self, e: ListItem):
self.generate_simple_tag(e)
def generate_OrderedList(self, e: OrderedList):
self.generate_simple_tag(e)
def generate_Para(self, e: Para):
self.generate_simple_tag(e)
def generate_Plain(self, e: Plain):
self.generate_simple_tag(e)
def generate_Caption(self, e: Caption):
self.generate_simple_tag(e)
def generate_TableBody(self, e: TableBody):
self.generate_simple_tag(e)
def generate_TableCell(self, e: TableCell):
self.generate_simple_tag(e)
def generate_TableFoot(self, e: TableFoot):
self.generate_simple_tag(e)
def generate_TableHead(self, e: TableHead):
self.generate_simple_tag(e)
def generate_TableRow(self, e: TableRow):
self.generate_simple_tag(e)
def generate_Doc(self, e: Doc):
if "header-includes" in e.metadata: # This is the pandoc way of doing things
self.generate(e.metadata["header-includes"])
if "header_content" in e.metadata:
self.generate(e.metadata["header_content"])
self.generate_simple_tag(e)
if "footer_content" in e.metadata:
self.generate(e.metadata["footer_content"])
def generate_BlockGroup(self, e: BlockGroup):
self.generate_simple_tag(e)
def generate_InlineGroup(self, e: InlineGroup):
self.generate_simple_tag(e)
# Special elements with more contents
def generate_Table(self, e: Table):
self.generate_simple_tag(e, content=[e.head, e.content, e.foot])
def generate_Figure(self, e: Figure):
self.generate_simple_tag(e, content=[e.content, e.caption])
# Emtpy tags
def generate_Null(self, e: Null):
self.generate_simple_tag(e)
def generate_HorizontalRule(self, e: HorizontalRule):
self.generate_simple_tag(e)
def generate_LineBreak(self, e: LineBreak):
self.generate_simple_tag(e)
# Raw Block tags
def generate_CodeBlock(self, e: CodeBlock):
self.generate_simple_tag(e)
def generate_RawBlock(self, e: RawBlock):
self.generate_simple_tag(e)
# Maybe move this to ImageProcessor?
def get_image_processor_args(self, attributes:dict[str,str]) -> dict:
# Attributes → image processor args
additional_args = {}
if "file-width" in attributes:
additional_args["width"] = int(attributes["file-width"])
if "file-height" in attributes:
additional_args["height"] = int(attributes["file-height"])
if "file-quality" in attributes:
additional_args["quality"] = int(attributes["file-quality"])
if "file-dpi" in attributes:
additional_args["dpi"] = int(attributes["file-dpi"])
if "file-deps" in attributes:
additional_args["deps"] = attributes["file-deps"].split(",")
return additional_args

View file

@ -1,41 +0,0 @@
from .nop_processor import NOPProcessor
from panflute import Div, Span, Null, Str, Plain, Quoted
from .context import Group, InlineGroup, BlockGroup
from .elements import FQuoted
from .whitespace import NBSP
from .nop_processor import ELCl
from typing import Callable
import sys
class PandocProcessor(NOPProcessor):
def get_posttransformers(self) -> list[Callable[[ELCl],ELCl]]:
return super().get_posttransformers()+[self.sanitize_attributes]
def sanitize_attributes(self, e: ELCl) -> ELCl:
if hasattr(e, "attributes"):
for key, value in e.attributes.items():
e.attributes[key] = str(value)
return e
def transform_Group(self, e: Group) -> Group:
e.content = self.transform(e.content)
return Div(*e.content)
def transform_InlineGroup(self, e: InlineGroup) -> InlineGroup:
e.content = self.transform(e.content)
return Span(*e.content)
def transform_BlockGroup(self, e: BlockGroup) -> BlockGroup:
e.content = self.transform(e.content)
return Div(*e.content)
def transform_Null(self, e: Null):
return Plain(Str(""))
def transform_FQuoted(self, e: FQuoted) -> Quoted:
return Quoted(*e.content)
def transform_NBSP(self, e: NBSP) -> Str:
return Str(" ") # Unicode nbsp

View file

@ -1,366 +0,0 @@
from panflute import Element, ListContainer, Inline, Block
from panflute import Cite, Code, Emph, Image, LineBreak, Link, Math, Note, Quoted, RawInline, SmallCaps, SoftBreak, Space, Span, Str, Strikeout, Strong, Subscript, Superscript, Underline
from panflute import BlockQuote, BulletList, Citation, CodeBlock, Definition, DefinitionItem, DefinitionList, Div, Figure, Header, HorizontalRule, LineBlock, LineItem, ListItem, MetaBlocks, MetaBool, MetaInlines, MetaList, MetaMap, MetaString, Null, OrderedList, Para, Plain, RawBlock, Table, TableBody, TableFoot, TableHead
from panflute import TableRow, TableCell, Caption, Doc
from panflute import MetaValue
from typing import Union
import os
from .output_generator import OutputGenerator
from .images import ImageProcessor, ImageProcessorNamespaceSearcher
from .whitespace import NBSP
from .elements import FQuoted
from .context import Group, InlineGroup, BlockGroup, Context
from .util import inlinify
class UCWTexGenerator(OutputGenerator):
imageProcessor: ImageProcessor
_bold: int
_italic: int
def __init__(self, output_file, imageProcessor: ImageProcessor, *args, **kwargs):
self.imageProcessor = imageProcessor
self._bold = 0
self._italic = 0
super().__init__(output_file, *args, **kwargs)
def escape_special_chars(self, text: str) -> str:
text = text.replace("&", r"\&")
text = text.replace("%", r"\%")
text = text.replace("$", r"\$")
text = text.replace("#", r"\#")
text = text.replace("_", r"\_")
text = text.replace("{", r"\{")
text = text.replace("}", r"\}")
text = text.replace("~", r"\textasciitilde{}")
text = text.replace("^", r"\textasciicircum{}")
text = text.replace("\\", r"\textbackslash{}")
text = text.replace(" ", "~") # We use unicode no-break spaces to force nbsp in output
text = text.replace("", "")
return text
def generate(self, e: Union[Element, ListContainer]):
if hasattr(e, "attributes") and "only" in e.attributes and e.attributes["only"] != "tex":
return
super().generate(e)
def writepar(self, text: str):
self.ensure_empty(2)
self.writeln(text)
self.ensure_empty(2)
def generate_Null(self, e: Null):
pass
def generate_LineBreak(self, e: LineBreak):
self.write(r"\\")
self.endln()
def generate_Para(self, e: Para):
self.ensure_empty(2)
self.generate(e.content)
self.ensure_empty(2)
def generate_HorizontalRule(self, e: HorizontalRule):
self.writepar(r"\vskip5pt\hrule\hfil\vskip5pt{}")
def generate_Doc(self, e: Doc):
self.writeln(r"\input ucwmac2.tex")
self.writeln(r"\ucwmodule{ofs}")
self.writeln(r"\ucwmodule{verb}")
self.writeln(r"\ucwmodule{link}")
self.writeln(r"\input formatitko.tex")
self.generate(e.content)
self.writeln(r"\bye")
def get_language_macro(self, lang: str):
if lang == "cs":
return r"\chyph\lefthyphenmin=2\righthyphenmin=2{}"
elif lang == "sk":
return r"\shyph\lefthyphenmin=2\righthyphenmin=2{}"
elif lang == "en":
return r"\ehyph\lefthyphenmin=2\righthyphenmin=2{}"
else:
return ""
def generate_InlineGroup(self, e: InlineGroup):
self.write(r"{")
self.write(self.get_language_macro(self.context.get_metadata("lang")))
self.generate(e.content)
self.write(r"}")
def generate_BlockGroup(self, e: BlockGroup):
self.writeln(r"\begingroup")
self.indent_more()
self.writeln(self.get_language_macro(self.context.get_metadata("lang")))
self.generate(e.content)
self.indent_less()
self.writeln(r"\endgroup")
def generate_Header(self, e: Header):
self.ensure_empty(2)
self.write("\\"+"sub"*(e.level-1)+"section{")
self.generate(e.content)
self.write(r"}")
self.ensure_empty(2)
def generate_Image(self, e: Image):
url = e.url
additional_args = self.get_image_processor_args(e.attributes)
additional_args["context"] = self.context
# The directory of the current file relative to the current working directory
source_dir = self.context.dir
# The directory of the current file relative to the md file we were called on
rel_dir = self.context.rel_dir
searcher = self.imageProcessor.get_searcher_by_path(url, rel_dir, source_dir)
url = self.imageProcessor.get_path_without_namespace(url)
_, ext = os.path.splitext(url)
ext = ext[1:]
# Conversions between various formats.
if ext in ["pdf", "png", "jpeg"]:
# Even supported elements have to be 'converted' because the
# processing contains finding and moving them to the cache
# directory.
url = self.imageProcessor.process_image(url, ext, searcher, **additional_args)
elif ext in ["svg"]: # FIXME
url = self.imageProcessor.process_image(url, "pdf", searcher, **additional_args)
elif ext in ["epdf"]:
url = self.imageProcessor.process_image(url, "pdf", searcher, **additional_args)
elif ext in ["jpg"]:
url = self.imageProcessor.process_image(url, "jpeg", searcher, **additional_args)
else:
url = self.imageProcessor.process_image(url, "pdf", searcher, **additional_args)
url = searcher.get_cache_searcher().find_image(url)
width = ""
if "width" in e.attributes:
width = e.attributes["width"]
# 50% → 0.5\hsize
if e.attributes["width"][-1] == "%":
width = str(int(e.attributes["width"][:-1])/100) + "\\hsize"
width = "width " + width
if isinstance(e.parent.parent, Figure):
self.writeln(f'\\putimage{{{width}}}{{{url}}}')
else:
self.writepar(f'\\putimage{{{width}}}{{{url}}}')
def generate_Code(self, e: Code):
self.write(r"\verb`")
self.write(e.text)
self.write(r"`")
def generate_Figure(self, e: Figure):
self.ensure_empty(2)
self.writeln(r"\vskip5pt")
self.writeln(r"\centerline{")
self.indent_more()
self.generate(e.content)
self.indent_less()
self.writeln(r"}")
self.writeln(r"\centerline{")
self.indent_more()
self.generate(e.caption)
self.indent_less()
self.writeln(r"}")
self.writeln(r"\vskip5pt{}")
self.ensure_empty(2)
def generate_Emph(self, e: Emph):
if self._bold > 0:
self.write(r"{\bi{}")
else:
self.write(r"{\I{}")
self._italic+=1
self.generate(e.content)
self._italic-=1
self.write(r"}")
def generate_Strong(self, e: Strong):
if self._italic > 0:
self.write(r"{\bi{}")
else:
self.write(r"{\bf{}")
self._bold+=1
self.generate(e.content)
self._bold-=1
self.write(r"}")
def generate_Caption(self, e: Caption):
self.generate_Emph(e)
def generate_Math(self, e: Math):
if e.format == "DisplayMath":
self.ensure_empty(2)
self.writeraw("$$")
self.writeraw(e.text.strip())
self.writeraw("$$")
self.ensure_empty(2)
else:
self.write("$")
self.write(e.text)
self.write("$")
def generate_Note(self, e: Note):
self.write(r"\fn{")
self.generate(inlinify(e))
self.write(r"}")
def generate_Table(self, e: Table):
aligns = {
"AlignLeft": r"\quad#\quad\hfil",
"AlignRight": r"\quad\hfil#\quad",
"AlignCenter": r"\quad\hfil#\hfil\quad",
"AlignDefault": r"\quad#\quad\hfil"
}
self.writeln(r"\vskip1em")
self.writeln(r"\halign{\strut"+"&".join([aligns[col[0]] for col in e.colspec])+r"\cr")
self.indent_more()
self.generate(e.head.content)
self.writeln(r"\noalign{\hrule}")
self.generate(e.content[0].content)
self.writeln(r"\noalign{\hrule}")
self.generate(e.foot.content)
self.indent_less()
self.writeln("}")
self.writeln(r"\vskip1em")
def generate_TableRow(self, e: TableRow):
for cell in e.content:
if cell.colspan > 1:
self.write(r"\multispan"+str(cell.colspan)+"{} ")
self.generate(cell.content)
if cell.next:
self.write(" & ")
self.write(r"\cr")
self.endln()
def generate_RawInline(self, e: RawInline):
if e.format == "tex":
self.write(e.text)
def generate_RawBlock(self, e: RawBlock):
if e.format == "tex":
self.writeraw(e.text)
def generate_Plain(self, e: Plain):
self.generate(e.content)
def generate_Span(self, e: Span):
self.generate(e.content)
def generate_CodeBlock(self, e: CodeBlock):
self.writeln(r"\verbatim{")
self.writeraw(e.text)
self.writeln(r"}")
def generate_Div(self, e: Div):
self.generate(e.content)
def generate_LineBlock(self, e: LineBlock):
self.writeln()
self.generate(e.content)
self.writeln()
def generate_LineItem(self, e: LineItem):
self.generate(e.content)
if e.next:
self.write(r"\\")
self.endln()
def generate_BulletList(self, e: BulletList):
self.ensure_empty(2)
self.writeln(r"\list{o}")
self.indent_more()
self.generate(e.content)
self.indent_less()
self.write(r"\endlist")
self.ensure_empty(2)
def generate_OrderedList(self, e: OrderedList):
self.ensure_empty(2)
styles = {
"DefaultStyle": "n",
"Decimal": "n",
"LowerRoman": "i",
"UpperRoman:": "I",
"LowerAlpha": "a",
"UpperAlpha": "A"
}
style = styles[e.style]
delimiters = {
"DefaultDelim": f"{style}.",
"Period": f"{style}.",
"OneParen": f"{style})",
"TwoParens": f"({style})"
}
style = delimiters[e.delimiter]
self.writeln(r"\list{"+style+r"}")
self.indent_more()
self.generate(e.content)
self.indent_less()
self.writeln(r"\endlist")
self.ensure_empty(2)
def generate_ListItem(self, e: ListItem):
self.endln()
self.write(r"\:")
self.generate(e.content)
self.endln()
def generate_BlockQuote(self, e: BlockQuote):
self.writeln(r"\blockquote{")
self.indent_more()
self.generate(e.content)
self.indent_less()
self.writeln(r"}")
def generate_Link(self, e: Link):
if len(e.content) == 1 and isinstance(e.content[0], Str) and e.content[0].text == e.url:
self.write(r"\url{")
else:
self.write(r"\linkurl{"+e.url+r"}{")
self.generate(e.content)
self.write(r"}") # }
def generate_Subscript(self, e: Subscript):
self.write(r"\subscript{")
self.generate(e.content)
self.write(r"}")
def generate_Superscript(self, e: Superscript):
self.write(r"\superscript{")
self.generate(e.content)
self.write(r"}")
def generate_simple_tag(self, e: Union[Element, None] = None, tag: str = "", attributes: Union[dict[str, str], None] = None, content: Union[ListContainer, Element, list[Union[Element, ListContainer]], str, None] = None, inline: Union[bool, None] = None):
print("dumbass: ", type(e))
# These are also disabled in pandoc so they shouldn't appear in the AST at all.
def generate_Citation(self, e: Citation):
self.writeln("% FIXME: Citations not implemented")
def generate_Cite(self, e: Cite):
self.writeln("% FIXME: Cites not implemented")
def generate_Definition(self, e: Definition):
self.writeln("% FIXME: Definitions not implemented")
def generate_DefinitionItem(self, e: DefinitionItem):
self.writeln("% FIXME: DefinitionItems not implemented")
def generate_DefinitionList(self, e: DefinitionList):
self.writeln("% FIXME: DefinitionLists not implemented")
def generate_Underline(self, e: Underline):
self.writeln("% FIXME: Underlines not implemented")
def generate_Strikeout(self, e: Strikeout):
self.writeln("% FIXME: Strikeouts not implemented")

View file

@ -1,308 +0,0 @@
from panflute import Element, ListContainer, Inline, Block
from panflute import Cite, Code, Emph, Image, LineBreak, Link, Math, Note, Quoted, RawInline, SmallCaps, SoftBreak, Space, Span, Str, Strikeout, Strong, Subscript, Superscript, Underline
from panflute import BlockQuote, BulletList, Citation, CodeBlock, Definition, DefinitionItem, DefinitionList, Div, Figure, Header, HorizontalRule, LineBlock, LineItem, ListItem, MetaBlocks, MetaBool, MetaInlines, MetaList, MetaMap, MetaString, Null, OrderedList, Para, Plain, RawBlock, Table, TableBody, TableFoot, TableHead
from panflute import TableRow, TableCell, Caption, Doc
from panflute import MetaValue
from panflute.containers import attach
from typing import Union, Callable
from types import ModuleType
import os
import re
import warnings
import importlib
import json
from .whitespace import NBSP
from .elements import FQuoted
from .context import Group, InlineGroup, BlockGroup
from .util import nullify, import_md
from .context import Context, CommandCallable
from .whitespace import Whitespace, bavlna
from .command import BlockCommand, InlineCommand, CodeCommand, Command, InlineError
from .command_util import handle_command_define, parse_command
from .nop_processor import NOPProcessor, ELCl, DoubleDocError
class TransformProcessor(NOPProcessor):
root_file_path: str
root_highlight_style: str = "default"
_command_modules: list[tuple[Union[dict[str, CommandCallable], ModuleType], str]] = []
class UnknownElementError(Exception):
"An unknown Element has been passed to the TransformProcessor, probably because panflute introduced a new one."
pass
def __init__(self, root_file_path: str, *args, **kwargs):
self.root_file_path = root_file_path
super().__init__(*args, **kwargs)
def add_command_module(self, module: Union[dict[str, CommandCallable], ModuleType], module_name: str=""):
self._command_modules.append((module, module_name))
def init_context(self, e: Doc) -> Context:
if self.context is not None:
raise DoubleDocError()
self.context = Context(e, self.root_file_path)
for module, module_name in self._command_modules:
self.context.add_commands_from_module(module, module_name)
e.content = [BlockGroup(*e.content, context=self.context)]
return self.context
def get_pretransformers(self) -> list[Callable[[ELCl],ELCl]]:
return super().get_pretransformers()+[self.handle_if_attribute, self.handle_ifnot_attribute]
def handle_if_attribute(self, e: ELCl) -> ELCl:
# `if` attribute. Only show this element if flag is set.
if hasattr(e, "attributes") and "if" in e.attributes:
if not self.context.is_flag_set(e.attributes["if"]):
return nullify(e)
return e
def handle_ifnot_attribute(self, e: ELCl) -> ELCl:
# `ifnot` attribute. Only show this element if flag is NOT set
if hasattr(e, "attributes") and "ifnot" in e.attributes:
if self.context.is_flag_set(e.attributes["ifnot"]):
return nullify(e)
return e
def transform_ListContainer(self, e: ListContainer) -> ListContainer:
try:
return super().transform_ListContainer(e)
except TypeError as err:
names = []
for el in e:
if hasattr(el, "attributes") and "c" in el.attributes:
names.append(el.attributes["c"])
if len(names) > 0:
raise InlineError(f"The command{'s' if len(names) > 1 else ''} {names[0] if len(names) == 1 else names} was called in an Inline way but returned Block content. Put it in a paragraph alone or execute it as a Div using: \n::: {{c={names[0] if len(names) == 1 else '<command_name>'}}}\n:::")
else:
raise err
def transform_Doc(self, e: Doc) -> Doc:
self.init_context(e)
e.content = self.transform(e.content)
return e
def transform_Quoted(self, e: Quoted) -> FQuoted:
e.content = self.transform(e.content)
quote_styles = {
"cs": "cs",
"en": "en",
"sk": "cs",
None: None
}
return FQuoted(*e.content, quote_type=e.quote_type, style=quote_styles[self.context.get_metadata("lang")])
def transform_Image(self, e: Image) -> Image:
e.content = self.transform(e.content)
# OG now has Context so this is not needed per se, but I'm keeping this here for the handling of attribute > context > default value
# Pass down "no-srcset" metadatum as attribute down to images.
if "no-srcset" not in e.attributes:
e.attributes["no-srcset"] = self.context.get_metadata("no-srcset") if self.context.get_metadata("no-srcset") is not None else False
if "no-img-link" not in e.attributes:
e.attributes["no-img-link"] = self.context.get_metadata("no-img-link") if self.context.get_metadata("no-img-link") is not None else False
return e
def create_Group(self, *content, new_context: Context, replaced:Element, inline: bool=False) -> Group:
old_context = self.context
self.context = new_context
if inline:
g = InlineGroup(*content, context=new_context)
else:
g = BlockGroup(*content, context=new_context)
attach(g, replaced.parent, replaced.location, replaced.index)
g = self.transform(g)
self.context = old_context
return g
def transform_Para(self, e: Para) -> Union[Para, Div]:
if len(e.content) == 1 and isinstance(e.content[0], Span):
# If the span turns out to be a command, it might return a Div. We should then replace ourselves with the Div
span = e.content[0]
span = self.transform(span)
if isinstance(span, Div):
return span
else:
e.content[0] = span
return super().transform_Para(e)
else:
return super().transform_Para(e)
def transform_Div(self, e: Div) -> Union[Div, Group, Null, RawBlock]:
if "group" in e.classes:
# `.group` class for Divs
# Content of Div is enclosed in a separate context, all attributes are passed as metadata
new_context = Context(Doc(), self.context.path, self.context, trusted=self.context.trusted)
for attribute, value in e.attributes.items():
new_context.set_metadata(attribute, value)
return self.create_Group(*e.content, replaced=e, new_context=new_context)
if "c" in e.attributes:
# Commands can be called multiple ways, this handles the following syntax:
# :::{c=commandname}
# :::
command = BlockCommand(*e.content, identifier=e.identifier, classes=e.classes, attributes=e.attributes)
attach(command, e.parent, e.location, e.index)
return self.transform(command)
if "partial" in e.attributes:
# `partial` attribute
# Used to include a file which is executed in a different (child) Context.
if not "type" in e.attributes:
e.attributes["type"] = "md"
if not self.context.trusted: # If we're in an untrusted context, we shouldn't allow inclusion of files outside the PWD.
full_path = os.path.abspath(self.context.dir + "/" + e.attributes["partial"])
pwd = os.path.abspath(".")
if os.path.commonpath([full_path, pwd]) != os.path.commonpath([pwd]):
return nullify(e)
filename = self.context.dir + "/" + e.attributes["partial"]
self.context.add_dep(filename)
text = open(filename, "r").read()
path = self.context.dir + "/" + e.attributes["partial"]
if e.attributes["type"] == "md":
includedDoc = import_md(text)
trusted = True
if "untrusted" in e.attributes and (e.attributes["untrusted"] == True or e.attributes["untrusted"] == 'True'):
trusted = False
if not self.context.trusted:
trusted = False
return self.create_Group(*includedDoc.content, replaced=e, new_context=Context(includedDoc, path, self.context, trusted=trusted))
elif e.attributes["type"] in ["tex", "html"]:
return RawBlock(text, e.attributes["type"])
if "header_content" in e.classes:
header_content = self.context.get_metadata("header_content")
header_content = [] if header_content is None else header_content
header_content.append(MetaBlocks(*self.transform(e.content)))
self.context.set_metadata("header_content", header_content)
return Null()
if "footer_content" in e.classes:
footer_content = self.context.get_metadata("footer_content")
footer_content = [] if footer_content is None else footer_content
footer_content.append(MetaBlocks(*self.transform(e.content)))
self.context.set_metadata("footer_content", footer_content)
return Null()
if "lang" in e.attributes:
warnings.warn("To set language in a way formátítko will understand, this Div has to have the `.group` class and be a Group.", UserWarning)
return super().transform_Div(e)
def transform_Span(self, e: Span) -> Span:
if "group" in e.classes:
# `.group` class for Spans
# Content of Span is enclosed in a separate context, all attributes are passed as metadata
new_context = Context(Doc(), self.context.path, self.context, trusted=self.context.trusted)
for attribute, value in e.attributes.items():
new_context.set_metadata(attribute, value)
return self.create_Group(*e.content, replaced=e, new_context=new_context, inline=True)
if "c" in e.attributes:
# Commands can be called multiple ways, this handles the following syntax:
# []{c=commandname} and
command = InlineCommand(*e.content, identifier=e.identifier, classes=e.classes, attributes=e.attributes)
attach(command, e.parent, e.location, e.index)
return self.transform(command)
if len(e.content) == 1 and isinstance(e.content[0], Str):
## Handle special command shorthand [!commandname]{}
if re.match(r"^![\w.]+$", e.content[0].text):
command = InlineCommand(identifier=e.identifier, classes=e.classes, attributes={**e.attributes, "c": e.content[0].text[1:]})
attach(command, e.parent, e.location, e.index)
return self.transform(command)
## Handle import [#ksp_formatitko as ksp]{}, [#ksp_formatitko]{type=module} or [#path/file.md]{type=md}
# Import a python module as commands (type=module, the default) or
# import all metadata from a md file, dropping its contents.
elif re.match(r"^#.+$", e.content[0].text):
if not "type" in e.attributes:
e.attributes["type"] = "module"
if e.attributes["type"] == "md":
filename = self.context.dir + "/" + e.content[0].text[1:]
self.context.add_dep(filename)
importedDoc = import_md(open(filename, "r").read())
self.transform(importedDoc.content)
elif e.attributes["type"] == "module":
matches = re.match(r"^(\w+)(?: as (\w+))?$", e.content[0].text[1:])
if not matches:
raise SyntaxError(f"`{e.content[0].text[1:]}`: invalid syntax")
module = importlib.import_module(matches.group(1))
module_name = matches.group(1) if matches.group(2) is None else matches.group(2)
self.context.add_commands_from_module(module, module_name)
elif e.attributes["type"] == "metadata":
filename = self.context.dir + "/" + e.content[0].text[1:]
self.context.add_dep(filename)
data = json.load(open(filename, "r"))
key = "" if not "key" in e.attributes else e.attributes["key"]
self.context.import_metadata(data, key)
else:
raise SyntaxError(f"`{e.attributes['type']}`: invalid import type")
return nullify(e)
## Handle metadata print [$key1.key2]{}
# This is a shorthand for just printing the content of some metadata.
elif re.match(r"^\$[\w.]+$", e.content[0].text):
val = self.context.get_metadata(e.content[0].text[1:], False)
if isinstance(val, MetaInlines): # TODO: Trust transform for this
e = Span(*val.content)
e = self.transform(e)
elif isinstance(val, MetaString):
e = Span(Str(val.string))
elif isinstance(val, MetaBool):
e = Span(Str(str(val.boolean)))
else:
raise TypeError(f"Cannot print value of metadatum '{e.content[0].text[1:]}' of type '{type(val)}'")
return e
return super().transform_Span(e)
def transform_CodeBlock(self, e: CodeBlock) -> Union[CodeBlock, Div, Null]:
if "markdown" in e.classes and "group" in e.classes:
includedDoc = import_md(e.text)
return self.create_Group(*includedDoc.content, replaced=e, new_context=Context(includedDoc, self.context.path, self.context, self.context.trusted))
if "python" in e.classes and "run" in e.classes:
if not self.context.trusted:
return nullify(e)
command_output = parse_command(e.text)(BlockCommand(), self.context, self)
command = BlockCommand().replaceSelf(*([] if command_output is None else command_output))
attach(command, e.parent, e.location, e.index)
return self.transform(command)
if "python" in e.classes and ("define" in e.attributes or "redefine" in e.attributes):
if not self.context.trusted:
return nullify(e)
return handle_command_define(e, self.context)
if "c" in e.attributes:
command = CodeCommand(e.text, identifier=e.identifier, classes=e.classes, attributes=e.attributes)
attach(command, e.parent, e.location, e.index)
return self.transform(command)
# Pass down metadata 'highlight' and 'highlight_style' as attribute to CodeBlocks
# OG now has Context so this is not needed per se, but I'm keeping this here for the handling of attribute > context > default value
if not "highlight" in e.attributes:
e.attributes["highlight"] = self.context.get_metadata("highlight") if self.context.get_metadata("highlight") is not None else True
if not "style" in e.attributes:
e.attributes["style"] = self.context.get_metadata("highlight-style") if self.context.get_metadata("highlight-style") is not None else "default"
return e
def transform_Command(self, e: Command) -> Union[Div, Span]:
if not self.context.get_command(e.attributes["c"]):
raise NameError(f"Command not defined '{e.attributes['c']}'.")
command_output = self.context.get_command(e.attributes["c"])(e, self.context, self)
e = e.replaceSelf(*([] if command_output is None else command_output))
return e
def transform_Whitespace(self, e: Whitespace) -> Whitespace:
if bavlna(e, self.context):
return NBSP()
else:
return e

View file

@ -1,8 +0,0 @@
# H1
## H2
### H3
#### H4
##### H5
###### H6
---

View file

@ -1,2 +0,0 @@
import graph;
draw(Circle((0,0),20)); // graph - Circle

View file

@ -1,10 +1,7 @@
```python {define=nop}
return element.content
appendChildren(element.content)
```
```python {define=opendatatask}
return import_md_list("Toto je praktická open-data úloha. V [odevzdávátku](https://ksp.mff.cuni.cz/h/odevzdavatko/) si necháte vygenerovat vstupy a odevzdáte příslušné výstupy. Záleží jen na vás, jak výstupy vyrobíte.")
```
```python {define=opendatatask2}
return [*parse_string("Toto je praktická open-data úloha. V "),pf.Link(pf.Str("odevzdávátku"), url="https://ksp.mff.cuni.cz/h/odevzdavatko/"),*parse_string(" si necháte vygenertovat vstupy a odevzdáte příslušné výstupy. Záleží jen na vás, jak výstupy vyrobíte.")]
println("Toto je praktická open-data úloha. V [odevzdávátku](https://ksp.mff.cuni.cz/h/odevzdavatko/) si necháte vygenerovat vstupy a odevzdáte příslušné výstupy. Záleží jen na vás, jak výstupy vyrobíte.")
```

View file

@ -11,33 +11,27 @@ And things...
:::
```python {.run}
``` {.python .run}
# I set my own flags!
context.set_flag("cat", True)
ctx.set_flag("cat", True)
```
```python {.run}
return import_md_list(f"The subdocument's title is\n\n# {context.get_metadata('title')}\n\nThe subdocument's subtitle is\n\n## {context.get_metadata('subtitle')}")
```
```python {.run}
return [
pf.Para(*parse_string("The subdocument's title is")),
pf.Header(*parse_string(context.get_metadata('title')), level=1),
pf.Para(*parse_string("The subdocument's subtitle is")),
pf.Header(*parse_string(context.get_metadata('subtitle')), level=2)
]
``` {.python .run}
println(f"The subdocument's title is \n\n# {ctx.get_metadata('title')}")
println()
println(f"The subdocument's subtitle is \n\n## {ctx.get_metadata('subtitle')}")
```
```markdown {.group}
---
lang: "cs"
language: "cs"
---
Tak toto je "v prádelně" pánové!
```
```markdown {.group}
---
lang: "en"
language: "en"
---
This is "in a laundry room" gentlemen!
```
@ -48,7 +42,6 @@ I am a duck.
This should be only shown to included cats.
:::
![A circle diagram](circle.asy){width=50px}
$$
\def\eqalign#1{NO, just, nooooo}
@ -56,16 +49,7 @@ $$
$$
<!--There is an inline *emphasis with $math \error$*.-->
<!--
```python {.run}
print("bruh")
raise Exception("Jsem piča")
```
-->
![This is a figure, go figure...](logo.svg){width=25%}What
![This is a figure, go figure...](logo.svg){width=25%}
![This is a figure, go figure...](logo.pdf){width=50%}

8
test/test-top.html Normal file
View file

@ -0,0 +1,8 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta charset='utf-8'>
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/katex@0.16.4/dist/katex.min.css' integrity='sha384-vKruj+a13U8yHIkAyGgK1J3ArTLzrFGBbBc0tDp4ad/EyewESeXE/Iv67Aj8gKZ0' crossorigin='anonymous'>
</head>
<body>

View file

@ -1,22 +0,0 @@
[
{
"first_name": "Jan",
"last_name": "Černohorský",
"abbrev": "GS",
"gender": "M",
"contacts_public": {
"homepage": "https://grsc.cz"
}
},
{
"first_name": "Ondřej",
"last_name": "Machota",
"domestic_name": "Ondra",
"abbrev": "OM",
"gender": "M",
"contacts_public": {
"email": "ondrejmachota@gmail.com",
"discord": "ondrejmachota#0196"
}
}
]

View file

@ -2,28 +2,11 @@
title: 'Wooooo a title'
subtitle: 'A subtitle'
are_we_there_yet: False
lang: "en"
header-includes: |
<style>
body {
color: forestgreen;
}
</style>
language: "en"
---
[#test-files/test-import.md]{type=md}
[#test.json]{type=metadata key=orgs}
```python {.run}
return parse_string(f"Hello, {context.get_metadata('orgs')[0]['first_name']}!")
```
[#test-files/test-import.md]{}
# Hello world!
## H2
### H3
#### H4
##### H5
###### H6
This is an *example* **yay**!
@ -42,14 +25,14 @@ This should only be shown to cats
:::
```python {.run}
context.set_flag("cat", True)
ctx.set_flag("cat", True)
```
```python {.run}
context.set_metadata("a", {})
context.set_metadata("a.b", {})
context.set_metadata("a.b.c", "Bruh **bruh** bruh")
return [*parse_string("The main document's title is "), pf.Quoted(*parse_string(context.get_metadata('title')), quote_type="SingleQuote"), pf.Str(".")]
println(f"The main document's title is '{ctx.get_metadata('title')}'")
ctx.set_metadata("a", {})
ctx.set_metadata("a.b", {})
ctx.set_metadata("a.b.c", "Bruh **bruh** bruh")
```
```python {style=native}
@ -57,11 +40,6 @@ def bruh(no):
wat
```
```python {style=dracula}
def bruhec(bruzek):
hah
```
Inline `code`
::::{if=cat}
@ -72,7 +50,7 @@ This should only be shown to cats the second time
```markdown {.group}
---
lang: cs
language: cs
---
V&#8203; pravém jízdním bruhu.
V pravém jízdním bruhu.
@ -80,7 +58,6 @@ V pravém jízdním bruhu.
V&nbsp;pravém jízdním bruhu.
[!opendatatask]{}
[!opendatatask2]{}
```
[This too!]{if=cat}
@ -91,8 +68,6 @@ V&nbsp;pravém jízdním bruhu.
[!nop]{a=b}<!-- A special command! WOW -->
[Hehehehe čeština bružek]{lang=cs}
> OOO a blockquote mate init
>
>> Nesting??
@ -108,8 +83,6 @@ A link with the link in the link: <https://bruh.com>
H~2~O is a liquid. 2^10^ is 1024.
[Kuchařka](ksp:///kucharky/uvodni)
[Underline]{.underline}
:::{only=html}
@ -131,17 +104,6 @@ $$
---
In this text, there might be some phrases [v češtině]{.group lang=cs} and
maybe even
:::{.group lang=cs}
celé pasáže textu v češtině.
Růžový bagr bez zeleného bagru se žlutým bagrem.
Yay můžeme mít izolované kontexty bez opakovaného volání pandocu jupí!
:::
This should be seen by all.^[This is a footnote]
| Matematicko-fyzikální fakulta University Karlovy
@ -196,32 +158,3 @@ ii. wym bro
1 1 1 1
------- ------ ---------- -------
```python {define=bash}
import subprocess
c = subprocess.run(["bash", "-c", element.text], stdout=subprocess.PIPE, check=True, encoding="utf-8")
return [pf.CodeBlock(c.stdout)]
```
```bash {c=bash}
cat /etc/os-release
```
::: {.group lang=cs}
```python {.run}
return processor.transform([
*parse_string("V "),
pf.Link(pf.Str("odevzdávátku"), url="https://ksp.mff.cuni.cz/z/odevzdavatko/"),
*parse_string(" si necháte vygenerovat vstupy a odevzdáte příslušné výstupy. Záleží jen na vás, jak výstupy vyrobíte.")
])
```
:::
```html
<div>
hahahahaah
</div>
```
`<div>`

267
tex.py Normal file
View file

@ -0,0 +1,267 @@
from panflute import *
import os
from whitespace import NBSP
from transform import FQuoted
from util import inlinify
from context import Group
from images import ImageProcessor
# Heavily inspired by: git://git.ucw.cz/labsconf2022.git
def tex(e: Element, i: ImageProcessor, indent_level: int=0, indent_str: str="\t") -> str:
# `only` attribute which makes transformed elements appear only in tex
# output or html output
if hasattr(e, "attributes") and "only" in e.attributes and e.attributes["only"] != "tex":
return ""
if isinstance(e, ListContainer):
return ''.join([tex(child, i, indent_level, indent_str) for child in e])
# Bits from which the final element output is built at the end of this
# function. Most elements override this by returning their own output.
content_foot = ""
content_head = ""
arguments = ""
open = "{"
close = "}"
tag = e.tag.lower()
tags = {
Header: "h"+chr(64 + e.level) if hasattr(e, "level") else "",
}
if type(e) in tags:
tag = tags[type(e)]
# These are also disabled in pandoc so they shouldn't appear in the AST at all.
not_implemented = {
Citation: True,
Cite: True,
Definition: True,
DefinitionItem: True,
DefinitionList: True
}
if type(e) in not_implemented:
return f'% FIXME: {type(e)}s not implemented \n'
# Elements which can be represented by a simple string
simple_string = {
NBSP: "~",
Space: " ",
Null: "",
LineBreak: f"\\\\",
SoftBreak: f" ",
HorizontalRule: "\\hr\n\n"
}
if type(e) in simple_string:
return simple_string[type(e)]
# Simplest basic elements
if isinstance(e, Str):
return e.text.replace(" ", "~")
if isinstance(e, Para):
return tex(e.content, i, 0, "")+"\n\n"
if isinstance(e, Span) or isinstance(e, Plain):
return tex(e.content, i, 0, "")
# Overriding elements with their own returns
if isinstance(e, Image):
url = e.url
# Attributes → image processor args
additional_args = {}
if "file-width" in e.attributes:
additional_args["width"] = int(e.attributes["file-width"])
if "file-height" in e.attributes:
additional_args["height"] = int(e.attributes["file-height"])
if "file-quality" in e.attributes:
additional_args["quality"] = int(e.attributes["file-quality"])
if "file-dpi" in e.attributes:
additional_args["dpi"] = int(e.attributes["file-dpi"])
# The directory of the current file, will also look for images there.
source_dir = e.attributes["source_dir"]
_, ext = os.path.splitext(url)
ext = ext[1:]
# Conversions between various formats.
if ext in ["pdf", "png", "jpeg"]:
# Even supported elements have to be 'converted' because the
# processing contains finding and moving them to the output
# directory.
url = i.process_image(url, ext, source_dir, relative=False, **additional_args)
elif ext in ["svg"]:
url = i.process_image(url, "pdf", source_dir, relative=False, **additional_args)
elif ext in ["epdf"]:
url = i.process_image(url, "pdf", source_dir, relative=False, **additional_args)
elif ext in ["jpg"]:
url = i.process_image(url, "jpeg", source_dir, relative=False, **additional_args)
else:
url = i.process_image(url, "pdf", source_dir, relative=False, **additional_args)
width = ""
if "width" in e.attributes:
width = e.attributes["width"]
# 50% → 0.5\hsize
if e.attributes["width"][-1] == "%":
width = str(int(e.attributes["width"][:-1])/100) + "\\hsize"
width = "width " + width
return f'\\image{{{width}}}{{{url}}}'
if isinstance(e, FQuoted):
if e.style == "cs":
if e.quote_type == "SingleQuote":
return f'{tex(e.content, i, 0, "")}'
elif e.quote_type == "DoubleQuote":
return f'{tex(e.content, i, 0, "")}'
elif e.style == "en":
if e.quote_type == "SingleQuote":
return f'{tex(e.content, i, 0, "")}'
elif e.quote_type == "DoubleQuote":
return f'{tex(e.content, i, 0, "")}'
else:
if e.quote_type == "SingleQuote":
return f'\'{tex(e.content, i, 0, "")}\''
elif e.quote_type == "DoubleQuote":
return f'"{tex(e.content, i, 0, "")}"'
else:
return f'"{tex(e.content, i, 0, "")}"'
if isinstance(e, Code):
return f'\\verb`{e.text.replace("`", "backtick")}`'
if isinstance(e, Figure):
return f'\\figure{{{tex(e.content, i, indent_level+1, indent_str)}}}{{{tex(e.caption, i, indent_level+1, indent_str)}}}\n\n'
# Figure caption
if isinstance(e, Caption):
if inlinify(e) is not None:
return f'\\figcaption{{{tex(e.content, i, 0, "")}}}'
if isinstance(e, Math):
if e.format == "DisplayMath":
return f'$${e.text}$$\n'
else:
return f'${e.text}$'
# Footnote
if isinstance(e, Note):
tag = "fn"
if inlinify(e) is not None:
return f'\\fn{{{tex(inlinify(e), i, 0, "")}}}'
if isinstance(e, Table):
aligns = {
"AlignLeft": "\\quad#\\quad\\hfil",
"AlignRight": "\\quad\\hfil#\\quad",
"AlignCenter": "\\quad\\hfil#\\hfil\\quad",
"AlignDefault": "\\quad#\\quad\\hfil"
}
text = "\strut"+"&".join([aligns[col[0]] for col in e.colspec])+"\cr\n"
text += tex(e.head.content, i, 0, "")
text += "\\noalign{\\hrule}\n"
text += tex(e.content[0].content, i, 0, "")
text += "\\noalign{\\hrule}\n"
text += tex(e.foot.content, i, 0, "")
return "\\vskip1em\n\\halign{"+text+"}\n\\vskip1em\n"
# FIXME: Implement rowspan
if isinstance(e, TableRow):
return "&".join([("\\multispan"+str(cell.colspan)+" " if cell.colspan > 1 else "")+tex(cell.content, i, 0, "") for cell in e.content])+"\cr\n"
if isinstance(e, RawInline):
if e.format == "tex":
return e.text
else:
return ""
if isinstance(e, RawBlock):
if e.format == "tex":
return f'{e.text}\n'
else:
return ""
# See https://pandoc.org/MANUAL.html#line-blocks
if isinstance(e, LineBlock):
return f'{tex(e.content, i, indent_level+1, indent_str)}\n'
if isinstance(e, LineItem):
return tex(e.content, i, 0, "") + ("\\\\\n" if e.next else "\n")
if type(e) is Div:
return f'{tex(e.content, i, indent_level+1, indent_str)}'
if isinstance(e, Doc):
return tex(e.content, i, indent_level, indent_str)+"\n\\bye" # Is having the \bye a bad idea here?
# Non-overriding elements, they get generated using the template at the end
# of this function
if isinstance(e, BulletList):
tag = "list"
open = ""
arguments = "{o}"
close = "\\endlist"
elif isinstance(e, OrderedList):
tag = "list"
open = ""
styles = {
"DefaultStyle": "n",
"Decimal": "n",
"LowerRoman": "i",
"UpperRoman:": "I",
"LowerAlpha": "a",
"UpperAlpha": "A"
}
style = styles[e.style]
delimiters = {
"DefaultDelim": f"{style}.",
"Period": f"{style}.",
"OneParen": f"{style})",
"TwoParens": f"({style})"
}
style = delimiters[e.delimiter]
arguments = f"{{{style}}}"
close = "\\endlist"
# FIXME: Starting number of list
elif isinstance(e, ListItem):
tag = ":"
elif isinstance(e, Link):
if len(e.content) == 1 and isinstance(e.content[0], Str) and e.content[0].text == e.url:
tag = "url"
else:
tag = "linkurl"
arguments = f'{{{e.url}}}'
elif isinstance(e, Group):
tag = "begingroup"
open = ""
if "language" in e.metadata and e.metadata["language"] is not None:
open = "\\language"+e.metadata["language"]
close = "\\endgroup"
# The default which all non-overriding elements get generated by. This
# includes elements, which were not explicitly mentioned in this function,
# e. g. Strong, Emph...
if isinstance(e, Inline):
return f'\\{tag}{arguments}{open}{content_head}{tex(e.content, i, 0, "") if hasattr(e, "_content") else ""}{e.text if hasattr(e, "text") else ""}{content_foot}{close}'
out_str = ""
out_str = f"\\{tag}{arguments}{open}\n"
out_str += content_head
if hasattr(e, "_content"):
out_str += tex(e.content, i, indent_level+1, indent_str)
if hasattr(e, "text"):
out_str += e.text
out_str += f"{content_foot}\n{close}\n\n"
return out_str

175
transform.py Normal file
View file

@ -0,0 +1,175 @@
from panflute import *
import re
# Import local files
from whitespace import *
from command import *
from util import *
from context import *
# This is a small extension to the Quoted panflute elements which allows to
# have language-aware quotation marks.
class FQuoted(Quoted):
def __init__(self, *args, **kwargs):
self.style = kwargs["style"]
del kwargs["style"]
super().__init__(*args, **kwargs)
# This is where tha magic happens. This function transforms a single element,
# to transform the entire tree, panflute's walk should be used.
def transform(e: Element, c: Context) -> Element:
# Determine if this space should be non-breakable. See whitespace.py.
if isinstance(e, Whitespace) and bavlna(e, c):
e = NBSP()
if hasattr(e, "attributes"):
# `if` attribute. Only show this element if flag is set.
if "if" in e.attributes:
if not c.is_flag_set(e.attributes["if"]):
return nullify(e)
# `ifn` attribute. Only show this element if flag is NOT set
if "ifn" in e.attributes:
if c.is_flag_set(e.attributes["ifn"]):
return nullify(e)
# There are multiple ways to call a command so we turn it into a
# unified element first and then call it at the end. This handles the
# []{c=commandname} and
# :::{c=commandname}
# :::
# syntax.
if (isinstance(e, Div) or isinstance(e, Span)) and "c" in e.attributes:
if isinstance(e, Div):
e = BlockCommand(*e.content, identifier=e.identifier, classes=e.classes, attributes=e.attributes)
else:
e = InlineCommand(*e.content, identifier=e.identifier, classes=e.classes, attributes=e.attributes)
# Isolated subdocuments using Group and a different Context. Can be
# separate files (using attribute `partial`) or be inline using the
# following syntax:
# ```markdown {.group}
# * file content *
# ```
# Both can contain their own metadata in a FrontMatter (YAML header)
if (isinstance(e, Div) and "partial" in e.attributes)\
or (isinstance(e, CodeBlock) and "markdown" in e.classes and "group" in e.classes):
if isinstance(e, Div):
if not c.trusted: # If we're in an untrusted context, we shouldn't allow inclusion of files outside the PWD.
full_path = os.path.abspath(c.dir + "/" + e.attributes["partial"])
pwd = os.path.abspath(".")
if os.path.commonpath([full_path, pwd]) != os.path.commonpath([pwd]):
return nullify(e)
text = open(c.dir + "/" + e.attributes["partial"], "r").read()
path = c.dir + "/" + e.attributes["partial"]
else:
text = e.text
path = c.path
if "type" in e.attributes and e.attributes["type"] in ["tex", "html"]:
e = RawBlock(text, e.attributes["type"])
else:
includedDoc = import_md(text)
trusted = True
if "untrusted" in e.attributes and (e.attributes["untrusted"] == True or e.attributes["untrusted"] == 'True'):
trusted = False
if not c.trusted:
trusted = False
nContext = Context(includedDoc, path, c, trusted=trusted)
language = includedDoc.get_metadata("language")
includedDoc = includedDoc.walk(transform, nContext)
e = Group(*includedDoc.content, metadata={"language": language})
# Transform panflute's Quoted to custom FQuoted, see above.
if isinstance(e, Quoted):
quote_styles = {
"cs": "cs",
"en": "en",
"sk": "cs",
None: None
}
e = FQuoted(*e.content, quote_type=e.quote_type, style=quote_styles[c.get_metadata("language")])
if isinstance(e, Image):
# Pass down the directory of the current source file for finding image
# files.
e.attributes["source_dir"] = c.dir
# Pass down "no-srcset" metadatum as attribute down to images.
if not "no-srcset" in e.attributes:
e.attributes["no-srcset"] = c.get_metadata("no-srcset") if c.get_metadata("no-srcset") is not None else False
# Pass down metadata 'highlight' and 'highlight_style' as attribute to CodeBlocks
if isinstance(e, CodeBlock):
if not "highlight" in e.attributes:
e.attributes["highlight"] = c.get_metadata("highlight") if c.get_metadata("highlight") is not None else True
if not "style" in e.attributes:
e.attributes["style"] = c.get_metadata("highlight-style") if c.get_metadata("highlight-style") is not None else "default"
e.attributes["noclasses"] = False
else:
e.attributes["noclasses"] = True
# Execute python code inside source code block. Works the same as commands.
# Syntax:
# ```python {.run}
# print("woo")
# ```
if isinstance(e, CodeBlock) and hasattr(e, "classes") and "python" in e.classes and "run" in e.classes:
if not c.trusted:
return nullify(e)
e = Div(*executeCommand(e.text, None, c))
e = e.walk(transform, c)
# Command defines for calling using BlockCommand and InlineCommand. If
# redefine is used instead of define, the program doesn't check if the
# command already exists.
# Syntax:
# ```python {define=commandname}
# print(wooo)
# ```
if isinstance(e, CodeBlock) and hasattr(e, "classes") and "python" in e.classes and hasattr(e, "attributes")\
and ("define" in e.attributes or "redefine" in e.attributes):
if not c.trusted:
return nullify(e)
e = handle_command_define(e, c)
## Shorthands
# Shorter (and sometimes the only) forms of certain features
if isinstance(e, Span) and len(e.content) == 1 and isinstance(e.content[0], Str):
## Handle special command shorthand [!commandname]{}
if re.match(r"^![\w]+$", e.content[0].text):
e = InlineCommand(identifier=e.identifier, classes=e.classes, attributes={**e.attributes, "c": e.content[0].text[1:]})
## Handle import [#path/file.md]{}
# This is the exact opposite of partials. We take the commands, flags
# and metadata but drop the content.
elif re.match(r"^#.+$", e.content[0].text):
importedDoc = import_md(open(c.dir + "/" + e.content[0].text[1:], "r").read())
importedDoc.walk(transform, c)
return nullify(e)
## Handle metadata print [$key1.key2]{}
# This is a shorthand for just printing the content of some metadata.
elif re.match(r"^\$[\w.]+$", e.content[0].text):
val = c.get_metadata(e.content[0].text[1:], False)
if isinstance(val, MetaInlines):
e = Span(*val.content)
e = e.walk(transform, c)
elif isinstance(val, MetaString):
e = Span(Str(val.string))
elif isinstance(val, MetaBool):
e = Span(Str(str(val.boolean)))
else:
raise TypeError(f"Cannot print value of metadatum '{e.content[0].text[1:]}' of type '{type(val)}'")
## Execute commands
# panflute's walk function transforms the children first, then the root
# element, so the content the command receives is already transformed.
# The output from the command is then transformed manually again.
if isinstance(e, Command):
if not c.get_command(e.attributes["c"]):
raise NameError(f"Command not defined '{e.attributes['c']}'.")
e = e.replaceSelf(executeCommand(c.get_command(e.attributes["c"]), e, c))
e = e.walk(transform, c)
return e

View file

@ -1,11 +1,10 @@
from panflute import Element, Block, Inline, Null, Str, Doc, convert_text, Para, Plain, Span, Space
from panflute import Element, Block, Inline, Null, Str, Doc, convert_text, Para, Plain
import re
from typing import Union
# It sometimes happens that an element contains a single paragraph or even a
# single plaintext line. It can be sometimes useful to extract this single
# paragraph, which is inline.
def inlinify(e: Element) -> Union[list[Element], None]:
def inlinify(e: Element) -> Element:
if len(e.content) == 1 and (isinstance(e.content[0], Para) or isinstance(e.content[0], Plain)):
return e.content[0].content
@ -13,31 +12,14 @@ def inlinify(e: Element) -> Union[list[Element], None]:
# cannot be removed from the tree entirely, because that would mess up the
# iteration process through the tree. We replace them with null elements
# instead which never make it to the output.
def nullify(e: Element) -> Union[Str, Null]:
def nullify(e: Element):
if isinstance(e, Inline):
return Str("")
else:
elif isinstance(e, Block):
return Null()
def parse_string(s: str) -> list[Union[Str, Space]]:
words = s.split(" ")
output = []
first_word, *words = words
if first_word != "":
output.append(Str(first_word))
for word in words:
output.append(Space())
if word != "":
output.append(Str(word))
return output
# A helper function to import markdown using panflute (which calls pandoc). If
# we ever want to disable or enable some of panflute's markdown extensions,
# this is the place to do it.
def import_md(s: str, standalone: bool=True) -> Union[Doc, list[Element]]:
return convert_text(s, standalone=standalone, input_format="markdown-definition_lists-latex_macros", extra_args=["--strip-comments"])
def import_md_list(s: str) -> list[Element]:
return import_md(s, standalone=False)
def import_md(s: str, standalone: bool=True) -> Doc:
return convert_text(s, standalone=standalone, input_format="markdown-definition_lists-citations")

View file

@ -2,7 +2,7 @@ from panflute import Space, SoftBreak, Str, Math
from typing import Union
# Import local files
from .context import Context
from context import Context
Whitespace = Union[Space,SoftBreak]
@ -12,8 +12,8 @@ class NBSP(Space):
# This function tries to determine if a space should be non-breaking. It is
# language-aware and tries to be sort-of smart about its decisions.
def bavlna(e: Whitespace, c: Context) -> bool:
if c.get_metadata("lang") == "cs":
if c.get_metadata("language") == "cs":
# Add no-break space after single letter prepositions and conjunctions.
# Also tries to find them inside elements, for instance
# `V [odevzdávátku]()` should get correctly detected.
@ -36,9 +36,9 @@ def bavlna(e: Whitespace, c: Context) -> bool:
if prevC in operators and nextC in numbers:
return True
# if isinstance(e.prev, Math) or isinstance(e.next, Math):
# # Add no-break spaces around TeX math.
# return True
if isinstance(e.prev, Math) or isinstance(e.next, Math):
# Add no-break spaces around TeX math.
return True