Initial commit

9f53616abc84e79b831bd28c437fd36954d160ca

permissionBRICK <you@example.com>

8 files changed, +138 -10Showing whitespace changes
Dockerfile+3 -1
@@ -11,9 +11,11 @@ WORKDIR /app
1111COPY requirements.txt /app/
1212RUN pip install --no-cache-dir -r requirements.txt
1313
1414COPY main.py epub_builder.py generate_reference_doc.py /app/
1515COPY center-v.lua /app/
16+COPY pagebreak.lua /app/
1617COPY epub-style.css /app/
18+RUN python generate_reference_doc.py
1719
1820# /vault - mount your markdown source folder here (read-write for flag reset)
1921# /output - mount your EPUB destination folder here
README.md+1 -1
@@ -6,7 +6,7 @@ This container watches a mounted markdown share for story folders and generates
66
771. The container scans the mounted vault under `/vault/<CREATIVE_FOLDER>`.
882. Each story folder is identified by a `metadata.md` file with YAML frontmatter.
993. 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.
10104. That DOCX is then converted into EPUB and MOBI with Calibre's `ebook-convert`.
11115. 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.
12126. The container resets `export: false` in the story's `metadata.md`.
epub_builder.py+32 -6
@@ -11,6 +11,7 @@ import os
1111import re
1212import subprocess
1313import tempfile
14+import unicodedata
1415from pathlib import Path
1516from typing import Optional
1617
@@ -19,9 +20,12 @@ import yaml
1920logger = logging.getLogger(__name__)
2021
2122METADATA_FILENAME = "metadata.md"
2223DEFAULT_OUTPUT_FILE_MODE = 0o6440o664
2324EPUB_SUBDIR = "epub"
2425MOBI_SUBDIR = "mobi"
26+REFERENCE_DOC_PATH = Path("/app/reference.docx")
27+DEFAULT_OUTPUT_UID = 99
28+DEFAULT_OUTPUT_GID = 100
2529
2630
2731def parse_frontmatter(text: str) -> dict:
@@ -128,13 +132,23 @@ def merge_chapters(chapters: list[Path], add_page_breaks: bool = True) -> str:
128132 continue
129133
130134 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")
132137
133138 merged_parts.append(content)
134139
135140 return "\n\n".join(merged_parts)
136141
137142
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+
138152def sanitize_title(title: str) -> str:
139153 """Convert a story title into a safe base filename."""
140154 return re.sub(r"[^\w\s-]", "", title).strip()
@@ -166,6 +180,12 @@ def set_output_permissions(*paths: Path):
166180 os.chmod(path, DEFAULT_OUTPUT_FILE_MODE)
167181
168182
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+
169189def run_command(cmd: list[str], title: str, output_label: str) -> bool:
170190 """Run an external command and log any failure details."""
171191 logger.debug(f"Running: {' '.join(cmd)}")
@@ -191,6 +211,7 @@ def build_outputs(
191211 story: dict,
192212 output_dir: Path,
193213 lua_filter: Optional[Path] = None,
214+ pagebreak_filter: Optional[Path] = None,
194215 css_file: Optional[Path] = None,
195216) -> Optional[dict[str, Path]]:
196217 """Build DOCX, EPUB, and MOBI outputs for a story."""
@@ -208,6 +229,7 @@ def build_outputs(
208229 output_dir.mkdir(parents=True, exist_ok=True)
209230 epub_dir.mkdir(parents=True, exist_ok=True)
210231 mobi_dir.mkdir(parents=True, exist_ok=True)
232+ set_output_ownership(output_dir, epub_dir, mobi_dir)
211233
212234 if not story["chapters"]:
213235 logger.warning(f"No chapters found for '{title}', skipping")
@@ -215,7 +237,7 @@ def build_outputs(
215237
216238 logger.info(f"Building book files: '{title}' ({len(story['chapters'])} chapters)")
217239
218240 merged_content = normalize_text_for_docx(merge_chapters(story["chapters"]))
219241 cover_path = get_cover_path(story)
220242
221243 with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False, encoding="utf-8") as tmp:
@@ -229,7 +251,7 @@ def build_outputs(
229251 "-o",
230252 str(docx_file),
231253 "--from",
232254 "markdown+raw_html+raw_tex",
233255 "--to",
234256 "docx",
235257 "--metadata",
@@ -238,13 +260,16 @@ def build_outputs(
238260 f"author={author}",
239261 "--metadata",
240262 f"lang={language}",
241- "--toc",
242- "--toc-depth=1",
243263 "--wrap=none",
244264 ]
245265
266+ if REFERENCE_DOC_PATH.exists():
267+ docx_cmd.extend(["--reference-doc", str(REFERENCE_DOC_PATH)])
268+
246269 if lua_filter and lua_filter.exists():
247270 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)])
248273
249274 if not run_command(docx_cmd, title, "DOCX"):
250275 return None
@@ -288,6 +313,7 @@ def build_outputs(
288313 logger.info(f"MOBI created: {mobi_file}")
289314
290315 set_output_permissions(docx_file, epub_file, mobi_file)
316+ set_output_ownership(docx_file, epub_file, mobi_file)
291317 return {"docx": docx_file, "epub": epub_file, "mobi": mobi_file}
292318
293319 except subprocess.TimeoutExpired:
generate_reference_doc.py+55 -0
@@ -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")
main.py+7 -2
@@ -13,7 +13,7 @@ import sys
1313import time
1414from pathlib import Path
1515
1616from epub_builder import build_outputs, discover_stories, set_output_ownership, write_frontmatter
1717
1818# ── Configuration from environment variables ──────────────────────────
1919
@@ -22,6 +22,7 @@ CREATIVE_FOLDER = os.environ.get("CREATIVE_FOLDER", "Creative")
2222OUTPUT_DIR = os.environ.get("OUTPUT_DIR", "/output")
2323
2424LUA_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")
2526CSS_FILE_PATH = os.environ.get("CSS_FILE_PATH", "/app/epub-style.css")
2627
2728# How often to scan for export flags (seconds)
@@ -67,6 +68,7 @@ def process_exports(vault_path: Path):
6768
6869 output_path = Path(OUTPUT_DIR)
6970 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
7072 css_file = Path(CSS_FILE_PATH) if CSS_FILE_PATH else None
7173
7274 for story in to_export:
@@ -77,6 +79,7 @@ def process_exports(vault_path: Path):
7779 story=story,
7880 output_dir=output_path,
7981 lua_filter=lua_filter,
82+ pagebreak_filter=pagebreak_filter,
8083 css_file=css_file if css_file and css_file.exists() else None,
8184 )
8285
@@ -98,7 +101,9 @@ def main():
98101 logger.info(f"Poll interval: {POLL_INTERVAL}s")
99102
100103 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)
102107
103108 if not vault_path.exists():
104109 logger.error(f"Vault path does not exist: {vault_path}")
pagebreak.lua+39 -0
@@ -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
reference.docx+0 -0

Binary file

requirements.txt+1 -0
@@ -1 +1,2 @@
11PyYAML>=6.0
2+python-docx>=1.1.2