a5bbdde480
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>
88 lines
3.3 KiB
JavaScript
88 lines
3.3 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { encodeGif } = require('../core/gif');
|
|
const raster = require('../core/raster');
|
|
const { guideSlug, renderAllImages } = require('./common');
|
|
|
|
/**
|
|
* Animated GIF exporter: one frame per image step, optional title card,
|
|
* optional title overlay and progress bar per frame.
|
|
*/
|
|
|
|
const DEFAULT_TEMPLATE = {
|
|
width: 800,
|
|
frameDelayCs: 220,
|
|
loop: 0,
|
|
titleCard: true,
|
|
titleOverlay: true,
|
|
progressBar: true,
|
|
background: '#FFFFFF',
|
|
};
|
|
|
|
function exportGifGuide(ast, outDir, template = {}) {
|
|
const tpl = { ...DEFAULT_TEMPLATE, ...template };
|
|
const images = renderAllImages(ast);
|
|
const stepsWithImages = ast.steps.filter((s) => images.has(s.stepId));
|
|
if (!stepsWithImages.length) throw new Error('gif export: guide has no image steps');
|
|
|
|
// Frame height derives from the median aspect ratio so most shots fit.
|
|
const ratios = stepsWithImages.map((s) => {
|
|
const img = images.get(s.stepId);
|
|
return img.height / img.width;
|
|
}).sort((a, b) => a - b);
|
|
const ratio = ratios[Math.floor(ratios.length / 2)];
|
|
const W = tpl.width;
|
|
const H = Math.round(W * ratio) + (tpl.titleOverlay ? 36 : 0) + (tpl.progressBar ? 8 : 0);
|
|
const bg = raster.parseColor(tpl.background, [255, 255, 255, 255]);
|
|
|
|
const frames = [];
|
|
|
|
if (tpl.titleCard) {
|
|
const card = raster.createImage(W, H, [31, 41, 55, 255]);
|
|
raster.drawTextCentered(card, W / 2, H / 2 - 14, fitText(ast.guide.title, W, 22), 22, [255, 255, 255, 255]);
|
|
raster.drawTextCentered(card, W / 2, H / 2 + 18, `${stepsWithImages.length} steps`, 12, [156, 163, 175, 255]);
|
|
frames.push(card);
|
|
}
|
|
|
|
let n = 0;
|
|
for (const step of stepsWithImages) {
|
|
n += 1;
|
|
const frame = raster.createImage(W, H, bg);
|
|
const headerH = tpl.titleOverlay ? 36 : 0;
|
|
const footerH = tpl.progressBar ? 8 : 0;
|
|
const availH = H - headerH - footerH;
|
|
|
|
const src = images.get(step.stepId);
|
|
let dw = W, dh = Math.round((src.height / src.width) * W);
|
|
if (dh > availH) { dh = availH; dw = Math.round((src.width / src.height) * availH); }
|
|
const scaled = raster.resize(src, dw, dh);
|
|
raster.drawImage(frame, scaled, Math.round((W - dw) / 2), headerH + Math.round((availH - dh) / 2));
|
|
|
|
if (tpl.titleOverlay) {
|
|
raster.fillRect(frame, 0, 0, W, headerH, [31, 41, 55, 255]);
|
|
raster.drawText(frame, 10, 10, fitText(`${step.number}. ${step.title || ''}`, W - 20, 14), 14, [255, 255, 255, 255]);
|
|
}
|
|
if (tpl.progressBar) {
|
|
raster.fillRect(frame, 0, H - footerH, W, footerH, [229, 231, 235, 255]);
|
|
raster.fillRect(frame, 0, H - footerH, Math.round((W * n) / stepsWithImages.length), footerH, [37, 99, 235, 255]);
|
|
}
|
|
frames.push(frame);
|
|
}
|
|
|
|
fs.mkdirSync(outDir, { recursive: true });
|
|
const file = path.join(outDir, `${guideSlug(ast)}.gif`);
|
|
fs.writeFileSync(file, encodeGif(frames, { delayCs: tpl.frameDelayCs, loop: tpl.loop }));
|
|
return { file, frameCount: frames.length, width: W, height: H };
|
|
}
|
|
|
|
function fitText(text, maxWidthPx, sizePx) {
|
|
const scale = Math.max(1, Math.round(sizePx / 8));
|
|
const maxChars = Math.max(4, Math.floor(maxWidthPx / (8 * scale)) - 1);
|
|
const t = String(text);
|
|
return t.length > maxChars ? `${t.slice(0, maxChars - 1)}…` : t;
|
|
}
|
|
|
|
module.exports = { exportGifGuide, DEFAULT_TEMPLATE };
|