Skip to content

Commit 31655e7

Browse files
committed
Generate PDF manual from Markdown
1 parent c54676f commit 31655e7

7 files changed

Lines changed: 181 additions & 5 deletions

File tree

.github/workflows/build-mac.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ jobs:
100100
test -f build-mac/installer/resources/license.rtf
101101
test -f build-mac/installer/resources/readme-mac.rtf
102102
test -f build-mac/installer/resources/intro.rtf
103+
test -f "build-mac/manual/${{matrix.project}} manual.pdf"
103104
test -f "manual/${{matrix.project}} manual.md"
104105
105106
- name: Build

.github/workflows/build-win.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ jobs:
8888
if (!(Test-Path "build-win\installer-docs\license.txt")) { throw "Missing generated license.txt" }
8989
if (!(Test-Path "build-win\installer-docs\readme-win.txt")) { throw "Missing generated readme-win.txt" }
9090
if (!(Test-Path "build-win\installer-docs\readme-win-demo.txt")) { throw "Missing generated readme-win-demo.txt" }
91+
if (!(Test-Path "build-win\manual\${{matrix.project}} manual.pdf")) { throw "Missing generated manual PDF" }
9192
if (!(Test-Path "manual\${{matrix.project}} manual.md")) { throw "Missing Markdown manual" }
9293
9394
- name: Add msbuild to PATH

TemplateProject/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@ iPlug2 template project
44

55
## Distribution docs
66

7-
Installer text lives in `installer/*.md` and the user manual lives in `manual/TemplateProject manual.md`. The packaging scripts generate installer-compatible RTF/TXT files from those Markdown sources during release builds.
7+
Installer text lives in `installer/*.md` and the user manual lives in `manual/TemplateProject manual.md`. The packaging scripts generate installer-compatible RTF/TXT files and a PDF manual from those Markdown sources during release builds.

TemplateProject/installer/TemplateProject.iss

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,13 @@ Source: "..\build-win\TemplateProject_x64.clap"; DestDir: {commoncf64}\CLAP\; Ch
7373
;Source: "..\build-win\TemplateProject.aaxplugin\Desktop.ini"; DestDir: "{cf64}\Avid\Audio\Plug-Ins\TemplateProject.aaxplugin\"; Check: Is64BitInstallMode; Components:aax_64; Flags: overwritereadonly ignoreversion; Attribs: hidden system;
7474
;Source: "..\build-win\TemplateProject.aaxplugin\PlugIn.ico"; DestDir: "{cf64}\Avid\Audio\Plug-Ins\TemplateProject.aaxplugin\"; Check: Is64BitInstallMode; Components:aax_64; Flags: overwritereadonly ignoreversion; Attribs: hidden system;
7575

76-
Source: "..\manual\TemplateProject manual.md"; DestDir: "{app}"
76+
Source: "..\build-win\manual\TemplateProject manual.pdf"; DestDir: "{app}"
7777
Source: "changelog.txt"; DestDir: "{app}"
7878
Source: "..\build-win\installer-docs\readme-win.txt"; DestDir: "{app}"; DestName: "readme.txt"; Flags: isreadme
7979

8080
[Icons]
8181
Name: "{group}\TemplateProject"; Filename: "{app}\TemplateProject_x64.exe"
82-
Name: "{group}\User guide"; Filename: "{app}\TemplateProject manual.md"
82+
Name: "{group}\User guide"; Filename: "{app}\TemplateProject manual.pdf"
8383
Name: "{group}\Changelog"; Filename: "{app}\changelog.txt"
8484
;Name: "{group}\readme"; Filename: "{app}\readme.txt"
8585
Name: "{group}\Uninstall TemplateProject"; Filename: "{app}\unins000.exe"

TemplateProject/scripts/makedist-mac.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,7 @@ if [ $BUILD_INSTALLER == 1 ]; then
295295
else
296296
cp installer/changelog.txt build-mac/installer/
297297
cp installer/known-issues.txt build-mac/installer/
298-
cp "manual/$PLUGIN_NAME manual.md" build-mac/installer/
298+
cp "build-mac/manual/$PLUGIN_NAME manual.pdf" build-mac/installer/
299299
hdiutil create build-mac/$ARCHIVE_NAME.dmg -format UDZO -srcfolder build-mac/installer/ -ov -anyowners -volname $PLUGIN_NAME
300300
fi
301301

