Skip to content
Snippets Groups Projects
Commit 4eeee712 authored by Marius Dumitru Florea's avatar Marius Dumitru Florea
Browse files

XWIKI-19270: Add support for performing the PDF export using a browser running...

XWIKI-19270: Add support for performing the PDF export using a browser running in a Docker container

(cherry picked from commit 1c147d77)
parent 63542bf2
No related branches found
No related tags found
No related merge requests found
Showing
with 466 additions and 6 deletions
......@@ -34,6 +34,7 @@
<properties>
<!-- Name to display by the Extension Manager -->
<xwiki.extension.name>PDF Export Default API Implementation</xwiki.extension.name>
<dockerJava.version>3.2.13</dockerJava.version>
</properties>
<dependencies>
<dependency>
......@@ -56,6 +57,23 @@
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
</dependency>
<!-- Used for running a headless Chrome web browser inside a Docker container. -->
<dependency>
<groupId>com.github.docker-java</groupId>
<artifactId>docker-java-core</artifactId>
<version>${dockerJava.version}</version>
</dependency>
<dependency>
<groupId>com.github.docker-java</groupId>
<artifactId>docker-java-transport-httpclient5</artifactId>
<version>${dockerJava.version}</version>
</dependency>
<!-- Used for interacting with the headless Chrome web browser. -->
<dependency>
<groupId>com.github.kklisura.cdt</groupId>
<artifactId>cdt-java-client</artifactId>
<version>4.0.0</version>
</dependency>
<!-- Test dependencies -->
<dependency>
<groupId>org.xwiki.commons</groupId>
......
/*
* See the NOTICE file distributed with this work for additional
* information regarding copyright ownership.
*
* This is free software; you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as
* published by the Free Software Foundation; either version 2.1 of
* the License, or (at your option) any later version.
*
* This software is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this software; if not, write to the Free
* Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
* 02110-1301 USA, or see the FSF site: http://www.fsf.org.
*/
package org.xwiki.export.pdf.internal.docker;
import java.util.Arrays;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Singleton;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.slf4j.Logger;
import org.xwiki.component.annotation.Component;
import org.xwiki.component.phase.Initializable;
import org.xwiki.component.phase.InitializationException;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.async.ResultCallbackTemplate;
import com.github.dockerjava.api.command.CreateContainerCmd;
import com.github.dockerjava.api.command.CreateContainerResponse;
import com.github.dockerjava.api.command.InspectContainerResponse;
import com.github.dockerjava.api.command.PullImageResultCallback;
import com.github.dockerjava.api.command.WaitContainerResultCallback;
import com.github.dockerjava.api.exception.NotFoundException;
import com.github.dockerjava.api.model.Bind;
import com.github.dockerjava.api.model.Container;
import com.github.dockerjava.api.model.ExposedPort;
import com.github.dockerjava.api.model.HostConfig;
import com.github.dockerjava.api.model.Ports;
import com.github.dockerjava.api.model.Volume;
import com.github.dockerjava.core.DefaultDockerClientConfig;
import com.github.dockerjava.core.DockerClientConfig;
import com.github.dockerjava.core.DockerClientImpl;
import com.github.dockerjava.httpclient5.ApacheDockerHttpClient;
import com.github.dockerjava.transport.DockerHttpClient;
/**
* Help perform various operations on Docker containers.
*
* @version $Id$
* @since 14.4.1
* @since 14.5RC1
*/
@Component(roles = ContainerManager.class)
@Singleton
public class ContainerManager implements Initializable
{
private static final String DOCKER_SOCK = "/var/run/docker.sock";
@Inject
private Logger logger;
private DockerClient client;
@Override
public void initialize() throws InitializationException
{
DockerClientConfig config = DefaultDockerClientConfig.createDefaultConfigBuilder().build();
DockerHttpClient httpClient = new ApacheDockerHttpClient.Builder().dockerHost(config.getDockerHost())
.sslConfig(config.getSSLConfig()).build();
this.client = DockerClientImpl.getInstance(config, httpClient);
}
public String maybeReuseContainerByName(String containerName)
{
List<Container> containers =
this.client.listContainersCmd().withNameFilter(Arrays.asList(containerName)).exec();
if (containers.isEmpty()) {
// There's no container with the specified name.
return null;
}
InspectContainerResponse container = this.client.inspectContainerCmd(containers.get(0).getId()).exec();
if (container.getState().getDead() == Boolean.TRUE) {
// The container is not reusable. Try to remove it so it can be recreated.
this.client.removeContainerCmd(container.getId()).exec();
return null;
} else if (container.getState().getPaused() == Boolean.TRUE) {
this.client.unpauseContainerCmd(container.getId()).exec();
} else if (container.getState().getRunning() != Boolean.TRUE
&& container.getState().getRestarting() != Boolean.TRUE) {
this.startContainer(container.getId());
}
return container.getId();
}
/**
* Checks if a given image is already pulled locally.
*
* @param imageName the image to check
* @return {@code true} if the specified image was already pulled, {@code false} otherwise
*/
public boolean isLocalImagePresent(String imageName)
{
try {
this.client.inspectImageCmd(imageName).exec();
return true;
} catch (NotFoundException e) {
return false;
}
}
/**
* Docker-pull the passed image.
*
* @param imageName the image to pull
*/
public void pullImage(String imageName)
{
PullImageResultCallback pullImageResultCallback =
this.client.pullImageCmd(imageName).exec(new PullImageResultCallback());
wait(pullImageResultCallback);
}
/**
* Creates a new container based on the specified image.
*
* @param imageName the image to use for the new container
* @param containerName the name to associate with the created container
* @param remoteDebuggingPort the port used for remote debugging
* @param parameters the parameters to specify when creating the container
* @return the id of the created container
*/
public String createContainer(String imageName, String containerName, int remoteDebuggingPort,
List<String> parameters)
{
ExposedPort exposedPort = ExposedPort.tcp(remoteDebuggingPort);
Ports portBindings = new Ports();
portBindings.bind(exposedPort, Ports.Binding.bindPort(remoteDebuggingPort));
CreateContainerCmd command = this.client.createContainerCmd(imageName);
CreateContainerResponse container = command.withCmd(parameters).withExposedPorts(exposedPort)
.withHostConfig(HostConfig.newHostConfig().withBinds(
// Make sure it also works when XWiki is running in Docker.
new Bind(DOCKER_SOCK, new Volume(DOCKER_SOCK))).withPortBindings(portBindings))
.withName(containerName).exec();
return container.getId();
}
/**
* Start the specified container and wait until it is ready.
*
* @param containerId the id of the container to start
*/
public void startContainer(String containerId)
{
// Start (and stop and remove automatically when the conversion is finished, thanks to the autoremove above).
this.client.startContainerCmd(containerId).exec();
// Wait for the container to be ready before continuing.
WaitContainerResultCallback resultCallback = new WaitContainerResultCallback();
this.client.waitContainerCmd(containerId).exec(resultCallback);
wait(resultCallback);
}
/**
* Stop the specified container.
*
* @param containerId the if of the container to stop
*/
public void stopContainer(String containerId)
{
if (containerId != null) {
this.client.stopContainerCmd(containerId).exec();
// Wait for the container to be fully stopped before continuing.
WaitContainerResultCallback resultCallback = new WaitContainerResultCallback();
this.client.waitContainerCmd(containerId).exec(resultCallback);
wait(resultCallback);
}
}
private void wait(ResultCallbackTemplate<?, ?> template)
{
try {
template.awaitCompletion();
} catch (InterruptedException e) {
this.logger.warn("Interrupted thread [{}]. Root cause: [{}]", Thread.currentThread().getName(),
ExceptionUtils.getRootCauseMessage(e));
// Restore interrupted state to be a good citizen...
Thread.currentThread().interrupt();
}
}
}
/*
* See the NOTICE file distributed with this work for additional
* information regarding copyright ownership.
*
* This is free software; you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as
* published by the Free Software Foundation; either version 2.1 of
* the License, or (at your option) any later version.
*
* This software is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this software; if not, write to the Free
* Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
* 02110-1301 USA, or see the FSF site: http://www.fsf.org.
*/
package org.xwiki.export.pdf.internal.docker;
import java.io.InputStream;
import java.net.URL;
import java.util.Arrays;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import org.xwiki.component.annotation.Component;
import org.xwiki.component.manager.ComponentLifecycleException;
import org.xwiki.component.phase.Disposable;
import org.xwiki.component.phase.Initializable;
import org.xwiki.component.phase.InitializationException;
import org.xwiki.export.pdf.PDFPrinter;
import com.github.kklisura.cdt.protocol.commands.Page;
import com.github.kklisura.cdt.protocol.types.page.PrintToPDF;
import com.github.kklisura.cdt.protocol.types.page.PrintToPDFTransferMode;
import com.github.kklisura.cdt.services.ChromeDevToolsService;
import com.github.kklisura.cdt.services.ChromeService;
import com.github.kklisura.cdt.services.impl.ChromeServiceImpl;
import com.github.kklisura.cdt.services.types.ChromeTab;
/**
* Prints the content of a given URL using a headless Chrome web browser running inside a Docker container.
*
* @version $Id$
* @since 14.4.1
* @since 14.5RC1
*/
@Component
@Singleton
@Named("docker")
public class DockerURL2PDFPrinter implements PDFPrinter<URL>, Initializable, Disposable
{
private static final String CHROME_IMAGE = "zenika/alpine-chrome:latest";
private static final String CONTAINER_NAME = "headless-chrome-pdf-printer";
private static final int REMOTE_DEBUGGING_PORT = 9222;
@Inject
private ContainerManager containerManager;
private String containerId;
private ChromeService chromeService;
@Override
public void initialize() throws InitializationException
{
initializeDockerContainer();
this.chromeService = new ChromeServiceImpl(REMOTE_DEBUGGING_PORT);
}
private void initializeDockerContainer() throws InitializationException
{
try {
this.containerId = this.containerManager.maybeReuseContainerByName(CONTAINER_NAME);
if (this.containerId == null) {
// The container doesn't exist so we have to create it.
// But first we need to pull the image used to create the container, if we don't have it already.
if (!this.containerManager.isLocalImagePresent(CHROME_IMAGE)) {
this.containerManager.pullImage(CHROME_IMAGE);
}
this.containerId = this.containerManager.createContainer(CHROME_IMAGE, CONTAINER_NAME,
REMOTE_DEBUGGING_PORT, Arrays.asList("--no-sandbox", "--remote-debugging-address=0.0.0.0",
"--remote-debugging-port=" + REMOTE_DEBUGGING_PORT));
this.containerManager.startContainer(containerId);
}
} catch (Exception e) {
throw new InitializationException("Failed to initialize the Docker container for the PDF export.", e);
}
}
@Override
public void dispose() throws ComponentLifecycleException
{
try {
this.containerManager.stopContainer(this.containerId);
} catch (Exception e) {
throw new ComponentLifecycleException("Failed to stop the Docker container used for PDF export.", e);
}
}
@Override
public InputStream print(URL input)
{
ChromeTab tab = chromeService.createTab();
ChromeDevToolsService devToolsService = chromeService.createDevToolsService(tab);
Page page = devToolsService.getPage();
page.enable();
page.navigate(input.toString());
InputStream[] pdfStreams = new InputStream[] {null};
page.onLoadEventFired(loadEventFired -> {
Boolean landscape = false;
Boolean displayHeaderFooter = false;
Boolean printBackground = false;
Double scale = 1d;
// A4 paper format
Double paperWidth = 8.27d;
Double paperHeight = 11.7d;
Double marginTop = 0d;
Double marginBottom = 0d;
Double marginLeft = 0d;
Double marginRight = 0d;
String pageRanges = "";
Boolean ignoreInvalidPageRanges = false;
String headerTemplate = "";
String footerTemplate = "";
Boolean preferCSSPageSize = false;
PrintToPDFTransferMode mode = PrintToPDFTransferMode.RETURN_AS_STREAM;
PrintToPDF printToPDF = devToolsService.getPage().printToPDF(landscape, displayHeaderFooter,
printBackground, scale, paperWidth, paperHeight, marginTop, marginBottom, marginLeft, marginRight,
pageRanges, ignoreInvalidPageRanges, headerTemplate, footerTemplate, preferCSSPageSize, mode);
pdfStreams[0] = new PrintToPDFInputStream(devToolsService.getIO(), printToPDF.getStream());
devToolsService.close();
});
devToolsService.waitUntilClosed();
return pdfStreams[0];
}
}
......@@ -17,33 +17,74 @@
* Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
* 02110-1301 USA, or see the FSF site: http://www.fsf.org.
*/
package org.xwiki.export.pdf.internal;
package org.xwiki.export.pdf.internal.docker;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import javax.inject.Named;
import javax.inject.Singleton;
import org.xwiki.component.annotation.Component;
import org.xwiki.export.pdf.PDFPrinter;
import com.github.kklisura.cdt.protocol.commands.IO;
import com.github.kklisura.cdt.protocol.types.io.Read;
/**
* Prints the content of a given URL using a headless Chrome web browser running inside a Docker container.
* Input stream used to read the result of printing a web page to PDF.
*
* @version $Id$
* @since 14.4.1
* @since 14.5RC1
*/
@Component
@Singleton
@Named("docker")
public class DockerURL2PDFPrinter implements PDFPrinter<URL>
public class PrintToPDFInputStream extends InputStream
{
private IO io;
private String stream;
private boolean finished;
private int bufferOffset;
private byte[] buffer = new byte[] {};
/**
* Creates a new instance for reading the specified PDF stream.
*
* @param io the service used to read the PDF data
* @param stream a handle of the stream that holds the PDF data
*/
public PrintToPDFInputStream(IO io, String stream)
{
this.io = io;
this.stream = stream;
}
@Override
public InputStream print(URL input)
public int read() throws IOException
{
// TODO
return null;
if (this.bufferOffset >= this.buffer.length) {
this.bufferOffset = 0;
this.buffer = readBuffer();
if (this.buffer.length == 0) {
io.close(stream);
return -1;
}
}
return this.buffer[this.bufferOffset++];
}
private byte[] readBuffer()
{
if (this.finished) {
return new byte[] {};
}
Read read = io.read(stream);
this.finished = read.getEof() == Boolean.TRUE;
if (read.getBase64Encoded() == Boolean.TRUE) {
return Base64.getDecoder().decode(read.getData());
} else {
return read.getData().getBytes(StandardCharsets.UTF_8);
}
}
}
......@@ -21,7 +21,7 @@
import java.io.Serializable;
import java.net.URL;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
......@@ -67,8 +67,6 @@ public class DefaultPDFExportJobRequestFactory implements PDFExportJobRequestFac
{
private static final String EXPORT = "export";
private static final String UTF_8 = "UTF-8";
@Inject
private Provider<XWikiContext> xcontextProvider;
......@@ -146,7 +144,7 @@ private void readPDFExportOptionsFromHTTPRequest(PDFExportJobRequest request)
private Map<String, String[]> getRequestParameters(String queryString)
{
Map<String, List<String>> params = new LinkedHashMap<>();
for (NameValuePair pair : URLEncodedUtils.parse(queryString, Charset.forName(UTF_8))) {
for (NameValuePair pair : URLEncodedUtils.parse(queryString, StandardCharsets.UTF_8)) {
List<String> values = params.getOrDefault(pair.getName(), new ArrayList<>());
values.add(pair.getValue());
params.put(pair.getName(), values);
......@@ -194,6 +192,6 @@ private String getPrintPreviewQueryString(List<String> jobId, String originalQue
new BasicNameValuePair("sheet", "XWiki.PDFExport.Sheet"),
new BasicNameValuePair("jobId", StringUtils.join(jobId, '/'))
);
return URLEncodedUtils.format(printPreviewParams, Charset.forName(UTF_8)) + '&' + originalQueryString;
return URLEncodedUtils.format(printPreviewParams, StandardCharsets.UTF_8) + '&' + originalQueryString;
}
}
org.xwiki.export.pdf.internal.docker.ContainerManager
org.xwiki.export.pdf.internal.docker.DockerURL2PDFPrinter
org.xwiki.export.pdf.internal.job.DefaultPDFExportJobRequestFactory
org.xwiki.export.pdf.internal.job.PDFExportContextStore
org.xwiki.export.pdf.internal.DefaultRequiredSkinExtensionsRecorder
org.xwiki.export.pdf.internal.DockerURL2PDFPrinter
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment