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 MASK_TAG_OUTPUT = "<mask";
private static String MASK_TAG_PATTERN = "\\d+\\s" + MASK_TAG_OUTPUT;

private final String id;

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

/**
*
* @return The number of <mask/> 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;
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 maskTagThreshold = 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 ||
(maskTagThreshold > 0 && pHandler.numberOfMaskTags() >= maskTagThreshold) ||
rasterizeCurrSlide) {

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

return new NuProcessBuilder(Arrays.asList("/usr/share/bbb-web/run-in-systemd.sh", timeout + "s", "/bin/sh", "-c", rawCommand));
Expand Down Expand Up @@ -494,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) {
Expand Down
Binary file added bbb-common-web/src/test/resources/sample-with-mask.pdf
Binary file not shown.
32 changes: 32 additions & 0 deletions bbb-common-web/src/test/resources/sample-with-mask.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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 '<image|<path|<use|<mask' slideN.svg | sort | uniq -c
* and the handler parses the resulting stdout. These tests replicate that
* pipeline against src/test/resources/sample-with-mask.svg, which was
* generated by pdftocairo 24.02.0 (Ubuntu 24.04) from
* src/test/resources/sample-with-mask.pdf, a minimal PDF containing an
* image XObject with an /SMask (soft mask) drawn twice - soft masks are
* what pdftocairo turns into SVG <mask> elements.
*/
class SvgConversionHandlerTest extends UnitSpec {

val sampleSvgFile = "src/test/resources/sample-with-mask.svg"
val analyzedTags = List("<image", "<mask", "<path", "<use")

private def handlerFedWith(stdout: String): SvgConversionHandler = {
val handler = new SvgConversionHandler("test")
handler.onStdout(ByteBuffer.wrap(stdout.getBytes(StandardCharsets.UTF_8)), true)
handler
}

private def countOccurrences(content: String, token: String): Int = {
var count = 0
var idx = content.indexOf(token)
while (idx > -1) {
count += 1
idx = content.indexOf(token, idx + token.length)
}
count
}

// Replicates the analysis pipeline: grep -oE '<image|<path|<use|<mask' | sort | uniq -c
private def uniqCountOutput(content: String): String = {
analyzedTags.map(tag => "%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, "<mask"))
assert(handler.numberOfMaskTags() == 2)
assert(handler.numberOfImageTags() == 3)
assert(handler.numberOfPaths() == 2)
assert(handler.numberOfUseTags() == 4)
}

it should "count zero mask tags when the analysis output has no mask line" in {
val handler = handlerFedWith(" 2 <image\n 159 <path\n 444 <use\n")

assert(handler.numberOfMaskTags() == 0)
assert(handler.numberOfImageTags() == 2)
assert(handler.numberOfPaths() == 159)
assert(handler.numberOfUseTags() == 444)
}

it should "count zero tags when the analysis output is not uniq -c formatted" in {
val handler = handlerFedWith("pdftocairo: unknown error\n")

assert(handler.numberOfMaskTags() == 0)
assert(handler.numberOfImageTags() == 0)
assert(handler.numberOfPaths() == 0)
assert(handler.numberOfUseTags() == 0)
}

}
1 change: 1 addition & 0 deletions bigbluebutton-tests/playwright/core/elements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]',
Expand Down
Binary file not shown.
14 changes: 14 additions & 0 deletions bigbluebutton-tests/playwright/presentation/presentation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,20 @@ test.describe.parallel('Presentation', { tag: '@ci' }, () => {
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
// <mask> 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);
Expand Down
39 changes: 39 additions & 0 deletions bigbluebutton-tests/playwright/presentation/presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <image href="data:image/png;base64,..."> and no vector elements. pdftocairo vector output
// instead uses xlink:href for embedded bitmaps and contains <path> elements.
expect(slideSvgContent, 'served slide SVG should be the rasterized embedded-PNG form').toMatch(
/<image href="data:image\/png;base64,/,
);
expect(slideSvgContent, 'rasterized slide SVG should contain no vector <path> elements').not.toMatch(/<path[\s>]/);
}

async uploadOtherPresentationsFormat() {
// wait for whiteboard to load and no notifications
await this.modPage.waitForSelector(e.whiteboard, ELEMENT_WAIT_LONGER_TIME);
Expand Down
6 changes: 6 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,12 @@ imageTagThreshold=800
# Maximum allowed number of <use> tags in generated svg, if exceeded the conversion will fallback to full BMP (default 10k)
useTagThreshold=10000

# Minimum number of <mask> 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.
#------------------------------------
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="maskTagThreshold" value="${maskTagThreshold}"/>
<property name="blankSvg" value="${BLANK_SVG}"/>
<property name="convPdfToSvgTimeout" value="${svgConversionTimeout}"/>
<property name="pdfFontsTimeout" value="${pdfFontsTimeout}"/>
Expand Down
Loading