diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff3c09f527..7542c66e7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,12 @@ jobs: # @template-customization-start + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + cache: pip + - run: pip install -r scripts/requirements.txt + - run: ./scripts/epub.sh - uses: actions/upload-artifact@v4 with: name: build diff --git a/.gitignore b/.gitignore index e5f795d8ce..e6ea0fcb52 100644 --- a/.gitignore +++ b/.gitignore @@ -153,3 +153,6 @@ dist # built files build/ + +# Vim undo file +*.un~ diff --git a/cspell.config.jsonc b/cspell.config.jsonc index 12afa91ba9..450d517ea4 100644 --- a/cspell.config.jsonc +++ b/cspell.config.jsonc @@ -34,6 +34,8 @@ "ated", "bauza", "BCME", + "baseprofile", + "beautifulsoup", "blueviolet", "cluable", "cluer", @@ -53,9 +55,22 @@ "dilorenzo", "discardable", "discarder", + "dtbncx", "duneaught", "esbenp", + "epub", "ESDCM", + "fecolormatrix", + "fecomponenttransfer", + "feflood", + "fefunca", + "fefuncb", + "fefuncg", + "fefuncr", + "femerge", + "femergenode", + "femorphology", + "feoffset", "finesseable", "fireheart", "floriman", @@ -64,14 +79,17 @@ "hanab", "hanabi", "hanabornoob", + "hgroup", "hideable", "iamjeff", "iamwhoiamhahaha", + "idref", "incentivized", "indego", "infima", "informalness", "isdariel", + "itemref", "iwguntoks", "jenn", "joelwool", @@ -83,11 +101,20 @@ "libster", "lightgreen", "lilliana", + "lineargradient", + "lxml", "mcvp", + "Mergenode", "metagame", "micerang", "misranked", + "navlabel", + "navmap", + "navpoint", + "NISO", + "OEBPS", "offsetblur", + "opendocument", "pianoblook", "playstyle", "PPCL", @@ -99,6 +126,8 @@ "razvogor", "rnrgames", "romain", + "rootfile", + "rootfiles", "rubbo", "rygb", "rygbi", @@ -114,9 +143,11 @@ "speedruns", "starcraft", "sucubis", + "svgs", "tccm", "tobin", "tocm", + "unallowed", "uncluable", "unclued", "unifiedjs", @@ -125,9 +156,11 @@ "utpf", "uutd", "valetta", + "viewbox", "xdragun", "xiwguntoks", "xlink", + "xmlified", "xrbm", "xrygb", "xrygbi", diff --git a/docs/about.mdx b/docs/about.mdx index a5e2643fa1..827850ce3c 100644 --- a/docs/about.mdx +++ b/docs/about.mdx @@ -22,6 +22,10 @@ Have you found your way here from the Internet? That's fine too. Feel free to ch - You can use `/` or `Ctrl + K` to open the search bar. - You can use `l` to navigate to a specific level. +## E-Book Format + +If you want to read this website on an e-reader or simply access it offline, you can download the [epub version](https://hanabi.github.io/assets/epub/hgroup-conventions.epub). (The epub is automatically generated by a script.) + ## Contributing If you want to contribute to this website, then see the [README.md](https://github.com/hanabi/hanabi.github.io/blob/main/README.md) for the repository and [the documentation on how to create example images](example-images.mdx). diff --git a/scripts/epub.py b/scripts/epub.py new file mode 100644 index 0000000000..dfee118180 --- /dev/null +++ b/scripts/epub.py @@ -0,0 +1,656 @@ +import os +import re +from uuid import uuid4 +from ast import literal_eval +from bs4 import BeautifulSoup +from markdown import Markdown + +ROOT_PATH = "../../../" +STYLE_PATH = f"{ROOT_PATH}src/css/custom.css" +CONFIG_PATH = f"{ROOT_PATH}docusaurus.config.ts" +TITLE_PAGE_PATH = f"{ROOT_PATH}src/pages/index.tsx" +SIDEBAR_CONFIG_PATH = f"{ROOT_PATH}sidebars.ts" +XHTML_TEMPLATE_PATH = f"{ROOT_PATH}static/epub/page-template.xhtml" + +LOGO_PATH = "epub-src/OEBPS/img/logo.png" +COVER_IMG_PATH = "epub-src/OEBPS/img/cover.png" +COVER_PATH = "epub-src/OEBPS/parts/cover.xhtml" +TOC_PATH = "epub-src/OEBPS/parts/toc.xhtml" +CONTENT_PATH = "epub-src/OEBPS/content.opf" +TOC_NCX_PATH = "epub-src/OEBPS/toc.ncx" +EXAMPLE_PIECES_DIR = "epub-src/OEBPS/img/pieces" +EXAMPLE_SCREENSHOTS_DIR = "epub-src/OEBPS/img/examples" + +TABS_P = re.compile(r"(]*?>)((?:.|\n|\r)*?)(<\/Tabs>)") +TAB_P = re.compile(r"(]*?>)((?:.|\r|\n)*?)(<\/TabItem>)") +GUIDE_PROGRESS_P = re.compile(r"]*?\/>") +SELF_CLOSE_MDX_P = re.compile(r"<[A-Z]\w*\s/>") +SVG_PLACEHOLDER_P = re.compile(r"\[example-svg-placeholder\]") +LIST_ITEM_P = re.compile(r"^\s*(?:[\*\-+]|\d+\.)\s*.*") +D_PREFIX_FILE_P = re.compile(r"(?:\d+[-_])+([A-Za-z0-1-_]+)") + +# Randomly generated UUID. Helps e-readers match multiples of the same epub. +EPUB_ID = "AF8C59C9-7DBC-4D40-BDEA-2CE8B997C472" +BOOK_TITLE = "H-Group Conventions" +BOOK_AUTHOR = "H-Group Contributors" + +CONTENT_EXCLUSIONS = ["example-images"] + + +def main(): + # Update cover page with alt text. + with open(COVER_PATH, encoding="utf-8") as f: + cover_soup = BeautifulSoup(f, "lxml") + cover_soup = update_cover(cover_soup) + with open(COVER_PATH, "w", encoding="utf-8") as f: + f.write(cover_soup.prettify()) + + # Collect toc file infos. + with open(SIDEBAR_CONFIG_PATH, "r", encoding="utf-8") as f: + sidebar_conf_lines = f.readlines() + toc = parse_content_files(sidebar_conf_lines) + + # Construct map from mdx path to xhtml. + link_map = construct_link_map(toc) + + # Collect linked file infos. + linked_files = collect_linked_files(toc, link_map) + linked_files_link_map = construct_link_map(linked_files) + link_map = link_map | linked_files_link_map + + # Combine mdx and html files (for svg file) to xhtml for epub + def write_xhtml(file_info, xhtml_str): + with open(ROOT_PATH + file_info["xhtml"], "w", encoding="utf-8") as f: + f.write(xhtml_str) + + construct_epub(toc, link_map, write_xhtml) + construct_epub(linked_files, link_map, write_xhtml) + + # Remove unallowed SVG attributes. + example_pieces_paths = collect_examples(EXAMPLE_PIECES_DIR) + for path in example_pieces_paths: + if ".svg" != os.path.splitext(path)[1]: + continue + piece_path = "epub-src/OEBPS/" + path + with open(piece_path, "r", encoding="utf-8") as f: + soup = BeautifulSoup(f, "lxml-xml") + cleaned_svg_soup = replace_unallowed_svg_attrs(soup) + with open(piece_path, "w", encoding="utf-8") as f: + f.write(cleaned_svg_soup.prettify()) + + # Update content.opf with new files. + with open(CONTENT_PATH, encoding="utf-8") as f: + content_soup = BeautifulSoup(f, "xml") + unneeded_parts = [ + "colophon", + "dedication", + "preface", + "chapter-01", + "conclusion", + "notes", + ] + example_screenshot_paths = collect_examples(EXAMPLE_SCREENSHOTS_DIR) + examples_paths = example_pieces_paths + example_screenshot_paths + content_soup = update_content( + content_soup, toc, linked_files, examples_paths, unneeded_parts + ) + with open(CONTENT_PATH, "w", encoding="utf-8") as f: + f.write(content_soup.prettify()) + + # Update TOC.ncx + with open(TOC_NCX_PATH, encoding="utf-8") as f: + toc_ncx_soup = BeautifulSoup(f, "lxml-xml") + toc_ncx_soup = update_toc_ncx(toc_ncx_soup, toc) + with open(TOC_NCX_PATH, "w", encoding="utf-8") as f: + f.write(toc_ncx_soup.prettify()) + + # Update TOC.xhtml + with open(TOC_PATH, encoding="utf-8") as f: + toc_soup = BeautifulSoup(f, "lxml") + toc_soup = update_toc(toc_soup, toc) + with open(TOC_PATH, "w", encoding="utf-8") as f: + f.write(toc_soup.prettify()) + + +def update_cover(cover_soup): + cover_img = cover_soup.find("img") + if cover_img: + cover_img["alt"] = "H-Group Conventions Cover" + return cover_soup + + +def parse_content_files(sidebar_conf_lines): + lines = sidebar_conf_lines + for i in range(len(lines)): + line = lines[i].strip() + if "mainSidebar: [" == line: + lines = lines[i + 1 :] + break + for i in range(len(lines) - 1): + # -1 in range call to avoid crash if no match + prev_line = lines[len(lines) - 2 - i].strip() + line = lines[len(lines) - 1 - i].strip() + if "};" == line and "]," == prev_line: + lines = lines[: -2 - i] + break + sidebar_str = "[" + "".join(lines) + "]" + sidebar_obj = literal_eval(sidebar_str) + return [link_src_build(i) for i in sidebar_obj] + + +def link_src_build(sidebar_part): + if isinstance(sidebar_part, str): + return build_file_info(sidebar_part, True) + elif isinstance(sidebar_part, dict): + entry_count = len(sidebar_part.keys()) + if entry_count > 1: + raise Exception(f"Expected 1 key-value pair, found {entry_count}.") + for k, v in sidebar_part.items(): + folder = { + "type": "folder", + "name": k, + } + if isinstance(v, list): + folder["children"] = [link_src_build(i) for i in v] + else: + raise Exception( + f"Expected list as value. Got {type(v).__name__} instead." + ) + return folder + else: + raise Exception("Sidebar config list items may only be dict, or str.") + + +def collect_linked_files(toc_tree, link_map, files=[]): + for node in toc_tree: + if "file" == node["type"]: + with open(ROOT_PATH + node["mdx"], encoding="utf-8") as f: + mdx_str = f.read() + converted_html = Markdown(extensions=["extra"]).convert(mdx_str) + converted_soup = BeautifulSoup(converted_html, "html.parser") + for a_tag in converted_soup.find_all("a", href=True): + href = a_tag["href"] + if href.startswith("http://") or href.startswith("https://"): + continue + elif href.startswith("#"): + continue # TODO: Delete and impl anchor links + + # Fix "malformed" anchor links. + href = href.replace("/#", "#") + + # TODO: Delete next 3 lines and implement anchor links. + anchor_parts = href.split("#") + if len(anchor_parts) > 1 and "" != anchor_parts[0]: + href = anchor_parts[0] + + if href.endswith(".mdx"): + href = href[:-4] + + r_href = reset_ref_root(href, node["docs_path"]) + + if r_href not in link_map: + files.append(build_file_info(r_href, False)) + + elif "folder" == node["type"]: + collect_linked_files(node["children"], files) + return files + + +def build_file_info(docs_path, in_toc): + build_path = docs_path + path_atoms = build_path.split("/") + if not in_toc: + fixed = False + for i in range(len(path_atoms)): + atom = path_atoms[i] + match = re.fullmatch(D_PREFIX_FILE_P, atom) + if match: + fixed = True + path_atoms[i] = match.group(1) + if fixed: + build_path = "/".join(path_atoms) + + # Adding 'a' to file id, because epub requires manifest item ids to + # start with a letter. + file_id = "a" + str(uuid4()) + return { + "type": "file", + "docs_path": docs_path, + "mdx": f"docs/{docs_path}.mdx", + "html": f"build/{build_path}/index.html", + "xhtml": f"build/assets/epub/epub-src/OEBPS/parts/{file_id}.xhtml", + "id": file_id, + "in_toc": in_toc, + } + + +def construct_link_map(toc, link_map={}): + for item in toc: + if "file" == item["type"]: + link_map[item["docs_path"]] = f"{item['id']}.xhtml" + elif "folder" == item["type"]: + construct_link_map(item["children"], link_map) + return link_map + + +def construct_epub(toc_tree, link_map, write): + for entry in toc_tree: + if "file" == entry["type"]: + xhtml_content, page_title = construct_xhtml(entry, link_map) + entry["title"] = page_title + write(entry, xhtml_content) + elif "folder" == entry["type"]: + construct_epub(entry["children"], link_map, write) + + +def construct_xhtml(file_info, link_map): + with open(ROOT_PATH + file_info["mdx"], encoding="utf-8") as f: + mdx_lines = f.readlines() + + # Pulls page title out of mdx frontmatter. + page_title = extract_frontmatter_title(mdx_lines) + # Strips frontmatter and import statements from mdx lines. + mdx_lines = strip_non_md_start(mdx_lines) + + # Fix list indentation depth for md parser. + min_list_indent = -1 + for i in range(len(mdx_lines)): + line = mdx_lines[i] + if not re.match(LIST_ITEM_P, line): + continue + content_len = len(line.lstrip()) + strip_delta = len(line) - content_len + if -1 == min_list_indent and 0 != content_len and 0 != strip_delta: + # Assumes first list indent is min indent. + min_list_indent = strip_delta + if min_list_indent > 0 and min_list_indent < 4 and 0 < strip_delta: + leading_s = line[:strip_delta] + mdx_lines[i] = leading_s + line + + # Join to single string. + mdx_source = "".join(mdx_lines) + + # Create page soup. + with open(XHTML_TEMPLATE_PATH, encoding="utf-8") as f: + page_soup = BeautifulSoup(f, "lxml") + chapter_div = page_soup.find("div", class_="chapter") + # Insert page title + page_soup.title.string = page_title + h2_tag = page_soup.new_tag("h2") + h2_tag.string = page_title + chapter_div.append(h2_tag) + + if file_info["docs_path"] in CONTENT_EXCLUSIONS: + p = page_soup.new_tag("p") + p.append("This content is not available in the epub version due to ") + p.append("formatting complications. Please view the content at ") + a = page_soup.new_tag("a") + a["href"] = f'https://hanabi.github.io{file_info["docs_path"]}' + a.string = f'https://hanabi.github.io{file_info["docs_path"]}' + p.append(a) + p.append(".") + chapter_div.append(p) + return page_soup.prettify(), page_title + + # Get compiled example images. + with open(ROOT_PATH + file_info["html"], encoding="utf-8") as f: + html_soup = BeautifulSoup(f, "lxml") + svgs = html_soup.find_all( + "svg", {"xmlns": "http://www.w3.org/2000/svg", "class": "example"} + ) + + # Removes and inserts page-break between contents of . + page_break = '
' + for tabs_open_tag, tabs_content, tabs_close_tag in TABS_P.findall(mdx_source): + mdx_source = mdx_source.replace(tabs_open_tag, "", 1) + for tab_open_tag, tab_content, tab_close_tag in TAB_P.findall(tabs_content): + mdx_source = mdx_source.replace(tab_open_tag, "", 1) + mdx_source = mdx_source.replace(tab_close_tag, page_break, 1) + # Removes last page break, to avoid double page break. + mdx_source = "".join(mdx_source.rsplit(page_break, 1)) + mdx_source = mdx_source.replace(tabs_close_tag, "", 1) + # Removes BeginnersGuideProgress + mdx_source = re.sub(GUIDE_PROGRESS_P, "", mdx_source) + + # Prepare SVG replacement for html soup. + mdx_matches = SELF_CLOSE_MDX_P.findall(mdx_source) + if len(mdx_matches) > len(svgs): + raise Exception( + f"Unexpected MDX tag found. Have {len(svgs)} SVGs to" + + f" insert, but found {len(mdx_matches)} MDX tags, namely:" + + f" {mdx_matches}." + ) + else: + i = 0 + for mdx_tag in mdx_matches: + mdx_source = mdx_source.replace(mdx_tag, "[example-svg-placeholder]") + i += 1 + + # Convert to html. + converted_html = Markdown(extensions=["extra"]).convert(mdx_source) + # Use html.parser instead of lxml to avoid nested , and tags. + converted_soup = BeautifulSoup(converted_html, "html.parser") + + # Insert converted md into page soup. + chapter_div.append(converted_soup) + + # Fit image into page. + for svg in svgs: + svg["class"] = svg.get("class", []) + ["image--full-width"] + + # Remove unsupported SVG attrs. + for svg in svgs: + replace_unallowed_svg_attrs(svg) + + # Fix rel SVG links. + for svg in svgs: + for image in svg.find_all("image"): + image["xlink:href"] = ".." + image["xlink:href"] + invalid_tags = [ + tag + for tag in svg.find_all(id=True) + if not re.match(r"^[A-Za-z_][A-Za-z0-9._-]*$", tag["id"]) + ] + for tag in invalid_tags: + tag["id"] = "_" + tag["id"] + + # Insert example SVGs into soup. + svg_placeholders = chapter_div.find_all(string=SVG_PLACEHOLDER_P) + i = 0 + for p in svg_placeholders: + wrapper = p.find_parent() + wrapper.replace_with(svgs[i]) + i += 1 + + # Map doc links to xhtml file. + for a_tag in chapter_div.find_all("a", href=True): + # TODO: Delete following if and implement anchor links. + if "#" in a_tag["href"]: + a_tag.replace_with(a_tag.text) + continue + + href = a_tag["href"] + if href.startswith("https://") or href.startswith("http://"): + continue + + if href.endswith(".mdx"): + href = href[:-4] + + r_href = reset_ref_root(href, file_info["docs_path"]) + + if r_href in link_map: + a_tag["href"] = link_map[r_href] + + # Fix example image links. + for img_tag in chapter_div.find_all("img", src=True): + if img_tag["src"].startswith("/img/examples/"): + img_tag["src"] = ".." + img_tag["src"] + + # Prepares soup for writing. + page_str = page_soup.prettify() + + # Fixes svg issues. + # The fixing of the svgs must happen in the string. Otherwise it will be + # screwed up by bs4 when prettifying. + repl_list = [ + ["baseprofile", "baseProfile"], + ["viewbox", "viewBox"], + ["feflood", "feFlood"], + ["feoffset", "feOffset"], + ["femorphology", "feMorphology"], + ["femerge", "feMerge"], + ["fecomponenttransfer", "feComponentTransfer"], + ["fefunca", "feFuncA"], + ["fefuncb", "feFuncB"], + ["fefuncg", "feFuncG"], + ["fefuncr", "feFuncR"], + ["femergenode", "feMergeNode"], + ["feMergenode", "feMergeNode"], + ["lineargradient", "linearGradient"], + ["fecolormatrix", "feColorMatrix"], + ] + for repl in repl_list: + page_str = page_str.replace(repl[0], repl[1]) + + return page_str, page_title + + +def extract_frontmatter_title(lines): + fm_start, fm_end = find_frontmatter_delimiters(lines) + page_title = None + frontmatter_start_line = -1 + frontmatter_end_line = -1 + for line in lines[fm_start + 1 : fm_end]: + if line.strip().startswith("title: "): + return line.strip()[7:] + return None + + +def strip_non_md_start(lines): + fm_start, fm_end = find_frontmatter_delimiters(lines) + # Strips imports + for i in range(fm_end + 1, len(lines)): + line = lines[i].strip() + if "" != line and not line.startswith("import "): + lines = lines[i:] + break + return lines + + +def find_frontmatter_delimiters(lines): + start_line = -1 + end_line = -1 + for i in range(len(lines)): + if lines[i].startswith("---") and -1 == start_line: + start_line = i + elif lines[i].startswith("---") and -1 != start_line: + end_line = i + return start_line, end_line + + +def collect_examples(base_dir): + paths = [] + for root, _, files in os.walk(base_dir): + for file in files: + folder = root.split("/", 2)[2] + paths.append(f"{folder}/{file}") + return paths + + +def replace_unallowed_svg_attrs(soup): + rem_list = ["paint-order"] + for rem in rem_list: + for tag in soup.find_all(attrs={"paint-order": True}): + tag.attrs.pop(rem) + return soup + + +def reset_ref_root(ref, from_doc_path): + parent_path_parts = from_doc_path.split("/")[:-1] + + while True: + root_ref = None + if ref.startswith("https://") or ref.startswith("http://"): + return ref + elif ref.startswith("../"): + back_c = ref.count("../") + root_ref = "/".join(parent_path_parts[:-back_c] + [ref[back_c * 3 :]]) + elif ref.startswith("/"): + root_ref = ref[1:] + else: + root_ref = "/".join(parent_path_parts + [ref]) + + if os.path.isfile(ROOT_PATH + "docs/" + root_ref + ".mdx"): + return root_ref + elif ref.count("../") < len(parent_path_parts): + ref = "../" + ref + else: + raise Exception( + f'Found reference "{ref}" in file ' + + f'"{from_doc_path}", which doesn\'t exist.' + ) + + +def update_content(content_soup, toc, linked_files, examples_paths, unneeded_items): + content_soup.find("dc:identifier", {"id": "BookId"}).string = EPUB_ID + content_soup.find("dc:title").string = BOOK_TITLE + dc_creator = content_soup.find("dc:creator") + dc_creator.string = BOOK_AUTHOR + dc_creator["opf:file-as"] = BOOK_AUTHOR + + def rem_item(id_): + item = content_soup.find("item", {"id": id_}) + itemref = content_soup.find("itemref", {"idref": id_}) + reference = content_soup.find("reference", {"href": f"parts/{id_}.xhtml"}) + for tag in [item, itemref, reference]: + if None != tag: + tag.decompose() + + for i in unneeded_items: + rem_item(i) + + manifest = content_soup.find("manifest") + spine = content_soup.find("spine") + + def add_pages(toc): + for toc_item in toc: + if "file" == toc_item["type"]: + item = content_soup.new_tag("item") + item["id"] = toc_item["id"] + item["href"] = f'parts/{toc_item["id"]}.xhtml' + item["media-type"] = "application/xhtml+xml" + manifest.append(item) + + itemref = content_soup.new_tag("itemref") + itemref["idref"] = toc_item["id"] + if not toc_item["in_toc"]: + itemref["linear"] = "no" + spine.append(itemref) + elif "folder" == toc_item["type"]: + add_pages(toc_item["children"]) + + add_pages(toc) + add_pages(linked_files) + + media_type_map = { + "svg": "image/svg+xml", + "png": "image/png", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + } + for path in examples_paths: + item = content_soup.new_tag("item") + item["id"] = path.replace("/", "_") + item["href"] = path + file_t = path.rsplit(".", 1)[1].lower() + if file_t not in media_type_map: + raise Exception( + f"Found unexpected file type {file_t}. Expected" + + f" one of {media_type_map.keys()}." + ) + item["media-type"] = media_type_map[file_t] + manifest.append(item) + + guide = content_soup.find("guide") + content_start_reference = content_soup.new_tag("reference") + content_start_reference["type"] = "text" + content_start_reference["title"] = "Content" + content_start_reference["href"] = f'parts/{toc[0]["id"]}.xhtml' + guide.append(content_start_reference) + + return content_soup + + +def update_toc_ncx(soup, toc): + soup.find("meta", {"name": "dtb:uid"})["content"] = EPUB_ID + soup.find("docTitle").find("text").string = BOOK_TITLE + soup.find("docAuthor").find("text").string = BOOK_AUTHOR + + navmap = soup.find("navMap") + navmap.append(create_ncx_navpoint(1, "Cover", "parts/cover.xhtml")) + navpoint_tree, _, depth = construct_navpoint_tree(toc, play_order_start=2) + navmap.append(navpoint_tree) + + soup.find("meta", {"name": "dtb:depth"})["content"] = str(depth) + + return soup + + +def construct_navpoint_tree(toc, play_order_start=1, depth=1): + play_order = play_order_start + soup = BeautifulSoup("", "lxml-xml") + for item in toc: + if "file" == item["type"]: + soup.append( + create_ncx_navpoint( + play_order, item["title"], f'parts/{item["id"]}.xhtml' + ) + ) + play_order += 1 + elif "folder" == item["type"]: + before_play_order = play_order + children_soup, play_order, depth = construct_navpoint_tree( + item["children"], play_order, 1 + depth + ) + first_content_src = children_soup.find("content")["src"] + navpoint = create_ncx_navpoint( + before_play_order, item["name"], first_content_src, True + ) + navpoint.append(children_soup) + soup.append(navpoint) + return soup, play_order, depth + + +def create_ncx_navpoint(play_order, label, content_src, is_parent=False): + soup = BeautifulSoup("", "lxml-xml") + + xmlified_id = content_src.replace("/", "_") + if is_parent: + xmlified_id = "p" + xmlified_id + navpoint = soup.new_tag("navPoint", id=xmlified_id, playOrder=str(play_order)) + + navlabel = soup.new_tag("navLabel") + text = soup.new_tag("text") + text.string = label + navlabel.append(text) + + content = soup.new_tag("content", src=content_src) + + navpoint.append(navlabel) + navpoint.append(content) + return navpoint + + +def update_toc(soup, toc): + body = soup.find("body") + body.append(construct_toc_tree(toc)) + return soup + + +def construct_toc_tree(toc): + soup = BeautifulSoup("", "lxml-xml") + ul = soup.new_tag("ul", attrs={"class": "toc"}) + for item in toc: + if "file" == item["type"]: + ul.append(create_toc_li(item["title"], f'{item["id"]}.xhtml')) + elif "folder" == item["type"]: + group_li = soup.new_tag("li") + group_li.string = item["name"] + group_li.append(construct_toc_tree(item["children"])) + ul.append(group_li) + return ul + + +def create_toc_li(label, href): + soup = BeautifulSoup("", "lxml") + + li = soup.new_tag("li") + a = soup.new_tag("a", href=href) + a.string = label + li.append(a) + + return li + + +if __name__ == "__main__": + main() diff --git a/scripts/epub.sh b/scripts/epub.sh new file mode 100755 index 0000000000..e175ae8e9c --- /dev/null +++ b/scripts/epub.sh @@ -0,0 +1,29 @@ +#! /bin/bash + +set -euo pipefail # Exit on errors and undefined variables. + +# Get the directory of this script: +# https://stackoverflow.com/questions/59895/getting-the-source-directory-of-a-bash-script-from-within +DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &> /dev/null && pwd) + +REPO_ROOT=$(realpath "$DIR/..") +EPUB_DIR="$REPO_ROOT/build/assets/epub" + +mkdir -p "$EPUB_DIR" +cp -r static/epub/epub-template/ "$EPUB_DIR/epub-src/" +cp static/img/cover.png "$EPUB_DIR/epub-src/OEBPS/img" +cp -r static/img/pieces "$EPUB_DIR/epub-src/OEBPS/img" +cp -r static/img/examples "$EPUB_DIR/epub-src/OEBPS/img" +cd "$EPUB_DIR" + +python ../../../scripts/epub.py + +FILE_NAME="hgroup-conventions.epub" +EPUB_OUT="epub-src/out" +mkdir -p "$EPUB_OUT" +cd epub-src +zip -X0 "../$EPUB_OUT/$FILE_NAME" mimetype +zip -9 -r "../$EPUB_OUT/$FILE_NAME" META-INF/ OEBPS/ -x '*.DS_Store' +cd .. +mv "$EPUB_OUT/$FILE_NAME" "$FILE_NAME" +rm -rf epub-src diff --git a/scripts/requirements.txt b/scripts/requirements.txt new file mode 100644 index 0000000000..9e37ef472f --- /dev/null +++ b/scripts/requirements.txt @@ -0,0 +1,4 @@ +beautifulsoup4==4.14.2 +lxml==6.0.2 +markdown==3.9.0 + diff --git a/static/epub/epub-template/META-INF/container.xml b/static/epub/epub-template/META-INF/container.xml new file mode 100644 index 0000000000..54bfc8da5a --- /dev/null +++ b/static/epub/epub-template/META-INF/container.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/static/epub/epub-template/OEBPS/content.opf b/static/epub/epub-template/OEBPS/content.opf new file mode 100644 index 0000000000..feacddeaac --- /dev/null +++ b/static/epub/epub-template/OEBPS/content.opf @@ -0,0 +1,52 @@ + + + + xxx content.opf The Title + xxx content.opf dc:creator + B581A839-681B-4263-AFB5-BB85F1E58147 + en + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/epub/epub-template/OEBPS/fonts/LibreBaskerville-Bold.otf b/static/epub/epub-template/OEBPS/fonts/LibreBaskerville-Bold.otf new file mode 100644 index 0000000000..fd3778ffcb Binary files /dev/null and b/static/epub/epub-template/OEBPS/fonts/LibreBaskerville-Bold.otf differ diff --git a/static/epub/epub-template/OEBPS/fonts/LibreBaskerville-BoldItalic.otf b/static/epub/epub-template/OEBPS/fonts/LibreBaskerville-BoldItalic.otf new file mode 100644 index 0000000000..ebc2aaa914 Binary files /dev/null and b/static/epub/epub-template/OEBPS/fonts/LibreBaskerville-BoldItalic.otf differ diff --git a/static/epub/epub-template/OEBPS/fonts/LibreBaskerville-Italic.otf b/static/epub/epub-template/OEBPS/fonts/LibreBaskerville-Italic.otf new file mode 100644 index 0000000000..d80372695d Binary files /dev/null and b/static/epub/epub-template/OEBPS/fonts/LibreBaskerville-Italic.otf differ diff --git a/static/epub/epub-template/OEBPS/fonts/LibreBaskerville-Regular.otf b/static/epub/epub-template/OEBPS/fonts/LibreBaskerville-Regular.otf new file mode 100644 index 0000000000..a1af97e218 Binary files /dev/null and b/static/epub/epub-template/OEBPS/fonts/LibreBaskerville-Regular.otf differ diff --git a/static/epub/epub-template/OEBPS/img/.gitkeep b/static/epub/epub-template/OEBPS/img/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/static/epub/epub-template/OEBPS/parts/cover.xhtml b/static/epub/epub-template/OEBPS/parts/cover.xhtml new file mode 100644 index 0000000000..a3da0dabe4 --- /dev/null +++ b/static/epub/epub-template/OEBPS/parts/cover.xhtml @@ -0,0 +1,18 @@ + + + + + + Cover + + + +
+ xxx parts/cover.xhtml Cover image +
+ + diff --git a/static/epub/epub-template/OEBPS/parts/toc.xhtml b/static/epub/epub-template/OEBPS/parts/toc.xhtml new file mode 100644 index 0000000000..e990150c8d --- /dev/null +++ b/static/epub/epub-template/OEBPS/parts/toc.xhtml @@ -0,0 +1,10 @@ + + + Table of Contents + + + + +

