Add binary exporters and template manager
Template tests / tests (push) Failing after 52s

- Native PDF exporter (cover, TOC, bookmarks, images, code, tables,
  text blocks); validated under Ghostscript
- Animated GIF exporter (title card, title overlay, progress bar)
- Image bundle exporter with watermark compositing
- DOCX and PPTX emitters (hand-built OOXML over our zip writer) with
  structural + relationship + XML well-formedness validation in tests
- Per-format template manager with .sfglt share archives
- Unified export dispatcher covering all nine formats
- 10 more workflow tests (52 total)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Iisyourdad
2026-06-10 16:59:19 -05:00
parent ca73db68e3
commit a5bbdde480
9 changed files with 1072 additions and 1 deletions
+99
View File
@@ -0,0 +1,99 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const { zipSync, unzipSync } = require('./zip');
const { writeJsonSync, readJsonSync, atomicWriteFileSync, nowIso } = require('./util');
/**
* Per-format export templates stored under settings/templates/<format>/.
* Templates are plain JSON option objects merged over each exporter's
* defaults, shareable as .sfglt zip files.
*/
const FORMATS = ['json', 'markdown', 'html-simple', 'html-rich', 'pdf', 'gif', 'image-bundle', 'docx', 'pptx'];
class TemplateManager {
constructor(templatesDir) {
this.dir = templatesDir;
}
formatDir(format) {
if (!FORMATS.includes(format)) throw new Error(`unknown export format: ${format}`);
return path.join(this.dir, format);
}
fileFor(format, name) {
if (!/^[A-Za-z0-9 _-]+$/.test(name)) throw new Error(`bad template name: ${name}`);
return path.join(this.formatDir(format), `${name}.template.json`);
}
list(format) {
const dir = this.formatDir(format);
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir)
.filter((f) => f.endsWith('.template.json'))
.map((f) => f.slice(0, -'.template.json'.length))
.sort();
}
load(format, name) {
const file = this.fileFor(format, name);
if (!fs.existsSync(file)) return null;
return readJsonSync(file).options || {};
}
save(format, name, options) {
writeJsonSync(this.fileFor(format, name), {
schemaVersion: 1, format, name, updatedAt: nowIso(), options,
});
return name;
}
rename(format, oldName, newName) {
const src = this.fileFor(format, oldName);
if (!fs.existsSync(src)) throw new Error(`template not found: ${oldName}`);
const options = readJsonSync(src).options;
this.save(format, newName, options);
fs.rmSync(src);
}
duplicate(format, name, copyName) {
const options = this.load(format, name);
if (options === null) throw new Error(`template not found: ${name}`);
return this.save(format, copyName || `${name} copy`, options);
}
remove(format, name) {
fs.rmSync(this.fileFor(format, name), { force: true });
}
/** Export one template as a shareable .sfglt file. */
exportTemplate(format, name, destFile) {
const options = this.load(format, name);
if (options === null) throw new Error(`template not found: ${name}`);
const manifest = { format: 'stepforge-template-archive', formatVersion: 1, exportFormat: format, name, exportedAt: nowIso() };
atomicWriteFileSync(destFile, zipSync([
{ name: 'manifest.json', data: JSON.stringify(manifest, null, 2) },
{ name: 'template.json', data: JSON.stringify({ options }, null, 2) },
]));
return destFile;
}
/** Import a .sfglt file; returns { format, name }. */
importTemplate(file) {
const entries = new Map(unzipSync(fs.readFileSync(file)).map((e) => [e.name, e.data]));
if (!entries.has('manifest.json') || !entries.has('template.json')) {
throw new Error('not a StepForge template archive');
}
const manifest = JSON.parse(entries.get('manifest.json').toString('utf8'));
if (manifest.format !== 'stepforge-template-archive') throw new Error('unsupported template archive');
const { options } = JSON.parse(entries.get('template.json').toString('utf8'));
let name = manifest.name || 'imported';
if (this.list(manifest.exportFormat).includes(name)) name = `${name} (imported)`;
this.save(manifest.exportFormat, name, options || {});
return { format: manifest.exportFormat, name };
}
}
module.exports = { TemplateManager, FORMATS };