Initial commit
| @@ -11,9 +11,11 @@ WORKDIR /app | ||
| 11 | 11 | COPY requirements.txt /app/ |
| 12 | 12 | RUN pip install --no-cache-dir -r requirements.txt |
| 13 | 13 | |
| 14 | 14 | COPY main.py epub_builder.py generate_reference_doc.py /app/ |
| 15 | 15 | COPY center-v.lua /app/ |
| 16 | +COPY pagebreak.lua /app/ | |
| 16 | 17 | COPY epub-style.css /app/ |
| 18 | +RUN python generate_reference_doc.py | |
| 17 | 19 | |
| 18 | 20 | # /vault - mount your markdown source folder here (read-write for flag reset) |
| 19 | 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 | 7 | 1. The container scans the mounted vault under `/vault/<CREATIVE_FOLDER>`. |
| 8 | 8 | 2. Each story folder is identified by a `metadata.md` file with YAML frontmatter. |
| 9 | 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 | 10 | 4. That DOCX is then converted into EPUB and MOBI with Calibre's `ebook-convert`. |
| 11 | 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 | 12 | 6. The container resets `export: false` in the story's `metadata.md`. |
| @@ -11,6 +11,7 @@ import os | ||
| 11 | 11 | import re |
| 12 | 12 | import subprocess |
| 13 | 13 | import tempfile |
| 14 | +import unicodedata | |
| 14 | 15 | from pathlib import Path |
| 15 | 16 | from typing import Optional |
| 16 | 17 | |
| @@ -19,9 +20,12 @@ import yaml | ||
| 19 | 20 | logger = logging.getLogger(__name__) |
| 20 | 21 | |
| 21 | 22 | METADATA_FILENAME = "metadata.md" |
| 22 | 23 | DEFAULT_OUTPUT_FILE_MODE = 0o6440o664 |
| 23 | 24 | EPUB_SUBDIR = "epub" |
| 24 | 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 | 31 | def parse_frontmatter(text: str) -> dict: |
| @@ -128,13 +132,23 @@ def merge_chapters(chapters: list[Path], add_page_breaks: bool = True) -> str: | ||
| 128 | 132 | continue |
| 129 | 133 | |
| 130 | 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 | 138 | merged_parts.append(content) |
| 134 | 139 | |
| 135 | 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 | 152 | def sanitize_title(title: str) -> str: |
| 139 | 153 | """Convert a story title into a safe base filename.""" |
| 140 | 154 | return re.sub(r"[^\w\s-]", "", title).strip() |
| @@ -166,6 +180,12 @@ def set_output_permissions(*paths: Path): | ||
| 166 | 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 | 189 | def run_command(cmd: list[str], title: str, output_label: str) -> bool: |
| 170 | 190 | """Run an external command and log any failure details.""" |
| 171 | 191 | logger.debug(f"Running: {' '.join(cmd)}") |
| @@ -191,6 +211,7 @@ def build_outputs( | ||
| 191 | 211 | story: dict, |
| 192 | 212 | output_dir: Path, |
| 193 | 213 | lua_filter: Optional[Path] = None, |
| 214 | + pagebreak_filter: Optional[Path] = None, | |
| 194 | 215 | css_file: Optional[Path] = None, |
| 195 | 216 | ) -> Optional[dict[str, Path]]: |
| 196 | 217 | """Build DOCX, EPUB, and MOBI outputs for a story.""" |
| @@ -208,6 +229,7 @@ def build_outputs( | ||
| 208 | 229 | output_dir.mkdir(parents=True, exist_ok=True) |
| 209 | 230 | epub_dir.mkdir(parents=True, exist_ok=True) |
| 210 | 231 | mobi_dir.mkdir(parents=True, exist_ok=True) |
| 232 | + set_output_ownership(output_dir, epub_dir, mobi_dir) | |
| 211 | 233 | |
| 212 | 234 | if not story["chapters"]: |
| 213 | 235 | logger.warning(f"No chapters found for '{title}', skipping") |
| @@ -215,7 +237,7 @@ def build_outputs( | ||
| 215 | 237 | |
| 216 | 238 | logger.info(f"Building book files: '{title}' ({len(story['chapters'])} chapters)") |
| 217 | 239 | |
| 218 | 240 | merged_content = normalize_text_for_docx(merge_chapters(story["chapters"])) |
| 219 | 241 | cover_path = get_cover_path(story) |
| 220 | 242 | |
| 221 | 243 | with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False, encoding="utf-8") as tmp: |
| @@ -229,7 +251,7 @@ def build_outputs( | ||
| 229 | 251 | "-o", |
| 230 | 252 | str(docx_file), |
| 231 | 253 | "--from", |
| 232 | 254 | "markdown+raw_html+raw_tex", |
| 233 | 255 | "--to", |
| 234 | 256 | "docx", |
| 235 | 257 | "--metadata", |
| @@ -238,13 +260,16 @@ def build_outputs( | ||
| 238 | 260 | f"author={author}", |
| 239 | 261 | "--metadata", |
| 240 | 262 | f"lang={language}", |
| 241 | - "--toc", | |
| 242 | - "--toc-depth=1", | |
| 243 | 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 | 269 | if lua_filter and lua_filter.exists(): |
| 247 | 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 | 274 | if not run_command(docx_cmd, title, "DOCX"): |
| 250 | 275 | return None |
| @@ -288,6 +313,7 @@ def build_outputs( | ||
| 288 | 313 | logger.info(f"MOBI created: {mobi_file}") |
| 289 | 314 | |
| 290 | 315 | set_output_permissions(docx_file, epub_file, mobi_file) |
| 316 | + set_output_ownership(docx_file, epub_file, mobi_file) | |
| 291 | 317 | return {"docx": docx_file, "epub": epub_file, "mobi": mobi_file} |
| 292 | 318 | |
| 293 | 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 | 13 | import time |
| 14 | 14 | from pathlib import Path |
| 15 | 15 | |
| 16 | 16 | from epub_builder import build_outputs, discover_stories, set_output_ownership, write_frontmatter |
| 17 | 17 | |
| 18 | 18 | # ── Configuration from environment variables ────────────────────────── |
| 19 | 19 | |
| @@ -22,6 +22,7 @@ CREATIVE_FOLDER = os.environ.get("CREATIVE_FOLDER", "Creative") | ||
| 22 | 22 | OUTPUT_DIR = os.environ.get("OUTPUT_DIR", "/output") |
| 23 | 23 | |
| 24 | 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 | 26 | CSS_FILE_PATH = os.environ.get("CSS_FILE_PATH", "/app/epub-style.css") |
| 26 | 27 | |
| 27 | 28 | # How often to scan for export flags (seconds) |
| @@ -67,6 +68,7 @@ def process_exports(vault_path: Path): | ||
| 67 | 68 | |
| 68 | 69 | output_path = Path(OUTPUT_DIR) |
| 69 | 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 | 72 | css_file = Path(CSS_FILE_PATH) if CSS_FILE_PATH else None |
| 71 | 73 | |
| 72 | 74 | for story in to_export: |
| @@ -77,6 +79,7 @@ def process_exports(vault_path: Path): | ||
| 77 | 79 | story=story, |
| 78 | 80 | output_dir=output_path, |
| 79 | 81 | lua_filter=lua_filter, |
| 82 | + pagebreak_filter=pagebreak_filter, | |
| 80 | 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 | 101 | logger.info(f"Poll interval: {POLL_INTERVAL}s") |
| 99 | 102 | |
| 100 | 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 | 108 | if not vault_path.exists(): |
| 104 | 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 | 1 | PyYAML>=6.0 |
| 2 | +python-docx>=1.1.2 | |