mirror of
https://github.com/khodges42/nightShift.git
synced 2026-06-14 10:08:37 +00:00
textdoc util
This commit is contained in:
parent
78dcf911d6
commit
74d613de34
420
utils/textdoc-compile/README.md
Normal file
420
utils/textdoc-compile/README.md
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
# Textdoc Compiler
|
||||
|
||||
# NightShift Story Compiler
|
||||
|
||||
Compile structured markdown fiction projects into novel-style builds.
|
||||
|
||||
Generates:
|
||||
|
||||
* paperback-style PDF
|
||||
* assembled markdown manuscript
|
||||
* HTML preview
|
||||
* optional cover support
|
||||
* front matter
|
||||
* act divider pages
|
||||
* table of contents
|
||||
|
||||
Designed for AI-assisted longform fiction pipelines.
|
||||
|
||||
---
|
||||
|
||||
# Features
|
||||
|
||||
* Pure Python
|
||||
* Windows-friendly
|
||||
* Natural scene sorting:
|
||||
|
||||
* `scene-003.md`
|
||||
* `scene-003a.md`
|
||||
* `scene-003b.md`
|
||||
* Front matter support via `chapter-000`
|
||||
* Act dividers parsed from `.nightshift/tasks.md`
|
||||
* Multiple chapter naming styles
|
||||
* Optional metadata/title pages
|
||||
* Paperback or manuscript formatting
|
||||
* Scene heading extraction
|
||||
* TOC generation
|
||||
* Clean build output folder
|
||||
|
||||
---
|
||||
|
||||
# Example Project Structure
|
||||
|
||||
```text
|
||||
project-root/
|
||||
│
|
||||
├── compile_story.py
|
||||
│
|
||||
├── .nightshift/
|
||||
│ └── tasks.md
|
||||
│
|
||||
└── story/
|
||||
├── TITLE.md
|
||||
├── metadata.json
|
||||
├── cover.png
|
||||
│
|
||||
├── chapters/
|
||||
│ ├── chapter-000/
|
||||
│ │ ├── scene-001.md
|
||||
│ │ └── scene-002.md
|
||||
│ │
|
||||
│ ├── chapter-001/
|
||||
│ │ ├── scene-001.md
|
||||
│ │ ├── scene-002.md
|
||||
│ │ └── scene-003a.md
|
||||
│ │
|
||||
│ ├── chapter-002/
|
||||
│ └── chapter-003/
|
||||
│
|
||||
└── build/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Install
|
||||
|
||||
```powershell
|
||||
pip install markdown reportlab
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Quick Start
|
||||
|
||||
```powershell
|
||||
python compile_story.py --root .
|
||||
```
|
||||
|
||||
Outputs:
|
||||
|
||||
```text
|
||||
story/build/
|
||||
manuscript.md
|
||||
manuscript.html
|
||||
manuscript.pdf
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Title Pages
|
||||
|
||||
## TITLE.md
|
||||
|
||||
If present:
|
||||
|
||||
```text
|
||||
story/TITLE.md
|
||||
```
|
||||
|
||||
Its contents are inserted as the title page.
|
||||
|
||||
Example:
|
||||
|
||||
```md
|
||||
# NightShift
|
||||
|
||||
## A Novel
|
||||
|
||||
KHodges42
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## metadata.json
|
||||
|
||||
Optional metadata fallback if `TITLE.md` is missing.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "NightShift",
|
||||
"subtitle": "A Novel",
|
||||
"author": "KHodges42",
|
||||
"language": "en"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Cover Support
|
||||
|
||||
Optional:
|
||||
|
||||
```text
|
||||
story/cover.png
|
||||
```
|
||||
|
||||
Currently:
|
||||
|
||||
* included in markdown/html
|
||||
* copied into build folder
|
||||
* ignored in ReportLab PDF for now
|
||||
|
||||
Future versions can embed directly into PDF.
|
||||
|
||||
---
|
||||
|
||||
# Front Matter
|
||||
|
||||
`chapter-000` is treated specially.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
story/chapters/chapter-000/
|
||||
```
|
||||
|
||||
Use for:
|
||||
|
||||
* foreword
|
||||
* acknowledgements
|
||||
* author notes
|
||||
* epigraphs
|
||||
* dedication
|
||||
|
||||
No chapter numbering is applied.
|
||||
|
||||
---
|
||||
|
||||
# Act Dividers
|
||||
|
||||
Acts are parsed from:
|
||||
|
||||
```text
|
||||
.nightshift/tasks.md
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```md
|
||||
# ACT 1 - LOW HEAT
|
||||
# ACT 2 - STATIC BODIES
|
||||
# ACT 3 - RECURSIVE CONTAMINATION
|
||||
```
|
||||
|
||||
Each act becomes a standalone divider page.
|
||||
|
||||
Only the ACT headings are parsed.
|
||||
|
||||
Everything else in `tasks.md` is ignored.
|
||||
|
||||
---
|
||||
|
||||
# Chapter Naming
|
||||
|
||||
Default:
|
||||
|
||||
```powershell
|
||||
--chapter-format folder
|
||||
```
|
||||
|
||||
Results in:
|
||||
|
||||
```text
|
||||
chapter-001
|
||||
chapter-002
|
||||
```
|
||||
|
||||
Other options:
|
||||
|
||||
```powershell
|
||||
--chapter-format number
|
||||
```
|
||||
|
||||
```text
|
||||
001
|
||||
002
|
||||
```
|
||||
|
||||
```powershell
|
||||
--chapter-format word
|
||||
```
|
||||
|
||||
```text
|
||||
Chapter 1
|
||||
Chapter 2
|
||||
```
|
||||
|
||||
```powershell
|
||||
--chapter-format chapter-dash
|
||||
```
|
||||
|
||||
```text
|
||||
Chapter-001
|
||||
Chapter-002
|
||||
```
|
||||
|
||||
```powershell
|
||||
--chapter-format none
|
||||
```
|
||||
|
||||
No chapter headings.
|
||||
|
||||
---
|
||||
|
||||
# Table of Contents
|
||||
|
||||
Default:
|
||||
|
||||
```powershell
|
||||
--toc full
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
## Full
|
||||
|
||||
```powershell
|
||||
--toc full
|
||||
```
|
||||
|
||||
Chapters + scenes.
|
||||
|
||||
## Chapters Only
|
||||
|
||||
```powershell
|
||||
--toc chapters
|
||||
```
|
||||
|
||||
## Compact Acts
|
||||
|
||||
```powershell
|
||||
--toc acts
|
||||
```
|
||||
|
||||
## Disable
|
||||
|
||||
```powershell
|
||||
--toc off
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# PDF Styles
|
||||
|
||||
## Paperback (default)
|
||||
|
||||
```powershell
|
||||
--pdf-style paperback
|
||||
```
|
||||
|
||||
* compact trim size
|
||||
* tighter margins
|
||||
* novel-like formatting
|
||||
|
||||
## Manuscript
|
||||
|
||||
```powershell
|
||||
--pdf-style manuscript
|
||||
```
|
||||
|
||||
* wider margins
|
||||
* larger spacing
|
||||
* draft/review friendly
|
||||
|
||||
---
|
||||
|
||||
# Scene Headings
|
||||
|
||||
By default:
|
||||
|
||||
* first `# Heading` in each scene file becomes scene title
|
||||
* heading is normalized into manuscript structure
|
||||
|
||||
Disable:
|
||||
|
||||
```powershell
|
||||
--no-scene-headings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Example Commands
|
||||
|
||||
## Default Build
|
||||
|
||||
```powershell
|
||||
python compile_story.py --root .
|
||||
```
|
||||
|
||||
## Paperback Build
|
||||
|
||||
```powershell
|
||||
python compile_story.py --root . --pdf-style paperback
|
||||
```
|
||||
|
||||
## Manuscript Draft
|
||||
|
||||
```powershell
|
||||
python compile_story.py --root . --pdf-style manuscript
|
||||
```
|
||||
|
||||
## No TOC
|
||||
|
||||
```powershell
|
||||
python compile_story.py --root . --toc off
|
||||
```
|
||||
|
||||
## Word Chapter Format
|
||||
|
||||
```powershell
|
||||
python compile_story.py --root . --chapter-format word
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Notes
|
||||
|
||||
## Natural Sorting
|
||||
|
||||
Scene files are sorted naturally.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
scene-001.md
|
||||
scene-002.md
|
||||
scene-003.md
|
||||
scene-003a.md
|
||||
scene-003b.md
|
||||
scene-004.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## EPUB
|
||||
|
||||
Not currently implemented.
|
||||
|
||||
Can be added later using:
|
||||
|
||||
* ebooklib
|
||||
* pandoc
|
||||
* markdown-it-py pipelines
|
||||
|
||||
---
|
||||
|
||||
# Planned Features
|
||||
|
||||
* EPUB export
|
||||
* embedded cover art in PDF
|
||||
* page numbers
|
||||
* running headers
|
||||
* chapter drop caps
|
||||
* better typography
|
||||
* custom fonts
|
||||
* widow/orphan control
|
||||
* scene separators
|
||||
* theme presets
|
||||
* print-ready trim sizes
|
||||
* LaTeX backend
|
||||
* AI-generated glossary/index support
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
# License
|
||||
|
||||
GPL3v2
|
||||
685
utils/textdoc-compile/compile.py
Normal file
685
utils/textdoc-compile/compile.py
Normal file
|
|
@ -0,0 +1,685 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Compile a NightShift-style story folder into:
|
||||
- story/build/manuscript.md
|
||||
- story/build/manuscript.html
|
||||
- story/build/manuscript.pdf
|
||||
|
||||
Expected layout:
|
||||
|
||||
root/
|
||||
story/
|
||||
TITLE.md optional
|
||||
metadata.json optional
|
||||
cover.png optional, currently only copied/referenced
|
||||
chapters/
|
||||
chapter-000/
|
||||
scene-001.md
|
||||
chapter-001/
|
||||
scene-001.md
|
||||
scene-002.md
|
||||
.nightshift/
|
||||
tasks.md
|
||||
|
||||
Install:
|
||||
pip install markdown reportlab
|
||||
|
||||
Example:
|
||||
python compile_story.py --root .
|
||||
python compile_story.py --root . --chapter-format word
|
||||
python compile_story.py --root . --toc off
|
||||
python compile_story.py --root . --pdf-style manuscript
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# Models
|
||||
# ----------------------------
|
||||
|
||||
@dataclass
|
||||
class Metadata:
|
||||
title: str | None = None
|
||||
subtitle: str | None = None
|
||||
author: str | None = None
|
||||
language: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BuildOptions:
|
||||
root: Path
|
||||
chapter_format: str
|
||||
toc: str
|
||||
pdf_style: str
|
||||
scene_headings: bool
|
||||
output_name: str
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# Natural sorting
|
||||
# ----------------------------
|
||||
|
||||
def natural_key(path: Path) -> list[object]:
|
||||
"""
|
||||
Sorts scene-003a.md after scene-003.md and before scene-004.md.
|
||||
"""
|
||||
text = path.name.lower()
|
||||
parts = re.split(r"(\d+)", text)
|
||||
return [int(p) if p.isdigit() else p for p in parts]
|
||||
|
||||
|
||||
def chapter_number(chapter_dir: Path) -> int | None:
|
||||
match = re.search(r"chapter-(\d+)", chapter_dir.name, re.I)
|
||||
if not match:
|
||||
return None
|
||||
return int(match.group(1))
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# Metadata / title / acts
|
||||
# ----------------------------
|
||||
|
||||
def load_metadata(story_dir: Path) -> Metadata:
|
||||
metadata_path = story_dir / "metadata.json"
|
||||
if not metadata_path.exists():
|
||||
return Metadata()
|
||||
|
||||
data = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
|
||||
return Metadata(
|
||||
title=data.get("title"),
|
||||
subtitle=data.get("subtitle"),
|
||||
author=data.get("author"),
|
||||
language=data.get("language"),
|
||||
)
|
||||
|
||||
|
||||
def read_title_page(story_dir: Path) -> str:
|
||||
title_path = story_dir / "TITLE.md"
|
||||
if not title_path.exists():
|
||||
return ""
|
||||
return title_path.read_text(encoding="utf-8").strip()
|
||||
|
||||
|
||||
def parse_act_headings(tasks_path: Path) -> list[str]:
|
||||
"""
|
||||
Reads only headings like:
|
||||
|
||||
# ACT 1 - LOW HEAT
|
||||
# ACT 2 - WHATEVER
|
||||
|
||||
Ignores task entries, descriptions, acceptance criteria, etc.
|
||||
"""
|
||||
if not tasks_path.exists():
|
||||
return []
|
||||
|
||||
acts: list[str] = []
|
||||
|
||||
for line in tasks_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
match = re.match(r"^#\s+(ACT\s+\d+\s+-\s+.+)$", line, re.I)
|
||||
if match:
|
||||
acts.append(match.group(1).strip())
|
||||
|
||||
return acts
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# Chapter / scene rendering
|
||||
# ----------------------------
|
||||
|
||||
def format_chapter_heading(chapter_dir: Path, fmt: str) -> str | None:
|
||||
num = chapter_number(chapter_dir)
|
||||
|
||||
if num == 0:
|
||||
return None
|
||||
|
||||
if fmt == "none":
|
||||
return None
|
||||
|
||||
if fmt == "folder":
|
||||
return chapter_dir.name
|
||||
|
||||
if fmt == "number":
|
||||
if num is None:
|
||||
return chapter_dir.name
|
||||
return f"{num:03d}"
|
||||
|
||||
if fmt == "word":
|
||||
if num is None:
|
||||
return chapter_dir.name
|
||||
return f"Chapter {num}"
|
||||
|
||||
if fmt == "chapter-dash":
|
||||
if num is None:
|
||||
return chapter_dir.name
|
||||
return f"Chapter-{num:03d}"
|
||||
|
||||
raise ValueError(f"Unknown chapter format: {fmt}")
|
||||
|
||||
|
||||
def first_heading(markdown_text: str) -> str | None:
|
||||
for line in markdown_text.splitlines():
|
||||
match = re.match(r"^#\s+(.+)$", line.strip())
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
return None
|
||||
|
||||
|
||||
def strip_top_heading(markdown_text: str) -> str:
|
||||
"""
|
||||
Removes the first top-level heading only.
|
||||
Useful if scene headings are being generated separately.
|
||||
"""
|
||||
lines = markdown_text.splitlines()
|
||||
output: list[str] = []
|
||||
removed = False
|
||||
|
||||
for line in lines:
|
||||
if not removed and re.match(r"^#\s+.+$", line.strip()):
|
||||
removed = True
|
||||
continue
|
||||
output.append(line)
|
||||
|
||||
return "\n".join(output).strip()
|
||||
|
||||
|
||||
def build_scene_markdown(scene_path: Path, include_scene_heading: bool) -> str:
|
||||
raw = scene_path.read_text(encoding="utf-8").strip()
|
||||
|
||||
if not include_scene_heading:
|
||||
return raw
|
||||
|
||||
heading = first_heading(raw)
|
||||
|
||||
if heading:
|
||||
body = strip_top_heading(raw)
|
||||
return f"### {heading}\n\n{body}".strip()
|
||||
|
||||
fallback = scene_path.stem.replace("-", " ").title()
|
||||
return f"### {fallback}\n\n{raw}".strip()
|
||||
|
||||
|
||||
def chapter_dirs(chapters_dir: Path) -> list[Path]:
|
||||
dirs = [p for p in chapters_dir.iterdir() if p.is_dir() and p.name.lower().startswith("chapter-")]
|
||||
return sorted(dirs, key=natural_key)
|
||||
|
||||
|
||||
def scene_files(chapter_dir: Path) -> list[Path]:
|
||||
files = [p for p in chapter_dir.iterdir() if p.is_file() and p.suffix.lower() == ".md"]
|
||||
return sorted(files, key=natural_key)
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# TOC
|
||||
# ----------------------------
|
||||
|
||||
def make_toc(chapter_map: list[tuple[Path, list[Path]]], opts: BuildOptions) -> str:
|
||||
if opts.toc == "off":
|
||||
return ""
|
||||
|
||||
lines = ["# Contents", ""]
|
||||
|
||||
for chapter_dir, scenes in chapter_map:
|
||||
ch_num = chapter_number(chapter_dir)
|
||||
|
||||
if ch_num == 0:
|
||||
label = "Front Matter"
|
||||
else:
|
||||
label = format_chapter_heading(chapter_dir, opts.chapter_format) or chapter_dir.name
|
||||
|
||||
if opts.toc == "acts":
|
||||
# Act-only TOC is handled elsewhere poorly without explicit mapping.
|
||||
# For now, treat as compact chapter-only.
|
||||
lines.append(f"- {label}")
|
||||
|
||||
elif opts.toc == "chapters":
|
||||
lines.append(f"- {label}")
|
||||
|
||||
elif opts.toc == "full":
|
||||
lines.append(f"- {label}")
|
||||
for scene in scenes:
|
||||
raw = scene.read_text(encoding="utf-8")
|
||||
heading = first_heading(raw) or scene.stem
|
||||
lines.append(f" - {heading}")
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown TOC style: {opts.toc}")
|
||||
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# Markdown assembly
|
||||
# ----------------------------
|
||||
|
||||
def assemble_markdown(opts: BuildOptions) -> str:
|
||||
story_dir = opts.root / "story"
|
||||
chapters_dir = story_dir / "chapters"
|
||||
tasks_path = opts.root / ".nightshift" / "tasks.md"
|
||||
|
||||
if not story_dir.exists():
|
||||
raise FileNotFoundError(f"Missing story directory: {story_dir}")
|
||||
|
||||
if not chapters_dir.exists():
|
||||
raise FileNotFoundError(f"Missing chapters directory: {chapters_dir}")
|
||||
|
||||
metadata = load_metadata(story_dir)
|
||||
title_page = read_title_page(story_dir)
|
||||
acts = parse_act_headings(tasks_path)
|
||||
|
||||
all_chapters = chapter_dirs(chapters_dir)
|
||||
chapter_map = [(chapter, scene_files(chapter)) for chapter in all_chapters]
|
||||
|
||||
parts: list[str] = []
|
||||
|
||||
# Optional cover reference for markdown/html.
|
||||
cover_path = story_dir / "cover.png"
|
||||
if cover_path.exists():
|
||||
parts.append("")
|
||||
parts.append(r"\newpage")
|
||||
|
||||
# TITLE.md wins over metadata title page.
|
||||
if title_page:
|
||||
parts.append(title_page)
|
||||
parts.append(r"\newpage")
|
||||
elif metadata.title or metadata.author:
|
||||
title_bits = []
|
||||
if metadata.title:
|
||||
title_bits.append(f"# {metadata.title}")
|
||||
if metadata.subtitle:
|
||||
title_bits.append(f"## {metadata.subtitle}")
|
||||
if metadata.author:
|
||||
title_bits.append(f"### {metadata.author}")
|
||||
parts.append("\n\n".join(title_bits))
|
||||
parts.append(r"\newpage")
|
||||
|
||||
toc_md = make_toc(chapter_map, opts)
|
||||
if toc_md:
|
||||
parts.append(toc_md)
|
||||
parts.append(r"\newpage")
|
||||
|
||||
act_index = 0
|
||||
|
||||
for chapter_dir, scenes in chapter_map:
|
||||
ch_num = chapter_number(chapter_dir)
|
||||
|
||||
# chapter-000 is front matter, no act divider, no chapter numbering.
|
||||
is_front_matter = ch_num == 0
|
||||
|
||||
# Insert act divider before chapter-001, chapter-002, chapter-003, etc.
|
||||
# This assumes ACT 1 maps to chapter-001, ACT 2 maps to chapter-002, etc.
|
||||
if not is_front_matter and ch_num is not None:
|
||||
expected_act_number = ch_num
|
||||
if expected_act_number - 1 < len(acts):
|
||||
act_heading = acts[expected_act_number - 1]
|
||||
parts.append(f"# {act_heading}")
|
||||
parts.append(r"\newpage")
|
||||
|
||||
chapter_heading = None if is_front_matter else format_chapter_heading(chapter_dir, opts.chapter_format)
|
||||
|
||||
if chapter_heading:
|
||||
parts.append(f"# {chapter_heading}")
|
||||
parts.append("")
|
||||
|
||||
for scene in scenes:
|
||||
scene_md = build_scene_markdown(scene, opts.scene_headings)
|
||||
if scene_md:
|
||||
parts.append(scene_md)
|
||||
parts.append("")
|
||||
|
||||
parts.append(r"\newpage")
|
||||
|
||||
return "\n\n".join(p for p in parts if p is not None).strip() + "\n"
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# HTML
|
||||
# ----------------------------
|
||||
|
||||
def markdown_to_html(md: str, metadata: Metadata) -> str:
|
||||
try:
|
||||
import markdown
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("Missing dependency: pip install markdown") from exc
|
||||
|
||||
body = markdown.markdown(
|
||||
md,
|
||||
extensions=[
|
||||
"extra",
|
||||
"toc",
|
||||
"sane_lists",
|
||||
],
|
||||
)
|
||||
|
||||
title = metadata.title or "Manuscript"
|
||||
|
||||
return f"""<!doctype html>
|
||||
<html lang="{html.escape(metadata.language or "en")}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{html.escape(title)}</title>
|
||||
<style>
|
||||
body {{
|
||||
max-width: 720px;
|
||||
margin: 3rem auto;
|
||||
padding: 0 1.5rem;
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-size: 18px;
|
||||
line-height: 1.65;
|
||||
color: #111;
|
||||
}}
|
||||
|
||||
h1, h2, h3 {{
|
||||
font-weight: normal;
|
||||
text-align: center;
|
||||
margin-top: 3rem;
|
||||
}}
|
||||
|
||||
h1 {{
|
||||
font-size: 2.1rem;
|
||||
page-break-before: always;
|
||||
}}
|
||||
|
||||
h2 {{
|
||||
font-size: 1.5rem;
|
||||
}}
|
||||
|
||||
h3 {{
|
||||
font-size: 1.2rem;
|
||||
margin-top: 2rem;
|
||||
}}
|
||||
|
||||
p {{
|
||||
text-indent: 1.5em;
|
||||
margin: 0 0 0.4rem 0;
|
||||
}}
|
||||
|
||||
h1 + p,
|
||||
h2 + p,
|
||||
h3 + p {{
|
||||
text-indent: 0;
|
||||
}}
|
||||
|
||||
ul, ol {{
|
||||
margin-left: 2rem;
|
||||
}}
|
||||
|
||||
img {{
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
margin: 2rem auto;
|
||||
}}
|
||||
|
||||
code {{
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 0.9em;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{body}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# PDF via ReportLab
|
||||
# ----------------------------
|
||||
|
||||
def write_pdf(md: str, output_path: Path, metadata: Metadata, pdf_style: str) -> None:
|
||||
try:
|
||||
from reportlab.lib.enums import TA_CENTER, TA_LEFT
|
||||
from reportlab.lib.pagesizes import A5, LETTER
|
||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.platypus import (
|
||||
SimpleDocTemplate,
|
||||
Paragraph,
|
||||
Spacer,
|
||||
PageBreak,
|
||||
)
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("Missing dependency: pip install reportlab") from exc
|
||||
|
||||
if pdf_style == "paperback":
|
||||
pagesize = A5
|
||||
margins = dict(
|
||||
leftMargin=0.65 * inch,
|
||||
rightMargin=0.65 * inch,
|
||||
topMargin=0.7 * inch,
|
||||
bottomMargin=0.7 * inch,
|
||||
)
|
||||
body_size = 10.5
|
||||
leading = 15
|
||||
|
||||
elif pdf_style == "manuscript":
|
||||
pagesize = LETTER
|
||||
margins = dict(
|
||||
leftMargin=1 * inch,
|
||||
rightMargin=1 * inch,
|
||||
topMargin=1 * inch,
|
||||
bottomMargin=1 * inch,
|
||||
)
|
||||
body_size = 12
|
||||
leading = 24
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown PDF style: {pdf_style}")
|
||||
|
||||
doc = SimpleDocTemplate(
|
||||
str(output_path),
|
||||
pagesize=pagesize,
|
||||
title=metadata.title or "Manuscript",
|
||||
author=metadata.author or "",
|
||||
**margins,
|
||||
)
|
||||
|
||||
styles = getSampleStyleSheet()
|
||||
|
||||
body = ParagraphStyle(
|
||||
"NovelBody",
|
||||
parent=styles["BodyText"],
|
||||
fontName="Times-Roman",
|
||||
fontSize=body_size,
|
||||
leading=leading,
|
||||
firstLineIndent=18,
|
||||
spaceAfter=4,
|
||||
alignment=TA_LEFT,
|
||||
)
|
||||
|
||||
h1 = ParagraphStyle(
|
||||
"NovelH1",
|
||||
parent=styles["Heading1"],
|
||||
fontName="Times-Roman",
|
||||
fontSize=18,
|
||||
leading=24,
|
||||
alignment=TA_CENTER,
|
||||
spaceBefore=36,
|
||||
spaceAfter=24,
|
||||
)
|
||||
|
||||
h2 = ParagraphStyle(
|
||||
"NovelH2",
|
||||
parent=styles["Heading2"],
|
||||
fontName="Times-Roman",
|
||||
fontSize=15,
|
||||
leading=20,
|
||||
alignment=TA_CENTER,
|
||||
spaceBefore=28,
|
||||
spaceAfter=18,
|
||||
)
|
||||
|
||||
h3 = ParagraphStyle(
|
||||
"NovelH3",
|
||||
parent=styles["Heading3"],
|
||||
fontName="Times-Roman",
|
||||
fontSize=13,
|
||||
leading=18,
|
||||
alignment=TA_CENTER,
|
||||
spaceBefore=20,
|
||||
spaceAfter=14,
|
||||
)
|
||||
|
||||
story = []
|
||||
|
||||
paragraphs = md.splitlines()
|
||||
buffer: list[str] = []
|
||||
|
||||
def flush_paragraph():
|
||||
nonlocal buffer
|
||||
text = " ".join(x.strip() for x in buffer).strip()
|
||||
buffer = []
|
||||
|
||||
if not text:
|
||||
return
|
||||
|
||||
safe = html.escape(text)
|
||||
story.append(Paragraph(safe, body))
|
||||
|
||||
for line in paragraphs:
|
||||
stripped = line.strip()
|
||||
|
||||
if stripped == r"\newpage":
|
||||
flush_paragraph()
|
||||
story.append(PageBreak())
|
||||
continue
|
||||
|
||||
if not stripped:
|
||||
flush_paragraph()
|
||||
story.append(Spacer(1, 6))
|
||||
continue
|
||||
|
||||
if stripped.startswith("# "):
|
||||
flush_paragraph()
|
||||
story.append(Paragraph(html.escape(stripped[2:].strip()), h1))
|
||||
continue
|
||||
|
||||
if stripped.startswith("## "):
|
||||
flush_paragraph()
|
||||
story.append(Paragraph(html.escape(stripped[3:].strip()), h2))
|
||||
continue
|
||||
|
||||
if stripped.startswith("### "):
|
||||
flush_paragraph()
|
||||
story.append(Paragraph(html.escape(stripped[4:].strip()), h3))
|
||||
continue
|
||||
|
||||
# crude markdown list support
|
||||
if re.match(r"^[-*]\s+", stripped):
|
||||
flush_paragraph()
|
||||
item = re.sub(r"^[-*]\s+", "• ", stripped)
|
||||
story.append(Paragraph(html.escape(item), body))
|
||||
continue
|
||||
|
||||
# skip images in ReportLab for now
|
||||
if stripped.startswith("!["):
|
||||
flush_paragraph()
|
||||
continue
|
||||
|
||||
buffer.append(stripped)
|
||||
|
||||
flush_paragraph()
|
||||
doc.build(story)
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# Build
|
||||
# ----------------------------
|
||||
|
||||
def build(opts: BuildOptions) -> None:
|
||||
story_dir = opts.root / "story"
|
||||
build_dir = story_dir / "build"
|
||||
build_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
metadata = load_metadata(story_dir)
|
||||
md = assemble_markdown(opts)
|
||||
|
||||
md_path = build_dir / f"{opts.output_name}.md"
|
||||
html_path = build_dir / f"{opts.output_name}.html"
|
||||
pdf_path = build_dir / f"{opts.output_name}.pdf"
|
||||
|
||||
md_path.write_text(md, encoding="utf-8")
|
||||
|
||||
html_doc = markdown_to_html(md, metadata)
|
||||
html_path.write_text(html_doc, encoding="utf-8")
|
||||
|
||||
write_pdf(md, pdf_path, metadata, opts.pdf_style)
|
||||
|
||||
cover = story_dir / "cover.png"
|
||||
if cover.exists():
|
||||
shutil.copy2(cover, build_dir / "cover.png")
|
||||
|
||||
print("Built:")
|
||||
print(f" {md_path}")
|
||||
print(f" {html_path}")
|
||||
print(f" {pdf_path}")
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# CLI
|
||||
# ----------------------------
|
||||
|
||||
def parse_args() -> BuildOptions:
|
||||
parser = argparse.ArgumentParser(description="Compile story markdown into a novel build.")
|
||||
|
||||
parser.add_argument("--root", default=".", help="Project root containing story/ and .nightshift/")
|
||||
parser.add_argument(
|
||||
"--chapter-format",
|
||||
default="folder",
|
||||
choices=["folder", "number", "word", "chapter-dash", "none"],
|
||||
help="How numbered chapters should be titled.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--toc",
|
||||
default="full",
|
||||
choices=["off", "chapters", "full", "acts"],
|
||||
help="Table of contents style. Default: full.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pdf-style",
|
||||
default="paperback",
|
||||
choices=["paperback", "manuscript"],
|
||||
help="PDF formatting style.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-scene-headings",
|
||||
action="store_true",
|
||||
help="Do not generate scene headings from scene markdown.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-name",
|
||||
default="manuscript",
|
||||
help="Base output filename.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
return BuildOptions(
|
||||
root=Path(args.root).resolve(),
|
||||
chapter_format=args.chapter_format,
|
||||
toc=args.toc,
|
||||
pdf_style=args.pdf_style,
|
||||
scene_headings=not args.no_scene_headings,
|
||||
output_name=args.output_name,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
opts = parse_args()
|
||||
build(opts)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in New Issue
Block a user