TemplateProject/scripts/makezip-win.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ def main():
7070
projectpath + installer,
7171
projectpath + "\\installer\\changelog.txt",
7272
projectpath + "\\installer\\known-issues.txt",
73-
projectpath + "\\manual\\TemplateProject manual.md"
73+
projectpath + "\\build-win\\manual\\TemplateProject manual.pdf"
7474
]
7575

7676
for f in files:

TemplateProject/scripts/prepare_installer_docs.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44
import os
55
import re
66
import shutil
7+
import textwrap
78

89

910
PRODUCT_NAME = "TemplateProject"
1011

1112
SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__))
1213
PROJECT_DIR = os.path.abspath(os.path.join(SCRIPT_DIR, os.pardir))
1314
INSTALLER_DIR = os.path.join(PROJECT_DIR, "installer")
15+
MANUAL_DIR = os.path.join(PROJECT_DIR, "manual")
1416

1517

1618
def read_text(path):
@@ -50,6 +52,16 @@ def markdown_to_plain_text(markdown):
5052
return "\n".join(lines).strip() + "\n"
5153

5254

55+
def markdown_inline_to_text(markdown):
56+
text = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", markdown)
57+
text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
58+
text = re.sub(r"__([^_]+)__", r"\1", text)
59+
text = re.sub(r"\*([^*]+)\*", r"\1", text)
60+
text = re.sub(r"_([^_]+)_", r"\1", text)
61+
text = re.sub(r"`([^`]+)`", r"\1", text)
62+
return text.strip()
63+
64+
5365
def rtf_escape(text):
5466
escaped = []
5567

@@ -95,8 +107,169 @@ def markdown_file_to_plain_text(name):
95107
return markdown_to_plain_text(read_text(os.path.join(INSTALLER_DIR, name + ".md")))
96108

97109

