Экспорт презентаций в HTML с внешними связанными изображениями

Обзор

По умолчанию Aspose.Slides экспортирует презентацию в автономный HTML‑файл. Изображения и другие ресурсы пишутся непосредственно в HTML, обычно в виде данных Base64. Это удобно, когда нужен один переносимый файл, но не всегда является лучшим форматом для веб‑сайта, CMS или серверного конвейера преобразования.

Используйте внешне связанные ресурсы, когда нужно:

  • уменьшить размер HTML‑документа;
  • кэшировать изображения, шрифты, аудио или видео отдельно в браузере или CDN;
  • проверять, заменять, сжимать или постобрабатывать сгенерированные ресурсы после экспорта;
  • сохранять структуру вывода ближе к тому, что ожидает веб‑приложение.

Для общего рабочего процесса преобразования HTML см. Convert PowerPoint Presentations to HTML. Эта статья сосредоточена на части экспорта, связанной с ресурсами.

Как работает экспорт связанных ресурсов

HtmlOptions может использовать пользовательский контроллер ссылок/встраивания, когда Aspose.Slides экспортирует презентацию в HTML. В PHP через Java этот сценарий обычно реализуется небольшим вспомогательным классом Java. Скомпилируйте этот класс, добавьте его в classpath PHP Java Bridge и создайте его экземпляр из PHP с помощью new Java(...).

Вспомогательный класс решает, ресурс за ресурсом, следует ли экспортёру встраивать данные в HTML или сохранять их внешне и писать ссылку. Для этого требуются три метода обратного вызова:

  • ExternalResourceController.getObjectStoringLocation определяет, следует ли ресурс связать или встроить.
  • ExternalResourceController.getUrl возвращает URL, который будет записан в сгенерированный HTML или в другой связанный ресурс.
  • ExternalResourceController.saveExternal записывает данные связанного ресурса на диск или в другое место хранения.

Путь в файловой системе и URL в браузере — это отдельные вопросы. Например, в примере ниже файлы ресурсов записываются в html-output/assets на диске, тогда как HTML содержит относительные URL, такие как assets/resource-1.svg. Браузер разрешает эти URL относительно файла, содержащего ссылку. Поэтому ссылка из presentation.html на SVG‑файл выглядит как assets/resource-1.svg, а ссылка из этого SVG‑файла на изображение, сохранённое в той же папке assets, выглядит как resource-4.jpg.

Создание вспомогательного класса Java

Создайте Java‑класс, например com.example.slides.ExternalResourceController, скомпилируйте его с Aspose.Slides для Java в classpath и сделайте скомпилированный класс или JAR доступным для PHP Java Bridge.

Приведённый ниже вспомогательный класс связывает общие изображения, шрифты, аудио, видео и CSS‑ресурсы, когда Aspose.Slides предоставляет или может вывести безопасное расширение файла. Неопознанные ресурсы остаются встроенными.

package com.example.slides;

import com.aspose.slides.ILinkEmbedController;
import com.aspose.slides.LinkEmbedDecision;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;

public final class ExternalResourceController implements ILinkEmbedController {
    private static final Map<String, String> EXTENSIONS_BY_CONTENT_TYPE = createExtensionMap();

    private final Path assetDirectory;
    private final String assetUrlPrefix;
    private final Map<Integer, String> fileNamesByResourceId = new HashMap<>();

    public ExternalResourceController(String assetDirectory, String assetUrlPrefix) {
        if (assetDirectory == null || assetDirectory.trim().isEmpty()) {
            throw new IllegalArgumentException("The asset output directory must not be empty.");
        }

        this.assetDirectory = Paths.get(assetDirectory);
        this.assetUrlPrefix = normalizeUrlPrefix(assetUrlPrefix);
    }

    @Override
    public int getObjectStoringLocation(
            int resourceId,
            byte[] entityData,
            String semanticName,
            String contentType,
            String recommendedExtension) {
        String extension = resolveExtension(contentType, recommendedExtension);
        if (extension == null) {
            return LinkEmbedDecision.Embed;
        }

        fileNamesByResourceId.put(resourceId, "resource-" + resourceId + extension);
        return LinkEmbedDecision.Link;
    }

    @Override
    public String getUrl(int resourceId, int referrer) {
        String fileName = fileNamesByResourceId.get(resourceId);
        if (fileName == null) {
            return null;
        }

        if (fileNamesByResourceId.containsKey(referrer)) {
            return fileName;
        }

        return assetUrlPrefix + fileName;
    }

