From 5c7dca8769f933ea580379334fab070302d705a5 Mon Sep 17 00:00:00 2001 From: Paul Trudel Date: Wed, 17 Dec 2025 09:35:38 -0500 Subject: [PATCH 1/3] Fallback to rasterization is svg has mask tags --- .../handlers/SvgConversionHandler.java | 23 +++++++++++++++++++ .../presentation/imp/SvgImageCreatorImp.java | 12 ++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) 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..33d2320e8496 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 @@ -222,10 +222,16 @@ private boolean generateSvgImage(File imagePresentationDir, UploadedPresentation } } + // Masks with color filters (common in scanned PDFs from pdftocairo) often + // render incorrectly in browsers, causing blank/dark overlays on slides. + // Any presence of masks should trigger rasterization for reliable rendering. + boolean hasMasks = pHandler.numberOfMaskTags() > 0; + if (destsvg.length() == 0 || pHandler.numberOfImageTags() > imageTagThreshold || pHandler.numberOfPaths() > pathsThreshold || pHandler.numberOfUseTags() > useTagThreshold || + hasMasks || rasterizeCurrSlide) { // We need t delete the destination file as we are starting a @@ -246,8 +252,10 @@ 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("hasMasks", hasMasks); logData.put("logCode", "potential_problem_with_svg"); - logData.put("message", "Potential problem with generated SVG"); + logData.put("message", "Potential problem with generated SVG, triggering rasterization fallback"); Gson gson = new Gson(); String logStr = gson.toJson(logData); @@ -412,7 +420,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 ' Date: Fri, 24 Jul 2026 12:35:02 -0400 Subject: [PATCH 2/3] fix(bbb-web): make mask-triggered rasterization configurable, disabled by default Refines the previous commit: masks are common in ordinary PDFs (soft-masked/alpha images - the bundled default.pdf title page yields 3), so rasterizing on the mere presence of a tag would rasterize most real-world decks. Replace the any-mask check with a configurable maskTagThreshold wired like the existing imageTagThreshold, useTagThreshold and placementsThreshold. The default is 0 (disabled), following the bigbluebutton.properties convention where 0 turns a limit off: pdftocairo 24.02.0 shipped with Ubuntu 24.04 - BBB 4.0's target OS - generates correct mask values, so no rasterization is needed on a stock install and vector slides are preserved. Setting maskTagThreshold=1 rasterizes any slide whose generated SVG contains a mask (the previous commit's behavior); setting it to N rasterizes slides with N or more masks. The mask count is always collected and reported in the potential_problem_with_svg analytics log entry for tuning. Adds a ScalaTest spec for the SvgConversionHandler tag counting, backed by a minimal soft-masked sample PDF and the SVG that pdftocairo 24.02.0 actually produced from it (2 , 3 , 2 , 4 ). Co-Authored-By: Claude Fable 5 --- .../presentation/imp/SvgImageCreatorImp.java | 15 ++-- .../src/test/resources/sample-with-mask.pdf | Bin 0 -> 1017 bytes .../src/test/resources/sample-with-mask.svg | 32 ++++++++ .../handlers/SvgConversionHandlerTest.scala | 76 ++++++++++++++++++ .../grails-app/conf/bigbluebutton.properties | 6 ++ .../grails-app/conf/spring/doc-conversion.xml | 1 + 6 files changed, 122 insertions(+), 8 deletions(-) create mode 100644 bbb-common-web/src/test/resources/sample-with-mask.pdf create mode 100644 bbb-common-web/src/test/resources/sample-with-mask.svg create mode 100644 bbb-common-web/src/test/scala/org/bigbluebutton/presentation/handlers/SvgConversionHandlerTest.scala 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 33d2320e8496..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; @@ -222,16 +223,11 @@ private boolean generateSvgImage(File imagePresentationDir, UploadedPresentation } } - // Masks with color filters (common in scanned PDFs from pdftocairo) often - // render incorrectly in browsers, causing blank/dark overlays on slides. - // Any presence of masks should trigger rasterization for reliable rendering. - boolean hasMasks = pHandler.numberOfMaskTags() > 0; - if (destsvg.length() == 0 || pHandler.numberOfImageTags() > imageTagThreshold || pHandler.numberOfPaths() > pathsThreshold || pHandler.numberOfUseTags() > useTagThreshold || - hasMasks || + (maskTagThreshold > 0 && pHandler.numberOfMaskTags() >= maskTagThreshold) || rasterizeCurrSlide) { // We need t delete the destination file as we are starting a @@ -253,9 +249,8 @@ private boolean generateSvgImage(File imagePresentationDir, UploadedPresentation logData.put("numberOfImages", pHandler.numberOfImageTags()); logData.put("numberOfPaths", pHandler.numberOfPaths()); logData.put("numberOfMasks", pHandler.numberOfMaskTags()); - logData.put("hasMasks", hasMasks); logData.put("logCode", "potential_problem_with_svg"); - logData.put("message", "Potential problem with generated SVG, triggering rasterization fallback"); + logData.put("message", "Potential problem with generated SVG"); Gson gson = new Gson(); String logStr = gson.toJson(logData); @@ -502,6 +497,10 @@ public void setUseTagThreshold(long threshold) { public void setPathsThreshold(long threshold) { pathsThreshold = threshold; } + + public void setMaskTagThreshold(long threshold) { + maskTagThreshold = threshold; + } public void setSlidesGenerationProgressNotifier( SlidesGenerationProgressNotifier notifier) { diff --git a/bbb-common-web/src/test/resources/sample-with-mask.pdf b/bbb-common-web/src/test/resources/sample-with-mask.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d9eea0fce8694a1f4c3b9e0adc421751e2cec9c7 GIT binary patch literal 1017 zcmb_b&2G~`5Y7QAv@g-Q73|J618#-$dqWp#vmDOizwy7cwx#{xG$`r>GcPF*8Nm4pVFw@RxCjX?1}= zk#TfxXg5%a($l#JP(74y<3LUh`oR0K$;VO;R617~bpWBGI5AS=ng}C@GEgDfOpiqV z8rsNV0=%r7<8G8~`SJI+#=X@lcz1Gtb<^B=f7M_Q9(}IoK|cOx9tT=n{zD>F&wF>c z2jgE~KYaS}Wp~Gk8{dEKUe|dqwZzUthgp9Iw0+<8;S6qNcynk#b}RF{HJNQ=H@9$( vSC@Hs + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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, " tags in generated svg, if exceeded the conversion will fallback to full BMP (default 10k) useTagThreshold=10000 +# Minimum number of tags in generated svg that triggers a fallback to full BMP rasterization (default 0, disabled). +# Masks are common in ordinary PDFs (soft-masked/alpha images; the bundled default.pdf title page produces 3), and +# pdftocairo shipped with Ubuntu 24.04 (poppler 24.02.0) generates correct mask values, so this check is disabled by default (0). +# Set to 1 to rasterize any slide whose svg contains a mask, or to N to rasterize only slides with N or more masks. +maskTagThreshold=0 + #------------------------------------ # Number of threads in the pool to do the presentation conversion. #------------------------------------ diff --git a/bigbluebutton-web/grails-app/conf/spring/doc-conversion.xml b/bigbluebutton-web/grails-app/conf/spring/doc-conversion.xml index acfadaefc11d..3de82ee924bb 100755 --- a/bigbluebutton-web/grails-app/conf/spring/doc-conversion.xml +++ b/bigbluebutton-web/grails-app/conf/spring/doc-conversion.xml @@ -107,6 +107,7 @@ with BigBlueButton; if not, see . + From 27f97fca3dedda3fe1fbee7dc01a184988527085 Mon Sep 17 00:00:00 2001 From: Anton Georgiev Date: Fri, 24 Jul 2026 12:35:05 -0400 Subject: [PATCH 3/3] test(bbb-web): add @setting-required Playwright e2e for mask rasterization fallback Uploads the same soft-masked sample PDF used by the bbb-common-web unit spec and asserts the served slide SVG is the rasterized embedded-PNG form produced by SvgImageCreatorImp.createSvgWithEmbeddedPng() - a single and no vector elements. BBB 4.0 renders slides as tldraw image assets, so a visual snapshot cannot tell a rasterized slide apart from a vector slide of the same content; inspecting the served SVG file isolates the feature instead. maskTagThreshold is a server-side bbb-web setting with no client-settings hook, so nothing applies it automatically: the test requires bbb-web to be restarted with maskTagThreshold=1 in /etc/bigbluebutton/ bbb-web.properties and is tagged @setting-required:maskTagThreshold, which keeps it out of the default CI gate. The mechanics (upload flow, conversion wait, served-SVG fetch via the tl-image asset URL incl. pageToken/sessionToken query params, and the embedded-PNG discriminator in both directions) were validated against a live 4.0 server: a default-config upload serves pdftocairo vector SVG (no match), and a forceRasterizeSlides=true upload serves the embedded-PNG form (match). Co-Authored-By: Claude Fable 5 --- .../playwright/core/elements.ts | 1 + .../core/media/sample-with-mask.pdf | Bin 0 -> 1017 bytes .../presentation/presentation.spec.ts | 14 +++++++ .../playwright/presentation/presentation.ts | 39 ++++++++++++++++++ 4 files changed, 54 insertions(+) create mode 100644 bigbluebutton-tests/playwright/core/media/sample-with-mask.pdf diff --git a/bigbluebutton-tests/playwright/core/elements.ts b/bigbluebutton-tests/playwright/core/elements.ts index 25d42e8019b2..ba5818d28e97 100644 --- a/bigbluebutton-tests/playwright/core/elements.ts +++ b/bigbluebutton-tests/playwright/core/elements.ts @@ -410,6 +410,7 @@ export const elements = { uploadPresentationFileName: 'uploadTest.png', presentationPPTX: 'BBB.pptx', presentationTXT: 'helloWorld.txt', + maskSamplePdf: 'sample-with-mask.pdf', startScreenSharing: 'button[data-test="startScreenShare"]', stopScreenSharing: 'button[data-test="stopScreenShare"]', managePresentations: 'div[data-test="managePresentations"]', diff --git a/bigbluebutton-tests/playwright/core/media/sample-with-mask.pdf b/bigbluebutton-tests/playwright/core/media/sample-with-mask.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d9eea0fce8694a1f4c3b9e0adc421751e2cec9c7 GIT binary patch literal 1017 zcmb_b&2G~`5Y7QAv@g-Q73|J618#-$dqWp#vmDOizwy7cwx#{xG$`r>GcPF*8Nm4pVFw@RxCjX?1}= zk#TfxXg5%a($l#JP(74y<3LUh`oR0K$;VO;R617~bpWBGI5AS=ng}C@GEgDfOpiqV z8rsNV0=%r7<8G8~`SJI+#=X@lcz1Gtb<^B=f7M_Q9(}IoK|cOx9tT=n{zD>F&wF>c z2jgE~KYaS}Wp~Gk8{dEKUe|dqwZzUthgp9Iw0+<8;S6qNcynk#b}RF{HJNQ=H@9$( vSC@Hs { 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( + /