Table of Contents

+ + diff --git a/static/epub/epub-template/OEBPS/styles/style.css b/static/epub/epub-template/OEBPS/styles/style.css new file mode 100644 index 0000000000..675418169f --- /dev/null +++ b/static/epub/epub-template/OEBPS/styles/style.css @@ -0,0 +1,120 @@ +@font-face { + font-family: "Libre Baskerville"; + font-style: normal; + font-weight: 400; + src: url("../fonts/LibreBaskerville-Regular.otf"); +} +@font-face { + font-family: "Libre Baskerville"; + font-style: italic; + font-weight: 400; + src: url("../fonts/LibreBaskerville-Italic.otf"); +} +@font-face { + font-family: "Libre Baskerville"; + font-style: normal; + font-weight: 700; + src: url("../fonts/LibreBaskerville-Bold.otf"); +} +@font-face { + font-family: "Libre Baskerville"; + font-style: italic; + font-weight: 700; + src: url("../fonts/LibreBaskerville-BoldItalic.otf"); +} + +@page { + margin: 5pt; +} + +html body { + margin: 0; + font-family: "Libre Baskerville", serif; +} + +.chapter { + margin-top: 1em; +} +.chapter__head { + width: 100%; + page-break-inside: avoid; /* Deprecated */ + break-inside: avoid; +} +h1 + p, +.chapter__head + p { + text-indent: 0; +} + +.text { + font-family: "Libre Baskerville", serif; + font-weight: 400; + font-style: normal; + font-size: 1em; + text-decoration: none; + font-variant: normal; + line-height: 1.2; + text-align: justify; + color: #000000; + text-indent: 1.5em; + margin: 0px; +} +.text--noindent { + text-indent: 0; +} +.text--em { + font-style: italic; +} +.text--bold { + font-weight: 700; +} +.text--small-caps { + font-variant: small-caps; +} +.text--small { + font-size: 0.75em; +} + +.quote { + /* Best used with
*/ + margin: 1em 1.5em; +} +.quote__title { + text-align: center; + margin: 0.5em 0; +} + +.illustration { + text-align: center; + margin: 1em 0; + clear: both; + page-break-inside: avoid; /* Deprecated */ + break-inside: avoid; +} + +a.note { + vertical-align: super; + font-size: 0.7em; +} + +.toc { + list-style-type: none; + padding: 0; +} + +.image--full-width { + /* Makes image as wide as possible. */ + width: 100%; + height: auto; + display: block; +} + +.image--cover { + /* Makes image as large as possible while note exceeding either width or height. */ + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + object-fit: contain; + display: block; + margin: auto; +} diff --git a/static/epub/epub-template/OEBPS/toc.ncx b/static/epub/epub-template/OEBPS/toc.ncx new file mode 100644 index 0000000000..ff2d0d5bc5 --- /dev/null +++ b/static/epub/epub-template/OEBPS/toc.ncx @@ -0,0 +1,18 @@ + + + + + + + + + + xxx toc.ncx docTitle + + + xxx toc.ncx docAuthor + + + + diff --git a/static/epub/epub-template/mimetype b/static/epub/epub-template/mimetype new file mode 100644 index 0000000000..57ef03f24a --- /dev/null +++ b/static/epub/epub-template/mimetype @@ -0,0 +1 @@ +application/epub+zip \ No newline at end of file diff --git a/static/epub/page-template.xhtml b/static/epub/page-template.xhtml new file mode 100644 index 0000000000..06face635f --- /dev/null +++ b/static/epub/page-template.xhtml @@ -0,0 +1,16 @@ + + + + + [page-title] + + + +
+ + diff --git a/static/img/cover.png b/static/img/cover.png new file mode 100644 index 0000000000..61d474d808 Binary files /dev/null and b/static/img/cover.png differ