110+
def pdf_text(text):
111+
text = text.encode("cp1252", "replace").decode("cp1252")
112+
return text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
113+
114+
115+
def write_pdf(path, page_streams):
116+
os.makedirs(os.path.dirname(path), exist_ok=True)
117+
118+
objects = [
119+
b"<< /Type /Catalog /Pages 2 0 R >>",
120+
None,
121+
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
122+
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>",
123+
]
124+
125+
page_refs = []
126+
for stream in page_streams:
127+
stream_bytes = stream.encode("cp1252", "replace")
128+
page_object_id = len(objects) + 1
129+
stream_object_id = len(objects) + 2
130+
page_refs.append(f"{page_object_id} 0 R")
131+
objects.append(
132+
(
133+
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] "
134+
"/Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> "
135+
f"/Contents {stream_object_id} 0 R >>"
136+
).encode("ascii")
137+
)
138+
objects.append(
139+
b"<< /Length " + str(len(stream_bytes)).encode("ascii") + b" >>\nstream\n" +
140+
stream_bytes + b"\nendstream"
141+
)
142+
143+
objects[1] = (
144+
f"<< /Type /Pages /Kids [{' '.join(page_refs)}] /Count {len(page_refs)} >>"
145+
).encode("ascii")
146+
147+
output = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
148+
offsets = [0]
149+
150+
for index, content in enumerate(objects, start=1):
151+
offsets.append(len(output))
152+
output.extend(f"{index} 0 obj\n".encode("ascii"))
153+
output.extend(content)
154+
output.extend(b"\nendobj\n")
155+
156+
xref_offset = len(output)
157+
output.extend(f"xref\n0 {len(objects) + 1}\n".encode("ascii"))
158+
output.extend(b"0000000000 65535 f \n")
159+
for offset in offsets[1:]:
160+
output.extend(f"{offset:010d} 00000 n \n".encode("ascii"))
161+
output.extend(
162+
(
163+
"trailer\n"
164+
f"<< /Size {len(objects) + 1} /Root 1 0 R >>\n"
165+
"startxref\n"
166+
f"{xref_offset}\n"
167+
"%%EOF\n"
168+
).encode("ascii")
169+
)
170+
171+
with open(path, "wb") as output_file:
172+
output_file.write(output)
173+
174+
175+
def write_markdown_pdf(markdown_path, pdf_path):
176+
markdown = read_text(markdown_path)
177+
page_width = 595
178+
page_height = 842
179+
margin = 54
180+
page_streams = []
181+
stream_lines = []
182+
y = page_height - margin
183+
184+
def new_page():
185+
nonlocal stream_lines, y
186+
if stream_lines:
187+
page_streams.append("\n".join(stream_lines))
188+
stream_lines = []
189+
y = page_height - margin
190+
191+
def add_vertical_space(points):
192+
nonlocal y
193+
if y - points < margin:
194+
new_page()
195+
else:
196+
y -= points
197+
198+
def add_text(text, font="F1", size=11, leading=15, indent=0):
199+
nonlocal y
200+
max_chars = max(10, int((page_width - (2 * margin) - indent) / (size * 0.52)))
201+
wrapped_lines = textwrap.wrap(text, width=max_chars) or [""]
202+
203+
for line in wrapped_lines:
204+
if y - leading < margin:
205+
new_page()
206+
x = margin + indent
207+
stream_lines.append(f"BT /{font} {size} Tf {x} {y:.2f} Td ({pdf_text(line)}) Tj ET")
208+
y -= leading
209+
210+
paragraph_lines = []
211+
in_code_block = False
212+
213+
def flush_paragraph():
214+
nonlocal paragraph_lines
215+
if paragraph_lines:
216+
add_text(markdown_inline_to_text(" ".join(paragraph_lines)))
217+
add_vertical_space(6)
218+
paragraph_lines = []
219+
220+
for raw_line in markdown.splitlines():
221+
stripped = raw_line.strip()
222+
223+
if stripped.startswith("```"):
224+
flush_paragraph()
225+
in_code_block = not in_code_block
226+
continue
227+
228+
if in_code_block:
229+
add_text(stripped, size=10, leading=13, indent=12)
230+
continue
231+
232+
if not stripped:
233+
flush_paragraph()
234+
continue
235+
236+
heading = re.match(r"^(#{1,6})\s+(.+)$", stripped)
237+
bullet = re.match(r"^[-*+]\s+(.+)$", stripped)
238+
numbered = re.match(r"^(\d+\.)\s+(.+)$", stripped)
239+
240+
if heading:
241+
flush_paragraph()
242+
level = len(heading.group(1))
243+
size = 22 if level == 1 else 16 if level == 2 else 13
244+
add_vertical_space(8 if y < page_height - margin else 0)
245+
add_text(markdown_inline_to_text(heading.group(2)), font="F2", size=size, leading=size + 6)
246+
add_vertical_space(6)
247+
elif bullet:
248+
flush_paragraph()
249+
add_text("- " + markdown_inline_to_text(bullet.group(1)), indent=12)
250+
elif numbered:
251+
flush_paragraph()
252+
add_text(numbered.group(1) + " " + markdown_inline_to_text(numbered.group(2)), indent=12)
253+
else:
254+
paragraph_lines.append(stripped)
255+
256+
flush_paragraph()
257+
if not stream_lines:
258+
add_text(" ")
259+
page_streams.append("\n".join(stream_lines))
260+
write_pdf(pdf_path, page_streams)
261+
262+
263+
def build_manual_pdf(build_dir_name):
264+
source_path = os.path.join(MANUAL_DIR, PRODUCT_NAME + " manual.md")
265+
target_path = os.path.join(PROJECT_DIR, build_dir_name, "manual", PRODUCT_NAME + " manual.pdf")
266+
write_markdown_pdf(source_path, target_path)
267+
print("Prepared manual PDF at " + target_path)
268+
269+
98270
def build_mac_docs():
99271
target_dir = os.path.join(PROJECT_DIR, "build-mac", "installer", "resources")
272+
build_manual_pdf("build-mac")
100273

101274
for name in ("license", "readme-mac", "intro"):
102275
source_text = markdown_file_to_plain_text(name)
@@ -112,6 +285,7 @@ def build_mac_docs():
112285

113286
def build_win_docs():
114287
target_dir = os.path.join(PROJECT_DIR, "build-win", "installer-docs")
288+
build_manual_pdf("build-win")
115289

116290
for name in ("license", "readme-win", "readme-win-demo"):
117291
write_text(os.path.join(target_dir, name + ".txt"), markdown_file_to_plain_text(name))

0 commit comments

Comments
 (0)