Compare commits

..

42 commits

Author SHA1 Message Date
8b26f63494 Forgotten readme edit 2024-09-25 20:02:31 +02:00
fa6b772674 Fixed typo in context.unset_data
Picked from 71e5c5b and 38029e3
2024-03-18 20:55:34 +01:00
2a0e90bb96 Quick fix srcsetů, hodně malé obrázky nenafoukneme víc, než je potřeba. 2024-03-07 19:47:31 +01:00
0e5735cba2 Added FileLink element for publishing and linking local files. 2024-03-07 17:58:18 +01:00
42d04b77de Added title attribute for images, fixed formatting of raw blocks. 2024-02-27 16:24:27 +01:00
583b3ba010 Stop adding NBSPs around TeX math. Patches welcome to make this smarter. 2024-02-27 10:14:19 +01:00
9ccd2886b9 Bump katex-server: Nobody needs a better nullish coalescing operator than OR 2024-02-27 10:11:39 +01:00
c90e00a1ae Fix broken transformation of divs and spans. 2024-02-27 10:02:19 +01:00
7f52abde14 katex-server bump: Removed null coalescing operator, because I don't want to update node on every computer in existence. 2024-02-24 02:11:57 +01:00
f0d939a65b Separate context init for transform processor into separate function, add option to not make images clickable. 2024-02-23 23:37:22 +01:00
f14f28d3a4 Added dependency printing 2024-02-22 14:02:32 +01:00
cd07b3abf8 Creating of dirs in namespaces only when they're accessed (this is needed because until then, we don't know what $dir is. Also telling pandoc to strip comments. 2024-02-22 11:50:50 +01:00
a1c439c32e Minor fixes, typos, generating of MetaValues, fixed error where KeyErrors from the inside of the tree would get eaten. 2024-02-21 23:34:23 +01:00
72b9bc7bf1 Add special StandaloneHTMLGenerator. Also handle prepending the document in a more pandocy way. 2024-02-21 21:43:49 +01:00
7f3490536e Attach Groups correctly in the tree, Images now support height in HTML. 2024-02-21 16:27:04 +01:00
3ce0b5037b Some changes to allow commands to touch the rest of the tree they're currently in. This shall only be done on parts of the tree not yet transformed, otherwise, very weird things can happen. 2024-02-21 15:04:25 +01:00
e2f2c4f5f0 Ještě více magie, která se snaží zachraňovat blokové výstupy z příkazů, které byly zavolány jako Span. 2024-02-20 20:09:58 +01:00
7b81919914 OK the submodule was broken. 2024-02-20 18:27:34 +01:00
6180b581b8 KaTeX server is now in separate repo. 2024-02-20 18:23:51 +01:00
93f5949361 Přidána data na kontextu nezávislá na docu. 2024-02-20 12:13:06 +01:00
5c066d46af Fix handlování cest obrázků, když jsou namespacové. 2024-02-20 01:00:36 +01:00
caef60d472 TP is now passed to commands. Output from the TP is not transformed automatically and has to be done manually from the commands. 2024-02-18 00:27:13 +01:00
84a4f6acb7 Merge pull request 'Přidány namespaces pro obrázky, jak jsme se o nich bavili s @mj' (#55) from image-namespaces into master
Reviewed-on: #55
2024-02-17 23:57:47 +01:00
b9c193d45f Implemented image processor namespaces 2024-02-17 23:47:30 +01:00
1950ab56e6 Errors now print full path from CWD, not just filename 2024-02-17 23:46:46 +01:00
ce0a3e1192 Actually deprecate the old stuff (forgot a few imports). 2024-02-17 21:34:11 +01:00
723038a2bd Deprecated old versions of transform, html and tex generation. 2024-02-17 18:24:08 +01:00
c7dd1a2e95 Merge pull request 'Experimental error handling with snippets of input for OutputGenerator.' (#54) from error-handling into master
Reviewed-on: #54
2024-02-17 18:21:41 +01:00
50b29b1ae3 Improved error messages 2024-02-17 18:07:47 +01:00
2e76687172 Merge branch 'master' into error-handling 2024-02-16 17:15:39 +01:00
6de4ea2743 Error handling now contains filename. 2024-02-15 18:19:10 +01:00
42a63b3163 Partial rewrite of error handling
Now the error doesn't handle itself, but offers a helper function to do
it.
2024-02-15 18:09:54 +01:00
a7963ba824 WIP: Experimental error handling with snippets of input for OutputGenerator. Would be nice to generalise for TransformProcessor, which is not easy as they don't have a common parent class. 2024-01-06 19:32:54 +01:00
6fe4ef8aaf Fix: Do HTML se nepsala dvojdolarová matematika 2023-12-25 13:50:10 +01:00
f49e791807 Fix #53 2023-12-11 02:27:31 +01:00
05ffd321d8 Inline KaTeX musí zůstat inline i v HTML jinak přidává mezery, kam nemá. 2023-11-15 16:03:51 +01:00
0f7ed0ae32 Menší změny po code review s @wipocket. 2023-10-01 15:33:34 +02:00
63cd7a212a Přidáno automatické volání TeXu a generování pdf, resolves #17. 2023-09-21 21:15:16 +02:00
dc3b6510bb Merge branch 'katex-socket'
Mergeuju, protože mě snad nenapadá žádný problém a nechci si vytvářet
další konflikty.
2023-09-21 20:28:53 +02:00
1b971ea3b4 První draft generátoru pro UCWTex. Ocením feedback od @jirikalvoda a @mj. #22 2023-09-21 17:22:59 +02:00
fa2cf0a5cc Fixed html escape of code blocks. 2023-09-20 23:58:41 +02:00
9fa0cb2582 Updated README #39. 2023-09-20 23:58:30 +02:00
31 changed files with 1213 additions and 1253 deletions

3
.gitmodules vendored
View file

@ -1,3 +1,6 @@
[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,7 +16,9 @@ Inkscape are used for image processing. Nodejs is used for KaTeX.
## Usage
```
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
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
positional arguments:
input_filename The markdown file to process.
@ -24,16 +26,27 @@ 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 not overwrite existing images. (default: public)
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)
-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: output.html)
The HTML file (for Web) to write into. (default: None)
-t OUTPUT_TEX, --output-tex OUTPUT_TEX
The TEX file to write into. (default: 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
```
## Format
@ -46,7 +59,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 `ifn` attribute will only be show if the flag is not set to True.
with the `ifnot` attribute will only be show if the flag is not set to True.
**Example:**
@ -59,7 +72,7 @@ flags:
[This will not be shown]{if=bar}
[This will be shown]{ifn=bar}
[This will be shown]{ifnot=bar}
```
### Including other files
@ -69,12 +82,28 @@ 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. The syntax is as follows:
libraries of commands.
[#test/empty.md]{}
There are three types of imports:
The curly braces are required for pandoc to parse the import properly and should
be left empty.
##### 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.
#### Partials
Partials are the very opposite of imports, they have their own context, which
@ -95,12 +124,19 @@ partial to `tex` or `html`.
### Groups
Groups are pieces of markdown with their own sandboxed context, in other words,
inline partials. They function exactly the same as partials, namely can have
their own front matter.
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:
```markdown {.group}
---
language: cs
lang: cs
---
OOOoo český mód
```
@ -114,6 +150,9 @@ 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.
@ -138,15 +177,38 @@ 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.
#### Context
#### Command environment
You can access the current context using the `ctx` variable. The 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
provides read/write access to the FrontMatter metadata. The context has the
following methods:
`ctx.get_metadata(key: str, simple: bool=True, immediate: bool=False)`
`context.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")`
@ -156,13 +218,13 @@ following methods:
- `immediate`: Only get metadatum from the current context, not from its
parents.
`ctx.set_metadata(key: str, value)`
`context.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
`ctx.unset_metadata(key: str)`
`context.unset_metadata(key: str)`
Delete the metadatum in the current context and allow it to inherit the value
from the parent context.
@ -172,26 +234,31 @@ from the parent context.
Helper functions for flags exist which work the same as for metadata:
`ctx.is_flag_set(flag: str) -> bool`
`context.is_flag_set(flag: str) -> bool`
`ctx.set_flag(flag: str, val: bool)`
`context.set_flag(flag: str, val: bool)`
`ctx.unset_flag(flag: str)`
`context.unset_flag(flag: str)`
#### Writing output
There are also other useful functions, which you can see for yourself in
`context.py`.
There are two modes of writing output, plaintext and element-based.
> **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).
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.
##### 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.
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`.
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.
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:**
@ -200,14 +267,15 @@ other mode cannot be called within the same block of code.
title: Foo
---
```python {.run}
println("*wooo*")
println()
println("The title of this file is: " + ctx.get_metadata("title"))
return [
pf.Para(pf.Emph(pf.Str("wooo"))),
pf.Para(*parse_string("The title of this file is: " + context.get_metadata("title")))
]
```
````
```python {.run}
appendChild(pf.Para(pf.Strong(pf.Str("foo"))))
return [pf.Strong(*parse_string("Hello world!"))]
```
### Defining and running commands
@ -218,7 +286,7 @@ Code blocks can be also saved and executed later. Defining is done using the
**Example:**
```python {define=commandname}
print("foo")
return [pf.Str("foo")]
```
If you try to define the same command twice, you will get an error. To redefine
@ -230,7 +298,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:
Or using the `c` attribute on a span or a div (new: or a codeblock!):
[Some content]{c=commandname}
@ -238,6 +306,16 @@ Or using the `c` attribute on a span or a div:
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.
@ -245,7 +323,7 @@ representation of the element.
**Example:**
```python {define=index}
appendChild(element.content[int(element.attributes["i"])])
return [element.content[int(element.attributes["i"])]]
```
[Pick the third element from this span]{c=index i=2}
@ -268,23 +346,24 @@ 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 in the **top-level document** this is to prevent the need for many
inline style definitions.
metadatum or the `style` attribute directly on the element.
**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 `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.
Formátítko is language aware, this means that the `lang` metadatum is
somewhat special. (It is also special for pandoc)
### NBSP
Formátítko automatically inserts no-break spaces according to its sorta smart
@ -303,12 +382,9 @@ language.
**Examples:**
```markdown {.group}
---
language: cs
---
::: {.group lang=cs}
"Uvozovky se v českém testu píší 'jinak' než v angličtině."
```
:::
"In Czech texts, quotes are written 'differently' than in English"
@ -339,6 +415,9 @@ 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
@ -348,12 +427,22 @@ 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.
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.
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"}
:::
::::
## Working with the produced output
@ -366,11 +455,15 @@ 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'>
```
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.)
You can see how this is done in `test/test.md`
### 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.

View file

@ -1,9 +1,6 @@
\input luatex85.sty
\input ucwmac2.tex
\ucwmodule{luaofs}
\ucwmodule{link}
\ucwmodule{verb}
\parskip=3pt plus 2pt minus 1pt
\parskip=5pt plus 3pt minus 2pt
\parindent=0sp
\def\strong#1{{%
@ -17,23 +14,17 @@
\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\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}}
\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}}
\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