    @Override
    public void saveExternal(int resourceId, byte[] entityData) {
        String fileName = fileNamesByResourceId.get(resourceId);
        if (fileName == null) {
            throw new IllegalStateException(
                    "Resource " + resourceId + " was not registered for external storage.");
        }

        if (entityData == null || entityData.length == 0) {
            throw new IllegalStateException(
                    "Resource " + resourceId + " contains no data and cannot be saved.");
        }

        Path filePath = assetDirectory.resolve(fileName);
        try {
            Files.createDirectories(assetDirectory);
            Files.write(filePath, entityData);
        } catch (IOException exception) {
            throw new IllegalStateException(
                    "Could not save linked resource " + resourceId + " to " + filePath + ".",
                    exception);
        }
    }

    private static Map<String, String> createExtensionMap() {
        Map<String, String> extensions = new HashMap<>();
        extensions.put("image/jpeg", ".jpg");
        extensions.put("image/png", ".png");
        extensions.put("image/gif", ".gif");
        extensions.put("image/bmp", ".bmp");
        extensions.put("image/svg+xml", ".svg");
        extensions.put("image/tiff", ".tiff");
        extensions.put("image/x-emf", ".emf");
        extensions.put("image/x-wmf", ".wmf");
        extensions.put("font/woff", ".woff");
        extensions.put("font/woff2", ".woff2");
        extensions.put("font/ttf", ".ttf");
        extensions.put("application/font-woff", ".woff");
        extensions.put("application/vnd.ms-fontobject", ".eot");
        extensions.put("application/x-font-ttf", ".ttf");
        extensions.put("text/css", ".css");
        extensions.put("audio/mpeg", ".mp3");
        extensions.put("audio/mp4", ".m4a");
        extensions.put("audio/wav", ".wav");
        extensions.put("video/mp4", ".mp4");
        extensions.put("video/webm", ".webm");
        return extensions;
    }

    private static String resolveExtension(String contentType, String recommendedExtension) {
        if (contentType != null && !contentType.trim().isEmpty()) {
            String mappedExtension = EXTENSIONS_BY_CONTENT_TYPE.get(contentType);
            if (mappedExtension != null) {
                return mappedExtension;
            }
        }

        if (!isSupportedContentType(contentType)) {
            return null;
        }

        return normalizeExtension(recommendedExtension);
    }

    private static boolean isSupportedContentType(String contentType) {
        return contentType != null &&
                (contentType.regionMatches(true, 0, "image/", 0, 6) ||
                 contentType.regionMatches(true, 0, "font/", 0, 5) ||
                 contentType.regionMatches(true, 0, "audio/", 0, 6) ||
                 contentType.regionMatches(true, 0, "video/", 0, 6));
    }

    private static String normalizeExtension(String extension) {
        if (extension == null || extension.trim().isEmpty()) {
            return null;
        }

        String extensionCharacters = extension.trim();
        while (extensionCharacters.startsWith(".")) {
            extensionCharacters = extensionCharacters.substring(1);
        }

        for (int characterIndex = 0; characterIndex < extensionCharacters.length(); characterIndex++) {
            if (!Character.isLetterOrDigit(extensionCharacters.charAt(characterIndex))) {
                return null;
            }
        }

        return "." + extensionCharacters.toLowerCase(Locale.ROOT);
    }

    private static String normalizeUrlPrefix(String urlPrefix) {
        if (urlPrefix == null || urlPrefix.isEmpty()) {
            return "";
        }

        String normalizedUrlPrefix = urlPrefix.replace('\\', '/');
        return normalizedUrlPrefix.endsWith("/")
                ? normalizedUrlPrefix
                : normalizedUrlPrefix + "/";
    }
}

Экспорт HTML со связанными ресурсами

Следующий PHP‑код создаёт выходной каталог, сохраняет туда HTML‑файл и хранит связанные ресурсы в подпапке assets. Он объединяет HtmlOptions, SVGOptions, SlideImageFormat и SaveFormat для экспорта.

$inputFilePath = "presentation.pptx";
$outputDirectory = "html-output";
$assetDirectoryName = "assets";
$assetDirectory = $outputDirectory . DIRECTORY_SEPARATOR . $assetDirectoryName;

if (!is_dir($outputDirectory) && !mkdir($outputDirectory, 0777, true)) {
    throw new RuntimeException("Could not create the HTML output directory: " . $outputDirectory);
}

