From 22dd8894bc905557adb5645967a98f33b49f3187 Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Sun, 4 Jul 2021 23:19:44 -0500 Subject: [PATCH] Ability to output GM sheets as EPUB files (without CSS for now). --- .travis.yml | 1 + VERSION | 2 +- dungeonsheets/epub.py | 139 +++++ dungeonsheets/exceptions.py | 3 + dungeonsheets/forms/monsters_template.html | 70 +++ .../forms/party_summary_template.html | 56 ++ dungeonsheets/forms/postamble.html | 3 + dungeonsheets/forms/preamble.html | 6 + .../forms/random_tables_template.html | 539 ++++++++++++++++++ dungeonsheets/latex.py | 2 +- dungeonsheets/make_sheets.py | 99 +++- requirements.txt | 1 + setup.py | 2 +- tests/test_html.py | 114 ++++ tests/test_make_sheets.py | 33 +- 15 files changed, 1029 insertions(+), 41 deletions(-) create mode 100644 dungeonsheets/epub.py create mode 100644 dungeonsheets/forms/monsters_template.html create mode 100644 dungeonsheets/forms/party_summary_template.html create mode 100644 dungeonsheets/forms/postamble.html create mode 100644 dungeonsheets/forms/preamble.html create mode 100644 dungeonsheets/forms/random_tables_template.html create mode 100644 tests/test_html.py diff --git a/.travis.yml b/.travis.yml index 2502e14..e0c8e04 100644 --- a/.travis.yml +++ b/.travis.yml @@ -24,6 +24,7 @@ script: - cd examples/ - makesheets --debug - makesheets --debug --fancy + - makesheets --debug --output-format=epub - cd ../ after_success: - coveralls \ No newline at end of file diff --git a/VERSION b/VERSION index 8076af5..d183d4a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.15.1 \ No newline at end of file +0.16.0 \ No newline at end of file diff --git a/dungeonsheets/epub.py b/dungeonsheets/epub.py new file mode 100644 index 0000000..1a0acd9 --- /dev/null +++ b/dungeonsheets/epub.py @@ -0,0 +1,139 @@ +from typing import Mapping + +from ebooklib import epub +from docutils import core +from sphinx.util.docstrings import prepare_docstring +from docutils.writers.html5_polyglot import Writer as HTMLWriter + +from dungeonsheets.latex import dice_re + + +def create_epub( + chapters: Mapping, + title: str, + basename: str, + use_dnd_decorations: bool = False +): + """Prepare an EPUB file from the list of chapters. + + Parameters + ========== + chapters + A mapping where the keys are chapter names (spines) and the + values are strings of HTML to be rendered as the chapter + contents. + basename + The basename for saving files (PDFs, etc). The resulting epub + file will be "{basename}.epub". + use_dnd_decorations + If true, style sheets will be included to produce D&D stylized + stat blocks, etc. + + """ + # Create a new epub book + book = epub.EpubBook() + book.set_identifier('id123456') + book.set_title(title) + book.set_language('en') + # Create the separate chapters + html_chapters = [] + for chap_title, content in chapters.items(): + chap_fname = "{}.html".format(chap_title.replace(" ", "_").lower()) + chapter = epub.EpubHtml(title=chap_title, file_name=chap_fname, lang="en") + chapter.set_content(content) + book.add_item(chapter) + html_chapters.append(chapter) + # Add the table of contents + book.toc = html_chapters + book.spine = ("nav", *html_chapters) + # add default NCX and Nav file + book.add_item(epub.EpubNcx()) + book.add_item(epub.EpubNav()) + # Save the file + epub_fname = f"{basename}.epub" + epub.write_epub(epub_fname, book) + + +def html_parts( + input_string, + source_path=None, + destination_path=None, + input_encoding="unicode", + doctitle=True, + initial_header_level=1, +): + """ + Given an input string, returns a dictionary of HTML document parts. + + Dictionary keys are the names of parts, and values are Unicode strings; + encoding is up to the client. + + Parameters: + + - `input_string`: A multi-line text string; required. + - `source_path`: Path to the source file or object. Optional, but useful + for diagnostic output (system messages). + - `destination_path`: Path to the file or object which will receive the + output; optional. Used for determining relative paths (stylesheets, + source links, etc.). + - `input_encoding`: The encoding of `input_string`. If it is an encoded + 8-bit string, provide the correct encoding. If it is a Unicode string, + use "unicode", the default. + - `doctitle`: Disable the promotion of a lone top-level section title to + document title (and subsequent section title to document subtitle + promotion); enabled by default. + - `initial_header_level`: The initial level for header elements (e.g. 1 + for "

