diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/presentation/handlers/SvgConversionHandler.java b/bbb-common-web/src/main/java/org/bigbluebutton/presentation/handlers/SvgConversionHandler.java index 3d2f7a4953ca..0e57e8f6f996 100644 --- a/bbb-common-web/src/main/java/org/bigbluebutton/presentation/handlers/SvgConversionHandler.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/presentation/handlers/SvgConversionHandler.java @@ -18,6 +18,9 @@ public class SvgConversionHandler extends AbstractCommandHandler { private static String USE_TAG_OUTPUT = " tags in the generated SVG. + */ + public int numberOfMaskTags() { + if (stdoutContains(MASK_TAG_OUTPUT)) { + try { + String out = stdoutBuilder.toString(); + Pattern r = Pattern.compile(MASK_TAG_PATTERN); + Matcher m = r.matcher(out); + m.find(); + return Integer.parseInt(m.group(0).replace(MASK_TAG_OUTPUT, "").trim()); + } catch (Exception e) { + log.error("Exception counting the number of mask tags", e); + return 0; + } + } + return 0; + } + @Override protected String getIdTag() { return id; diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/presentation/imp/SvgImageCreatorImp.java b/bbb-common-web/src/main/java/org/bigbluebutton/presentation/imp/SvgImageCreatorImp.java index 63809d73f5d2..10450f0144ed 100755 --- a/bbb-common-web/src/main/java/org/bigbluebutton/presentation/imp/SvgImageCreatorImp.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/presentation/imp/SvgImageCreatorImp.java @@ -32,6 +32,7 @@ public class SvgImageCreatorImp implements SvgImageCreator { private long imageTagThreshold; private long useTagThreshold; private long pathsThreshold; + private long maskTagThreshold = 0; private int convPdfToSvgTimeout = 60; private int pdfFontsTimeout = 3; private int svgResolutionPpi = 300; @@ -226,6 +227,7 @@ private boolean generateSvgImage(File imagePresentationDir, UploadedPresentation pHandler.numberOfImageTags() > imageTagThreshold || pHandler.numberOfPaths() > pathsThreshold || pHandler.numberOfUseTags() > useTagThreshold || + (maskTagThreshold > 0 && pHandler.numberOfMaskTags() >= maskTagThreshold) || rasterizeCurrSlide) { // We need t delete the destination file as we are starting a @@ -246,6 +248,7 @@ private boolean generateSvgImage(File imagePresentationDir, UploadedPresentation logData.put("fileExists", destsvg.exists()); logData.put("numberOfImages", pHandler.numberOfImageTags()); logData.put("numberOfPaths", pHandler.numberOfPaths()); + logData.put("numberOfMasks", pHandler.numberOfMaskTags()); logData.put("logCode", "potential_problem_with_svg"); logData.put("message", "Potential problem with generated SVG"); Gson gson = new Gson(); @@ -412,7 +415,7 @@ private NuProcessBuilder createConversionProcess(String format, int page, String rawCommand += " -q -f " + String.valueOf(page) + " -l " + String.valueOf(page) + " " + source + " " + destFile; if (analyze) { - rawCommand += " && grep -oE ' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bbb-common-web/src/test/scala/org/bigbluebutton/presentation/handlers/SvgConversionHandlerTest.scala b/bbb-common-web/src/test/scala/org/bigbluebutton/presentation/handlers/SvgConversionHandlerTest.scala new file mode 100644 index 000000000000..cf3daa2ace65 --- /dev/null +++ b/bbb-common-web/src/test/scala/org/bigbluebutton/presentation/handlers/SvgConversionHandlerTest.scala @@ -0,0 +1,76 @@ +package org.bigbluebutton.presentation.handlers + +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets +import java.nio.file.{ Files, Paths } + +import org.bigbluebutton.api.util.UnitSpec + +/** + * Tests the tag-count parsing of SvgConversionHandler. + * + * During SVG slide analysis the conversion process runs + * grep -oE ' elements. + */ +class SvgConversionHandlerTest extends UnitSpec { + + val sampleSvgFile = "src/test/resources/sample-with-mask.svg" + val analyzedTags = List(" -1) { + count += 1 + idx = content.indexOf(token, idx + token.length) + } + count + } + + // Replicates the analysis pipeline: grep -oE ' "%7d %s\n".format(countOccurrences(content, tag), tag)).mkString + } + + it should "count mask tags from the analysis output of a generated svg with soft masks" in { + val svgContent = new String(Files.readAllBytes(Paths.get(sampleSvgFile)), StandardCharsets.UTF_8) + val handler = handlerFedWith(uniqCountOutput(svgContent)) + + assert(handler.numberOfMaskTags() == countOccurrences(svgContent, " { await presentation.uploadSinglePresentationTest(); }); + // maskTagThreshold is a SERVER-SIDE bbb-web setting with no client-settings hook, so nothing + // applies it automatically: this test requires bbb-web to be restarted with `maskTagThreshold=1` + // in /etc/bigbluebutton/bbb-web.properties (rasterize any slide whose generated SVG contains a + // tag). The @setting-required tag keeps it out of the default CI gate. + test( + 'Masked slide is rasterized when maskTagThreshold is set', + { tag: '@setting-required:maskTagThreshold' }, + async ({ browser, context, page }, testInfo) => { + const presentation = new Presentation(browser, context); + await presentation.initModPage(page, { testInfo }); + await presentation.maskRasterizationFallbackTest(); + }, + ); + test('Upload Other Presentations Format', async ({ browser, context, page }, testInfo) => { const presentation = new Presentation(browser, context); await presentation.initPages(page, testInfo); diff --git a/bigbluebutton-tests/playwright/presentation/presentation.ts b/bigbluebutton-tests/playwright/presentation/presentation.ts index 76da5daed370..024e92eafb66 100644 --- a/bigbluebutton-tests/playwright/presentation/presentation.ts +++ b/bigbluebutton-tests/playwright/presentation/presentation.ts @@ -333,6 +333,45 @@ export class Presentation extends MultiUsers { } } + async maskRasterizationFallbackTest() { + // wait for whiteboard to load and no notifications + await this.modPage.waitForSelector(e.whiteboard, ELEMENT_WAIT_LONGER_TIME); + await this.modPage.waitForSelector(e.skipSlide); + await this.modPage.closeAllToastNotifications(); + + await uploadSinglePresentation(this.modPage, e.maskSamplePdf, UPLOAD_PDF_WAIT_TIME); + + // secondary check: the uploaded slide is visibly present + await this.modPage.hasElement(e.currentSlideImg, 'should display the uploaded slide as the current slide image'); + + // 4.0 renders slides as tldraw image assets, so a visual snapshot cannot tell a rasterized + // (embedded-PNG) slide apart from a vector slide of the same content. Inspect the served + // slide SVG file instead, deriving its URL from the current tl-image asset. + const slideSvgUrl = await this.modPage.page.evaluate( + ([selector]) => { + const element = document.querySelector(selector) as HTMLElement | null; + return element?.style?.backgroundImage?.split('"')[1] ?? null; + }, + [e.currentSlideImg], + ); + // the asset URL carries pageToken/sessionToken query params, e.g. .../svg/1?pageToken=... + expect(slideSvgUrl, 'should resolve the served slide SVG url from the current slide asset').toMatch( + /\/svg\/\d+(\?|$)/, + ); + + const slideSvgResponse = await this.modPage.page.request.get(slideSvgUrl as string); + expect(slideSvgResponse.ok(), 'should fetch the served slide SVG').toBeTruthy(); + const slideSvgContent = await slideSvgResponse.text(); + + // A rasterized slide is the embedded-PNG SVG produced by SvgImageCreatorImp.createSvgWithEmbeddedPng(): + // a single and no vector elements. pdftocairo vector output + // instead uses xlink:href for embedded bitmaps and contains elements. + expect(slideSvgContent, 'served slide SVG should be the rasterized embedded-PNG form').toMatch( + / +