if (!is_dir($assetDirectory) && !mkdir($assetDirectory, 0777, true)) {
    throw new RuntimeException("Could not create the asset output directory: " . $assetDirectory);
}

$assetUrlPrefix = $assetDirectoryName . "/";
$controller = new Java("com.example.slides.ExternalResourceController", $assetDirectory, $assetUrlPrefix);
$svgOptions = new SVGOptions($controller);
$slideImageFormat = SlideImageFormat::svg($svgOptions);

$htmlOptions = new HtmlOptions($controller);
$htmlFormatter = java("com.aspose.slides.HtmlFormatter")->createDocumentFormatter("", false);
$htmlOptions->setHtmlFormatter($htmlFormatter);
$htmlOptions->setSlideImageFormat($slideImageFormat);

$presentation = new Presentation($inputFilePath);
try {
    $htmlFilePath = $outputDirectory . DIRECTORY_SEPARATOR . "presentation.html";
    $presentation->save($htmlFilePath, SaveFormat::Html, $htmlOptions);
} finally {
    $presentation->dispose();
}

После экспорта в выходной папке будет следующая структура:

html-output/
  presentation.html
  assets/
    resource-1.svg
    resource-2.svg
    resource-3.svg
    resource-4.jpg
    resource-5.png

Точные файлы зависят от содержимого презентации и параметров экспорта. Например, растровые изображения обычно экспортируются как JPEG или PNG. Aspose.Slides может выбрать иной кодек изображения, чем использовался в исходной презентации, если это приводит к меньшему или более подходящему файлу. Изображения с прозрачностью экспортируются как PNG.

Выбор URL‑ов для развёртывания

В примере используется относительный префикс URL: assets/. Если presentation.html открыт из html-output/presentation.html, браузер загружает html-output/assets/resource-1.svg.

Когда один связанный ресурс ссылается на другой связанный ресурс, пример использует параметр referrer в ExternalResourceController.getUrl и возвращает только имя файла. Например, если resource-1.svg и resource-4.jpg находятся в папке assets, SVG‑файл должен ссылаться на resource-4.jpg, а не на assets/resource-4.jpg.

Используйте другой префикс URL, когда файлы развёрнуты в другом месте:

  • Используйте assets/, когда каталог ресурсов находится рядом с HTML‑файлом.
  • Используйте ../assets/, когда каталог ресурсов находится на один уровень выше HTML‑файла.
  • Используйте https://cdn.example.com/presentations/job-123/assets/, когда файлы загружаются в CDN или на статический файловый сервер.

URL, возвращаемый ExternalResourceController.getUrl, должен соответствовать окончательному месту размещения файла, записанного ExternalResourceController.saveExternal. В серверных приложениях используйте уникальный выходной каталог или префикс объектного хранилища для каждой задачи преобразования, чтобы избежать перезаписи файлов от другого экспорта.

Когда вместо этого встраивать

Встроенный Base64 HTML всё ещё полезен, когда вывод должен быть одним файлом, например вложением письма, офлайн‑превью или документом, который будет перемещён без сопутствующей папки ресурсов. Связанные ресурсы более подходят, когда HTML будет обслуживаться веб‑приложением, храниться в CMS, оптимизироваться конвейером сборки или кэшироваться браузерами независимо от HTML.

FAQ

Могу ли я вынести наружу только изображения и оставить остальные ресурсы встроенными?

Да. В ExternalResourceController.getObjectStoringLocation возвращайте значение Link из LinkEmbedDecision только для тех типов контента, которые вы хотите сохранить в виде отдельных файлов, и возвращайте значение Embed для всех остальных.

Почему расширение экспортированного изображения отличается от исходной презентации?

Aspose.Slides может перекодировать растровые изображения во время экспорта в HTML, чтобы уменьшить размер или повысить совместимость с браузерами. Например, изображение из исходного файла может быть записано как JPEG или PNG в зависимости от результата рендеринга.

Работают ли относительные URL после перемещения HTML‑файла?

Относительные URL работают только при сохранении той же относительной структуры папок. Если HTML ссылается на assets/resource-1.png, папка assets должна оставаться рядом с HTML‑файлом, если только вы не генерируете другой префикс URL.

Должны ли серверные приложения повторно использовать одну и ту же папку вывода?

Нет. Используйте уникальный выходной каталог или префикс хранилища для каждой задачи преобразования. Это предотвращает конфликты имён файлов и не позволяет одному экспорту перезаписать ресурсы, созданные другим экспортом.