You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
246 lines
6.7 KiB
246 lines
6.7 KiB
// you may not need all of these but they come up a lot
|
|
import fs from "fs";
|
|
import logging from '../lib/logging.js';
|
|
import path from "path";
|
|
import { defer } from "../lib/api.js";
|
|
import PDFDocument from "pdfkit";
|
|
import assert from "assert";
|
|
import { mkdir_to, glob, changed } from "../lib/builderator.js";
|
|
|
|
const log = logging.create(import.meta.url);
|
|
|
|
const title_box = { x: 17, y: 415, width: 405, align: "center" };
|
|
const summary_box = { width: 405, height: 540 };
|
|
const main_title = { align: "center", width: 1196, height: 883, x: 586, y: 99};
|
|
const main_box = { width: 928, height: 535, x: 720, y: 323};
|
|
const FORCE = false;
|
|
|
|
export const description = "Generates a PDF presentation from an input .md file and a background template."
|
|
|
|
// your command uses the npm package commander's options format
|
|
export const options = [
|
|
["--heading-font <path>", "Font file to use for headings.", "static/fonts/VictorMono-Bold.ttf"],
|
|
["--body-font <path>", "Font file to use for body text.", "static/fonts/VictorMono-Medium.ttf"],
|
|
["--no-exit", "Normally only used when called directly from other commands."],
|
|
["--force", "Force the generation no matter what.", FORCE]
|
|
]
|
|
|
|
// put required options in the required variable
|
|
export const required = [
|
|
["--input <path>", "The input .md file _GLOB_ for the source files."],
|
|
["--template <path>", "The background template image to use."],
|
|
]
|
|
|
|
export const start_pdf = (opts) => {
|
|
const doc = new PDFDocument({
|
|
font: "Courier",
|
|
size: [1920, 1080],
|
|
info: {
|
|
"Title": "Test",
|
|
"Author": "Zed A. Shaw",
|
|
"Subject": "Test subject",
|
|
"Keywords": "test test test",
|
|
"CreationDate": new Date(),
|
|
"ModDate": new Date(),
|
|
}});
|
|
|
|
const out_stream = doc.pipe(fs.createWriteStream(opts.output));
|
|
|
|
doc.registerFont("heading", opts.headingFont);
|
|
doc.registerFont("body", opts.bodyFont);
|
|
|
|
return [doc, out_stream];
|
|
}
|
|
|
|
const valign = (doc, text, options) => {
|
|
assert(options.y !== undefined, "Missing y in options");
|
|
assert(options.height !== undefined, "Missing height in options");
|
|
|
|
const h = doc.heightOfString(text, options);
|
|
const start = options.y + (0.5 * (options.height - h));
|
|
|
|
return start;
|
|
}
|
|
|
|
const write_page = (doc, template, content) => {
|
|
// place the background image
|
|
doc.image(template, 0, 0, {
|
|
width: 1920,
|
|
height: 1080
|
|
});
|
|
|
|
// create the left side constant summary
|
|
doc.fontSize(40);
|
|
doc.font("heading").text(content.title, title_box.x, title_box.y, title_box);
|
|
doc.fontSize(30);
|
|
doc.moveDown();
|
|
// the lesson summary
|
|
doc.font("body").text(content.summary, summary_box);
|
|
doc.fillColor("white");
|
|
|
|
|
|
if(content.type == "title-bullets") {
|
|
doc.fontSize(120);
|
|
// add the title/body text
|
|
doc.font("heading").text(content.slide_title, main_title.x, main_title.y, main_title);
|
|
|
|
doc.fontSize(80);
|
|
|
|
doc.list(content.slide_body, main_box.x, main_box.y, main_box);
|
|
|
|
} else if(content.type == "title-image") {
|
|
// it's a title only slide
|
|
doc.fontSize(120);
|
|
// add the title/body text
|
|
doc.font("heading").text(content.slide_title, main_title.x, main_title.y, main_title);
|
|
|
|
doc.image(content.slide_body, main_box.x, main_box.y, {
|
|
fit: [main_box.width, main_box.height],
|
|
align: "center",
|
|
valign: "center"
|
|
});
|
|
} else if(content.type == "title-only") {
|
|
// it's a title only slide
|
|
doc.fontSize(160);
|
|
|
|
const title_start = valign(doc, content.slide_title, main_title);
|
|
|
|
doc.font("heading").text(content.slide_title, main_title.x, title_start, main_title);
|
|
} else {
|
|
// this handles title-text slides, and anything else
|
|
// if I put a # I want this to be a subtitle
|
|
doc.fontSize(120);
|
|
doc.font("heading");
|
|
|
|
if(content.slide_body.startsWith("#")) {
|
|
// calculate the title height
|
|
let title_start = valign(doc, content.slide_title, main_title);
|
|
|
|
// calculate 1 line for the move down
|
|
doc.fontSize(100);
|
|
doc.font("body");
|
|
title_start -= doc.heightOfString("H", main_title);
|
|
|
|
doc.fontSize(120);
|
|
// add the title/body text
|
|
doc.font("heading").text(content.slide_title, main_title.x, title_start, main_title);
|
|
|
|
doc.fontSize(100);
|
|
const trim_body = content.slide_body.slice(1).trim();
|
|
doc.moveDown(1);
|
|
|
|
doc.font("body").text(trim_body, {
|
|
align: "center",
|
|
});
|
|
} else {
|
|
doc.fontSize(120);
|
|
// add the title/body text
|
|
doc.font("heading").text(content.slide_title, main_title.x, main_title.y, main_title);
|
|
|
|
doc.moveDown(1);
|
|
|
|
doc.fontSize(60);
|
|
doc.font("body").text(content.slide_body, main_box.x, main_box.y, main_box);
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
const next_page = (doc) => {
|
|
doc.addPage();
|
|
}
|
|
|
|
const end_pdf = (doc) => {
|
|
doc.end();
|
|
}
|
|
|
|
const parse_input = (input) => {
|
|
const data = fs.readFileSync(input).toString();
|
|
const result = [];
|
|
|
|
const [head_data, slides_data] = data.split("===");
|
|
const head = JSON.parse(head_data);
|
|
|
|
for(let slide of slides_data.split("---")) {
|
|
const split = slide.trim().split("\n");
|
|
const title = split.shift();
|
|
const body = split.join("\n").trim();
|
|
|
|
if(title || body) {
|
|
const page = {
|
|
slide_title: title,
|
|
slide_body: body,
|
|
...head
|
|
}
|
|
|
|
if(!body) {
|
|
page.type = "title-only";
|
|
} else if(body.startsWith("*")) {
|
|
page.type = "title-bullets";
|
|
page.slide_body = body.split("\n").map(l => l.slice(1).trim());
|
|
} else if(body.startsWith("[")) {
|
|
page.type = "title-image";
|
|
page.slide_body = body.slice(1,-1);
|
|
} else {
|
|
page.type = "title-text";
|
|
}
|
|
|
|
result.push(page);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
const make_pdf_path = (input) => {
|
|
const result = path.parse(input);
|
|
const target = path.join(result.dir, `${result.name}.pdf`);
|
|
mkdir_to(target);
|
|
return target;
|
|
}
|
|
|
|
const generate_presentation = (opts, waiting) => {
|
|
try {
|
|
const [doc, out_stream] = start_pdf(opts);
|
|
|
|
out_stream.on("finish", () => waiting.resolve());
|
|
|
|
const pages = parse_input(opts.input);
|
|
|
|
for(let i = 0; i < pages.length; i++) {
|
|
const page = pages[i];
|
|
|
|
write_page(doc, opts.template, page);
|
|
|
|
// don't add a page when at the end
|
|
if(i !== pages.length - 1) {
|
|
next_page(doc);
|
|
}
|
|
}
|
|
|
|
end_pdf(doc);
|
|
} catch(error) {
|
|
console.error(error);
|
|
waiting.resolve();
|
|
}
|
|
}
|
|
|
|
export const main = async (opts) => {
|
|
const in_files = glob(opts.input);
|
|
|
|
for(let file of in_files) {
|
|
const settings = {...opts};
|
|
settings.input = file;
|
|
settings.output = make_pdf_path(settings.input);
|
|
|
|
if(opts.force || changed(settings.input, settings.output)) {
|
|
const waiting = defer(file);
|
|
generate_presentation(settings, waiting);
|
|
await waiting;
|
|
} else {
|
|
console.log("SKIP", settings.input);
|
|
}
|
|
}
|
|
|
|
if(!opts.noExit) process.exit(0);
|
|
}
|
|
|