"). + """ + # Remove indentation, etc + input_string = "\n".join(prepare_docstring(input_string)) + # Parse from rst to TeX + overrides = { + "input_encoding": input_encoding, + "doctitle_xform": doctitle, + "initial_header_level": initial_header_level, + } + writer = HTMLWriter() + parts = core.publish_parts( + source=input_string, + source_path=source_path, + destination_path=destination_path, + writer=writer, + settings_overrides=overrides, + ) + return parts + + +def rst_to_html(rst, top_heading_level=0): + """Basic markup of reST to HTML code. + + The translation between reST headings and LaTeX headings is + modified by the *top_heading_level* parameter. A value of 0 + (default) translates "# Heading" -> "

{Heading}

". A value + of 1 translates "# Heading" -> "

{Heading}

", etc. + + Note: heading translation is currently broken. + + Parameters + ========== + rst + reStructured text input to be parsed. + top_heading_level : optional + The highest level heading that will be added to the HTML as + described above. + + Returns + ======= + html : str + The reST text parsed into HTML markup. + + """ + if rst is None: + # No reST, so return an empty string + html = "" + else: + # Mark hit dice in monospace font + rst = dice_re.sub(r"``\1``", rst) + _html_parts = html_parts(rst) + html = _html_parts["body"] + return html diff --git a/dungeonsheets/exceptions.py b/dungeonsheets/exceptions.py index 9f2d0fc..9ba138f 100644 --- a/dungeonsheets/exceptions.py +++ b/dungeonsheets/exceptions.py @@ -24,3 +24,6 @@ class JSONFormatError(RuntimeError): class UnknownFileType(RuntimeError): """The input file does not match one of the known formats.""" + +class UnknownOutputFormat(RuntimeError): + """The output format requested is not one of the known outputs.""" diff --git a/dungeonsheets/forms/monsters_template.html b/dungeonsheets/forms/monsters_template.html new file mode 100644 index 0000000..3eb8730 --- /dev/null +++ b/dungeonsheets/forms/monsters_template.html @@ -0,0 +1,70 @@ +

Monsters

+ +[% for monster in monsters|sort(attribute='name') %] +

[[ monster.name ]]

+ +[% if monster.description %] +

[[ monster.description ]]