View file

@ -16,7 +16,7 @@ class InlineCommand(Span, Command):
if len(content) == 1 and (isinstance(content[0], Para) or isinstance(content[0], Plain)):
return Span(*content[0].content)
else:
raise InlineError(f"The command {self.attributes['c']} returned multiple Paragraphs and must be executed using `::: {{c={self.attributes['c']}}}\\n:::`.\n\n{content}")
return Div(*content)
pass
class BlockCommand(Div, Command):

View file

@ -5,4 +5,5 @@ 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

@ -15,10 +15,10 @@ def parse_command(code: str) -> CommandCallable:
indented_code_lines = []
for line in code_lines:
indented_code_lines.append(("\t" if tabs else " ")+line)
code = "def command(element: Command, context: Context) -> list[Element]:\n"+"\n".join(indented_code_lines)
globals = command_env.__dict__
exec(code, globals)
return globals["command"]
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

View file

@ -3,11 +3,10 @@ from panflute import Doc, Element, Div, Span
from typing import Union, Callable
from types import ModuleType
import os
import warnings
from .command import Command
CommandCallable = Callable[[Command, 'Context'], list[Element]] # This is here because of a wild circular import dependency between many functions and classes
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,
@ -23,20 +22,29 @@ CommandCallable = Callable[[Command, 'Context'], list[Element]] # This is here b
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", {})
@ -63,7 +71,7 @@ class Context:
self.set_command(prefix+name, func)
def is_flag_set(self, flag: str):
if self.get_metadata("flags."+flag):
if self.get_metadata("flags."+flag) is not None:
if self.get_metadata("flags."+flag):
return True
else:
@ -109,7 +117,57 @@ class Context:
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.

