Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ public class SvgConversionHandler extends AbstractCommandHandler {
private static String USE_TAG_OUTPUT = "<use";
private static String USE_TAG_PATTERN = "\\d+\\s" + USE_TAG_OUTPUT;

private static String FILTER_TAG_OUTPUT = "<filter";
private static String FILTER_TAG_PATTERN = "\\d+\\s" + FILTER_TAG_OUTPUT;

private final String id;

public SvgConversionHandler(String id) {
Expand Down Expand Up @@ -84,6 +87,31 @@ public int numberOfUseTags() {
return 0;
}

/**
*
* @return The number of &lt;filter/&gt; tags in the generated SVG. pdftocairo emits a
* &lt;filter&gt; (an alpha-to-luminance feColorMatrix) when it converts a PDF transparency
* group used as a soft mask (SMask). Browsers render this construct unreliably and it can
* show up blank, so its presence is used to fall back to a rasterized slide. Note: plain
* alpha images produce only &lt;mask&gt; (no &lt;filter&gt;) and render fine, so gating on
* &lt;filter&gt; avoids needlessly rasterizing them. See issue #23953.
*/
public int numberOfFilterTags() {
if (stdoutContains(FILTER_TAG_OUTPUT)) {
try {
String out = stdoutBuilder.toString();
Pattern r = Pattern.compile(FILTER_TAG_PATTERN);
Matcher m = r.matcher(out);
m.find();
return Integer.parseInt(m.group(0).replace(FILTER_TAG_OUTPUT, "").trim());
} catch (Exception e) {
log.error("Exception counting the number of filter tags", e);
return 0;
}
}
return 0;
}

@Override
protected String getIdTag() {
return id;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public class SvgImageCreatorImp implements SvgImageCreator {
private long imageTagThreshold;
private long useTagThreshold;
private long pathsThreshold;
private long filterTagThreshold = 0;
private int convPdfToSvgTimeout = 60;
private int pdfFontsTimeout = 3;
private int svgResolutionPpi = 300;
Expand Down Expand Up @@ -226,6 +227,7 @@ private boolean generateSvgImage(File imagePresentationDir, UploadedPresentation
pHandler.numberOfImageTags() > imageTagThreshold ||
pHandler.numberOfPaths() > pathsThreshold ||
pHandler.numberOfUseTags() > useTagThreshold ||
pHandler.numberOfFilterTags() > filterTagThreshold ||
rasterizeCurrSlide) {

// We need t delete the destination file as we are starting a
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 '<image|<path|<use' "+destFile+" | sort | uniq -c ";
rawCommand += " && grep -oE '<image|<path|<use|<filter' "+destFile+" | sort | uniq -c ";
}

return new NuProcessBuilder(Arrays.asList("/usr/share/bbb-web/run-in-systemd.sh", timeout + "s", "/bin/sh", "-c", rawCommand));
}

// Rasterizes a single PDF page to PNG using poppler's splash backend (pdftoppm) instead of
// the cairo backend (pdftocairo -png). The cairo backend renders some PDF transparency
// groups (e.g. a soft-masked ICCBased image) as blank, whereas the splash backend composites
// them correctly. "-singlefile" makes pdftoppm write exactly "<destFileRoot>.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'";
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions bigbluebutton-tests/playwright/core/elements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]',
Expand Down
Binary file not shown.
11 changes: 11 additions & 0 deletions bigbluebutton-tests/playwright/presentation/presentation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
40 changes: 40 additions & 0 deletions bigbluebutton-tests/playwright/presentation/presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
checkSvgIndex,
expectSlidesEqualBetweenPages,
getCurrentPresentationHeight,
getCurrentSlideDarkPixelRatio,
getSlideOuterHtml,
uploadMultiplePresentations,
uploadSinglePresentation,
Expand Down Expand Up @@ -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);
Expand Down
55 changes: 55 additions & 0 deletions bigbluebutton-tests/playwright/presentation/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> {
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<void>((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 });
}
Expand Down
7 changes: 7 additions & 0 deletions bigbluebutton-web/grails-app/conf/bigbluebutton.properties
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ imageTagThreshold=800
# Maximum allowed number of <use> tags in generated svg, if exceeded the conversion will fallback to full BMP (default 10k)
useTagThreshold=10000

# Maximum allowed number of <filter> tags in generated svg, if exceeded the conversion will fallback to a rasterized
# slide (default 0). pdftocairo emits a <filter> (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
# <mask> (no <filter>) 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.
#------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
<property name="imageTagThreshold" value="${imageTagThreshold}"/>
<property name="useTagThreshold" value="${useTagThreshold}"/>
<property name="pathsThreshold" value="${placementsThreshold}"/>
<property name="filterTagThreshold" value="${filterTagThreshold}"/>
<property name="blankSvg" value="${BLANK_SVG}"/>
<property name="convPdfToSvgTimeout" value="${svgConversionTimeout}"/>
<property name="pdfFontsTimeout" value="${pdfFontsTimeout}"/>
Expand Down
Loading