PDF file: include meeting and room name

This commit is contained in:
Daniel Petri Rocha 2022-04-29 13:50:42 +02:00
parent 717c692468
commit 7851d54484
12 changed files with 138 additions and 55 deletions

View File

@ -35,6 +35,14 @@ class StoreExportJobInRedisPresAnnEvent extends AbstractPresentationWithAnnotati
eventMap.put(JOB_TYPE, jobType)
}
def setMeetingName(meetingName: String) {
eventMap.put(MEETING_NAME, meetingName)
}
def setPresName(presName: String) {
eventMap.put(PRES_NAME, presName)
}
def setPresId(presId: String) {
eventMap.put(PRES_ID, presId)
}
@ -63,6 +71,8 @@ class StoreExportJobInRedisPresAnnEvent extends AbstractPresentationWithAnnotati
object StoreExportJobInRedisPresAnnEvent {
protected final val JOB_ID = "jobId"
protected final val JOB_TYPE = "jobType"
protected final val MEETING_NAME = "meetingName"
protected final val PRES_NAME = "presName"
protected final val PRES_ID = "presId"
protected final val PRES_LOCATION = "presLocation"
protected final val ALL_PAGES = "allPages"

View File

@ -753,6 +753,7 @@ class MeetingActor(
def handleMakePresentationWithAnnotationDownloadReqMsg(m: MakePresentationWithAnnotationDownloadReqMsg, state: MeetingState2x, liveMeeting: LiveMeeting): Unit = {
val meetingId = liveMeeting.props.meetingProp.intId
val meetingName: String = liveMeeting.props.meetingProp.name
// Whiteboard ID
val presId: String = m.body.presId match {
@ -765,18 +766,31 @@ class MeetingActor(
// Determine page amount
val presentationPods: Vector[PresentationPod] = state.presentationPodManager.getAllPresentationPodsInMeeting()
val currentPres = presentationPods.flatMap(_.getCurrentPresentation()).head
val pageCount = currentPres.pages.size
val currentPres = presentationPods.flatMap(_.getCurrentPresentation()).headOption
currentPres match {
case None =>
log.error(s"No presentation set in meeting ${meetingId}")
return
case _ => ()
}
val pageCount = currentPres.get.pages.size
val pagesRange: List[Int] = if (allPages) (1 to pageCount).toList else pages
var storeAnnotationPages = new Array[PresentationPageForExport](pagesRange.size)
var resultingPage = 0
for (pageNumber <- pagesRange) {
if (pageNumber < 1 || pageNumber > pageCount) {
println(pagesRange.length)
log.error(s"Page ${pageNumber} requested for export out of range, aborting")
return
}
var whiteboardId = s"${presId}/${pageNumber.toString}"
val presentationPage: PresentationPage = currentPres.pages(whiteboardId)
val presentationPage: PresentationPage = currentPres.get.pages(whiteboardId)
val xOffset: Double = presentationPage.xOffset
val yOffset: Double = presentationPage.yOffset
val widthRatio: Double = presentationPage.widthRatio
@ -796,7 +810,7 @@ class MeetingActor(
// 2) Insert Export Job in Redis
val jobType = "PresentationWithAnnotationDownloadJob"
val presLocation = s"/var/bigbluebutton/${meetingId}/${meetingId}/${presId}"
val exportJob = new ExportJob(jobId, jobType, presId, presLocation, allPages, pagesRange, meetingId, "")
val exportJob = new ExportJob(jobId, jobType, meetingName, currentPres.get.name, presId, presLocation, allPages, pagesRange, meetingId, "")
var job = buildStoreExportJobInRedisSysMsg(exportJob)
outGW.send(job)
}
@ -804,25 +818,33 @@ class MeetingActor(
def handleExportPresentationWithAnnotationReqMsg(m: ExportPresentationWithAnnotationReqMsg, state: MeetingState2x, liveMeeting: LiveMeeting): Unit = {
val meetingId = liveMeeting.props.meetingProp.intId
val meetingName: String = liveMeeting.props.meetingProp.name
val userId = m.header.userId
val presId: String = getMeetingInfoPresentationDetails.id
val parentMeetingId: String = m.body.parentMeetingId
val allPages: Boolean = m.body.allPages
val presentationPods: Vector[PresentationPod] = state.presentationPodManager.getAllPresentationPodsInMeeting()
val currentPres = presentationPods.flatMap(_.getCurrentPresentation()).head
val currentPage: PresentationPage = PresentationInPod.getCurrentPage(currentPres).get
val currentPres = presentationPods.flatMap(_.getCurrentPresentation()).headOption
val pageCount = currentPres.pages.size
currentPres match {
case None =>
log.error(s"No presentation set in meeting ${meetingId}")
return
case _ => ()
}
val currentPage: PresentationPage = PresentationInPod.getCurrentPage(currentPres.get).get
val pageCount = currentPres.get.pages.size
val pagesRange: List[Int] = if (allPages) (1 to pageCount).toList else List(currentPage.num)
var storeAnnotationPages = new Array[PresentationPageForExport](pagesRange.size)
var resultingPage = 0
for (pageNumber <- pagesRange) {
var whiteboardId = s"${presId}/${pageNumber.toString}"
val presentationPage: PresentationPage = currentPres.pages(whiteboardId)
val presentationPage: PresentationPage = currentPres.get.pages(whiteboardId)
val xOffset: Double = presentationPage.xOffset
val yOffset: Double = presentationPage.yOffset
val widthRatio: Double = presentationPage.widthRatio
@ -837,7 +859,7 @@ class MeetingActor(
val jobId = RandomStringGenerator.randomAlphanumericString(16)
// Informs bbb-web about the token so that when we use it to upload the presentation, it is able to look it up in the list of tokens
outGW.send(buildPresentationUploadTokenSysPubMsg(parentMeetingId, userId, presentationUploadToken, currentPres.name))
outGW.send(buildPresentationUploadTokenSysPubMsg(parentMeetingId, userId, presentationUploadToken, currentPres.get.name))
// 1) Send Annotations to Redis
var annotations = new StoredAnnotations(jobId, presId, storeAnnotationPages)
@ -846,7 +868,7 @@ class MeetingActor(
// 2) Insert Export Job in Redis
val jobType: String = "PresentationWithAnnotationExportJob"
val presLocation = s"/var/bigbluebutton/${meetingId}/${meetingId}/${presId}"
val exportJob = new ExportJob(jobId, jobType, presId, presLocation, allPages, pagesRange, parentMeetingId, presentationUploadToken)
val exportJob = new ExportJob(jobId, jobType, meetingName, currentPres.get.name, presId, presLocation, allPages, pagesRange, parentMeetingId, presentationUploadToken)
var job = buildStoreExportJobInRedisSysMsg(exportJob)
outGW.send(job)
}

View File

@ -75,6 +75,8 @@ class ExportAnnotationsActor(
ev.setJobId(msg.body.exportJob.jobId)
ev.setJobType(msg.body.exportJob.jobType)
ev.setMeetingName(msg.body.exportJob.meetingName)
ev.setPresName(msg.body.exportJob.presName)
ev.setPresId(msg.body.exportJob.presId)
ev.setPresLocation(msg.body.exportJob.presLocation)
ev.setAllPages(msg.body.exportJob.allPages.toString)

View File

@ -21,6 +21,8 @@ case class StoredAnnotations(
case class ExportJob(
jobId: String,
jobType: String,
meetingName: String,
presName: String,
presId: String,
presLocation: String,
allPages: Boolean,

View File

@ -27,9 +27,9 @@
}
}
location ~^\/bigbluebutton\/presentation\/(?<meeting_id_1>[A-Za-z0-9\-]+)\/(?<meeting_id_2>[A-Za-z0-9\-]+)\/(?<pres_id>[A-Za-z0-9\-]+)\/pdf\/(?<job_id>[A-Za-z0-9]+)$ {
location ~^\/bigbluebutton\/presentation\/(?<meeting_id_1>[A-Za-z0-9\-]+)\/(?<meeting_id_2>[A-Za-z0-9\-]+)\/(?<pres_id>[A-Za-z0-9\-]+)\/pdf\/(?<job_id>[A-Za-z0-9]+)/(?<filename>.*)$ {
default_type application/pdf;
alias /var/bigbluebutton/$meeting_id_2/$meeting_id_2/$pres_id/pdfs/annotated_slides_$job_id.pdf;
alias /var/bigbluebutton/$meeting_id_2/$meeting_id_2/$pres_id/pdfs/$job_id/annotated_$filename.pdf;
if ($bbb_loadbalancer_node) {
add_header 'Access-Control-Allow-Origin' $bbb_loadbalancer_node always;
}

View File

@ -4,13 +4,14 @@ const fs = require('fs');
const redis = require('redis');
const { commandOptions } = require('redis');
const { Worker } = require('worker_threads');
const path = require('path');
const logger = new Logger('presAnn Master');
logger.info("Running export-annotations");
const kickOffCollectorWorker = (jobId) => {
return new Promise((resolve, reject) => {
const worker = new Worker('./workers/collector.js', { workerData: jobId });
const worker = new Worker(path.join(__dirname, 'workers', 'collector.js'), { workerData: jobId });
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code) => {
@ -44,11 +45,11 @@ const kickOffCollectorWorker = (jobId) => {
const exportJob = JSON.parse(job.element);
// Create folder in dropbox
let dropbox = `${config.shared.presAnnDropboxDir}/${exportJob.jobId}`
let dropbox = path.join(config.shared.presAnnDropboxDir, exportJob.jobId);
fs.mkdirSync(dropbox, { recursive: true })
// Drop job into dropbox as JSON
fs.writeFile(`${dropbox}/job`, job.element, function(err) {
fs.writeFile(path.join(dropbox, 'job'), job.element, function(err) {
if(err) { return logger.error(err); }
});

View File

@ -12,6 +12,7 @@
"form-data": "^4.0.0",
"image-size": "^1.0.1",
"redis": "^4.0.3",
"sanitize-filename": "^1.6.3",
"xmlbuilder2": "^3.0.2"
}
},
@ -288,11 +289,32 @@
"node": ">=4"
}
},
"node_modules/sanitize-filename": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz",
"integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==",
"dependencies": {
"truncate-utf8-bytes": "^1.0.0"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
},
"node_modules/truncate-utf8-bytes": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
"integrity": "sha1-QFkjkJWS1W94pYGENLC3hInKXys=",
"dependencies": {
"utf8-byte-length": "^1.0.1"
}
},
"node_modules/utf8-byte-length": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz",
"integrity": "sha1-9F8VDExm7uloGGUFq5P8u4rWv2E="
},
"node_modules/xmlbuilder2": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-3.0.2.tgz",
@ -529,11 +551,32 @@
"redis-errors": "^1.0.0"
}
},
"sanitize-filename": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz",
"integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==",
"requires": {
"truncate-utf8-bytes": "^1.0.0"
}
},
"sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
},
"truncate-utf8-bytes": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
"integrity": "sha1-QFkjkJWS1W94pYGENLC3hInKXys=",
"requires": {
"utf8-byte-length": "^1.0.1"
}
},
"utf8-byte-length": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz",
"integrity": "sha1-9F8VDExm7uloGGUFq5P8u4rWv2E="
},
"xmlbuilder2": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-3.0.2.tgz",

View File

@ -10,6 +10,7 @@
"form-data": "^4.0.0",
"image-size": "^1.0.1",
"redis": "^4.0.3",
"sanitize-filename": "^1.6.3",
"xmlbuilder2": "^3.0.2"
}
}

View File

@ -3,8 +3,8 @@ const config = require('../config');
const fs = require('fs');
const redis = require('redis');
const { execSync } = require("child_process");
const { Worker, workerData, parentPort } = require('worker_threads')
const path = require('path');
const jobId = workerData;
@ -23,10 +23,10 @@ const kickOffProcessWorker = (jobId) => {
})
}
let dropbox = `${config.shared.presAnnDropboxDir}/${jobId}`
let dropbox = path.join(config.shared.presAnnDropboxDir, jobId);
// Takes the Job from the dropbox
let job = fs.readFileSync(`${dropbox}/job`);
let job = fs.readFileSync(path.join(dropbox, 'job'));
let exportJob = JSON.parse(job);
// Collect the annotations from Redis
@ -51,17 +51,17 @@ let exportJob = JSON.parse(job);
let whiteboard = JSON.parse(annotations);
let pages = JSON.parse(whiteboard.pages);
fs.writeFile(`${dropbox}/whiteboard`, annotations, function(err) {
fs.writeFile(path.join(dropbox, 'whiteboard'), annotations, function(err) {
if(err) { return logger.error(err); }
});
// Collect the Presentation Page files from the presentation directory
let path = `${exportJob.presLocation}/${exportJob.presId}`;
let pdfFileExists = fs.existsSync(`${path}.pdf`);
let presentationFile = path.join(exportJob.presLocation, exportJob.presId);
let pdfFileExists = fs.existsSync(`${presentationFile}.pdf`);
for (let p of pages) {
let pageNumber = p.page;
let file = `${dropbox}/slide${pageNumber}`;
let outputFile = path.join(dropbox, `slide${pageNumber}`);
if(pdfFileExists) {
let extactSlideAsPDFCommands = [
@ -70,8 +70,8 @@ let exportJob = JSON.parse(job);
'-f', pageNumber,
'-l', pageNumber,
'-singlefile',
`${path}.pdf`,
file
`${presentationFile}.pdf`,
outputFile
].join(' ');
execSync(extactSlideAsPDFCommands, (error, stderr) => {
@ -85,19 +85,19 @@ let exportJob = JSON.parse(job);
})
}
else if (fs.existsSync(`${path}.png`)) {
fs.copyFileSync(`${path}.png`, `${file}.png`);
else if (fs.existsSync(`${presentationFile}.png`)) {
fs.copyFileSync(`${presentationFile}.png`, `${outputFile}.png`);
}
else if (fs.existsSync(`${path}.jpeg`)) {
else if (fs.existsSync(`${presentationFile}.jpeg`)) {
let convertImageToPngCommands = [
'convert',
`${path}.jpeg`,
`${presentationFile}.jpeg`,
'-background', 'white',
'-resize', '1600x1600',
'-auto-orient',
'-flatten',
`${file}.png`
`${outputFile}.png`
].join(' ');
execSync(convertImageToPngCommands, (error, stderr) => {

View File

@ -4,15 +4,16 @@ const fs = require('fs');
const FormData = require('form-data');
const redis = require('redis');
const axios = require('axios').default;
const path = require('path');
const { workerData, parentPort } = require('worker_threads')
const [jobType, jobId] = workerData;
const [jobType, jobId, filename] = workerData;
const logger = new Logger('presAnn Notifier Worker');
const dropbox = `${config.shared.presAnnDropboxDir}/${jobId}`
let job = fs.readFileSync(`${dropbox}/job`);
let job = fs.readFileSync(path.join(dropbox, 'job'));
let exportJob = JSON.parse(job);
async function notifyMeetingActor() {
@ -25,7 +26,7 @@ async function notifyMeetingActor() {
await client.connect();
client.on('error', (err) => logger.info('Redis Client Error', err));
let link = `${config.notifier.protocol}://${config.notifier.host}/bigbluebutton/presentation/${exportJob.parentMeetingId}/${exportJob.parentMeetingId}/${exportJob.presId}/pdf/${jobId}`;
let link = `${config.notifier.protocol}://${config.notifier.host}/bigbluebutton/presentation/${exportJob.parentMeetingId}/${exportJob.parentMeetingId}/${exportJob.presId}/pdf/${jobId}/${filename}`;
// Notify Meeting Actor of file availability by sending a message through Redis PubSub
const notification = {
envelope: {
@ -56,13 +57,10 @@ async function upload(exportJob) {
let callbackUrl = `http://${config.bbbWeb.host}:${config.bbbWeb.port}/bigbluebutton/presentation/${exportJob.presentationUploadToken}/upload`
let formData = new FormData();
formData.append('presentation_name', 'annotated_slides.pdf');
formData.append('Filename', 'annotated_slides');
formData.append('conference', exportJob.parentMeetingId);
formData.append('room', exportJob.parentMeetingId);
formData.append('pod_id', config.notifier.pod_id);
formData.append('is_downloadable', config.notifier.is_downloadable);
formData.append('fileUpload', fs.createReadStream(`${exportJob.presLocation}/pdfs/annotated_slides_${jobId}.pdf`));
formData.append('fileUpload', fs.createReadStream(`${exportJob.presLocation}/pdfs/${jobId}/${filename}.pdf`));
let res = await axios.post(callbackUrl, formData, { headers: formData.getHeaders() });
logger.info(`Upload of job ${exportJob.jobId} returned ${res.data}`);

View File

@ -5,6 +5,8 @@ const sizeOf = require('image-size');
const { create } = require('xmlbuilder2', { encoding: 'utf-8' });
const { execSync } = require("child_process");
const { Worker, workerData, parentPort } = require('worker_threads');
const path = require('path');
const sanitize = require("sanitize-filename");
const jobId = workerData;
const MAGIC_MYSTERY_NUMBER = 2;
@ -12,9 +14,9 @@ const MAGIC_MYSTERY_NUMBER = 2;
const logger = new Logger('presAnn Process Worker');
logger.info("Processing PDF for job " + jobId);
const kickOffNotifierWorker = (jobType) => {
const kickOffNotifierWorker = (jobType, sanitizedFilename) => {
return new Promise((resolve, reject) => {
const worker = new Worker('./workers/notifier.js', { workerData: [jobType, jobId] });
const worker = new Worker('./workers/notifier.js', { workerData: [jobType, jobId, sanitizedFilename] });
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code) => {
@ -43,7 +45,7 @@ function render_HTMLTextBox(htmlFilePath, id, width, height) {
'--crop-h', height,
'--log-level', 'none',
'--quality', '100',
htmlFilePath, `${dropbox}/text${id}.png`
htmlFilePath, path.join(dropbox, `text${id}.png`)
]
execSync(commands.join(' '), (error, stderr) => {
@ -176,8 +178,8 @@ function overlay_poll(svg, annotation, w, h) {
let poll_width = Math.round(scale_shape(w, annotation.points[2]));
let poll_height = Math.round(scale_shape(h, annotation.points[3]));
let pollId = annotation.id.replace(/\//g, '');
let pollSVG = `${dropbox}/poll-${pollId}.svg`
let pollJSON = `${dropbox}/poll-${pollId}.json`
let pollSVG = path.join(dropbox, `poll-${pollId}.svg`);
let pollJSON = path.join(dropbox, `poll-${pollId}.json`);
// Rename 'numVotes' key to 'num_votes'
let pollJSONContent = annotation.result.map(result => {
@ -281,7 +283,7 @@ function overlay_text(svg, annotation, w, h) {
</p>
</html>`;
var htmlFilePath = `${dropbox}/text${annotation.id}.html`
var htmlFilePath = path.join(dropbox, `text${annotation.id}.html`)
fs.writeFileSync(htmlFilePath, html, function (err) {
if (err) logger.error(err)
@ -331,19 +333,19 @@ function overlay_annotations(svg, currentSlideAnnotations, w, h) {
// Process the presentation pages and annotations into a PDF file
// 1. Get the job
const dropbox = `${config.shared.presAnnDropboxDir}/${jobId}`
let job = fs.readFileSync(`${dropbox}/job`);
const dropbox = path.join(config.shared.presAnnDropboxDir, jobId);
let job = fs.readFileSync(path.join(dropbox, 'job'));
let exportJob = JSON.parse(job);
// 2. Get the annotations
let annotations = fs.readFileSync(`${dropbox}/whiteboard`);
let annotations = fs.readFileSync(path.join(dropbox, 'whiteboard'));
let whiteboard = JSON.parse(annotations);
let pages = JSON.parse(whiteboard.pages);
let ghostScriptInput = ""
// 3. Convert annotations to SVG
for (let currentSlide of pages) {
var dimensions = sizeOf(`${dropbox}/slide${currentSlide.page}.png`);
var dimensions = sizeOf(path.join(dropbox, `slide${currentSlide.page}.png`));
var slideWidth = dimensions.width;
var slideHeight = dimensions.height;
@ -381,8 +383,8 @@ for (let currentSlide of pages) {
svg = svg.end({ prettyPrint: true });
// Write annotated SVG file
let SVGfile = `${dropbox}/annotated-slide${currentSlide.page}.svg`
let PDFfile = `${dropbox}/annotated-slide${currentSlide.page}.pdf`
let SVGfile = path.join(dropbox, `annotated-slide${currentSlide.page}.svg`)
let PDFfile = path.join(dropbox, `annotated-slide${currentSlide.page}.pdf`)
fs.writeFileSync(SVGfile, svg, function(err) {
if(err) { return logger.error(err); }
@ -408,14 +410,16 @@ for (let currentSlide of pages) {
}
// Create PDF output directory if it doesn't exist
let output_dir = `${exportJob.presLocation}/pdfs`;
if (!fs.existsSync(output_dir)) { fs.mkdirSync(output_dir); }
let output_dir = path.join(exportJob.presLocation, 'pdfs', jobId);
let filename = sanitize(`annotated_${exportJob.meetingName}_${path.parse(exportJob.presName).name}`).replace(/\s/g, '_');
if (!fs.existsSync(output_dir)) { fs.mkdirSync(output_dir, { recursive: true }); }
let mergePDFs = [
'gs',
'-dNOPAUSE',
'-sDEVICE=pdfwrite',
`-sOUTPUTFILE=${output_dir}/annotated_slides_${jobId}.pdf`,
`-sOUTPUTFILE="${path.join(output_dir, `${filename}.pdf`)}"`,
`-dBATCH`,
ghostScriptInput,
].join(' ');
@ -432,8 +436,8 @@ execSync(mergePDFs, (error, stderr) => {
});
// Launch Notifier Worker depending on job type
logger.info(`Saved PDF at ${output_dir}/annotated_slides_${jobId}.pdf`);
logger.info(`Saved PDF at ${output_dir}/${jobId}/${filename}.pdf`);
kickOffNotifierWorker(exportJob.jobType);
kickOffNotifierWorker(exportJob.jobType, filename);
parentPort.postMessage({ message: workerData })