View file

@ -1,4 +1,4 @@
from panflute import Quoted
from panflute import Quoted, Link
from .command import Command, InlineCommand, BlockCommand, CodeCommand
@ -14,3 +14,6 @@ class FQuoted(Quoted):
del kwargs["style"]
super().__init__(*args, **kwargs)
class FileLink(Link):
pass

View file

@ -2,19 +2,20 @@
import argparse
import sys
import tempfile
import subprocess
import shutil
# Import local files
from .transform import transform
from .util import import_md
from .context import Context, BlockGroup
from .katex import KatexClient
from .html import html
from .tex import tex
from .images import ImageProcessor
from .output_generator import OutputGenerator
from .html_generator import HTMLGenerator
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
@ -26,13 +27,17 @@ def main():
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:
@ -49,27 +54,78 @@ def main():
doc = import_md(open(args.input_filename, "r").read())
if args.debug:
OutputGenerator(sys.stdout).generate(doc)
try:
OutputGenerator(sys.stdout).generate(doc)
except FormatitkoRecursiveError as e:
e.pretty_print(tracebacklimit=args.traceback_limit)
doc = TransformProcessor(args.input_filename).transform(doc)
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(args.img_public_dir, args.img_web_path, args.img_cache_dir, *args.img_lookup_dirs)
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:
HTMLGenerator(open(args.output_html, "w"), katexClient, imageProcessor).generate(doc)
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:
open(args.output_md, "w").write(convert_text(PandocProcessor().transform(doc), input_format="panflute", output_format="markdown"))
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:
open(args.output_json, "w").write(convert_text(PandocProcessor().transform(doc), input_format="panflute", output_format="json"))
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("-----------------------------------")
OutputGenerator(sys.stdout).generate(doc)
try:
OutputGenerator(sys.stdout).generate(doc)
except FormatitkoRecursiveError as e:
e.pretty_print(tracebacklimit=args.traceback_limit)
if __name__ == "__main__":

