From c2503af6835bbe819511f73322f681fe8c15d3cd Mon Sep 17 00:00:00 2001 From: hiroshisuga <45039819+hiroshisuga@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:34:41 +0900 Subject: [PATCH 01/16] Update PresentationController.groovy --- .../controllers/PresentationController.groovy | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/PresentationController.groovy b/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/PresentationController.groovy index e65d840c44fd..eecd34424032 100755 --- a/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/PresentationController.groovy +++ b/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/PresentationController.groovy @@ -263,6 +263,7 @@ class PresentationController { } def isDownloadable = params.boolean('is_downloadable') //instead of params.is_downloadable + def expandAnimations = params.boolean('expand_animations') ?: false def podId = params.pod_id // Defaults current to false (optional upload parameter) @@ -327,6 +328,36 @@ class PresentationController { uploadFailReasons ) if (isPresentationMimeTypeValid) { + if (expandAnimations && filenameExt.equalsIgnoreCase("pptx")) { + File animationExpansionMarker = + new File(pres.absolutePath + ".expand-animations") + + try { + if (!animationExpansionMarker.createNewFile() + && !animationExpansionMarker.exists()) { + throw new IOException( + "Unable to create PowerPoint animation expansion marker" + ) + } + log.info( + "PowerPoint animation expansion requested for " + + presFilename + " (presId: " + presId + ")" + ) + } catch (IOException e) { + log.error( + "Unable to create PowerPoint animation expansion marker for " + + presFilename, + e + ) + org.bigbluebutton.presentation.Util.deleteDirectoryFromFileHandlingErrors(pres) + response.status = 500 + response.addHeader("Cache-Control", "no-cache") + response.contentType = 'text/plain' + response.outputStream << 'upload-failed' + return + } + } + if (isDownloadable) { log.debug "@Setting file to be downloadable..." uploadedPres.setDownloadable(); From 3b3e78386de378c9b69675491b248a694a211425 Mon Sep 17 00:00:00 2001 From: hiroshisuga <45039819+hiroshisuga@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:40:43 +0900 Subject: [PATCH 02/16] Update convert-local.sh --- bbb-libreoffice/assets/convert-local.sh | 41 ++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/bbb-libreoffice/assets/convert-local.sh b/bbb-libreoffice/assets/convert-local.sh index 4bb0b4ef0df2..6c3a2fbdcf4c 100644 --- a/bbb-libreoffice/assets/convert-local.sh +++ b/bbb-libreoffice/assets/convert-local.sh @@ -43,6 +43,45 @@ then convertToParam="$convertToParam --writer" fi -cp "${source}" "$tempDir/file" +animationExpander="/usr/share/bbb-libreoffice-conversion/expand_pptx_animations.py" +animationMarker="${source}.expand-animations" +inputForConversion="${source}" + +if [[ "${source,,}" == *.pptx && -f "${animationMarker}" ]]; then + expandedPresentation="${source%.*}.expanded.pptx" + temporaryExpandedPresentation="${expandedPresentation}.tmp.$$.pptx" + + echo "Expanding PowerPoint animations before LibreOffice conversion: ${source}" + + if [[ ! -r "${animationExpander}" ]]; then + echo "PowerPoint animation expander is not readable: ${animationExpander}" >&2 + exit 1 + fi + + rm -f "${temporaryExpandedPresentation}" + + if ! /usr/bin/timeout "${timeoutSecs}s" \ + /usr/bin/python3 \ + "${animationExpander}" \ + "${source}" \ + "${temporaryExpandedPresentation}"; then + echo "PowerPoint animation expansion failed: ${source}" >&2 + rm -f "${temporaryExpandedPresentation}" + exit 1 + fi + + if [[ ! -s "${temporaryExpandedPresentation}" ]]; then + echo "Expanded PowerPoint file was not created: ${temporaryExpandedPresentation}" >&2 + rm -f "${temporaryExpandedPresentation}" + exit 1 + fi + + # Publish the complete file atomically. + mv -f "${temporaryExpandedPresentation}" "${expandedPresentation}" + + inputForConversion="${expandedPresentation}" +fi + +cp "${inputForConversion}" "${tempDir}/file" sudo /usr/bin/docker run --rm --memory=1g --memory-swap=1g --network none --env="HOME=/tmp/" -w /tmp/ --user=$(printf %05d `id -u`) -v "$tempDir/":/data/ -v /usr/share/fonts/:/usr/share/fonts/:ro -v /usr/share/fontconfig/:/usr/share/fontconfig/:ro -v /etc/fonts/:/etc/fonts/:ro -v /var/cache/fontconfig/:/var/cache/fontconfig/:ro --rm bbb-soffice sh -c "timeout $(printf %03d $timeoutSecs)s /usr/bin/soffice -env:UserInstallation=file:///tmp/ $convertToParam --outdir /data /data/file" cp "$tempDir/file.$convertTo" "${dest}" From 5157119b26f78a56d04412a282a7590d0735ca47 Mon Sep 17 00:00:00 2001 From: hiroshisuga <45039819+hiroshisuga@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:52:15 +0900 Subject: [PATCH 03/16] Create expand_pptx_animations.py --- .../assets/expand_pptx_animations.py | 974 ++++++++++++++++++ 1 file changed, 974 insertions(+) create mode 100644 bbb-libreoffice/assets/expand_pptx_animations.py diff --git a/bbb-libreoffice/assets/expand_pptx_animations.py b/bbb-libreoffice/assets/expand_pptx_animations.py new file mode 100644 index 000000000000..8eaa94ff0df4 --- /dev/null +++ b/bbb-libreoffice/assets/expand_pptx_animations.py @@ -0,0 +1,974 @@ +#!/usr/bin/env python3 +""" +Expand PowerPoint object and bullet-paragraph animations into static slides. + +Supported scope +--------------- +- Normal text boxes and body placeholders +- Bullet items animated one paragraph at a time +- Object-level entrance/exit animation +- p:set visibility changes and p:animEffect transition="in"/"out" +- Paragraph targets represented by p:txEl/p:pRg +- Effects themselves (fade, wipe, etc.) are ignored + +The script keeps hidden paragraphs in the text box and makes their text/bullet +invisible. This preserves the original text-box layout better than deleting +paragraphs. + +Not supported +------------- +- Character/word-by-word animation (p:charRg) +- SmartArt/chart sub-elements +- Trigger animations +- Motion, rotation, emphasis, color and media animations +- Complex build sequences that do not contain paragraph ranges + +Usage +----- + pip install lxml + python3 expand_pptx_animations.py input.pptx output-expanded.pptx + +Use --omit-initial to omit the pre-animation state of animated slides. +Use --strict to stop when an unsupported animation target is found. +""" + +from __future__ import annotations + +import argparse +import copy +import posixpath +import re +import sys +import zipfile +from collections import OrderedDict +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, Optional + +from lxml import etree + + +NS = { + "p": "http://schemas.openxmlformats.org/presentationml/2006/main", + "a": "http://schemas.openxmlformats.org/drawingml/2006/main", + "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + "rel": "http://schemas.openxmlformats.org/package/2006/relationships", + "ct": "http://schemas.openxmlformats.org/package/2006/content-types", +} + +P = f"{{{NS['p']}}}" +A = f"{{{NS['a']}}}" +R = f"{{{NS['r']}}}" +REL = f"{{{NS['rel']}}}" +CT = f"{{{NS['ct']}}}" + +SLIDE_REL_TYPE = ( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" +) +NOTES_REL_TYPE = ( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide" +) +SLIDE_CONTENT_TYPE = ( + "application/vnd.openxmlformats-officedocument.presentationml.slide+xml" +) +NOTES_CONTENT_TYPE = ( + "application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml" +) + +SHAPE_TAGS = { + P + "sp", + P + "pic", + P + "graphicFrame", + P + "cxnSp", + P + "grpSp", + P + "contentPart", +} + +FILL_TAGS = { + A + "noFill", + A + "solidFill", + A + "gradFill", + A + "blipFill", + A + "pattFill", + A + "grpFill", +} + +BULLET_TAGS = { + A + "buClr", + A + "buClrTx", + A + "buSzPct", + A + "buSzPts", + A + "buSzTx", + A + "buFont", + A + "buFontTx", + A + "buNone", + A + "buAutoNum", + A + "buChar", + A + "buBlip", +} + +SPACING_TAGS = { + A + "lnSpc", + A + "spcBef", + A + "spcAft", +} + + +@dataclass(frozen=True) +class Target: + spid: str + start_paragraph: Optional[int] = None + end_paragraph: Optional[int] = None + + @property + def is_paragraph_target(self) -> bool: + return self.start_paragraph is not None + + +@dataclass(frozen=True) +class Action: + operation: str # "show" or "hide" + target: Target + + +class ConversionError(RuntimeError): + pass + + +def parse_xml(data: bytes) -> etree._Element: + parser = etree.XMLParser(remove_blank_text=False, resolve_entities=False) + return etree.fromstring(data, parser=parser) + + +def serialize_xml(root: etree._Element) -> bytes: + return etree.tostring( + root, + xml_declaration=True, + encoding="UTF-8", + standalone=True, + ) + + +def read_package(path: Path) -> dict[str, bytes]: + with zipfile.ZipFile(path, "r") as archive: + bad = archive.testzip() + if bad: + raise ConversionError(f"Broken ZIP member in input PPTX: {bad}") + return {name: archive.read(name) for name in archive.namelist()} + + +def write_package(path: Path, files: dict[str, bytes]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for name in sorted(files): + archive.writestr(name, files[name]) + + with zipfile.ZipFile(path, "r") as archive: + bad = archive.testzip() + if bad: + raise ConversionError(f"Broken ZIP member in output PPTX: {bad}") + + +def rel_target_to_part(base_part: str, target: str) -> str: + return posixpath.normpath( + posixpath.join(posixpath.dirname(base_part), target) + ) + + +def part_to_rel_target(base_part: str, target_part: str) -> str: + return posixpath.relpath(target_part, posixpath.dirname(base_part)) + + +def relationships_part(part: str) -> str: + return posixpath.join( + posixpath.dirname(part), + "_rels", + posixpath.basename(part) + ".rels", + ) + + +def numeric_suffix(names: Iterable[str], pattern: str) -> int: + rx = re.compile(pattern) + values = [] + for name in names: + match = rx.fullmatch(name) + if match: + values.append(int(match.group(1))) + return max(values, default=0) + + +def max_relationship_number(rels_root: etree._Element) -> int: + values = [] + for rel in rels_root.findall("rel:Relationship", NS): + match = re.fullmatch(r"rId(\d+)", rel.get("Id", "")) + if match: + values.append(int(match.group(1))) + return max(values, default=0) + + +def ensure_override( + content_types: etree._Element, + part: str, + content_type: str, +) -> None: + part_name = "/" + part + existing = content_types.xpath( + "./ct:Override[@PartName=$name]", + namespaces=NS, + name=part_name, + ) + if existing: + return + + override = etree.Element(CT + "Override") + override.set("PartName", part_name) + override.set("ContentType", content_type) + content_types.append(override) + + +def remove_timing(slide_root: etree._Element) -> None: + for timing in slide_root.xpath("./p:timing", namespaces=NS): + timing.getparent().remove(timing) + + +def find_shape(slide_root: etree._Element, spid: str) -> Optional[etree._Element]: + matches = slide_root.xpath( + ".//p:cNvPr[@id=$spid]", + namespaces=NS, + spid=str(spid), + ) + if not matches: + return None + + node = matches[0] + while node is not None: + if node.tag in SHAPE_TAGS: + return node + node = node.getparent() + return None + + +def shape_paragraphs(shape: etree._Element) -> list[etree._Element]: + # A normal p:sp has a direct p:txBody. Descendant search also handles + # a few grouped/placeholder variants without affecting ordinary shapes. + return shape.xpath(".//p:txBody/a:p", namespaces=NS) + + +def get_target(effect: etree._Element) -> tuple[Optional[Target], Optional[str]]: + targets = effect.xpath( + ".//p:tgtEl/p:spTgt", + namespaces=NS, + ) + if not targets: + return None, "animation has no shape target" + + sp_tgt = targets[0] + spid = sp_tgt.get("spid") + if not spid: + return None, "animation shape target has no spid" + + if sp_tgt.xpath("./p:txEl/p:charRg", namespaces=NS): + return None, f"shape {spid}: character-range animation is unsupported" + + ranges = sp_tgt.xpath("./p:txEl/p:pRg", namespaces=NS) + if ranges: + paragraph_range = ranges[0] + try: + start = int(paragraph_range.get("st", "0")) + end = int(paragraph_range.get("end", str(start))) + except ValueError: + return None, f"shape {spid}: invalid paragraph range" + + if end < start: + start, end = end, start + return Target(spid, start, end), None + + return Target(spid), None + + +def set_visibility_value(effect: etree._Element) -> Optional[str]: + attributes = [ + (node.text or "").strip() + for node in effect.xpath(".//p:attrName", namespaces=NS) + ] + if "style.visibility" not in attributes: + return None + + values = effect.xpath(".//p:to//*[@val]/@val", namespaces=NS) + if not values: + values = effect.xpath(".//p:to/@val", namespaces=NS) + if not values: + return None + + return str(values[0]).strip().lower() + + +def action_from_effect( + effect: etree._Element, +) -> tuple[Optional[Action], Optional[str]]: + target, warning = get_target(effect) + if target is None: + return None, warning + + local_name = etree.QName(effect).localname + + if local_name == "set": + value = set_visibility_value(effect) + if value in {"visible", "true", "1"}: + return Action("show", target), None + if value in {"hidden", "false", "0"}: + return Action("hide", target), None + return None, None + + if local_name == "animEffect": + transition = (effect.get("transition") or "").strip().lower() + if transition == "in": + return Action("show", target), None + if transition == "out": + return Action("hide", target), None + + return None, None + + +def direct_time_node(container: etree._Element) -> Optional[etree._Element]: + if container.tag in {P + "set", P + "animEffect"}: + nodes = container.xpath("./p:cBhvr/p:cTn", namespaces=NS) + else: + nodes = container.xpath("./p:cTn", namespaces=NS) + return nodes[0] if nodes else None + + +def click_anchor(effect: etree._Element) -> Optional[etree._Element]: + """ + Return the nearest timing container whose direct cTn is clickEffect. + + With/after effects nested below that container are therefore grouped into + the same generated slide. + """ + candidates = [effect, *effect.iterancestors()] + for container in candidates: + c_tn = direct_time_node(container) + if c_tn is not None and c_tn.get("nodeType") == "clickEffect": + return container + return None + + +def nearest_node_type(effect: etree._Element) -> Optional[str]: + for container in [effect, *effect.iterancestors()]: + c_tn = direct_time_node(container) + if c_tn is not None and c_tn.get("nodeType"): + return c_tn.get("nodeType") + return None + + +def extract_click_groups( + slide_root: etree._Element, +) -> tuple[list[list[Action]], list[str]]: + warnings: list[str] = [] + effects = slide_root.xpath(".//p:set | .//p:animEffect", namespaces=NS) + + anchored: "OrderedDict[int, list[Action]]" = OrderedDict() + fallback_groups: list[list[Action]] = [] + current_fallback: Optional[list[Action]] = None + + for effect in effects: + action, warning = action_from_effect(effect) + if warning: + warnings.append(warning) + if action is None: + continue + + anchor = click_anchor(effect) + if anchor is not None: + key = id(anchor) + anchored.setdefault(key, []).append(action) + continue + + node_type = nearest_node_type(effect) + if current_fallback is None or node_type == "clickEffect": + current_fallback = [] + fallback_groups.append(current_fallback) + current_fallback.append(action) + + # Anchored groups are already inserted in document encounter order. + groups = [group for group in anchored.values() if group] + + # Fallback is used only for unusual but still simple timing trees. + groups.extend(group for group in fallback_groups if group) + + # Remove exact duplicates within a click. PowerPoint frequently stores an + # animEffect plus a visibility set for the same entrance/exit. + deduplicated: list[list[Action]] = [] + for group in groups: + seen: set[Action] = set() + cleaned = [] + for action in group: + if action not in seen: + seen.add(action) + cleaned.append(action) + if cleaned: + deduplicated.append(cleaned) + + return deduplicated, warnings + + +def remove_fill(properties: etree._Element) -> None: + for child in list(properties): + if child.tag in FILL_TAGS: + properties.remove(child) + properties.insert(0, etree.Element(A + "noFill")) + + # Prevent a text outline from remaining visible. + line = properties.find(A + "ln") + if line is not None: + for child in list(line): + if child.tag in FILL_TAGS: + line.remove(child) + line.insert(0, etree.Element(A + "noFill")) + + highlight = properties.find(A + "highlight") + if highlight is not None: + properties.remove(highlight) + + +def ensure_run_properties(run: etree._Element) -> etree._Element: + properties = run.find(A + "rPr") + if properties is None: + properties = etree.Element(A + "rPr") + run.insert(0, properties) + return properties + + +def hide_bullet(paragraph: etree._Element) -> None: + p_pr = paragraph.find(A + "pPr") + if p_pr is None: + p_pr = etree.Element(A + "pPr") + paragraph.insert(0, p_pr) + + for child in list(p_pr): + if child.tag in BULLET_TAGS: + p_pr.remove(child) + + insertion_index = 0 + for index, child in enumerate(list(p_pr)): + if child.tag in SPACING_TAGS: + insertion_index = index + 1 + p_pr.insert(insertion_index, etree.Element(A + "buNone")) + + default_properties = p_pr.find(A + "defRPr") + if default_properties is None: + default_properties = etree.Element(A + "defRPr") + # defRPr should be after bullet/tab settings and before extLst. + ext = p_pr.find(A + "extLst") + if ext is None: + p_pr.append(default_properties) + else: + p_pr.insert(p_pr.index(ext), default_properties) + remove_fill(default_properties) + + +def hide_paragraph(paragraph: etree._Element) -> None: + hide_bullet(paragraph) + + for run in paragraph.findall(A + "r"): + remove_fill(ensure_run_properties(run)) + + for field in paragraph.findall(A + "fld"): + remove_fill(ensure_run_properties(field)) + + for line_break in paragraph.findall(A + "br"): + remove_fill(ensure_run_properties(line_break)) + + end_properties = paragraph.find(A + "endParaRPr") + if end_properties is None: + end_properties = etree.Element(A + "endParaRPr") + paragraph.append(end_properties) + remove_fill(end_properties) + + +def expanded_indices( + target: Target, + slide_root: etree._Element, + warnings: list[str], +) -> list[int]: + if not target.is_paragraph_target: + return [] + + shape = find_shape(slide_root, target.spid) + if shape is None: + warnings.append(f"shape {target.spid}: target shape was not found") + return [] + + paragraphs = shape_paragraphs(shape) + if not paragraphs: + warnings.append(f"shape {target.spid}: target has no text paragraphs") + return [] + + start = target.start_paragraph or 0 + end = target.end_paragraph if target.end_paragraph is not None else start + + if start >= len(paragraphs): + warnings.append( + f"shape {target.spid}: paragraph {start} is outside " + f"0..{len(paragraphs) - 1}" + ) + return [] + + end = min(end, len(paragraphs) - 1) + return list(range(start, end + 1)) + + +def initial_state( + slide_root: etree._Element, + groups: list[list[Action]], + warnings: list[str], +) -> tuple[dict[str, bool], dict[str, dict[int, bool]]]: + object_state: dict[str, bool] = {} + paragraph_state: dict[str, dict[int, bool]] = {} + + first_object_action: dict[str, str] = {} + first_paragraph_action: dict[tuple[str, int], str] = {} + + for group in groups: + for action in group: + target = action.target + if target.is_paragraph_target: + for index in expanded_indices(target, slide_root, warnings): + first_paragraph_action.setdefault( + (target.spid, index), + action.operation, + ) + else: + first_object_action.setdefault(target.spid, action.operation) + + for spid, operation in first_object_action.items(): + # Entrance => initially hidden; exit => initially visible. + object_state[spid] = operation != "show" + + for (spid, index), operation in first_paragraph_action.items(): + paragraph_state.setdefault(spid, {})[index] = operation != "show" + + return object_state, paragraph_state + + +def apply_action( + action: Action, + slide_root: etree._Element, + object_state: dict[str, bool], + paragraph_state: dict[str, dict[int, bool]], + warnings: list[str], +) -> None: + visible = action.operation == "show" + target = action.target + + if target.is_paragraph_target: + for index in expanded_indices(target, slide_root, warnings): + paragraph_state.setdefault(target.spid, {})[index] = visible + else: + object_state[target.spid] = visible + + +def render_state( + base_root: etree._Element, + object_state: dict[str, bool], + paragraph_state: dict[str, dict[int, bool]], + warnings: list[str], +) -> etree._Element: + root = copy.deepcopy(base_root) + + for spid, visible in object_state.items(): + if visible: + continue + shape = find_shape(root, spid) + if shape is None: + warnings.append(f"shape {spid}: object target was not found") + continue + parent = shape.getparent() + if parent is not None: + parent.remove(shape) + + for spid, states in paragraph_state.items(): + # If the whole shape is hidden, there is nothing else to do. + if object_state.get(spid) is False: + continue + + shape = find_shape(root, spid) + if shape is None: + warnings.append(f"shape {spid}: paragraph target was not found") + continue + + paragraphs = shape_paragraphs(shape) + for index, visible in states.items(): + if not visible and 0 <= index < len(paragraphs): + hide_paragraph(paragraphs[index]) + + remove_timing(root) + return root + + +def visual_signature( + object_state: dict[str, bool], + paragraph_state: dict[str, dict[int, bool]], +) -> tuple: + return ( + tuple(sorted(object_state.items())), + tuple( + (spid, tuple(sorted(states.items()))) + for spid, states in sorted(paragraph_state.items()) + ), + ) + + +def expand_slide( + slide_xml: bytes, + omit_initial: bool, + strict: bool, +) -> tuple[list[etree._Element], list[str], int]: + base_root = parse_xml(slide_xml) + groups, warnings = extract_click_groups(base_root) + + if strict and warnings: + raise ConversionError("; ".join(warnings)) + + if not groups: + root = copy.deepcopy(base_root) + remove_timing(root) + return [root], warnings, 0 + + object_state, paragraph_state = initial_state( + base_root, groups, warnings + ) + + roots: list[etree._Element] = [] + previous_signature = None + + if not omit_initial: + roots.append( + render_state( + base_root, + object_state, + paragraph_state, + warnings, + ) + ) + previous_signature = visual_signature(object_state, paragraph_state) + + for group in groups: + for action in group: + apply_action( + action, + base_root, + object_state, + paragraph_state, + warnings, + ) + + signature = visual_signature(object_state, paragraph_state) + if signature == previous_signature: + continue + + roots.append( + render_state( + base_root, + object_state, + paragraph_state, + warnings, + ) + ) + previous_signature = signature + + if not roots: + root = copy.deepcopy(base_root) + remove_timing(root) + roots.append(root) + + if strict and warnings: + raise ConversionError("; ".join(warnings)) + + return roots, warnings, len(groups) + + +def clone_notes_for_slide( + files: dict[str, bytes], + slide_part: str, + cloned_slide_part: str, + cloned_slide_rels: etree._Element, + content_types: etree._Element, + next_notes_number: int, +) -> int: + """ + Clone the original notes slide for an additional generated slide. + + This keeps speaker notes available on every generated static slide instead + of leaving all clones linked to one notes slide with a stale back-reference. + """ + notes_relationship = None + for rel in cloned_slide_rels.findall("rel:Relationship", NS): + if rel.get("Type") == NOTES_REL_TYPE: + notes_relationship = rel + break + + if notes_relationship is None: + return next_notes_number + + original_notes_part = rel_target_to_part( + slide_part, + notes_relationship.get("Target", ""), + ) + if original_notes_part not in files: + cloned_slide_rels.remove(notes_relationship) + return next_notes_number + + new_notes_part = f"ppt/notesSlides/notesSlide{next_notes_number}.xml" + files[new_notes_part] = files[original_notes_part] + ensure_override(content_types, new_notes_part, NOTES_CONTENT_TYPE) + + original_notes_rels_part = relationships_part(original_notes_part) + new_notes_rels_part = relationships_part(new_notes_part) + + if original_notes_rels_part in files: + notes_rels = parse_xml(files[original_notes_rels_part]) + + for rel in notes_rels.findall("rel:Relationship", NS): + if rel.get("Type") == SLIDE_REL_TYPE: + rel.set( + "Target", + part_to_rel_target(new_notes_part, cloned_slide_part), + ) + + files[new_notes_rels_part] = serialize_xml(notes_rels) + + notes_relationship.set( + "Target", + part_to_rel_target(cloned_slide_part, new_notes_part), + ) + return next_notes_number + 1 + + +def convert( + input_path: Path, + output_path: Path, + omit_initial: bool, + strict: bool, + preserve_notes: bool, +) -> None: + files = read_package(input_path) + + required = { + "[Content_Types].xml", + "ppt/presentation.xml", + "ppt/_rels/presentation.xml.rels", + } + missing = required.difference(files) + if missing: + raise ConversionError( + "Input is not a normal PPTX package; missing: " + + ", ".join(sorted(missing)) + ) + + presentation = parse_xml(files["ppt/presentation.xml"]) + presentation_rels = parse_xml( + files["ppt/_rels/presentation.xml.rels"] + ) + content_types = parse_xml(files["[Content_Types].xml"]) + + slide_id_list = presentation.find("p:sldIdLst", NS) + if slide_id_list is None: + raise ConversionError("presentation.xml has no p:sldIdLst") + + relationship_by_id = { + rel.get("Id"): rel + for rel in presentation_rels.findall("rel:Relationship", NS) + } + + original_entries = [] + for slide_id in list(slide_id_list): + rid = slide_id.get(R + "id") + relationship = relationship_by_id.get(rid) + if relationship is None or relationship.get("Type") != SLIDE_REL_TYPE: + continue + slide_part = rel_target_to_part( + "ppt/presentation.xml", + relationship.get("Target", ""), + ) + original_entries.append((slide_id, slide_part)) + + if not original_entries: + raise ConversionError("No slides were found in the PPTX") + + next_slide_number = numeric_suffix( + files, + r"ppt/slides/slide(\d+)\.xml", + ) + 1 + next_notes_number = numeric_suffix( + files, + r"ppt/notesSlides/notesSlide(\d+)\.xml", + ) + 1 + next_rid_number = max_relationship_number(presentation_rels) + 1 + next_slide_id = max( + (int(node.get("id", "255")) for node, _ in original_entries), + default=255, + ) + 1 + + new_slide_id_nodes = [] + total_generated = 0 + total_clicks = 0 + + for original_index, (original_slide_id, slide_part) in enumerate( + original_entries, + start=1, + ): + if slide_part not in files: + raise ConversionError(f"Missing slide part: {slide_part}") + + roots, warnings, click_count = expand_slide( + files[slide_part], + omit_initial=omit_initial, + strict=strict, + ) + total_clicks += click_count + + print( + f"Slide {original_index}: " + f"{click_count} click group(s) -> {len(roots)} generated slide(s)" + ) + for warning in sorted(set(warnings)): + print(f" Warning: {warning}", file=sys.stderr) + + # Reuse the original slide part and relationship for the first state. + files[slide_part] = serialize_xml(roots[0]) + new_slide_id_nodes.append(original_slide_id) + total_generated += 1 + + original_slide_rels_part = relationships_part(slide_part) + original_slide_rels = files.get(original_slide_rels_part) + + # Additional states become newly numbered slide parts. + for root in roots[1:]: + cloned_slide_part = ( + f"ppt/slides/slide{next_slide_number}.xml" + ) + files[cloned_slide_part] = serialize_xml(root) + ensure_override( + content_types, + cloned_slide_part, + SLIDE_CONTENT_TYPE, + ) + + if original_slide_rels is not None: + cloned_rels = parse_xml(original_slide_rels) + + if preserve_notes: + next_notes_number = clone_notes_for_slide( + files, + slide_part, + cloned_slide_part, + cloned_rels, + content_types, + next_notes_number, + ) + else: + for rel in list( + cloned_rels.findall("rel:Relationship", NS) + ): + if rel.get("Type") == NOTES_REL_TYPE: + cloned_rels.remove(rel) + + files[relationships_part(cloned_slide_part)] = ( + serialize_xml(cloned_rels) + ) + + rid = f"rId{next_rid_number}" + relationship = etree.Element(REL + "Relationship") + relationship.set("Id", rid) + relationship.set("Type", SLIDE_REL_TYPE) + relationship.set( + "Target", + part_to_rel_target( + "ppt/presentation.xml", + cloned_slide_part, + ), + ) + presentation_rels.append(relationship) + + slide_id = etree.Element(P + "sldId") + slide_id.set("id", str(next_slide_id)) + slide_id.set(R + "id", rid) + new_slide_id_nodes.append(slide_id) + + next_slide_number += 1 + next_rid_number += 1 + next_slide_id += 1 + total_generated += 1 + + for child in list(slide_id_list): + slide_id_list.remove(child) + for child in new_slide_id_nodes: + slide_id_list.append(child) + + files["ppt/presentation.xml"] = serialize_xml(presentation) + files["ppt/_rels/presentation.xml.rels"] = serialize_xml( + presentation_rels + ) + files["[Content_Types].xml"] = serialize_xml(content_types) + + write_package(output_path, files) + + print(f"Output: {output_path}") + print(f"Original slides: {len(original_entries)}") + print(f"Recognized click groups: {total_clicks}") + print(f"Generated slides: {total_generated}") + + +def main() -> int: + parser = argparse.ArgumentParser( + description=( + "Expand simple object and bullet-paragraph animations " + "into static PPTX slides." + ) + ) + parser.add_argument("input", type=Path, help="input .pptx") + parser.add_argument("output", type=Path, help="output .pptx") + parser.add_argument( + "--omit-initial", + action="store_true", + help="omit the pre-animation state on animated slides", + ) + parser.add_argument( + "--strict", + action="store_true", + help="stop if an unsupported animation target is detected", + ) + parser.add_argument( + "--drop-notes", + action="store_true", + help="do not copy speaker notes to additional generated slides", + ) + args = parser.parse_args() + + if args.input.resolve() == args.output.resolve(): + parser.error("input and output paths must be different") + if args.input.suffix.lower() != ".pptx": + parser.error("input must have a .pptx extension") + if args.output.suffix.lower() != ".pptx": + parser.error("output must have a .pptx extension") + if not args.input.is_file(): + parser.error(f"input file does not exist: {args.input}") + + try: + convert( + args.input, + args.output, + omit_initial=args.omit_initial, + strict=args.strict, + preserve_notes=not args.drop_notes, + ) + except (ConversionError, etree.XMLSyntaxError, zipfile.BadZipFile) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 86956fbe03fba1a4d4dca29d562b7815e199f5eb Mon Sep 17 00:00:00 2001 From: hiroshisuga <45039819+hiroshisuga@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:02:15 +0900 Subject: [PATCH 04/16] Update component.jsx --- .../presentation-uploader/component.jsx | 209 ++++++++++++------ 1 file changed, 136 insertions(+), 73 deletions(-) diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/component.jsx b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/component.jsx index b504645bb92a..c6fc3e72fb8b 100755 --- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/component.jsx @@ -269,6 +269,18 @@ const intlMessages = defineMessages({ id: 'app.presentation.actionsLabel', description: 'actions label', }, + expandAnimations: { + id: 'app.presentationUploader.expandAnimations', + description: 'label for expanding PowerPoint animations', + }, + expandAnimationsYes: { + id: 'app.presentationUploader.expandAnimationsYes', + description: 'expand PowerPoint animations', + }, + expandAnimationsNo: { + id: 'app.presentationUploader.expandAnimationsNo', + description: 'do not expand PowerPoint animations', + }, }); const handleDismissToast = (id) => toast.dismiss(id); @@ -889,80 +901,131 @@ class PresentationUploader extends Component { const disableExportDropdown = shouldDisableExportButtonForAllDocuments || shouldDisableExportButton; + const showAnimationExpansionOptions = Boolean(item.file) + && /\.pptx$/i.test(item.name); + return ( - - - this.handleCurrentChange(item.presentationId)} - disabled={disableActions || hasError} - /> - - - {item.name} - - { - isActualCurrent - ? ( - - - {intl.formatMessage(intlMessages.currentBadge)} - - - ) - : null - } - - {renderPresentationItemStatus(item, intl)} - - { - hasError ? null : ( - - {allowDownloadOriginal || allowDownloadWithAnnotations || allowDownloadConverted ? ( - Session.setItem('showUploadPresentationView', false)} - handleDownloadingOfPresentation={(fileStateType) => this - .handleDownloadingOfPresentation(item, fileStateType)} - /> - ) : null} - {removable ? ( - this.handleRemove(item)} - animations={animations} - /> - ) : null} - - )} - + + + + this.handleCurrentChange(item.presentationId)} + disabled={disableActions || hasError} + /> + + + {item.name} + + { + isActualCurrent + ? ( + + + {intl.formatMessage(intlMessages.currentBadge)} + + + ) + : null + } + + {renderPresentationItemStatus(item, intl)} + + { + hasError ? null : ( + + {allowDownloadOriginal || allowDownloadWithAnnotations || allowDownloadConverted ? ( + Session.setItem('showUploadPresentationView', false)} + handleDownloadingOfPresentation={(fileStateType) => this + .handleDownloadingOfPresentation(item, fileStateType)} + /> + ) : null} + {removable ? ( + this.handleRemove(item)} + animations={animations} + /> + ) : null} + + )} + + + {showAnimationExpansionOptions ? ( + + + + + + {intl.formatMessage(intlMessages.expandAnimations)} + + + this.updateFileKey( + item.presentationId, + 'expandAnimations', + true, + )} + disabled={disableActions} + /> + + this.updateFileKey( + item.presentationId, + 'expandAnimations', + false, + )} + disabled={disableActions} + /> + + + + ) : null} + ); } From ef65d57f1b1554831aa037e68d06619cb67e396a Mon Sep 17 00:00:00 2001 From: hiroshisuga <45039819+hiroshisuga@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:04:01 +0900 Subject: [PATCH 05/16] Update service.js --- .../presentation/presentation-uploader/service.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/service.js b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/service.js index 583bda144f03..3ae5b27389cd 100644 --- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/service.js +++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/service.js @@ -89,6 +89,7 @@ const uploadAndConvertPresentation = ( onProgress, onConversion, current, + expandAnimations, ) => { if (!file) return Promise.resolve(); @@ -104,6 +105,11 @@ const uploadAndConvertPresentation = ( data.append('is_downloadable', downloadable); data.append('current', current); + data.append( + 'expand_animations', + expandAnimations === true ? 'true' : 'false', + ); + const opts = { method: 'POST', body: data, @@ -132,6 +138,7 @@ const uploadAndConvertPresentations = ( p.name, p.presentationId, p.file, p.downloadable, meetingId, uploadEndpoint, p.onUpload, p.onProgress, p.onConversion, p.current, + p.expandAnimations === true, ))); const removePresentations = ( @@ -257,6 +264,7 @@ function handleFiledrop(files, files2, that, intl, intlMessages) { return { file, downloadable: false, // by default new presentations are set not to be downloadable + expandAnimation: false, isRemovable: true, presentationId: id, name: file.name, From e3c76d60a6a80772b8893e90881cedef6584d743 Mon Sep 17 00:00:00 2001 From: hiroshisuga <45039819+hiroshisuga@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:06:11 +0900 Subject: [PATCH 06/16] Update styles.js --- .../presentation-uploader/styles.js | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/styles.js b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/styles.js index aa3ff474b899..fab1000934f3 100644 --- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/styles.js +++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/styles.js @@ -529,6 +529,38 @@ const TableItemActions = styled.td` `} `; +const AnimationOptionsRow = styled.tr` + background-color: rgba(0, 128, 129, 0.05); + + td { + padding-top: 0 !important; + padding-bottom: ${smPaddingY} !important; + } +`; + +const AnimationOptions = styled.div` + display: flex; + align-items: center; + flex-wrap: wrap; + gap: ${smPaddingX}; + color: ${colorText}; + + [dir="rtl"] & { + flex-direction: row-reverse; + justify-content: flex-end; + } +`; + +const AnimationOptionsLabel = styled.span` + font-weight: bold; + margin-right: ${smPaddingX}; + + [dir="rtl"] & { + margin-right: 0; + margin-left: ${smPaddingX}; + } +`; + const ExtraHint = styled.div` margin-top: 1rem; font-weight: bold; @@ -640,6 +672,9 @@ export default { CurrentLabel, TableItemName, TableItemStatus, + AnimationOptionsRow, + AnimationOptions, + AnimationOptionsLabel, ItemAction, RemoveButton, UploaderDropzone, From b862ab7d1ad27e3e0438aff63b5f5ecb0d5b3cd6 Mon Sep 17 00:00:00 2001 From: hiroshisuga <45039819+hiroshisuga@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:07:46 +0900 Subject: [PATCH 07/16] Update en.json --- bigbluebutton-html5/public/locales/en.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bigbluebutton-html5/public/locales/en.json b/bigbluebutton-html5/public/locales/en.json index b6fda6361649..6ce72b1022ad 100755 --- a/bigbluebutton-html5/public/locales/en.json +++ b/bigbluebutton-html5/public/locales/en.json @@ -331,6 +331,9 @@ "app.presentationUploader.dropdownExportOptionsUncomplete": "Disabled until upload is complete", "app.presentationUploader.export.linkAvailable": "Link for downloading {presentationName} available on the public chat.", "app.presentationUploader.currentPresentationLabel": "Current presentation", + "app.presentationUploader.expandAnimations": "Expand pptx animation", + "app.presentationUploader.expandAnimationsYes": "Yes", + "app.presentationUploader.expandAnimationsNo": "No", "app.presentationUploder.extraHint": "IMPORTANT: each file may not exceed {maxFileSize} MB and {maxFilePages} pages.", "app.presentationUploder.uploadLabel": "Upload", "app.presentationUploder.confirmLabel": "Confirm", From a2c576f071b73a083ece850773032866bbd4ee5e Mon Sep 17 00:00:00 2001 From: hiroshisuga <45039819+hiroshisuga@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:09:12 +0900 Subject: [PATCH 08/16] Update ja.json --- bigbluebutton-html5/public/locales/ja.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bigbluebutton-html5/public/locales/ja.json b/bigbluebutton-html5/public/locales/ja.json index 5721918d8cab..c9de8221db49 100644 --- a/bigbluebutton-html5/public/locales/ja.json +++ b/bigbluebutton-html5/public/locales/ja.json @@ -330,6 +330,9 @@ "app.presentationUploader.dropdownExportOptionsUncomplete": "アップロード終了まで無効", "app.presentationUploader.export.linkAvailable": "{presentationName}をダウンロードするためのリンクが、公開チャットから利用できます。", "app.presentationUploader.currentPresentationLabel": "プレゼンテーションファイル", + "app.presentationUploader.expandAnimations": "pptxファイルのアニメーションを展開", + "app.presentationUploader.expandAnimationsYes": "する", + "app.presentationUploader.expandAnimationsNo": "しない", "app.presentationUploder.extraHint": "重要:それぞれのファイルが{maxFileSize}MB、{maxFilePages}ページを超えないようにしてください。", "app.presentationUploder.uploadLabel": "アップロード", "app.presentationUploder.confirmLabel": "決定", From c23b92aefdb568a2e54d7a0cbb7b8244d2e978ed Mon Sep 17 00:00:00 2001 From: hiroshisuga <45039819+hiroshisuga@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:31:27 +0900 Subject: [PATCH 09/16] Update expand_pptx_animations.py --- bbb-libreoffice/assets/expand_pptx_animations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbb-libreoffice/assets/expand_pptx_animations.py b/bbb-libreoffice/assets/expand_pptx_animations.py index 8eaa94ff0df4..dfdc5bc047ef 100644 --- a/bbb-libreoffice/assets/expand_pptx_animations.py +++ b/bbb-libreoffice/assets/expand_pptx_animations.py @@ -25,7 +25,7 @@ Usage ----- - pip install lxml + pip install lxml (usually unnecessary for normal BBB installation?) python3 expand_pptx_animations.py input.pptx output-expanded.pptx Use --omit-initial to omit the pre-animation state of animated slides. From dd9dcf9eb7a2d578dd87ea260a947542c634ea8b Mon Sep 17 00:00:00 2001 From: hiroshisuga <45039819+hiroshisuga@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:47:25 +0900 Subject: [PATCH 10/16] Remove --drop-notes option from expand_pptx_animations.py because it's not necessary --- .../assets/expand_pptx_animations.py | 70 ++++++------------- 1 file changed, 22 insertions(+), 48 deletions(-) diff --git a/bbb-libreoffice/assets/expand_pptx_animations.py b/bbb-libreoffice/assets/expand_pptx_animations.py index dfdc5bc047ef..6510fe1fcd54 100644 --- a/bbb-libreoffice/assets/expand_pptx_animations.py +++ b/bbb-libreoffice/assets/expand_pptx_animations.py @@ -25,7 +25,7 @@ Usage ----- - pip install lxml (usually unnecessary for normal BBB installation?) + sudo apt install python3-lxml python3 expand_pptx_animations.py input.pptx output-expanded.pptx Use --omit-initial to omit the pre-animation state of animated slides. @@ -40,7 +40,6 @@ import re import sys import zipfile -from collections import OrderedDict from dataclasses import dataclass from pathlib import Path from typing import Iterable, Optional @@ -365,47 +364,45 @@ def extract_click_groups( slide_root: etree._Element, ) -> tuple[list[list[Action]], list[str]]: warnings: list[str] = [] - effects = slide_root.xpath(".//p:set | .//p:animEffect", namespaces=NS) - - anchored: "OrderedDict[int, list[Action]]" = OrderedDict() - fallback_groups: list[list[Action]] = [] + effects = slide_root.xpath( + ".//p:set | .//p:animEffect", + namespaces=NS, + ) + groups: list[list[Action]] = [] + anchor_groups: dict[etree._Element, list[Action]] = {} current_fallback: Optional[list[Action]] = None - for effect in effects: action, warning = action_from_effect(effect) if warning: warnings.append(warning) if action is None: continue - anchor = click_anchor(effect) if anchor is not None: - key = id(anchor) - anchored.setdefault(key, []).append(action) + group = anchor_groups.get(anchor) + if group is None: + group = [] + anchor_groups[anchor] = group + groups.append(group) + group.append(action) + current_fallback = None continue - node_type = nearest_node_type(effect) + if current_fallback is None or node_type == "clickEffect": current_fallback = [] - fallback_groups.append(current_fallback) + groups.append(current_fallback) current_fallback.append(action) - - # Anchored groups are already inserted in document encounter order. - groups = [group for group in anchored.values() if group] - - # Fallback is used only for unusual but still simple timing trees. - groups.extend(group for group in fallback_groups if group) - - # Remove exact duplicates within a click. PowerPoint frequently stores an - # animEffect plus a visibility set for the same entrance/exit. deduplicated: list[list[Action]] = [] + for group in groups: seen: set[Action] = set() - cleaned = [] + cleaned: list[Action] = [] for action in group: - if action not in seen: - seen.add(action) - cleaned.append(action) + if action in seen: + continue + seen.add(action) + cleaned.append(action) if cleaned: deduplicated.append(cleaned) @@ -750,7 +747,6 @@ def convert( output_path: Path, omit_initial: bool, strict: bool, - preserve_notes: bool, ) -> None: files = read_package(input_path) @@ -858,22 +854,6 @@ def convert( if original_slide_rels is not None: cloned_rels = parse_xml(original_slide_rels) - if preserve_notes: - next_notes_number = clone_notes_for_slide( - files, - slide_part, - cloned_slide_part, - cloned_rels, - content_types, - next_notes_number, - ) - else: - for rel in list( - cloned_rels.findall("rel:Relationship", NS) - ): - if rel.get("Type") == NOTES_REL_TYPE: - cloned_rels.remove(rel) - files[relationships_part(cloned_slide_part)] = ( serialize_xml(cloned_rels) ) @@ -939,11 +919,6 @@ def main() -> int: action="store_true", help="stop if an unsupported animation target is detected", ) - parser.add_argument( - "--drop-notes", - action="store_true", - help="do not copy speaker notes to additional generated slides", - ) args = parser.parse_args() if args.input.resolve() == args.output.resolve(): @@ -961,7 +936,6 @@ def main() -> int: args.output, omit_initial=args.omit_initial, strict=args.strict, - preserve_notes=not args.drop_notes, ) except (ConversionError, etree.XMLSyntaxError, zipfile.BadZipFile) as exc: print(f"Error: {exc}", file=sys.stderr) From aadf8da2b41dec0cdd7cb8198842d934986516d5 Mon Sep 17 00:00:00 2001 From: hiroshisuga <45039819+hiroshisuga@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:41:16 +0900 Subject: [PATCH 11/16] Update service.js --- .../ui/components/presentation/presentation-uploader/service.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/service.js b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/service.js index 3ae5b27389cd..5522e5ac1ba2 100644 --- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/service.js +++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/service.js @@ -264,7 +264,7 @@ function handleFiledrop(files, files2, that, intl, intlMessages) { return { file, downloadable: false, // by default new presentations are set not to be downloadable - expandAnimation: false, + expandAnimations: false, isRemovable: true, presentationId: id, name: file.name, From 35a693bc37f3d16682d97cb35fdb3a43544a5c50 Mon Sep 17 00:00:00 2001 From: hiroshisuga <45039819+hiroshisuga@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:07:05 +0900 Subject: [PATCH 12/16] Update expand_pptx_animations.py --- bbb-libreoffice/assets/expand_pptx_animations.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/bbb-libreoffice/assets/expand_pptx_animations.py b/bbb-libreoffice/assets/expand_pptx_animations.py index 6510fe1fcd54..a9a8679f44fa 100644 --- a/bbb-libreoffice/assets/expand_pptx_animations.py +++ b/bbb-libreoffice/assets/expand_pptx_animations.py @@ -854,6 +854,15 @@ def convert( if original_slide_rels is not None: cloned_rels = parse_xml(original_slide_rels) + next_notes_number = clone_notes_for_slide( + files, + slide_part, + cloned_slide_part, + cloned_rels, + content_types, + next_notes_number, + ) + files[relationships_part(cloned_slide_part)] = ( serialize_xml(cloned_rels) ) From d22d33564cdad454fb2f4461c72052fb5d83356f Mon Sep 17 00:00:00 2001 From: hiroshisuga <45039819+hiroshisuga@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:20:42 +0900 Subject: [PATCH 13/16] Add guard for empty original_slide_rels --- bbb-libreoffice/assets/expand_pptx_animations.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bbb-libreoffice/assets/expand_pptx_animations.py b/bbb-libreoffice/assets/expand_pptx_animations.py index a9a8679f44fa..cf3f1a11c1e2 100644 --- a/bbb-libreoffice/assets/expand_pptx_animations.py +++ b/bbb-libreoffice/assets/expand_pptx_animations.py @@ -839,6 +839,12 @@ def convert( original_slide_rels_part = relationships_part(slide_part) original_slide_rels = files.get(original_slide_rels_part) + if len(roots) > 1 and original_slide_rels is None: + raise ConversionError( + f"Missing relationships for slide to be cloned: " + f"{original_slide_rels_part}" + ) + # Additional states become newly numbered slide parts. for root in roots[1:]: cloned_slide_part = ( From 09424f49cb5855e3e6b20f669bc3a9a09b42c79a Mon Sep 17 00:00:00 2001 From: hiroshisuga <45039819+hiroshisuga@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:39:21 +0900 Subject: [PATCH 14/16] replace clone_notes_for_slide --- .../assets/expand_pptx_animations.py | 105 +++++++++++++----- 1 file changed, 79 insertions(+), 26 deletions(-) diff --git a/bbb-libreoffice/assets/expand_pptx_animations.py b/bbb-libreoffice/assets/expand_pptx_animations.py index cf3f1a11c1e2..666f9e411112 100644 --- a/bbb-libreoffice/assets/expand_pptx_animations.py +++ b/bbb-libreoffice/assets/expand_pptx_animations.py @@ -695,50 +695,103 @@ def clone_notes_for_slide( ) -> int: """ Clone the original notes slide for an additional generated slide. - - This keeps speaker notes available on every generated static slide instead - of leaving all clones linked to one notes slide with a stale back-reference. """ - notes_relationship = None - for rel in cloned_slide_rels.findall("rel:Relationship", NS): - if rel.get("Type") == NOTES_REL_TYPE: - notes_relationship = rel - break + notes_relationships = [ + rel + for rel in cloned_slide_rels.findall( + "rel:Relationship", + NS, + ) + if rel.get("Type") == NOTES_REL_TYPE + ] - if notes_relationship is None: + if not notes_relationships: return next_notes_number + if len(notes_relationships) > 1: + raise ConversionError( + f"Slide has multiple notes relationships: {slide_part}" + ) + + notes_relationship = notes_relationships[0] + target = notes_relationship.get("Target") + + if not target: + raise ConversionError( + f"Notes relationship has no target: {slide_part}" + ) + original_notes_part = rel_target_to_part( slide_part, - notes_relationship.get("Target", ""), + target, ) + if original_notes_part not in files: - cloned_slide_rels.remove(notes_relationship) - return next_notes_number + raise ConversionError( + f"Missing notes slide referenced by {slide_part}: " + f"{original_notes_part}" + ) - new_notes_part = f"ppt/notesSlides/notesSlide{next_notes_number}.xml" - files[new_notes_part] = files[original_notes_part] - ensure_override(content_types, new_notes_part, NOTES_CONTENT_TYPE) + original_notes_rels_part = relationships_part( + original_notes_part + ) - original_notes_rels_part = relationships_part(original_notes_part) + if original_notes_rels_part not in files: + raise ConversionError( + f"Missing notes relationships: " + f"{original_notes_rels_part}" + ) + + new_notes_part = ( + f"ppt/notesSlides/notesSlide{next_notes_number}.xml" + ) new_notes_rels_part = relationships_part(new_notes_part) - if original_notes_rels_part in files: - notes_rels = parse_xml(files[original_notes_rels_part]) + notes_rels = parse_xml( + files[original_notes_rels_part] + ) - for rel in notes_rels.findall("rel:Relationship", NS): - if rel.get("Type") == SLIDE_REL_TYPE: - rel.set( - "Target", - part_to_rel_target(new_notes_part, cloned_slide_part), - ) + slide_back_references = [ + rel + for rel in notes_rels.findall( + "rel:Relationship", + NS, + ) + if rel.get("Type") == SLIDE_REL_TYPE + ] - files[new_notes_rels_part] = serialize_xml(notes_rels) + if not slide_back_references: + raise ConversionError( + f"Notes slide has no back-reference to its slide: " + f"{original_notes_rels_part}" + ) + + for rel in slide_back_references: + rel.set( + "Target", + part_to_rel_target( + new_notes_part, + cloned_slide_part, + ), + ) + + files[new_notes_part] = files[original_notes_part] + files[new_notes_rels_part] = serialize_xml(notes_rels) + + ensure_override( + content_types, + new_notes_part, + NOTES_CONTENT_TYPE, + ) notes_relationship.set( "Target", - part_to_rel_target(cloned_slide_part, new_notes_part), + part_to_rel_target( + cloned_slide_part, + new_notes_part, + ), ) + return next_notes_number + 1 From 9354c48fa2c6412500a5bec213a9f99ade6b82f2 Mon Sep 17 00:00:00 2001 From: hiroshisuga <45039819+hiroshisuga@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:16:04 +0900 Subject: [PATCH 15/16] Update install-local.sh install the python script without excute permission --- bbb-libreoffice/install-local.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/bbb-libreoffice/install-local.sh b/bbb-libreoffice/install-local.sh index 675128adc908..e82e25bab073 100755 --- a/bbb-libreoffice/install-local.sh +++ b/bbb-libreoffice/install-local.sh @@ -37,6 +37,7 @@ if [ "$FOLDER_CHECK" = "0" ]; then install -Dm755 assets/convert-local.sh /usr/share/bbb-libreoffice-conversion/convert.sh install -Dm755 assets/convert-cool.sh /usr/share/bbb-libreoffice-conversion/convert-cool.sh install -Dm755 assets/etherpad-export.sh /usr/share/bbb-libreoffice-conversion/etherpad-export.sh + install -Dm644 assets/expand_pptx_animations.py /usr/share/bbb-libreoffice-conversion/expand_pptx_animations.py chown -R root /usr/share/bbb-libreoffice-conversion/ else echo "Install folder already exists" From a6f531c6ed5bd5502153e51337c7e3f45fd13480 Mon Sep 17 00:00:00 2001 From: hiroshisuga <45039819+hiroshisuga@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:09:33 +0900 Subject: [PATCH 16/16] [bugfix] expand_pptx_animations.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text emergence animation was not properly processed before this fix. PowerPoint stores “With Previous” and “After Previous” animations as separate timing nodes, even though they do not require an additional click. The previous implementation treated these nodes as separate click groups, which caused extra intermediate slides to be generated. This change associates withEffect and afterEffect actions with the most recent clickEffect group. As a result, all animations triggered by the same click are expanded into a single static slide state. --- .../assets/expand_pptx_animations.py | 39 ++++++++++++++++--- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/bbb-libreoffice/assets/expand_pptx_animations.py b/bbb-libreoffice/assets/expand_pptx_animations.py index 666f9e411112..5d9f11e4e2b8 100644 --- a/bbb-libreoffice/assets/expand_pptx_animations.py +++ b/bbb-libreoffice/assets/expand_pptx_animations.py @@ -364,45 +364,72 @@ def extract_click_groups( slide_root: etree._Element, ) -> tuple[list[list[Action]], list[str]]: warnings: list[str] = [] + effects = slide_root.xpath( ".//p:set | .//p:animEffect", namespaces=NS, ) + groups: list[list[Action]] = [] anchor_groups: dict[etree._Element, list[Action]] = {} - current_fallback: Optional[list[Action]] = None + + # The most recently encountered click group. + # withEffect and afterEffect actions belong to this group. + current_group: Optional[list[Action]] = None + for effect in effects: action, warning = action_from_effect(effect) + if warning: warnings.append(warning) + if action is None: continue + anchor = click_anchor(effect) + if anchor is not None: group = anchor_groups.get(anchor) + if group is None: group = [] anchor_groups[anchor] = group groups.append(group) + group.append(action) - current_fallback = None + current_group = group continue + node_type = nearest_node_type(effect) - if current_fallback is None or node_type == "clickEffect": - current_fallback = [] - groups.append(current_fallback) - current_fallback.append(action) + # "With Previous" and "After Previous" do not require another click. + # They must therefore be included in the preceding click state. + if ( + node_type in {"withEffect", "afterEffect"} + and current_group is not None + ): + current_group.append(action) + continue + + # Fallback for an unusual timing structure that cannot be associated + # with a preceding click group. + current_group = [] + groups.append(current_group) + current_group.append(action) + deduplicated: list[list[Action]] = [] for group in groups: seen: set[Action] = set() cleaned: list[Action] = [] + for action in group: if action in seen: continue + seen.add(action) cleaned.append(action) + if cleaned: deduplicated.append(cleaned)