bigbluebutton-Github/bbb-export-annotations/workers/process.js
danielpetri1 415ad5c0fe Force GhostScript orientation
GhostScript is incorrectly inferring the orientation of a slide by looking at the rotation of SVG elements such as whiteboard text. This PR forces GhostScript to retain the orientation of each page.
2024-09-24 22:10:50 +00:00

454 lines
14 KiB
JavaScript

import Logger from '../lib/utils/logger.js';
import fs from 'fs';
import {createSVGWindow} from 'svgdom';
import {SVG as svgCanvas, registerWindow} from '@svgdotjs/svg.js';
import cp from 'child_process';
import WorkerStarter from '../lib/utils/worker-starter.js';
import {workerData} from 'worker_threads';
import path from 'path';
import sanitize from 'sanitize-filename';
import redis from 'redis';
import {PresAnnStatusMsg} from '../lib/utils/message-builder.js';
import {sortByKey} from '../shapes/helpers.js';
import {Draw} from '../shapes/Draw.js';
import {Highlight} from '../shapes/Highlight.js';
import {Line} from '../shapes/Line.js';
import {Arrow} from '../shapes/Arrow.js';
import {TextShape} from '../shapes/TextShape.js';
import {StickyNote} from '../shapes/StickyNote.js';
import {createGeoObject} from '../shapes/geoFactory.js';
import {Frame} from '../shapes/Frame.js';
const jobId = workerData.jobId;
const logger = new Logger('presAnn Process Worker');
const config = JSON.parse(fs.readFileSync('./config/settings.json', 'utf8'));
logger.info('Processing PDF for job ' + jobId);
const dropbox = path.join(config.shared.presAnnDropboxDir, jobId);
const job = fs.readFileSync(path.join(dropbox, 'job'));
const exportJob = JSON.parse(job);
const statusUpdate = new PresAnnStatusMsg(exportJob,
PresAnnStatusMsg.EXPORT_STATUSES.PROCESSING);
/**
* Converts measured points to pixels, using the predefined points-per-inch
* and pixels-per-inch ratios from the configuration.
*
* @function toPx
* @param {number} pt - The measurement in points to be converted.
* @return {number} The converted measurement in pixels.
*/
function toPx(pt) {
return (pt / config.process.pointsPerInch) * config.process.pixelsPerInch;
}
/**
* Creates a new drawing instance from the provided annotation
* and then adds the resulting drawn element to the SVG.
*
* @function overlayDraw
* @param {Object} svg - The SVG element to which the drawing will be added.
* @param {Object} annotation - The annotation data used to create the drawing.
* @return {void}
*/
function overlayDraw(svg, annotation) {
const drawing = new Draw(annotation);
const drawnDrawing = drawing.draw();
svg.add(drawnDrawing);
}
/**
* Creates a geometric object from the annotation and then adds
* the rendered shape to the SVG.
* @function overlayGeo
* @param {Object} svg - SVG element to which the geometric shape will be added.
* @param {Object} annotation - Annotation data used to create the geo shape.
* @return {void}
*/
function overlayGeo(svg, annotation) {
const geo = createGeoObject(annotation);
const geoDrawn = geo.draw();
svg.add(geoDrawn);
}
/**
* Applies a highlight effect to an SVG element using the provided annotation.
* Adjusts the annotation's opacity and draws the highlight.
* @function overlayHighlight
* @param {Object} svg - SVG element to which the highlight will be applied.
* @param {Object} annotation - JSON annotation data.
* @return {void}
*/
function overlayHighlight(svg, annotation) {
// Adjust JSON properties
annotation.opacity = 0.3;
const highlight = new Highlight(annotation);
const highlightDrawn = highlight.draw();
svg.add(highlightDrawn);
}
/**
* Adds a line to an SVG element based on the provided annotation.
* It creates a line object from the annotation and then adds
* the rendered line to the SVG.
* @function overlayLine
* @param {Object} svg - SVG element to which the line will be added.
* @param {Object} annotation - JSON annotation data for the line.
* @return {void}
*/
function overlayLine(svg, annotation) {
const line = new Line(annotation);
const lineDrawn = line.draw();
svg.add(lineDrawn);
}
/**
* Adds an arrow to an SVG element using the provided annotation data.
* It constructs an arrow object and then appends the drawn arrow to the SVG.
* @function overlayArrow
* @param {Object} svg - The SVG element where the arrow will be added.
* @param {Object} annotation - JSON annotation data for the arrow.
* @return {void}
*/
function overlayArrow(svg, annotation) {
const arrow = new Arrow(annotation);
const arrowDrawn = arrow.draw();
svg.add(arrowDrawn);
}
/**
* Overlays a sticky note onto an SVG element based on the given annotation.
* Creates a sticky note instance and then appends the rendered note to the SVG.
* @function overlaySticky
* @param {Object} svg - SVG element to which the sticky note will be added.
* @param {Object} annotation - JSON annotation data for the sticky note.
* @return {void}
*/
function overlaySticky(svg, annotation) {
const stickyNote = new StickyNote(annotation);
const stickyNoteDrawn = stickyNote.draw();
svg.add(stickyNoteDrawn);
}
/**
* Overlays text onto an SVG element using the provided annotation data.
* Initializes a text shape object with the annotation and then adds
* the rendered text to the SVG.
* @function overlayText
* @param {Object} svg - The SVG element where the text will be added.
* @param {Object} annotation - JSON annotation data for the text.
* @return {void}
*/
function overlayText(svg, annotation) {
if (annotation?.props?.size == null || annotation?.props?.text?.length == 0) {
return;
}
const text = new TextShape(annotation);
const textDrawn = text.draw();
svg.add(textDrawn);
}
/**
* Adds a frame shape to the canvas.
* @function overlayText
* @param {Object} svg - The SVG element where the frame will be added.
* @param {Object} annotation - JSON frame data.
* @return {void}
*/
function overlayFrame(svg, annotation) {
const frameShape = new Frame(annotation);
const frame = frameShape.draw();
svg.add(frame);
}
/**
* Determines the annotation type and overlays the corresponding shape
* onto the SVG element. It delegates the rendering to the specific
* overlay function based on the annotation type.
* @function overlayAnnotation
* @param {Object} svg - SVG element onto which the annotation will be overlaid.
* @param {Object} annotation - JSON annotation data.
* @return {void}
*/
export function overlayAnnotation(svg, annotation) {
try {
switch (annotation.type) {
case 'draw':
overlayDraw(svg, annotation);
break;
case 'geo':
overlayGeo(svg, annotation);
break;
case 'highlight':
overlayHighlight(svg, annotation);
break;
case 'line':
overlayLine(svg, annotation);
break;
case 'arrow':
overlayArrow(svg, annotation);
break;
case 'text':
overlayText(svg, annotation);
break;
case 'note':
overlaySticky(svg, annotation);
break;
case 'frame':
overlayFrame(svg, annotation);
break;
default:
logger.info(`Unknown annotation type ${annotation.type}.`);
logger.info(annotation);
}
} catch (error) {
logger.warn('Failed to overlay annotation',
{failedAnnotation: annotation, error: error});
}
}
/**
* Overlays a collection of annotations onto an SVG element.
* It sorts the annotations by their index before overlaying them to maintain
* the stacking order.
* @function overlayAnnotations
* @param {Object} svg - SVG element onto which annotations will be overlaid.
* @param {Array} slideAnnotations - Array of JSON annotation data objects.
* @return {void}
*/
function overlayAnnotations(svg, slideAnnotations) {
// Sort annotations by lowest child index
slideAnnotations = sortByKey(slideAnnotations, 'annotationInfo', 'index');
// Map to store frames and their children
const frameMap = new Map();
// First pass to identify frames and initialize them in the map
slideAnnotations.forEach((ann) => {
if (ann.annotationInfo.type === 'frame') {
frameMap.set(
ann.annotationInfo.id,
{children: []});
}
});
// Second pass to add children to the frames
slideAnnotations.forEach((child) => {
// Get the parent of this annotation
const parentId = child.annotationInfo.parentId;
// Check if the annotation is in a frame.
if (frameMap.has(parentId)) {
const frame = frameMap.get(parentId);
frame.children.push(child.annotationInfo);
}
});
for (const annotation of slideAnnotations) {
switch (annotation.annotationInfo.type) {
case 'group':
// Get annotations that have this group as parent
for (const childId of annotation.annotationInfo.children) {
const childAnnotation =
slideAnnotations.find((ann) => ann.id == childId);
overlayAnnotation(svg, childAnnotation.annotationInfo);
}
break;
case 'frame':
const annotationId = annotation.annotationInfo.id;
// Add children to this frame
annotation.annotationInfo.children =
frameMap.get(annotationId).children;
// Intentionally fall through to default case
default:
const parentId = annotation.annotationInfo.parentId;
// Don't render an annotation if it is contained in a frame.
if (!frameMap.has(parentId)) {
overlayAnnotation(svg, annotation.annotationInfo);
}
}
}
}
/**
* Processes presentation slides and associated annotations into
* a single PDF file.
* @async
* @function processPresentationAnnotations
* @return {Promise<void>} A promise that resolves when the process is complete.
*/
async function processPresentationAnnotations() {
const client = redis.createClient({
password: config.redis.password,
socket: {
host: config.redis.host,
port: config.redis.port
}
});
await client.connect();
client.on('error', (err) => logger.info('Redis Client Error', err));
// Get the annotations
const annotations = fs.readFileSync(path.join(dropbox, 'whiteboard'));
const whiteboardJSON = JSON.parse(annotations);
const pages = JSON.parse(whiteboardJSON.pages);
const ghostScriptInput = [];
for (const currentSlide of pages) {
const bgImagePath = path.join(dropbox, `slide${currentSlide.page}`);
const svgBackgroundSlide = path.join(
exportJob.presLocation,
'svgs',
`slide${currentSlide.page}.svg`);
let backgroundFormat = '';
if (fs.existsSync(svgBackgroundSlide)) {
backgroundFormat = 'svg';
} else if (fs.existsSync(`${bgImagePath}.png`)) {
backgroundFormat = 'png';
} else if (fs.existsSync(`${bgImagePath}.jpg`)) {
backgroundFormat = 'jpg';
} else if (fs.existsSync(`${bgImagePath}.jpeg`)) {
backgroundFormat = 'jpeg';
} else {
logger.error(`Skipping slide ${currentSlide.page} (${jobId}): unknown extension`);
continue;
}
// Rescale slide width and height to match tldraw coordinates
const slideWidth = currentSlide.width;
const slideHeight = currentSlide.height;
if (!slideWidth || !slideHeight) {
logger.error(`Skipping slide ${currentSlide.page} (${jobId}): unknown dimensions`);
continue;
}
const maxImageWidth = config.process.maxImageWidth;
const maxImageHeight = config.process.maxImageHeight;
const ratio = Math.min(maxImageWidth / slideWidth,
maxImageHeight / slideHeight);
const scaledWidth = slideWidth * ratio;
const scaledHeight = slideHeight * ratio;
// Create a window with a document and an SVG root node
const window = createSVGWindow();
const document = window.document;
// Register window and document
registerWindow(window, document);
// Create the canvas (root SVG element)
const canvas = svgCanvas(document.documentElement)
.size(scaledWidth, scaledHeight)
.attr({
'xmlns': 'http://www.w3.org/2000/svg',
'xmlns:xlink': 'http://www.w3.org/1999/xlink',
});
// Add the image element
canvas
.image(`file://${dropbox}/slide${currentSlide.page}.${backgroundFormat}`)
.size(scaledWidth, scaledHeight);
// Add a group element with class 'whiteboard'
const whiteboard = canvas.group().attr({class: 'wb'});
// 4. Overlay annotations onto slides
overlayAnnotations(whiteboard, currentSlide.annotations);
const svg = canvas.svg();
// Write annotated SVG file
const SVGfile = path.join(dropbox,
`annotated-slide${currentSlide.page}.svg`);
const PDFfile = path.join(dropbox,
`annotated-slide${currentSlide.page}.pdf`);
fs.writeFileSync(SVGfile, svg, function(err) {
if (err) {
return logger.error(err);
}
});
/**
* Constructs the command arguments for converting an annotated slide from SVG to PDF format.
* `cairoSVGUnsafeFlag` should be enabled (true) for CairoSVG versions >= 2.7.0
* to allow external resources, such as presentation slides, to be loaded.
*
* @const {string[]} convertAnnotatedSlide - The command arguments for the conversion process.
*/
const convertAnnotatedSlide = [
SVGfile,
'--output-width', toPx(slideWidth),
'--output-height', toPx(slideHeight),
...(config.process.cairoSVGUnsafeFlag ? ['-u'] : []),
'-o', PDFfile,
];
try {
cp.spawnSync(config.shared.cairosvg,
convertAnnotatedSlide, {shell: false});
} catch (error) {
logger.error(`Processing slide ${currentSlide.page}
failed for job ${jobId}: ${error.message}`);
statusUpdate.setError();
}
await client.publish(config.redis.channels.publish,
statusUpdate.build(currentSlide.page));
ghostScriptInput.push(PDFfile);
}
const outputDir = path.join(exportJob.presLocation, 'pdfs', jobId);
// Create PDF output directory if it doesn't exist
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, {recursive: true});
}
const serverFilename = exportJob.serverSideFilename.replace(/\s/g, '_');
const sanitizedServerFilename = sanitize(serverFilename);
const serverFilenameWithExtension = `${sanitizedServerFilename}.pdf`;
const mergePDFs = [
'-dNOPAUSE',
'-dAutoRotatePages=/None',
'-sDEVICE=pdfwrite',
`-sOUTPUTFILE="${path.join(outputDir, serverFilenameWithExtension)}"`,
`-dBATCH`].concat(ghostScriptInput);
// Resulting PDF file is stored in the presentation dir
try {
cp.spawnSync(config.shared.ghostscript, mergePDFs, {shell: false});
} catch (error) {
const errorMessage = 'GhostScript failed to merge PDFs in job' +
`${jobId}: ${error.message}`;
return logger.error(errorMessage);
}
// Launch Notifier Worker depending on job type
logger.info('Saved PDF at ',
`${outputDir}/${jobId}/${serverFilenameWithExtension}`);
const notifier = new WorkerStarter({
jobType: exportJob.jobType, jobId,
serverSideFilename: serverFilenameWithExtension,
filename: exportJob.filename});
notifier.notify();
await client.disconnect();
}
processPresentationAnnotations();