View file

@ -1,311 +0,0 @@
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 typing import Union
from .whitespace import NBSP
from .elements import FQuoted
from .katex import KatexClient
from .util import inlinify
from .context import Group
from .images import ImageProcessor
import warnings
warnings.warn("The html function has been deprecated, is left only for reference and will be removed in future commits. HTML_generator should be used in its place.", DeprecationWarning)
def html(e: Union[Element, ListContainer], k: KatexClient, i: ImageProcessor, indent_level: int=0, indent_str: str="\t") -> str:
warnings.warn("The html function has been deprecated, is left only for reference and will be removed in future commits. HTML_generator should be used in its place.", DeprecationWarning)
# `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 isinstance(e, Header) 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

View file

@ -1,4 +1,4 @@
from panflute import Cite, Emph, Image, LineBreak, Link, Math, Note, RawInline, SmallCaps, Str, Strikeout, Subscript, Superscript, Underline
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
@ -17,8 +17,10 @@ from .whitespace import NBSP
from .context import Group, BlockGroup, InlineGroup
from .output_generator import OutputGenerator
from .katex import KatexClient
from .images import ImageProcessor
from .images import ImageProcessor, ImageProcessorNamespaceSearcher
from .util import inlinify
from .elements import FileLink
class HTMLGenerator(OutputGenerator):
imageProcessor: ImageProcessor
@ -37,7 +39,7 @@ class HTMLGenerator(OutputGenerator):
def escape_special_chars(self, text: str) -> str:
text = text.replace("&", "&amp;")
text = text.replace("<", "&lt;")
text = text.replace(">", "&rt;")
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?
@ -125,15 +127,26 @@ class HTMLGenerator(OutputGenerator):
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, will also look for images there.
# 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:]
@ -143,16 +156,16 @@ class HTMLGenerator(OutputGenerator):
# 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, source_dir, **additional_args)
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", source_dir, **additional_args)
url = self.imageProcessor.process_image(url, "svg", searcher, **additional_args)
elif ext in ["jpg"]:
url = self.imageProcessor.process_image(url, "jpeg", source_dir, **additional_args)
url = self.imageProcessor.process_image(url, "jpeg", searcher, **additional_args)
else:
url = self.imageProcessor.process_image(url, "png", source_dir, **additional_args)
url = self.imageProcessor.process_image(url, "png", searcher, **additional_args)
# Srcset generation - multiple alternative sizes of images browsers can
# choose from.
@ -163,23 +176,27 @@ class HTMLGenerator(OutputGenerator):
# 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(url, [self.imageProcessor.cache_dir])
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'{self.imageProcessor.web_path}/{url}', f'{width}w'))
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, self.imageProcessor.cache_dir, width=size[0], height=size[1], quality=quality)
self.imageProcessor.publish_image(cache_img)
srcset.append((f'{self.imageProcessor.web_path}/{cache_img}', f'{size[0]}w'))
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'))
self.imageProcessor.publish_image(url)
url = self.imageProcessor.web_path + "/" + url
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
@ -188,17 +205,38 @@ class HTMLGenerator(OutputGenerator):
HTMLGenerator(fake_out, self.katexClient, self.imageProcessor).generate(e.content)
attributes["alt"] = fake_out.getvalue()
if len(srcset) != 0:
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)
@ -238,7 +276,11 @@ class HTMLGenerator(OutputGenerator):
"DisplayMath": True,
"InlineMath": False
}
self.writeln(self.katexClient.render(e.text, {"displayMode": formats[e.format]}))
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":
@ -286,13 +328,13 @@ class HTMLGenerator(OutputGenerator):
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_Cite(self, e: Cite):
self.generate_simple_tag(e, tag="a", attributes=self.common_attributes(e) | {"href": f"#ref-{e.citations[0].id}"})
def generate_Definition(self, e: Definition):
self.writeln("<!-- FIXME: Definitions not implemented -->")
@ -301,3 +343,34 @@ class HTMLGenerator(OutputGenerator):
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

@ -4,54 +4,182 @@ 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 ImageProcessor:
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]):
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]
if not os.path.exists(self.public_dir):
os.mkdir(self.public_dir)
if not os.path.exists(self.cache_dir):
os.mkdir(self.cache_dir)
self.include_src = include_src
def process_image(self, input_filename: str, format: str, source_dir: str, width: int=None, height:int=None, quality: int=None, dpi: int=None, fit: bool=True, deps: list[str]=[]) -> str:
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 = self.find_image(input_filename, [source_dir])
full_path = searcher.find_image(input_filename)
if full_path is None:
raise FileNotFoundError(f'Image {input_filename} not found in {self.lookup_dirs} or {source_dir}.')
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 = self.find_image(dep, [source_dir])
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)
@ -65,7 +193,7 @@ class ImageProcessor:
if quality is not None:
suffix += f'_q{quality}'
target_name = base+suffix+"."+format
target_path = self.cache_dir + "/" + target_name
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):
@ -80,13 +208,13 @@ class ImageProcessor:
# 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]) is not None and not self.is_outdated(self.find_image(target_name, [source_dir]), deps):
shutil.copyfile(self.find_image(target_name, [source_dir]), target_path)
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 = self.cache_dir + "/" + name + "_deps"
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:
@ -96,7 +224,7 @@ class ImageProcessor:
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, self.cache_dir + "/" + target_name)
shutil.move(deps_dir + "/" + target_name, searcher.get_cache_dir() + "/" + target_name)
# Convert SVGs using inkscape
elif ext == "svg":
@ -114,6 +242,8 @@ class ImageProcessor:
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]):
@ -124,38 +254,7 @@ class ImageProcessor:
return True
return False
def publish_image(self, target_name, relative: bool=True) -> str:
import sys
cache_path = self.cache_dir + "/" + target_name
if not os.path.isfile(cache_path):
raise FileNotFoundError(f'Image {target_name} not cached')
target_path = self.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
def get_image_size(self, input_filename: str, additional_dirs: list[str]=[]) -> tuple[int, int]:
full_path = self.find_image(input_filename, additional_dirs)
if full_path is None:
raise FileNotFoundError(f'Image {input_filename} not found.')
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
def find_image(self, input_filename: str, additional_dirs: list[str]=[]) -> Union[str, None]:
for dir in [*self.lookup_dirs, *additional_dirs]:
if os.path.isfile(dir + "/" + input_filename):
return dir + "/" + input_filename

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

View file

@ -1 +0,0 @@
node_modules

View file

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

View file

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

View file

@ -1,131 +0,0 @@
// 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);
console.log("OK")
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.
*/
let 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
} else if (line === "init") {
macroStack = [{}]
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')
}
}
}

View file

@ -1,39 +0,0 @@
{
"name": "ksp-katex-server",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ksp-katex-server",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"katex": "^0.16.3"
}
},
"node_modules/commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"engines": {
"node": ">= 12"
}
},
"node_modules/katex": {
"version": "0.16.3",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.3.tgz",
"integrity": "sha512-3EykQddareoRmbtNiNEDgl3IGjryyrp2eg/25fHDEnlHymIDi33bptkMv6K4EOC2LZCybLW/ZkEo6Le+EM9pmA==",
"funding": [
"https://opencollective.com/katex",
"https://github.com/sponsors/katex"
],
"dependencies": {
"commander": "^8.0.0"
},
"bin": {
"katex": "cli.js"
}
}
}
}

View file

@ -1,14 +0,0 @@
{
"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"
}
}

View file

@ -3,6 +3,7 @@ import subprocess
import tempfile
import json
import os
import shutil
class KatexError(Exception):
pass
@ -20,8 +21,10 @@ class KatexClient:
_socket_file: str
_temp_dir: tempfile.TemporaryDirectory[str]
_connected: bool
_katex_server_path: str
def __init__(self, socket: str=None, connect: bool=True):
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:
@ -38,20 +41,21 @@ class KatexClient:
self._temp_dir = tempfile.TemporaryDirectory(prefix='formatitko')
self._socket_file = self._temp_dir.name + "/katex-socket"
srcdir = os.path.dirname(os.path.realpath(__file__))
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...")
try:
subprocess.run(["npm", "install"], cwd=srcdir+"/katex-server", check=True)
except subprocess.CalledProcessError as e:
if e.returncode == 127:
# 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.")
else:
raise e
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", srcdir + "/katex-server/index.mjs", self._socket_file], stdout=subprocess.PIPE)
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":
@ -79,7 +83,7 @@ class KatexClient:
if "error" in response:
raise KatexServerError(response["error"])
if "error" in response["results"][0]:
raise KatexError(response["results"][0]["error"])
raise KatexError(response["results"][0]["error"] + " in $" + tex + "$")
else:
return response["results"][0]["html"]

View file

@ -6,16 +6,21 @@ from panflute import MetaValue
from typing import Union, Callable
from .whitespace import NBSP
from .elements import FQuoted
from .context import Group, InlineGroup, BlockGroup
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."
@ -83,6 +88,7 @@ class NOPProcessor:
Underline: self.transform_Underline,
NBSP: self.transform_NBSP,
FQuoted: self.transform_FQuoted,
FileLink: self.transform_FileLink,
InlineCommand: self.transform_InlineCommand,
BlockCommand: self.transform_BlockCommand,
@ -96,32 +102,45 @@ class NOPProcessor:
return []
def transform(self, e: ELCl) -> ELCl:
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:
e = self.TYPE_DICT[type(e)](e)
except KeyError:
raise self.UnknownElementError(type(e))
if isinstance(e, list):
return self.transform_list(e)
elif isinstance(e, ListContainer):
return self.transform_ListContainer(e)
for transformer in self.get_posttransformers():
e = transformer(e)
for transformer in self.get_pretransformers():
e = transformer(e)
return 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]]:
for i in range(len(e)):
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:
for i in range(len(e)):
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
@ -281,6 +300,10 @@ class NOPProcessor:
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)
@ -293,6 +316,9 @@ class NOPProcessor:
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

View file

@ -3,20 +3,58 @@ from panflute import Cite, Code, Emph, Image, LineBreak, Link, Math, Note, Quote
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
from .elements import FQuoted, FileLink
from .context import Group, InlineGroup, BlockGroup, Context
import re
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:
_at_start_of_line: bool
_empty_lines: int
context: Union[Context, None]
indent_level: int
indent_str: str
@ -29,7 +67,7 @@ class OutputGenerator:
self.output_file = output_file
self.indent_str = indent_str
self.indent_level = initial_indent_level
self._at_start_of_line = True
self._empty_lines = 1
self.context = None
self.TYPE_DICT_MISC = {
@ -89,6 +127,7 @@ class OutputGenerator:
Underline: self.generate_Underline,
NBSP: self.generate_NBSP,
FQuoted: self.generate_FQuoted,
FileLink: self.generate_FileLink,
InlineGroup: self.generate_InlineGroup
}
@ -101,28 +140,36 @@ class OutputGenerator:
}
def generate(self, e: Union[Element, ListContainer, list[Union[Element, ListContainer]]]):
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:
self.TYPE_DICT_MISC[type(e)](e)
except KeyError:
raise UnknownElementError(type(e))
if isinstance(e, Group):
self.context = old_context
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
@ -136,29 +183,32 @@ class OutputGenerator:
def indent_less(self):
self.indent_level -= 1
def write(self, text: str):
if self._at_start_of_line:
def write(self, text: str=""):
if self._empty_lines > 0:
self.output_file.write(self.indent())
self.output_file.write(text)
self._at_start_of_line = False
self._empty_lines = 0
def writeln(self, text: str):
if not self._at_start_of_line:
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._at_start_of_line = True
self._empty_lines = 1
def writeraw(self, text: str):
if not self._at_start_of_line:
def writeraw(self, text: str=""):
if self._empty_lines == 0:
self.output_file.write("\n")
self.output_file.write(text+"\n")
self._at_start_of_line = True
self._empty_lines = 1
def endln(self):
if not self._at_start_of_line:
def ensure_empty(self, n: int=1):
while self._empty_lines < n:
self.output_file.write("\n")
self._at_start_of_line = True
self._empty_lines+=1
def endln(self):
self.ensure_empty(1)
def start_tag(self, tag: str, attributes: dict[str,str]={}) -> str:
return tag
@ -223,9 +273,9 @@ class OutputGenerator:
self.write(self.end_tag(tag))
def generate_raw_block_tag(self, tag: str, text: str, attributes: dict[str,str]={}):
self.writeln(self.start_tag(tag, attributes))
self.writeraw(self.start_tag(tag, attributes))
self.writeraw(text)
self.writeln(self.end_tag(tag))
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))
@ -244,9 +294,10 @@ class OutputGenerator:
def generate_MetaValue(self, e: MetaValue):
try:
self.TYPE_DICT_META[type(e)](e)
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)
@ -255,16 +306,23 @@ class OutputGenerator:
self.generate(e.content)
def generate_MetaBool(self, e: MetaBool):
self.generate_simple_tag(e)
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.generate_simple_tag(e)
self.write(e.text)
def generate_Inline(self, e: Inline):
self.TYPE_DICT_INLINE[type(e)](e)
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))
@ -310,7 +368,9 @@ class OutputGenerator:
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):
@ -365,7 +425,11 @@ class OutputGenerator:
def generate_Block(self, e: Block):
self.TYPE_DICT_BLOCK[type(e)](e)
try:
method = self.TYPE_DICT_BLOCK[type(e)]
except KeyError as err:
raise UnknownElementError(type(e)) from err
method(e)
# Block elements
@ -430,12 +494,14 @@ class OutputGenerator:
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)

View file

@ -1,270 +0,0 @@
from panflute import *
import os
from typing import Union
from .whitespace import NBSP
from .elements 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: Union[Element, ListContainer], 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 isinstance(e, Header) 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
# TODO: This should use OutputGenerator's get_image_processor_args
# 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 cache
# directory.
url = i.process_image(url, ext, source_dir, **additional_args)
elif ext in ["svg"]:
url = i.process_image(url, "pdf", source_dir, **additional_args)
elif ext in ["epdf"]:
url = i.process_image(url, "pdf", source_dir, **additional_args)
elif ext in ["jpg"]:
url = i.process_image(url, "jpeg", source_dir, **additional_args)
else:
url = i.process_image(url, "pdf", source_dir, **additional_args)
url = i.find_image(url, [i.cache_dir])
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 "lang" in e.metadata and e.metadata["lang"] is not None:
open = "\\language"+e.metadata["lang"]
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

View file

@ -0,0 +1,366 @@
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,176 +0,0 @@
from panflute import Element, Div, Span, Quoted, Image, CodeBlock, Str, MetaInlines, MetaString, MetaBool, RawBlock
import re
import os
# Import local files
from .whitespace import Whitespace, NBSP, bavlna
from .util import nullify, import_md
from .context import Context, BlockGroup
from .command import Command, BlockCommand, InlineCommand
from .command_util import handle_command_define, parse_command
from .elements import FQuoted
import warnings
warnings.warn("The transform function has been deprecated, is left only for reference and will be removed in future commits. TransformProcessor should be used in its place.", DeprecationWarning)
# 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:
warnings.warn("The transform function has been deprecated, is left only for reference and will be removed in future commits. TransformProcessor should be used in its place.", DeprecationWarning)
# 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("lang")
includedDoc = includedDoc.walk(transform, nContext)
e = BlockGroup(*includedDoc.content, context=nContext, metadata={"lang": 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("lang")])
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
# I think this is supposed to enable inline styles for highlighting when the style differs from the document, but it clearly doesn't work. a) HTML_generator never accesses it and b) Only the top-level document contains a style so you have to ask the top level context, not the current context.
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)
command_output = parse_command(e.text)(BlockCommand(), c)
e = BlockCommand().replaceSelf(*([] if command_output is None else command_output))
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']}'.")
command_output = c.get_command(e.attributes["c"])(e, c)
e = e.replaceSelf(*command_output)
e = e.walk(transform, c)
return e

View file

