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(
+ /
+