415ad5c0fe
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.
454 lines
14 KiB
JavaScript
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();
|