// 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 ", "Font file to use for headings.", "static/fonts/VictorMono-Bold.ttf"], ["--body-font ", "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 ", "The input .md file _GLOB_ for the source files."], ["--template ", "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); }