+[% endif %] + + + + + + + + + + + + + +
Armor ClassHit PointsSpeed
[[ monster.armor_class ]][[ monster.hp_max ]] ([[ monster.hit_dice ]])[[ monster.speed ]][% if monster.swim_speed %], + [[ monster.swim_speed ]] swim[% endif %][% if monster.fly_speed %], + [[ monster.fly_speed ]] fly[% endif %][% if monster.burrow_speed %], + [[ monster.burrow_speed ]] burrow[% endif %]
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
STRDEXCONINTWISCHA
[[ monster.strength.value ]][[ monster.dexterity.value ]][[ monster.constitution.value ]][[ monster.intelligence.value ]][[ monster.wisdom.value ]][[ monster.charisma.value ]]
([[ monster.strength.modifier|mod_str ]])([[ monster.dexterity.modifier|mod_str ]])([[ monster.constitution.modifier|mod_str ]])([[ monster.intelligence.modifier|mod_str ]])([[ monster.wisdom.modifier|mod_str ]])([[ monster.charisma.modifier|mod_str ]])
+ +
+ [% if monster.skills != "" %]
Skills:
[[ monster.skills ]]
[% endif %] +
Senses:
[% if monster.senses != "" %][[ monster.senses ]][% else %]--[% endif %]
+
Languages:
[% if monster.languages != "" %][[ monster.languages ]][% else %]--[% endif %]
+ [% if monster.damage_resistances != "" %]
Damage Resistances:
[[ monster.damage_resistances ]]
[% endif %] + [% if monster.damage_immunities != "" %]
Damage Immunities:
[[ monster.damage_immunities ]]
[% endif %] + [% if monster.damage_vulnerabilities != "" %]
Damage Vulnerabilities:
[[ monster.damage_vulnerabilities ]]
[% endif %] + [% if monster.condition_immunities != "" %]
Condition Immunuties:
[[ monster.condition_immunities ]]
[% endif %] + [% if monster.saving_throws != "" %]
Saving Throws:
[[ monster.saving_throws ]]
[% endif %] +
Challenge:
[[ monster.challenge_rating ]]
+
+ + +[[ monster.__doc__ | rst_to_html(top_heading_level=2) ]] + +[% endfor %] diff --git a/dungeonsheets/forms/party_summary_template.html b/dungeonsheets/forms/party_summary_template.html new file mode 100644 index 0000000..511d3c8 --- /dev/null +++ b/dungeonsheets/forms/party_summary_template.html @@ -0,0 +1,56 @@ +[% if summary %] + +

Summary

+ +[[ summary | rst_to_html ]] + +[% endif %] + +[% if party %] + +

Party

+ + + + + + + + + + + + [% for member in party %] + + + + + + + + + + [% endfor %] +
 StrDexConIntWisCha
[[ member.name ]][[ member.strength.modifier | mod_str ]][[ member.dexterity.modifier | mod_str ]][[ member.constitution.modifier | mod_str ]][[ member.intelligence.modifier | mod_str ]][[ member.wisdom.modifier | mod_str ]][[ member.charisma.modifier | mod_str ]]
+ + + + + + + + + [% for member in party %] + + + + + + + [% endfor %] +
 ACPass. Per.Spell DC
[[ member.name[:28] ]][[ member.armor_class ]][[ member.perception + 10 ]][% for class in member.class_list %] + [% if class.spellcasting_ability %] [[ member.spell_save_dc(class) ]], [% endif %] + [% endfor %] +
+ +[% endif %] diff --git a/dungeonsheets/forms/postamble.html b/dungeonsheets/forms/postamble.html new file mode 100644 index 0000000..11a09ca --- /dev/null +++ b/dungeonsheets/forms/postamble.html @@ -0,0 +1,3 @@ + + + diff --git a/dungeonsheets/forms/preamble.html b/dungeonsheets/forms/preamble.html new file mode 100644 index 0000000..1e53a30 --- /dev/null +++ b/dungeonsheets/forms/preamble.html @@ -0,0 +1,6 @@ + + + +My title + + diff --git a/dungeonsheets/forms/random_tables_template.html b/dungeonsheets/forms/random_tables_template.html new file mode 100644 index 0000000..6e7308a --- /dev/null +++ b/dungeonsheets/forms/random_tables_template.html @@ -0,0 +1,539 @@ +

Random Tables

+ +[% if conjure_animals %] + + +

Conjure Animals

+ + + + + + + + + + + + + + + + + + + + + + + +
1d4Number of Beasts
1One beast of challenge rating 2
2Two beasts of challenge rating 1
3Four beasts of challenge rating 1/2
4Eight beasts of challenge rating 1/4 or lower
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
1d20CR2 Beasts
1-2Allosaurus
3-4Giant Boar
5-6Giant Constrictor Snake
7-8Giant Elk
9-10Hunter Shark
11Plesiosaurus
12-13Polar Bear
14-15Rhinoceros
16-17Saber-toothed Tiger
18-19Swarm of Poisonous Snakes
20Roll on CR 1 Beast Table
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
1d12Challenge Rating 1 Beasts
1Brown Bear
2Dire Wolf
3Fire Snake
4Giant Eagle
5Giant Hyena
6Giant Octopus
7Giant Spider
8Giant Toad
9Giant Vulture
10Lion
11Tiger
12Roll on CR ½ Beast Table
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
1d20Challenge Rating ½ Beasts
1-2Ape
3-4Black Bear
5-6Crocodile
7-8Giant Goat
9-10Giant Sea Horse
11-12Giant Wasp
13-14Reef Shark
15-16Swarm of Insects (below)
17-18Warhorse
19Worg
20Roll on Lesser Beast Menu Table
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
1d6Swarm of Insects
1Ant
2Beatles
3Centipedes
4Locusts
5Spiders
6Wasps
+ + + + + + + + + + + + + + + + + + + +
1d6CR ¼ and Lesser Beast Menu
1-2Menu A
3-4Menu B
5-6Menu C
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
1d20Lesser Beast Menu A
1Axe Beak
2Baboon
3Badger
4Bat
5Blood Hawk
6Boar
7Camel
8Cat
9Chicken*
10Constrictor Snake
11Crab
12Deer
13Draft Horse
14Eagle
15Elk
16Flying Snake
17Frog
18Giant Badger
19Giant Bat
20Giant Centipede
+ + +
+
*Chicken:
+
Raven stats with Advantage on checks to wake + up targets instead of mimicry
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
1d20Lesser Beast Menu B
1Giant Crab
2Giant Fire Beetle
3Giant Frog
4Giant Lizard
5Giant Owl
6Giant Poisonous Snake
7Giant Rat
8Giant Weasel
9Giant Wolf Spider
10Goat
11Hawk
12Hyena
13Jackal
14Lemur*
15Lizard
16Mastiff
17Mule
18Newt**
19Octopus
20Octopus, Cascadian Tree***
+ +
+
*Lemur
+
Weasel stats with a common Climb speed instead of a + bite attack
+
**Newt:
+
Lizard stats with Amphibious instead of a bite + attack
+
***Octopus, Cascadian Tree:
+
Octopus stats with Amphibious + and a 10 ft land speed instead of camouflage
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
1d20Lesser Beast Menu C
1Owl
2Panther
3Poisonous Snake
4Pony
5Pteranodon
6Quipper
7Rat
8Raven
9Riding Horse
10Scorpion
11Sea Horse
12Shocker Lizard*
13Spider
14Swarm of Bats
15Swarm of Rats
16Swarm of Ravens
17Turtle**
18Vulture
19Weasel
20Wolf
+ +
+
*Shocker Lizard:
+
Lizard stats with Static Electricity ranged attack of 1d6 + Electricity damage Close/Medium.
+
**Turtle:
+
Lizard stats with 14 natural armor and no climb speed.
+
+ +[% endif %] diff --git a/dungeonsheets/latex.py b/dungeonsheets/latex.py index 94a87a3..50329fb 100644 --- a/dungeonsheets/latex.py +++ b/dungeonsheets/latex.py @@ -46,7 +46,7 @@ def _remove_temp_files(basename_): filename.unlink() -def create_latex_pdf(tex, basename, keep_temp_files=False, use_dnd_decorations=False): +def create_latex_pdf(tex: str, basename: str, keep_temp_files: bool=False, use_dnd_decorations: bool=False): # Create tex document tex_file = f"{basename}.tex" with open(tex_file, mode="w", encoding="utf-8") as f: diff --git a/dungeonsheets/make_sheets.py b/dungeonsheets/make_sheets.py index 8ff223e..fad5ca3 100755 --- a/dungeonsheets/make_sheets.py +++ b/dungeonsheets/make_sheets.py @@ -13,7 +13,7 @@ from typing import Union, Sequence, Optional from jinja2 import Environment, PackageLoader -from dungeonsheets import character as _char, exceptions, readers, latex, monsters +from dungeonsheets import character as _char, exceptions, readers, latex, epub, monsters from dungeonsheets.stats import mod_str from dungeonsheets.content_registry import find_content from dungeonsheets.fill_pdf_template import ( @@ -49,6 +49,7 @@ jinja_env = Environment( variable_end_string="]]", ) jinja_env.filters["rst_to_latex"] = latex.rst_to_latex +jinja_env.filters["rst_to_html"] = epub.rst_to_html jinja_env.filters["mod_str"] = mod_str @@ -83,22 +84,24 @@ def create_magic_items_tex( return template.render(character=character, use_dnd_decorations=use_dnd_decorations) -def create_monsters_tex( +def create_monsters_content( monsters: Sequence[Union[monsters.Monster, str]], + suffix: str, use_dnd_decorations: bool = False, ) -> str: # Convert strings to Monster objects - template = jinja_env.get_template("monsters_template.tex") + template = jinja_env.get_template(f"monsters_template.{suffix}") return template.render(monsters=monsters, use_dnd_decorations=use_dnd_decorations) -def create_party_summary_tex( +def create_party_summary_content( party: Sequence[Entity], summary_rst: str, + suffix: str, use_dnd_decorations: bool = False, ) -> str: log.debug("Preparing summary table for party: %s", party) - template = jinja_env.get_template("party_summary_template.tex") + template = jinja_env.get_template(f"party_summary_template.{suffix}") return template.render( party=party, summary=summary_rst, use_dnd_decorations=use_dnd_decorations ) @@ -130,11 +133,12 @@ def create_druid_shapes_tex( return template.render(character=character, use_dnd_decorations=use_dnd_decorations) -def create_random_tables_tex( +def create_random_tables_content( conjure_animals: bool, + suffix: str, use_dnd_decorations: bool = False, ) -> str: - template = jinja_env.get_template("random_tables_template.tex") + template = jinja_env.get_template(f"random_tables_template.{suffix}") return template.render(conjure_animals=conjure_animals, use_dnd_decorations=use_dnd_decorations) @@ -142,6 +146,7 @@ def create_random_tables_tex( def make_sheet( sheet_file: File, flatten: bool = False, + output_format: str = "pdf", fancy_decorations: bool = False, debug: bool = False, ): @@ -153,6 +158,8 @@ def make_sheet( flatten : bool, optional If true, the resulting PDF will look better and won't be fillable form. + output_format + Either "pdf" or "epub" to generate a PDF file or an EPUB file. fancy_decorations : bool, optional Use fancy page layout and decorations for extra sheets, namely the dnd style file: https://github.com/rpgtex/DND-5e-LaTeX-Template. @@ -167,6 +174,7 @@ def make_sheet( if sheet_props.get("sheet_type", "") == "gm": ret = make_gm_sheet( gm_file=sheet_file, + output_format=output_format, fancy_decorations=fancy_decorations, debug=debug, ) @@ -180,8 +188,15 @@ def make_sheet( return ret +format_suffixes = { + "pdf": "tex", + "epub": "html", +} + + def make_gm_sheet( gm_file: Union[str, Path], + output_format: str = "pdf", fancy_decorations: bool = False, debug: bool = False, ): @@ -191,6 +206,8 @@ def make_gm_sheet( ---------- gm_file The file with the gm_sheet definitions. + output_format + Either "pdf" or "epub" to generate a PDF file or an EPUB file. fancy_decorations Use fancy page layout and decorations for extra sheets, namely the dnd style file: https://github.com/rpgtex/DND-5e-LaTeX-Template. @@ -203,8 +220,9 @@ def make_gm_sheet( basename = gm_file.stem gm_props = readers.read_sheet_file(gm_file) # Create the intro tex - tex = [ - jinja_env.get_template("preamble.tex").render( + content_suffix = format_suffixes[output_format] + content = [ + jinja_env.get_template(f"preamble.{content_suffix}").render( use_dnd_decorations=fancy_decorations, title=gm_props.pop("session_title", "GM Session Notes"), ) @@ -222,9 +240,9 @@ def make_gm_sheet( member = _char.Character.load(character_props) party.append(member) summary = gm_props.pop("summary", "") - tex.append( - create_party_summary_tex( - party, summary_rst=summary, use_dnd_decorations=fancy_decorations + content.append( + create_party_summary_content( + party, summary_rst=summary, suffix=content_suffix, use_dnd_decorations=fancy_decorations ) ) # Add the monsters @@ -244,20 +262,21 @@ def make_gm_sheet( new_monster = MyMonster() monsters_.append(new_monster) if len(monsters_) > 0: - tex.append( - create_monsters_tex(monsters_, use_dnd_decorations=fancy_decorations) + content.append( + create_monsters_content(monsters_, suffix=content_suffix, use_dnd_decorations=fancy_decorations) ) # Add the random tables random_tables = [s.replace(" ", "_").lower() for s in gm_props.pop("random_tables", [])] - tex.append( - create_random_tables_tex( + content.append( + create_random_tables_content( conjure_animals=("conjure_animals" in random_tables), + suffix=content_suffix, use_dnd_decorations=fancy_decorations, ) ) # Add the closing TeX - tex.append( - jinja_env.get_template("postamble.tex").render( + content.append( + jinja_env.get_template(f"postamble.{format_suffixes[output_format]}").render( use_dnd_decorations=fancy_decorations ) ) @@ -268,17 +287,30 @@ def make_gm_sheet( msg = f"Unhandled attributes in '{str(gm_file)}': {','.join(gm_props.keys())}" log.warn(msg) warnings.warn(msg) - # Typeset combined LaTeX file - try: - if len(tex) > 2: - latex.create_latex_pdf( - tex="".join(tex), - basename=basename, - keep_temp_files=debug, - use_dnd_decorations=fancy_decorations, - ) - except exceptions.LatexNotFoundError: - log.warning(f"``pdflatex`` not available. Skipping {basename}") + # Produce the combined output depending on the format requested + if output_format == "pdf": + # Typeset combined LaTeX file + try: + if len(content) > 2: + latex.create_latex_pdf( + tex="".join(content), + basename=basename, + keep_temp_files=debug, + use_dnd_decorations=fancy_decorations, + ) + except exceptions.LatexNotFoundError: + log.warning(f"``pdflatex`` not available. Skipping {basename}") + elif output_format == "epub": + epub.create_epub( + chapters={"GM Sheet": "".join(content)}, + basename=basename, + title=gm_props.get("session_title", f"GM Notes: {basename}"), + use_dnd_decorations=fancy_decorations, + ) + else: + raise exceptions.UnknownOutputFormat( + f"Unknown output format requested: {output_format}. Valid options are: 'pdf', 'epub'" + ) def make_character_sheet( @@ -435,6 +467,7 @@ def _build(filename, args) -> int: make_sheet( sheet_file=filename, flatten=(not args.editable), + output_format=args.output_format, debug=args.debug, fancy_decorations=args.fancy_decorations, ) @@ -484,6 +517,12 @@ def main(args=None): "(experimental, requires https://github.com/rpgtex/DND-5e-LaTeX-Template)" ), ) + parser.add_argument( + "--output-format", "-o", + help="Specify the output format for the sheets.", + choices=["pdf", "epub"], + default="pdf", + ) parser.add_argument( "--debug", "-d", @@ -529,7 +568,7 @@ def main(args=None): # Process the requested files if args.debug: for filename in filenames: - print("building") + log.debug("building") _build(filename, args) else: with Pool(cpu_count()) as p: diff --git a/requirements.txt b/requirements.txt index 307271e..49e78fb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ npyscreen jinja2 sphinx pdfrw +EbookLib diff --git a/setup.py b/setup.py index 54fd47c..67c3605 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup(name='dungeonsheets', '../VERSION'] }, install_requires=[ - 'fdfgen', 'npyscreen', 'jinja2', 'pdfrw', 'sphinx', + 'fdfgen', 'npyscreen', 'jinja2', 'pdfrw', 'sphinx', 'EbookLib', ], entry_points={ 'console_scripts': [ diff --git a/tests/test_html.py b/tests/test_html.py new file mode 100644 index 0000000..d711728 --- /dev/null +++ b/tests/test_html.py @@ -0,0 +1,114 @@ +import unittest + +from dungeonsheets import spells, features, epub + + +class MarkdownTestCase(unittest.TestCase): + """Check that conversion of markdown formats to LaTeX code works + correctly.""" + + def test_rst_bold(self): + text = epub.rst_to_html("**hello**") + self.assertEqual(text, "

