Initial commit
| @@ -11,9 +11,11 @@ WORKDIR /app | |||
| 11 | COPY requirements.txt /app/ | 11 | COPY requirements.txt /app/ |
| 12 | RUN pip install --no-cache-dir -r requirements.txt | 12 | RUN pip install --no-cache-dir -r requirements.txt |
| 13 | 13 | ||
| 14 | COPY main.py epub_builder.py /app/ | 14 | COPY main.py epub_builder.py generate_reference_doc.py /app/ |
| 15 | COPY center-v.lua /app/ | 15 | COPY center-v.lua /app/ |
| 16 | COPY pagebreak.lua /app/ | ||
| 16 | COPY epub-style.css /app/ | 17 | COPY epub-style.css /app/ |
| 18 | RUN python generate_reference_doc.py | ||
| 17 | 19 | ||
| 18 | # /vault - mount your markdown source folder here (read-write for flag reset) | 20 | # /vault - mount your markdown source folder here (read-write for flag reset) |
| 19 | # /output - mount your EPUB destination folder here | 21 | # /output - mount your EPUB destination folder here |
| @@ -6,7 +6,7 @@ This container watches a mounted markdown share for story folders and generates | |||
| 6 | 6 | ||
| 7 | 1. The container scans the mounted vault under `/vault/<CREATIVE_FOLDER>`. | 7 | 1. The container scans the mounted vault under `/vault/<CREATIVE_FOLDER>`. |
| 8 | 2. Each story folder is identified by a `metadata.md` file with YAML frontmatter. | 8 | 2. Each story folder is identified by a `metadata.md` file with YAML frontmatter. |
| 9 | 3. If that frontmatter contains `export: true`, the container merges the sibling chapter `.md` files in that folder and exports a DOCX with `pandoc`. | 9 | 3. If that frontmatter contains `export: true`, the container merges the sibling chapter `.md` files in that folder and exports a DOCX with `pandoc`, using a custom reference document so headings are black and the document uses Times New Roman instead of the default Office theme. |
| 10 | 4. That DOCX is then converted into EPUB and MOBI with Calibre's `ebook-convert`. | 10 | 4. That DOCX is then converted into EPUB and MOBI with Calibre's `ebook-convert`. |
| 11 | 5. The finished files are written to `/output`, with `.docx` in the root plus `/output/epub` and `/output/mobi` subfolders for the other formats. All generated files are set to mode `0644` so they are readable by all users. | 11 | 5. The finished files are written to `/output`, with `.docx` in the root plus `/output/epub` and `/output/mobi` subfolders for the other formats. All generated files are set to mode `0644` so they are readable by all users. |
| 12 | 6. The container resets `export: false` in the story's `metadata.md`. | 12 | 6. The container resets `export: false` in the story's `metadata.md`. |
| @@ -11,6 +11,7 @@ import os | |||
| 11 | import re | 11 | import re |
| 12 | import subprocess | 12 | import subprocess |
| 13 | import tempfile | 13 | import tempfile |
| 14 | import unicodedata | ||
| 14 | from pathlib import Path | 15 | from pathlib import Path |
| 15 | from typing import Optional | 16 | from typing import Optional |
| 16 | 17 | ||
| @@ -19,9 +20,12 @@ import yaml | |||
| 19 | logger = logging.getLogger(__name__) | 20 | logger = logging.getLogger(__name__) |
| 20 | 21 | ||
| 21 | METADATA_FILENAME = "metadata.md" | 22 | METADATA_FILENAME = "metadata.md" |
| 22 | DEFAULT_OUTPUT_FILE_MODE = 0o644 | 23 | DEFAULT_OUTPUT_FILE_MODE = 0o664 |
| 23 | EPUB_SUBDIR = "epub" | 24 | EPUB_SUBDIR = "epub" |
| 24 | MOBI_SUBDIR = "mobi" | 25 | MOBI_SUBDIR = "mobi" |
| 26 | REFERENCE_DOC_PATH = Path("/app/reference.docx") | ||
| 27 | DEFAULT_OUTPUT_UID = 99 | ||
| 28 | DEFAULT_OUTPUT_GID = 100 | ||
| 25 | 29 | ||
| 26 | 30 | ||
| 27 | def parse_frontmatter(text: str) -> dict: | 31 | def parse_frontmatter(text: str) -> dict: |
| @@ -128,13 +132,23 @@ def merge_chapters(chapters: list[Path], add_page_breaks: bool = True) -> str: | |||
| 128 | continue | 132 | continue |
| 129 | 133 | ||
| 130 | if i > 0 and add_page_breaks: | 134 | if i > 0 and add_page_breaks: |
| 131 | merged_parts.append('\n\n<div style="page-break-before: always;"></div>\n\n') | 135 | # Use Pandoc's explicit page break marker so DOCX gets a real break. |
| 136 | merged_parts.append("\n\n\\newpage\n\n") | ||
| 132 | 137 | ||
| 133 | merged_parts.append(content) | 138 | merged_parts.append(content) |
| 134 | 139 | ||
| 135 | return "\n\n".join(merged_parts) | 140 | return "\n\n".join(merged_parts) |
| 136 | 141 | ||
| 137 | 142 | ||
| 143 | def normalize_text_for_docx(text: str) -> str: | ||
| 144 | """Normalize markdown text before DOCX export. | ||
| 145 | |||
| 146 | NFC is a conservative choice that can reduce weirdness around combining | ||
| 147 | characters without stripping the visual effect from decorated text. | ||
| 148 | """ | ||
| 149 | return unicodedata.normalize("NFC", text) | ||
| 150 | |||
| 151 | |||
| 138 | def sanitize_title(title: str) -> str: | 152 | def sanitize_title(title: str) -> str: |
| 139 | """Convert a story title into a safe base filename.""" | 153 | """Convert a story title into a safe base filename.""" |
| 140 | return re.sub(r"[^\w\s-]", "", title).strip() | 154 | return re.sub(r"[^\w\s-]", "", title).strip() |
| @@ -166,6 +180,12 @@ def set_output_permissions(*paths: Path): | |||
| 166 | os.chmod(path, DEFAULT_OUTPUT_FILE_MODE) | 180 | os.chmod(path, DEFAULT_OUTPUT_FILE_MODE) |
| 167 | 181 | ||
| 168 | 182 | ||
| 183 | def set_output_ownership(*paths: Path): | ||
| 184 | """Set predictable ownership on generated output paths.""" | ||
| 185 | for path in paths: | ||
| 186 | os.chown(path, DEFAULT_OUTPUT_UID, DEFAULT_OUTPUT_GID) | ||
| 187 | |||
| 188 | |||
| 169 | def run_command(cmd: list[str], title: str, output_label: str) -> bool: | 189 | def run_command(cmd: list[str], title: str, output_label: str) -> bool: |
| 170 | """Run an external command and log any failure details.""" | 190 | """Run an external command and log any failure details.""" |
| 171 | logger.debug(f"Running: {' '.join(cmd)}") | 191 | logger.debug(f"Running: {' '.join(cmd)}") |
| @@ -191,6 +211,7 @@ def build_outputs( | |||
| 191 | story: dict, | 211 | story: dict, |
| 192 | output_dir: Path, | 212 | output_dir: Path, |
| 193 | lua_filter: Optional[Path] = None, | 213 | lua_filter: Optional[Path] = None, |
| 214 | pagebreak_filter: Optional[Path] = None, | ||
| 194 | css_file: Optional[Path] = None, | 215 | css_file: Optional[Path] = None, |
| 195 | ) -> Optional[dict[str, Path]]: | 216 | ) -> Optional[dict[str, Path]]: |
| 196 | """Build DOCX, EPUB, and MOBI outputs for a story.""" | 217 | """Build DOCX, EPUB, and MOBI outputs for a story.""" |
| @@ -208,6 +229,7 @@ def build_outputs( | |||
| 208 | output_dir.mkdir(parents=True, exist_ok=True) | 229 | output_dir.mkdir(parents=True, exist_ok=True) |
| 209 | epub_dir.mkdir(parents=True, exist_ok=True) | 230 | epub_dir.mkdir(parents=True, exist_ok=True) |
| 210 | mobi_dir.mkdir(parents=True, exist_ok=True) | 231 | mobi_dir.mkdir(parents=True, exist_ok=True) |
| 232 | set_output_ownership(output_dir, epub_dir, mobi_dir) | ||
| 211 | 233 | ||
| 212 | if not story["chapters"]: | 234 | if not story["chapters"]: |
| 213 | logger.warning(f"No chapters found for '{title}', skipping") | 235 | logger.warning(f"No chapters found for '{title}', skipping") |
| @@ -215,7 +237,7 @@ def build_outputs( | |||
| 215 | 237 | ||
| 216 | logger.info(f"Building book files: '{title}' ({len(story['chapters'])} chapters)") | 238 | logger.info(f"Building book files: '{title}' ({len(story['chapters'])} chapters)") |
| 217 | 239 | ||
| 218 | merged_content = merge_chapters(story["chapters"]) | 240 | merged_content = normalize_text_for_docx(merge_chapters(story["chapters"])) |
| 219 | cover_path = get_cover_path(story) | 241 | cover_path = get_cover_path(story) |
| 220 | 242 | ||
| 221 | with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False, encoding="utf-8") as tmp: | 243 | with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False, encoding="utf-8") as tmp: |
| @@ -229,7 +251,7 @@ def build_outputs( | |||
| 229 | "-o", | 251 | "-o", |
| 230 | str(docx_file), | 252 | str(docx_file), |
| 231 | "--from", | 253 | "--from", |
| 232 | "markdown+raw_html", | 254 | "markdown+raw_html+raw_tex", |
| 233 | "--to", | 255 | "--to", |
| 234 | "docx", | 256 | "docx", |
| 235 | "--metadata", | 257 | "--metadata", |
| @@ -238,13 +260,16 @@ def build_outputs( | |||
| 238 | f"author={author}", | 260 | f"author={author}", |
| 239 | "--metadata", | 261 | "--metadata", |
| 240 | f"lang={language}", | 262 | f"lang={language}", |
| 241 | "--toc", | ||
| 242 | "--toc-depth=1", | ||
| 243 | "--wrap=none", | 263 | "--wrap=none", |
| 244 | ] | 264 | ] |
| 245 | 265 | ||
| 266 | if REFERENCE_DOC_PATH.exists(): | ||
| 267 | docx_cmd.extend(["--reference-doc", str(REFERENCE_DOC_PATH)]) | ||
| 268 | |||
| 246 | if lua_filter and lua_filter.exists(): | 269 | if lua_filter and lua_filter.exists(): |
| 247 | docx_cmd.extend(["--lua-filter", str(lua_filter)]) | 270 | docx_cmd.extend(["--lua-filter", str(lua_filter)]) |
| 271 | if pagebreak_filter and pagebreak_filter.exists(): | ||
| 272 | docx_cmd.extend(["--lua-filter", str(pagebreak_filter)]) | ||
| 248 | 273 | ||
| 249 | if not run_command(docx_cmd, title, "DOCX"): | 274 | if not run_command(docx_cmd, title, "DOCX"): |
| 250 | return None | 275 | return None |
| @@ -288,6 +313,7 @@ def build_outputs( | |||
| 288 | logger.info(f"MOBI created: {mobi_file}") | 313 | logger.info(f"MOBI created: {mobi_file}") |
| 289 | 314 | ||
| 290 | set_output_permissions(docx_file, epub_file, mobi_file) | 315 | set_output_permissions(docx_file, epub_file, mobi_file) |
| 316 | set_output_ownership(docx_file, epub_file, mobi_file) | ||
| 291 | return {"docx": docx_file, "epub": epub_file, "mobi": mobi_file} | 317 | return {"docx": docx_file, "epub": epub_file, "mobi": mobi_file} |
| 292 | 318 | ||
| 293 | except subprocess.TimeoutExpired: | 319 | except subprocess.TimeoutExpired: |
| @@ -0,0 +1,55 @@ | |||
| 1 | from docx import Document | ||
| 2 | from docx.enum.style import WD_STYLE_TYPE | ||
| 3 | from docx.oxml.ns import qn | ||
| 4 | from docx.shared import Pt, RGBColor | ||
| 5 | |||
| 6 | |||
| 7 | def set_font(font, name: str, size_pt: int, *, bold: bool = False): | ||
| 8 | font.name = name | ||
| 9 | font.size = Pt(size_pt) | ||
| 10 | font.bold = bold | ||
| 11 | font.color.rgb = RGBColor(0x00, 0x00, 0x00) | ||
| 12 | |||
| 13 | |||
| 14 | def set_run_fonts(style, name: str): | ||
| 15 | r_pr = style.element.get_or_add_rPr() | ||
| 16 | r_fonts = r_pr.get_or_add_rFonts() | ||
| 17 | r_fonts.set(qn("w:ascii"), name) | ||
| 18 | r_fonts.set(qn("w:hAnsi"), name) | ||
| 19 | r_fonts.set(qn("w:eastAsia"), name) | ||
| 20 | r_fonts.set(qn("w:cs"), name) | ||
| 21 | |||
| 22 | |||
| 23 | def style_paragraph(style, name: str, size_pt: int, *, bold: bool = False, space_before: int = 0, space_after: int = 0): | ||
| 24 | set_font(style.font, name, size_pt, bold=bold) | ||
| 25 | set_run_fonts(style, name) | ||
| 26 | fmt = style.paragraph_format | ||
| 27 | fmt.space_before = Pt(space_before) | ||
| 28 | fmt.space_after = Pt(space_after) | ||
| 29 | |||
| 30 | |||
| 31 | doc = Document() | ||
| 32 | styles = doc.styles | ||
| 33 | |||
| 34 | normal = styles["Normal"] | ||
| 35 | style_paragraph(normal, "Times New Roman", 12) | ||
| 36 | |||
| 37 | title = styles["Title"] | ||
| 38 | style_paragraph(title, "Times New Roman", 22, bold=True, space_after=12) | ||
| 39 | |||
| 40 | heading1 = styles["Heading 1"] | ||
| 41 | style_paragraph(heading1, "Times New Roman", 18, bold=True, space_before=18, space_after=10) | ||
| 42 | |||
| 43 | heading2 = styles["Heading 2"] | ||
| 44 | style_paragraph(heading2, "Times New Roman", 16, bold=True, space_before=14, space_after=8) | ||
| 45 | |||
| 46 | heading3 = styles["Heading 3"] | ||
| 47 | style_paragraph(heading3, "Times New Roman", 14, bold=True, space_before=12, space_after=6) | ||
| 48 | |||
| 49 | if "First Paragraph" not in [style.name for style in styles]: | ||
| 50 | styles.add_style("First Paragraph", WD_STYLE_TYPE.PARAGRAPH) | ||
| 51 | |||
| 52 | first_paragraph = styles["First Paragraph"] | ||
| 53 | style_paragraph(first_paragraph, "Times New Roman", 12) | ||
| 54 | |||
| 55 | doc.save("reference.docx") | ||
| @@ -13,7 +13,7 @@ import sys | |||
| 13 | import time | 13 | import time |
| 14 | from pathlib import Path | 14 | from pathlib import Path |
| 15 | 15 | ||
| 16 | from epub_builder import build_outputs, discover_stories, write_frontmatter | 16 | from epub_builder import build_outputs, discover_stories, set_output_ownership, write_frontmatter |
| 17 | 17 | ||
| 18 | # ── Configuration from environment variables ────────────────────────── | 18 | # ── Configuration from environment variables ────────────────────────── |
| 19 | 19 | ||
| @@ -22,6 +22,7 @@ CREATIVE_FOLDER = os.environ.get("CREATIVE_FOLDER", "Creative") | |||
| 22 | OUTPUT_DIR = os.environ.get("OUTPUT_DIR", "/output") | 22 | OUTPUT_DIR = os.environ.get("OUTPUT_DIR", "/output") |
| 23 | 23 | ||
| 24 | LUA_FILTER_PATH = os.environ.get("LUA_FILTER_PATH", "/app/center-v.lua") | 24 | LUA_FILTER_PATH = os.environ.get("LUA_FILTER_PATH", "/app/center-v.lua") |
| 25 | PAGEBREAK_FILTER_PATH = os.environ.get("PAGEBREAK_FILTER_PATH", "/app/pagebreak.lua") | ||
| 25 | CSS_FILE_PATH = os.environ.get("CSS_FILE_PATH", "/app/epub-style.css") | 26 | CSS_FILE_PATH = os.environ.get("CSS_FILE_PATH", "/app/epub-style.css") |
| 26 | 27 | ||
| 27 | # How often to scan for export flags (seconds) | 28 | # How often to scan for export flags (seconds) |
| @@ -67,6 +68,7 @@ def process_exports(vault_path: Path): | |||
| 67 | 68 | ||
| 68 | output_path = Path(OUTPUT_DIR) | 69 | output_path = Path(OUTPUT_DIR) |
| 69 | lua_filter = Path(LUA_FILTER_PATH) if LUA_FILTER_PATH else None | 70 | lua_filter = Path(LUA_FILTER_PATH) if LUA_FILTER_PATH else None |
| 71 | pagebreak_filter = Path(PAGEBREAK_FILTER_PATH) if PAGEBREAK_FILTER_PATH else None | ||
| 70 | css_file = Path(CSS_FILE_PATH) if CSS_FILE_PATH else None | 72 | css_file = Path(CSS_FILE_PATH) if CSS_FILE_PATH else None |
| 71 | 73 | ||
| 72 | for story in to_export: | 74 | for story in to_export: |
| @@ -77,6 +79,7 @@ def process_exports(vault_path: Path): | |||
| 77 | story=story, | 79 | story=story, |
| 78 | output_dir=output_path, | 80 | output_dir=output_path, |
| 79 | lua_filter=lua_filter, | 81 | lua_filter=lua_filter, |
| 82 | pagebreak_filter=pagebreak_filter, | ||
| 80 | css_file=css_file if css_file and css_file.exists() else None, | 83 | css_file=css_file if css_file and css_file.exists() else None, |
| 81 | ) | 84 | ) |
| 82 | 85 | ||
| @@ -98,7 +101,9 @@ def main(): | |||
| 98 | logger.info(f"Poll interval: {POLL_INTERVAL}s") | 101 | logger.info(f"Poll interval: {POLL_INTERVAL}s") |
| 99 | 102 | ||
| 100 | vault_path = Path(VAULT_PATH) | 103 | vault_path = Path(VAULT_PATH) |
| 101 | Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True) | 104 | output_path = Path(OUTPUT_DIR) |
| 105 | output_path.mkdir(parents=True, exist_ok=True) | ||
| 106 | set_output_ownership(output_path) | ||
| 102 | 107 | ||
| 103 | if not vault_path.exists(): | 108 | if not vault_path.exists(): |
| 104 | logger.error(f"Vault path does not exist: {vault_path}") | 109 | logger.error(f"Vault path does not exist: {vault_path}") |
| @@ -0,0 +1,39 @@ | |||
| 1 | -- pagebreak.lua | ||
| 2 | -- Converts explicit page break markers into format-specific page breaks. | ||
| 3 | |||
| 4 | local DOCX_PAGEBREAK = [[ | ||
| 5 | <w:p> | ||
| 6 | <w:r> | ||
| 7 | <w:br w:type="page"/> | ||
| 8 | </w:r> | ||
| 9 | </w:p> | ||
| 10 | ]] | ||
| 11 | |||
| 12 | local HTML_PAGEBREAK = '<div style="page-break-before: always;"></div>' | ||
| 13 | |||
| 14 | local function is_pagebreak(el) | ||
| 15 | return ( | ||
| 16 | (el.t == "RawBlock" and el.format == "tex" and (el.text == "\\newpage" or el.text == "\\pagebreak")) or | ||
| 17 | (el.t == "Para" and #el.content == 1 and el.content[1].t == "Str" and el.content[1].text == "\f") | ||
| 18 | ) | ||
| 19 | end | ||
| 20 | |||
| 21 | function RawBlock(el) | ||
| 22 | if el.format == "tex" and (el.text == "\\newpage" or el.text == "\\pagebreak") then | ||
| 23 | if FORMAT == "docx" or FORMAT == "openxml" then | ||
| 24 | return pandoc.RawBlock("openxml", DOCX_PAGEBREAK) | ||
| 25 | elseif FORMAT == "epub" or FORMAT == "epub3" or FORMAT == "html" or FORMAT == "html5" then | ||
| 26 | return pandoc.RawBlock("html", HTML_PAGEBREAK) | ||
| 27 | end | ||
| 28 | end | ||
| 29 | end | ||
| 30 | |||
| 31 | function Para(el) | ||
| 32 | if is_pagebreak(el) then | ||
| 33 | if FORMAT == "docx" or FORMAT == "openxml" then | ||
| 34 | return pandoc.RawBlock("openxml", DOCX_PAGEBREAK) | ||
| 35 | elseif FORMAT == "epub" or FORMAT == "epub3" or FORMAT == "html" or FORMAT == "html5" then | ||
| 36 | return pandoc.RawBlock("html", HTML_PAGEBREAK) | ||
| 37 | end | ||
| 38 | end | ||
| 39 | end | ||
Binary file
| @@ -1 +1,2 @@ | |||
| 1 | PyYAML>=6.0 | 1 | PyYAML>=6.0 |
| 2 | python-docx>=1.1.2 | ||