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..7d086412f08b 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 = " imageTagThreshold || pHandler.numberOfPaths() > pathsThreshold || pHandler.numberOfUseTags() > useTagThreshold || + pHandler.numberOfFilterTags() > filterTagThreshold || rasterizeCurrSlide) { // We need t delete the destination file as we are starting a @@ -274,9 +276,13 @@ private boolean generateSvgImage(File imagePresentationDir, UploadedPresentation log.error(" --analytics-- data={}", logStr, ioException); } - // Step 1: Convert a PDF page to PNG using a raw pdftocairo - NuProcessBuilder convertPdfToPng = createConversionProcess("-png", page, source, - tempPng.getAbsolutePath().substring(0, tempPng.getAbsolutePath().lastIndexOf('.')), false, + // Step 1: Rasterize the PDF page to PNG using poppler's splash backend (pdftoppm). + // We intentionally avoid pdftocairo (cairo backend) here: it fails to composite + // PDF transparency groups that combine a soft mask (SMask) with an ICCBased + // colorspace, producing a blank raster. The splash backend renders them correctly. + // See issue #23953. + NuProcessBuilder convertPdfToPng = createRasterizationProcess(page, source, + tempPng.getAbsolutePath().substring(0, tempPng.getAbsolutePath().lastIndexOf('.')), convPdfToSvgTimeout); Pdf2PngPageConverterHandler pngHandler = new Pdf2PngPageConverterHandler("pdf2png-" + pres.getMeetingId() + "-" + pres.getId() + "-" + page); @@ -412,12 +418,30 @@ 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 '.png" (no page + // number suffix), matching the temp file the caller created. See issue #23953. + private NuProcessBuilder createRasterizationProcess(int page, String source, String destFileRoot, long timeout) { + String rawCommand = "pdftoppm -q -png -singlefile -r " + this.svgResolutionPpi; + + //Resize png resolution to avoid too large files + if (this.pngWidthRasterizedSlides != 0) { + rawCommand += " -scale-to-x " + this.pngWidthRasterizedSlides + " -scale-to-y -1"; + } + + rawCommand += " -f " + String.valueOf(page) + " -l " + String.valueOf(page) + " " + source + " " + destFileRoot; + + return new NuProcessBuilder(Arrays.asList("/usr/share/bbb-web/run-in-systemd.sh", timeout + "s", "/bin/sh", "-c", rawCommand)); + } + private NuProcessBuilder createDetectFontType3Process(String source, int page, long timeout) { String rawCommand = "pdffonts -f " + String.valueOf(page) + " -l " + String.valueOf(page) + " " + source; rawCommand += " | grep -m 1 'Type 3'"; @@ -494,6 +518,10 @@ public void setUseTagThreshold(long threshold) { public void setPathsThreshold(long threshold) { pathsThreshold = threshold; } + + public void setFilterTagThreshold(long threshold) { + filterTagThreshold = threshold; + } public void setSlidesGenerationProgressNotifier( SlidesGenerationProgressNotifier notifier) { diff --git a/bigbluebutton-tests/playwright/core/elements.ts b/bigbluebutton-tests/playwright/core/elements.ts index 77b27a79cc72..ed547e2f1213 100644 --- a/bigbluebutton-tests/playwright/core/elements.ts +++ b/bigbluebutton-tests/playwright/core/elements.ts @@ -339,6 +339,7 @@ export const elements = { uploadPresentationFileName: 'uploadTest.png', presentationPPTX: 'BBB.pptx', presentationTXT: 'helloWorld.txt', + blurImagePresentationFileName: 'blurImage.pdf', startScreenSharing: 'button[data-test="startScreenShare"]', stopScreenSharing: 'button[data-test="stopScreenShare"]', managePresentations: 'li[data-test="managePresentations"]', diff --git a/bigbluebutton-tests/playwright/core/media/blurImage.pdf b/bigbluebutton-tests/playwright/core/media/blurImage.pdf new file mode 100644 index 000000000000..f93adcb7813b Binary files /dev/null and b/bigbluebutton-tests/playwright/core/media/blurImage.pdf differ diff --git a/bigbluebutton-tests/playwright/presentation/presentation.spec.ts b/bigbluebutton-tests/playwright/presentation/presentation.spec.ts index d2c613715bff..5d2e1ae40954 100644 --- a/bigbluebutton-tests/playwright/presentation/presentation.spec.ts +++ b/bigbluebutton-tests/playwright/presentation/presentation.spec.ts @@ -137,6 +137,17 @@ test.describe.parallel('Presentation', { tag: '@ci' }, () => { await presentation.uploadOtherPresentationsFormat(); }); + test('Upload PDF with embedded soft-masked image renders (not blank)', async ({ + browser, + context, + page, + }, testInfo) => { + linkIssue(23953); + const presentation = new Presentation(browser, context); + await presentation.initPages(page, testInfo); + await presentation.blurredImagePresentationRendersTest(); + }); + // https://docs.bigbluebutton.org/3.0/testing/release-testing/#uploading-multiple-presentations-automated test('Upload multiple presentations', async ({ browser, context, page }, testInfo) => { const presentation = new Presentation(browser, context); diff --git a/bigbluebutton-tests/playwright/presentation/presentation.ts b/bigbluebutton-tests/playwright/presentation/presentation.ts index 05a25a49e34c..15dae4e2986e 100644 --- a/bigbluebutton-tests/playwright/presentation/presentation.ts +++ b/bigbluebutton-tests/playwright/presentation/presentation.ts @@ -14,6 +14,7 @@ import { checkSvgIndex, expectSlidesEqualBetweenPages, getCurrentPresentationHeight, + getCurrentSlideDarkPixelRatio, getSlideOuterHtml, uploadMultiplePresentations, uploadSinglePresentation, @@ -334,6 +335,45 @@ export class Presentation extends MultiUsers { } } + // Regression test for https://github.com/bigbluebutton/bigbluebutton/issues/23953 + // The PDF embeds a soft-masked (SMask) image inside a transparency group. poppler's + // cairo backend (pdftocairo) fails to composite it and produces a fully blank slide, + // while the client reports the conversion as successful. The slide must render its + // content (a large black rectangle) instead of a blank page. + async blurredImagePresentationRendersTest() { + await this.modPage.waitForSelector(e.whiteboard, ELEMENT_WAIT_LONGER_TIME); + await this.modPage.waitForSelector(e.skipSlide); + await this.modPage.closeAllToastNotifications(); + + // Snapshot the attendee's slide first: uploadSinglePresentation only waits on + // the moderator page, so the attendee assertion below must wait for the new + // slide to propagate before measuring it. + const userSlideBeforeUpload = await getSlideOuterHtml(this.userPage); + + await uploadSinglePresentation(this.modPage, e.blurImagePresentationFileName, UPLOAD_PDF_WAIT_TIME); + await this.modPage.closeAllToastNotifications(); + + const modDarkRatio = await getCurrentSlideDarkPixelRatio(this.modPage); + expect( + modDarkRatio, + 'the uploaded PDF slide should render its embedded figure (a black rectangle), not a blank page (issue #23953)', + ).toBeGreaterThan(0.02); + + // The attendee must receive the same non-blank slide. + await this.userPage.closeAllToastNotifications(); + await expect + .poll(() => getSlideOuterHtml(this.userPage), { + message: 'the attendee should receive the uploaded presentation slide', + timeout: ELEMENT_WAIT_LONGER_TIME, + }) + .not.toBe(userSlideBeforeUpload); + const userDarkRatio = await getCurrentSlideDarkPixelRatio(this.userPage); + expect( + userDarkRatio, + 'the attendee should also see the embedded figure rendered on the slide (issue #23953)', + ).toBeGreaterThan(0.02); + } + async uploadMultiplePresentationsTest() { // wait for whiteboard to load and no notifications await this.modPage.waitForSelector(e.whiteboard, ELEMENT_WAIT_LONGER_TIME); diff --git a/bigbluebutton-tests/playwright/presentation/util.ts b/bigbluebutton-tests/playwright/presentation/util.ts index 44e48dd5aa26..80ffcaf574d1 100644 --- a/bigbluebutton-tests/playwright/presentation/util.ts +++ b/bigbluebutton-tests/playwright/presentation/util.ts @@ -57,6 +57,61 @@ export async function getCurrentPresentationHeight(locator: Locator) { return locator.evaluate((element) => window.getComputedStyle(element).getPropertyValue('height')); } +// Rasterizes the current slide image (the server-generated SVG referenced by the +// tl-image background) onto an off-screen canvas and returns the fraction of +// near-black pixels. Used to detect slides that converted to a blank image, e.g. +// when an embedded figure is silently dropped during conversion (issue #23953). +export async function getCurrentSlideDarkPixelRatio(testPage: Page): Promise { + await testPage.waitForSelector(e.currentSlideImg); + const slideUrl = await testPage.page.evaluate( + ([selector]) => { + const el = document.querySelector(selector) as HTMLElement | null; + return el?.style?.backgroundImage?.split('"')[1] ?? null; + }, + [e.currentSlideImg], + ); + expect(slideUrl, 'should resolve the current slide image URL from the whiteboard').toBeTruthy(); + + // Fetch the SVG bytes through the authenticated browser context, then rasterize + // it in-page via a same-origin blob URL so the canvas is not tainted. + const response = await testPage.page.request.get(slideUrl as string); + expect(response.ok(), `should fetch the current slide image (HTTP ${response.status()})`).toBeTruthy(); + const svg = await response.text(); + + return testPage.page.evaluate(async (svgText) => { + const blob = new Blob([svgText], { type: 'image/svg+xml' }); + const url = URL.createObjectURL(blob); + try { + const img = new Image(); + await new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(new Error('failed to load slide image')); + img.src = url; + }); + const width = 400; + const height = Math.max(1, Math.round((width * (img.height || 595)) / (img.width || 842))); + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('failed to get canvas 2d context'); + // Composite over white so transparent regions read as white, matching the client. + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, width, height); + ctx.drawImage(img, 0, 0, width, height); + const { data } = ctx.getImageData(0, 0, width, height); + let dark = 0; + const total = width * height; + for (let i = 0; i < data.length; i += 4) { + if (data[i] + data[i + 1] + data[i + 2] < 150) dark += 1; + } + return dark / total; + } finally { + URL.revokeObjectURL(url); + } + }, svg); +} + export async function getCurrentPresentationToastLocator(testPage: Page) { return testPage.page.locator(e.smallToastMsg).filter({ hasText: e.defaultCurrentPresentationLabel }); } diff --git a/bigbluebutton-web/grails-app/conf/bigbluebutton.properties b/bigbluebutton-web/grails-app/conf/bigbluebutton.properties index 930f5c04f6f7..dbea89b6c662 100644 --- a/bigbluebutton-web/grails-app/conf/bigbluebutton.properties +++ b/bigbluebutton-web/grails-app/conf/bigbluebutton.properties @@ -72,6 +72,13 @@ imageTagThreshold=800 # Maximum allowed number of tags in generated svg, if exceeded the conversion will fallback to full BMP (default 10k) useTagThreshold=10000 +# Maximum allowed number of tags in generated svg, if exceeded the conversion will fallback to a rasterized +# slide (default 0). pdftocairo emits a (alpha-to-luminance) for PDF transparency groups used as soft masks +# (SMask), which some browsers render blank; rasterizing those slides keeps them visible. Plain alpha images use only +# (no ) and are unaffected. Raise this if such slides should stay vector. +# See https://github.com/bigbluebutton/bigbluebutton/issues/23953 +filterTagThreshold=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..0c4f79291666 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 . +