hello

\n") + + def test_hit_dice(self): + text = epub.rst_to_html("1d6+3") + self.assertEqual(text.strip("\n"), '

1d6+3

') + + def test_no_text(self): + text = epub.rst_to_html(None) + self.assertEqual(text, "") + + def test_verbatim(self): + text = epub.rst_to_html("``hello, world``") + self.assertIn('

hello, world

', text) + + def test_literal_backslash(self): + text = epub.rst_to_html(r"\\") + self.assertEqual(r"

\

", text.strip("\n")) + + @unittest.skip( + "Headings are all screwed up because it treats them as the document title" + ) + def test_headings(self): + # Simple heading by itself + text = epub.rst_to_html("Hello, world\n------------\n\nGoodbye, world") + self.assertEqual("\\section*{Hello, world}\n", text) + # Simple heading with leading whitespace + text = epub.rst_to_html(" Hello, world\n ============\n") + self.assertEqual("\\section*{Hello, world}\n", text) + # Heading with text after it + text = epub.rst_to_html("Hello, world\n============\n\nThis is some text") + self.assertEqual("\\section*{Hello, world}\n\nThis is some text", text) + # Heading with text before it + text = epub.rst_to_html("This is a paragraph\n\nHello, world\n============\n") + self.assertEqual("This is a paragraph\n\n\\section*{Hello, world}\n", text) + # Check that levels of headings are parsed appropriately + text = epub.rst_to_html("Hello, world\n^^^^^^^^^^^^\n") + self.assertEqual("\\subsubsection*{Hello, world}\n", text) + text = epub.rst_to_html("Hello, world\n^^^^^^^^^^^^\n", top_heading_level=3) + self.assertEqual("\\subparagraph*{Hello, world}\n", text) + # This is a bad heading missing with all the underline on one line + text = epub.rst_to_html("Hello, world^^^^^^^^^^^^\n") + self.assertEqual("Hello, world\\^\\^\\^\\^\\^\\^\\^\\^\\^\\^\\^\\^\n", text) + + def test_bullet_list(self): + tex = epub.rst_to_html("\n- Hello\n- World\n\n") + expected_tex = '' + self.assertEqual(expected_tex, tex.strip("\n")) + # Other bullet characters + tex = epub.rst_to_html("\n* Hello\n* World\n\n") + self.assertEqual(expected_tex, tex.strip("\n")) + tex = epub.rst_to_html("\n+ Hello\n+ World\n\n") + self.assertEqual(expected_tex, tex.strip("\n")) + # A real list taken from a docstring + real_list = """ + - Secondhand (you have heard of the target) - +5 + - Firsthand (you have met the target) - +0 + - Familiar (you know the target well) - -5 + + """ + tex = epub.rst_to_html(real_list) + self.assertIn('