Initial commit

9f53616abc84e79b831bd28c437fd36954d160ca

permissionBRICK <you@example.com>

8 files changed, +138 -10Ignore whitespace
Dockerfile+3 -1
@@ -11,9 +11,11 @@ WORKDIR /app
11COPY requirements.txt /app/11COPY requirements.txt /app/
12RUN pip install --no-cache-dir -r requirements.txt12RUN pip install --no-cache-dir -r requirements.txt
1313
14COPY main.py epub_builder.py /app/14COPY main.py epub_builder.py generate_reference_doc.py /app/
15COPY center-v.lua /app/15COPY center-v.lua /app/
16COPY pagebreak.lua /app/
16COPY epub-style.css /app/17COPY epub-style.css /app/
18RUN python generate_reference_doc.py
1719
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 here21# /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
71. The container scans the mounted vault under `/vault/<CREATIVE_FOLDER>`.71. The container scans the mounted vault under `/vault/<CREATIVE_FOLDER>`.
82. Each story folder is identified by a `metadata.md` file with YAML frontmatter.82. Each story folder is identified by a `metadata.md` file with YAML frontmatter.
93. If that frontmatter contains `export: true`, the container merges the sibling chapter `.md` files in that folder and exports a DOCX with `pandoc`.93. 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.
104. That DOCX is then converted into EPUB and MOBI with Calibre's `ebook-convert`.104. That DOCX is then converted into EPUB and MOBI with Calibre's `ebook-convert`.
115. 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.115. 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.
126. The container resets `export: false` in the story's `metadata.md`.126. The container resets `export: false` in the story's `metadata.md`.
epub_builder.py+32 -6
@@ -11,6 +11,7 @@ import os
11import re11import re
12import subprocess12import subprocess
13import tempfile13import tempfile
14import unicodedata
14from pathlib import Path15from pathlib import Path
15from typing import Optional16from typing import Optional
1617
@@ -19,9 +20,12 @@ import yaml
19logger = logging.getLogger(__name__)20logger = logging.getLogger(__name__)
2021
21METADATA_FILENAME = "metadata.md"22METADATA_FILENAME = "metadata.md"
22DEFAULT_OUTPUT_FILE_MODE = 0o64423DEFAULT_OUTPUT_FILE_MODE = 0o664
23EPUB_SUBDIR = "epub"24EPUB_SUBDIR = "epub"
24MOBI_SUBDIR = "mobi"25MOBI_SUBDIR = "mobi"
26REFERENCE_DOC_PATH = Path("/app/reference.docx")
27DEFAULT_OUTPUT_UID = 99
28DEFAULT_OUTPUT_GID = 100
2529
2630
27def parse_frontmatter(text: str) -> dict:31def parse_frontmatter(text: str) -> dict:
@@ -128,13 +132,23 @@ def merge_chapters(chapters: list[Path], add_page_breaks: bool = True) -> str:
128 continue132 continue
129133
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")
132137
133 merged_parts.append(content)138 merged_parts.append(content)
134139
135 return "\n\n".join(merged_parts)140 return "\n\n".join(merged_parts)
136141
137142
143def 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
138def sanitize_title(title: str) -> str:152def 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)
167181
168182
183def 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
169def run_command(cmd: list[str], title: str, output_label: str) -> bool:189def 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)
211233
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(
215237
216 logger.info(f"Building book files: '{title}' ({len(story['chapters'])} chapters)")238 logger.info(f"Building book files: '{title}' ({len(story['chapters'])} chapters)")
217239
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)
220242
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 ]
245265
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)])
248273
249 if not run_command(docx_cmd, title, "DOCX"):274 if not run_command(docx_cmd, title, "DOCX"):
250 return None275 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}")
289314
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}
292318
293 except subprocess.TimeoutExpired:319 except subprocess.TimeoutExpired:
generate_reference_doc.py+55 -0
@@ -0,0 +1,55 @@
1from docx import Document
2from docx.enum.style import WD_STYLE_TYPE
3from docx.oxml.ns import qn
4from docx.shared import Pt, RGBColor
5
6
7def 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
14def 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
23def 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
31doc = Document()
32styles = doc.styles
33
34normal = styles["Normal"]
35style_paragraph(normal, "Times New Roman", 12)
36
37title = styles["Title"]
38style_paragraph(title, "Times New Roman", 22, bold=True, space_after=12)
39
40heading1 = styles["Heading 1"]
41style_paragraph(heading1, "Times New Roman", 18, bold=True, space_before=18, space_after=10)
42
43heading2 = styles["Heading 2"]
44style_paragraph(heading2, "Times New Roman", 16, bold=True, space_before=14, space_after=8)
45
46heading3 = styles["Heading 3"]
47style_paragraph(heading3, "Times New Roman", 14, bold=True, space_before=12, space_after=6)
48
49if "First Paragraph" not in [style.name for style in styles]:
50 styles.add_style("First Paragraph", WD_STYLE_TYPE.PARAGRAPH)
51
52first_paragraph = styles["First Paragraph"]
53style_paragraph(first_paragraph, "Times New Roman", 12)
54
55doc.save("reference.docx")
main.py+7 -2
@@ -13,7 +13,7 @@ import sys
13import time13import time
14from pathlib import Path14from pathlib import Path
1515
16from epub_builder import build_outputs, discover_stories, write_frontmatter16from epub_builder import build_outputs, discover_stories, set_output_ownership, write_frontmatter
1717
18# ── Configuration from environment variables ──────────────────────────18# ── Configuration from environment variables ──────────────────────────
1919
@@ -22,6 +22,7 @@ CREATIVE_FOLDER = os.environ.get("CREATIVE_FOLDER", "Creative")
22OUTPUT_DIR = os.environ.get("OUTPUT_DIR", "/output")22OUTPUT_DIR = os.environ.get("OUTPUT_DIR", "/output")
2323
24LUA_FILTER_PATH = os.environ.get("LUA_FILTER_PATH", "/app/center-v.lua")24LUA_FILTER_PATH = os.environ.get("LUA_FILTER_PATH", "/app/center-v.lua")
25PAGEBREAK_FILTER_PATH = os.environ.get("PAGEBREAK_FILTER_PATH", "/app/pagebreak.lua")
25CSS_FILE_PATH = os.environ.get("CSS_FILE_PATH", "/app/epub-style.css")26CSS_FILE_PATH = os.environ.get("CSS_FILE_PATH", "/app/epub-style.css")
2627
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):
6768
68 output_path = Path(OUTPUT_DIR)69 output_path = Path(OUTPUT_DIR)
69 lua_filter = Path(LUA_FILTER_PATH) if LUA_FILTER_PATH else None70 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 None72 css_file = Path(CSS_FILE_PATH) if CSS_FILE_PATH else None
7173
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 )
8285
@@ -98,7 +101,9 @@ def main():
98 logger.info(f"Poll interval: {POLL_INTERVAL}s")101 logger.info(f"Poll interval: {POLL_INTERVAL}s")
99102
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)
102107
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}")
pagebreak.lua+39 -0
@@ -0,0 +1,39 @@
1-- pagebreak.lua
2-- Converts explicit page break markers into format-specific page breaks.
3
4local DOCX_PAGEBREAK = [[
5<w:p>
6 <w:r>
7 <w:br w:type="page"/>
8 </w:r>
9</w:p>
10]]
11
12local HTML_PAGEBREAK = '<div style="page-break-before: always;"></div>'
13
14local 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 )
19end
20
21function 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
29end
30
31function 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
39end
reference.docx+0 -0

Binary file

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