@ -3,6 +3,7 @@ from panflute import Cite, Code, Emph, Image, LineBreak, Link, Math, Note, Quote
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
@ -18,17 +19,12 @@ 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
from .command import BlockCommand, InlineCommand, CodeCommand, Command, InlineError
from .command_util import handle_command_define, parse_command
from .nop_processor import NOPProcessor, ELCl
class DoubleDocError(Exception):
"TransformProcessor should only ever see a single Doc."
pass
from .nop_processor import NOPProcessor, ELCl, DoubleDocError
class TransformProcessor(NOPProcessor):
context: Union[Context, None] = None
root_file_path: str
root_highlight_style: str = "default"
_command_modules: list[tuple[Union[dict[str, CommandCallable], ModuleType], str]] = []
@ -44,6 +40,15 @@ class TransformProcessor(NOPProcessor):
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]
@ -61,15 +66,22 @@ class TransformProcessor(NOPProcessor):
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:
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)
self.init_context(e)
e.content = self.transform(e.content)
e.content = [BlockGroup(*e.content, context=self.context)]
return e
@ -87,22 +99,38 @@ class TransformProcessor(NOPProcessor):
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 not "no-srcset" in e.attributes:
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, inline: bool=False) -> Group:
def create_Group(self, *content, new_context: Context, replaced:Element, inline: bool=False) -> Group:
old_context = self.context
self.context = new_context
content = self.transform([*content])
self.context = old_context
if inline:
return InlineGroup(*content, context=new_context)
g = InlineGroup(*content, context=new_context)
else:
return BlockGroup(*content, context=new_context)
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]:
e.content = self.transform(e.content)
if "group" in e.classes:
# `.group` class for Divs
@ -110,14 +138,15 @@ class TransformProcessor(NOPProcessor):
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, new_context=new_context)
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}
# :::
e = BlockCommand(*e.content, identifier=e.identifier, classes=e.classes, attributes=e.attributes)
return self.transform(e)
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
@ -129,7 +158,9 @@ class TransformProcessor(NOPProcessor):
pwd = os.path.abspath(".")
if os.path.commonpath([full_path, pwd]) != os.path.commonpath([pwd]):
return nullify(e)
text = open(self.context.dir + "/" + e.attributes["partial"], "r").read()
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)
@ -138,7 +169,7 @@ class TransformProcessor(NOPProcessor):
trusted = False
if not self.context.trusted:
trusted = False
return self.create_Group(*includedDoc.content, new_context=Context(includedDoc, path, self.context, trusted=trusted))
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"])
@ -159,10 +190,9 @@ class TransformProcessor(NOPProcessor):
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 e
return super().transform_Div(e)
def transform_Span(self, e: Span) -> Span:
e.content = self.transform(e.content)
if "group" in e.classes:
# `.group` class for Spans
@ -170,19 +200,21 @@ class TransformProcessor(NOPProcessor):
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, new_context=new_context, inline=True)
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
e = InlineCommand(*e.content, identifier=e.identifier, classes=e.classes, attributes=e.attributes)
return self.transform(e)
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):
e = InlineCommand(identifier=e.identifier, classes=e.classes, attributes={**e.attributes, "c": e.content[0].text[1:]})
return self.transform(e)
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
@ -191,7 +223,9 @@ class TransformProcessor(NOPProcessor):
if not "type" in e.attributes:
e.attributes["type"] = "module"
if e.attributes["type"] == "md":
importedDoc = import_md(open(self.context.dir + "/" + e.content[0].text[1:], "r").read())
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:])
@ -201,7 +235,9 @@ class TransformProcessor(NOPProcessor):
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":
data = json.load(open(self.context.dir + "/" + e.content[0].text[1:], "r"))
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:
@ -213,7 +249,7 @@ class TransformProcessor(NOPProcessor):
# 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):
if isinstance(val, MetaInlines): # TODO: Trust transform for this
e = Span(*val.content)
e = self.transform(e)
elif isinstance(val, MetaString):
@ -224,19 +260,20 @@ class TransformProcessor(NOPProcessor):
raise TypeError(f"Cannot print value of metadatum '{e.content[0].text[1:]}' of type '{type(val)}'")
return e
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, new_context=Context(includedDoc, self.context.path, self.context, self.context.trusted))
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)
e = BlockCommand().replaceSelf(*([] if command_output is None else command_output))
return self.transform(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:
@ -244,7 +281,9 @@ class TransformProcessor(NOPProcessor):
return handle_command_define(e, self.context)
if "c" in e.attributes:
return self.transform(CodeCommand(e.text, identifier=e.identifier, classes=e.classes, attributes=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
@ -257,9 +296,9 @@ class TransformProcessor(NOPProcessor):
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)
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 self.transform(e)
return e
def transform_Whitespace(self, e: Whitespace) -> Whitespace:
if bavlna(e, self.context):

View file

@ -37,7 +37,7 @@ def parse_string(s: str) -> list[Union[Str, Space]]:
# 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")
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)

View file

@ -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

View file

@ -56,7 +56,16 @@ $$
$$
![This is a figure, go figure...](logo.svg){width=25%}\
<!--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.pdf){width=50%}

View file

@ -1,8 +0,0 @@
<!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

@ -3,12 +3,13 @@ title: 'Wooooo a title'
subtitle: 'A subtitle'
are_we_there_yet: False
lang: "en"
header-includes: |
<style>
body {
color: forestgreen;
}
</style>
---
:::: {.header_content}
::: {partial="test-top.html" type="html"}
:::
::::
[#test-files/test-import.md]{type=md}
[#test.json]{type=metadata key=orgs}
@ -56,6 +57,11 @@ def bruh(no):
wat
```
```python {style=dracula}
def bruhec(bruzek):
hah
```
Inline `code`
::::{if=cat}
@ -193,11 +199,29 @@ ii. wym bro
```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))]
return [pf.CodeBlock(c.stdout)]
```
```bash {c=bash}
cat /etc/hostname
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>`