/*
* 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.office.viewer.internal;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.InputStream;
import java.net.URL;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.artofsolving.jodconverter.document.DocumentFamily;
import org.artofsolving.jodconverter.document.DocumentFormat;
import org.slf4j.Logger;
import org.xwiki.bridge.DocumentAccessBridge;
import org.xwiki.cache.Cache;
import org.xwiki.cache.CacheException;
import org.xwiki.cache.CacheManager;
import org.xwiki.cache.config.LRUCacheConfiguration;
import org.xwiki.component.annotation.Component;
import org.xwiki.component.phase.Initializable;
import org.xwiki.component.phase.InitializationException;
import org.xwiki.model.reference.AttachmentReference;
import org.xwiki.model.reference.AttachmentReferenceResolver;
import org.xwiki.model.reference.DocumentReference;
import org.xwiki.model.reference.EntityReferenceSerializer;
import org.xwiki.office.viewer.OfficeResourceViewer;
import org.xwiki.officeimporter.builder.PresentationBuilder;
import org.xwiki.officeimporter.builder.XDOMOfficeDocumentBuilder;
import org.xwiki.officeimporter.converter.OfficeConverter;
import org.xwiki.officeimporter.document.XDOMOfficeDocument;
import org.xwiki.officeimporter.server.OfficeServer;
import org.xwiki.properties.ConverterManager;
import org.xwiki.rendering.block.Block;
import org.xwiki.rendering.block.ExpandedMacroBlock;
import org.xwiki.rendering.block.ImageBlock;
import org.xwiki.rendering.block.MetaDataBlock;
import org.xwiki.rendering.block.XDOM;
import org.xwiki.rendering.block.match.ClassBlockMatcher;
import org.xwiki.rendering.listener.MetaData;
import org.xwiki.rendering.listener.reference.ResourceReference;
import org.xwiki.rendering.listener.reference.ResourceType;
import org.xwiki.rendering.renderer.reference.ResourceReferenceTypeSerializer;
import org.xwiki.rendering.syntax.Syntax;
import org.xwiki.resource.ResourceReferenceSerializer;
import org.xwiki.resource.temporary.TemporaryResourceReference;
import org.xwiki.resource.temporary.TemporaryResourceStore;
import org.xwiki.url.ExtendedURL;
/**
* Default implementation of {@link org.xwiki.office.viewer.OfficeResourceViewer}.
*
* @since 5.4.6
* @since 6.2.2
* @version $Id: fcef771ca9c1c12c9e13a7274ffd3e05809393f7 $
*/
@Component
@Singleton
public class DefaultOfficeResourceViewer implements OfficeResourceViewer, Initializable
{
/**
* The module name used when creating temporary files. This is the module used by the temporary resource action to
* retrieve the temporary file.
*/
private static final String MODULE_NAME = "officeviewer";
/**
* Used to access attachment content.
*/
@Inject
private DocumentAccessBridge documentAccessBridge;
@Inject
private TemporaryResourceStore temporaryResourceStore;
@Inject
@Named("standard/tmp")
private ResourceReferenceSerializer<TemporaryResourceReference, ExtendedURL> urlTemporaryResourceReferenceSerializer;
/**
* Used for serializing {@link AttachmentReference}s.
*/
@Inject
private EntityReferenceSerializer<String> serializer;
@Inject
private ResourceReferenceTypeSerializer resourceReferenceSerializer;
@Inject
@Named("current")
private AttachmentReferenceResolver<String> attachmentResolver;
/**
* Used to initialize the view cache.
*/
@Inject
private CacheManager cacheManager;
/**
* Attachment based office document view cache.
*/
private Cache<AttachmentOfficeDocumentView> attachmentCache;
/**
* External file based office document view cache.
*/
private Cache<OfficeDocumentView> externalCache;
/**
* Used to build XDOM documents from office documents.
*/
@Inject
private XDOMOfficeDocumentBuilder documentBuilder;
/**
* Used to build XDOM documents from office presentations.
*/
@Inject
private PresentationBuilder presentationBuilder;
/**
* Used to access the document converter.
*/
@Inject
private OfficeServer officeServer;
@Inject
private ConverterManager converter;
/**
* The logger to log.
*/
@Inject
private Logger logger;
/**
* Processes all the image blocks in the given XDOM and changes image URL to point to a temporary file for those
* images that are view artifacts.
*
* @param xdom the XDOM whose image blocks are to be processed
* @param artifacts specify which of the image blocks should be processed; only the image blocks that were generated
* during the office import process should be processed
* @param ownerDocumentReference specifies the document that owns the office file
* @param resourceReference a reference to the office file that is being viewed; this reference is used to compute
* the path to the temporary directory holding the image artifacts
* @param parameters the build parameters. Note that currently only {@code filterStyles} is supported and if "true"
* it means that styles will be filtered to the maximum and the focus will be put on importing only the
* @return the set of temporary files corresponding to image artifacts
*/
private Set<File> processImages(XDOM xdom, Map<String, byte[]> artifacts, DocumentReference ownerDocumentReference,
String resourceReference, Map<String, ?> parameters)
{
// Process all image blocks.
Set<File> temporaryFiles = new HashSet<File>();
List<ImageBlock> imgBlocks = xdom.getBlocks(new ClassBlockMatcher(ImageBlock.class), Block.Axes.DESCENDANT);
for (ImageBlock imgBlock : imgBlocks) {
String imageReference = imgBlock.getReference().getReference();
// Check whether there is a corresponding artifact.
if (artifacts.containsKey(imageReference)) {
try {
List<String> resourcePath = Arrays.asList(String.valueOf(parameters.hashCode()), imageReference);
TemporaryResourceReference temporaryResourceReference =
new TemporaryResourceReference(MODULE_NAME, resourcePath, ownerDocumentReference);
// Write the image into a temporary file.
File tempFile = this.temporaryResourceStore.createTemporaryFile(temporaryResourceReference,
new ByteArrayInputStream(artifacts.get(imageReference)));
// Create a URL image reference which links to above temporary image file.
String temporaryResourceURL =
this.urlTemporaryResourceReferenceSerializer.serialize(temporaryResourceReference).serialize();
ResourceReference urlImageReference =
new ResourceReference(temporaryResourceURL, ResourceType.PATH);
urlImageReference.setTyped(true);
// Replace the old image block with a new one that uses the above URL image reference.
Block newImgBlock = new ImageBlock(urlImageReference, false, imgBlock.getParameters());
imgBlock.getParent().replaceChild(Arrays.asList(newImgBlock), imgBlock);
// Make sure the new image block is not inside an ExpandedMacroBlock whose's content syntax doesn't
// support relative path resource references (we use relative paths to refer the temporary files).
maybeFixExpandedMacroAncestor(newImgBlock);
// Collect the temporary file so that it can be cleaned up when the view is disposed from cache.
temporaryFiles.add(tempFile);
} catch (Exception ex) {
String message = "Error while processing artifact image [%s].";
this.logger.error(String.format(message, imageReference), ex);
}
}
}
return temporaryFiles;
}
private void maybeFixExpandedMacroAncestor(Block block)
{
ExpandedMacroBlock expandedMacro =
block.getFirstBlock(new ClassBlockMatcher(ExpandedMacroBlock.class), Block.Axes.ANCESTOR_OR_SELF);
if (expandedMacro != null) {
Block parent = expandedMacro.getParent();
if (!(parent instanceof MetaDataBlock) || !((MetaDataBlock) parent).getMetaData().contains(MODULE_NAME)) {
MetaDataBlock metaData = new MetaDataBlock(Collections.<Block>emptyList());
// Use a syntax that supports relative path resource references (we use relative paths to include the
// temporary files).
metaData.getMetaData().addMetaData(MetaData.SYNTAX, Syntax.XWIKI_2_1);
metaData.getMetaData().addMetaData(MODULE_NAME, true);
parent.replaceChild(metaData, expandedMacro);
metaData.addChild(expandedMacro);
}
}
}
/**
* Creates a {@link XDOM} representation of the specified office attachment.
*
* @param attachmentReference a reference to the office file to be parsed into XDOM
* @param parameters the build parameters. Note that currently only {@code filterStyles} is supported and if "true"
* it means that styles will be filtered to the maximum and the focus will be put on importing only the
* content
* @return the {@link XDOMOfficeDocument} corresponding to the specified office file
* @throws Exception if building the XDOM fails
*/
private XDOMOfficeDocument createXDOM(AttachmentReference attachmentReference, Map<String, ?> parameters)
throws Exception
{
InputStream officeFileStream = this.documentAccessBridge.getAttachmentContent(attachmentReference);
String officeFileName = attachmentReference.getName();
return createXDOM(attachmentReference.getDocumentReference(), officeFileStream, officeFileName, parameters);
}
private XDOMOfficeDocument createXDOM(DocumentReference ownerDocument, ResourceReference resourceReference,
Map<String, ?> parameters) throws Exception
{
InputStream officeFileStream;
String officeFileName;
if (resourceReference.getType().equals(ResourceType.URL)) {
URL url = new URL(resourceReference.getReference());
officeFileStream = url.openStream();
officeFileName = StringUtils.substringAfterLast(url.getPath(), "/");
} else {
throw new Exception(String.format("Unsupported resource type [%s].", resourceReference.getType()));
}
return createXDOM(ownerDocument, officeFileStream, officeFileName, parameters);
}
private XDOMOfficeDocument createXDOM(DocumentReference ownerDocument, InputStream officeFileStream,
String officeFileName, Map<String, ?> parameters) throws Exception
{
try {
if (isPresentation(officeFileName)) {
return this.presentationBuilder.build(officeFileStream, officeFileName, ownerDocument);
} else {
boolean filterStyles = this.converter.convert(boolean.class, parameters.get("filterStyles"));
return this.documentBuilder.build(officeFileStream, officeFileName, ownerDocument, filterStyles);
}
} finally {
IOUtils.closeQuietly(officeFileStream);
}
}
/**
* Utility method for checking if a file name corresponds to an office presentation.
*
* @param fileName attachment file name
* @return {@code true} if the file extension represents an office presentation format, {@code false} otherwise
*/
private boolean isPresentation(String fileName)
{
String extension = fileName.substring(fileName.lastIndexOf('.') + 1);
OfficeConverter officeConverter = this.officeServer.getConverter();
if (officeConverter != null) {
DocumentFormat format = officeConverter.getFormatRegistry().getFormatByExtension(extension);
return format != null && format.getInputFamily() == DocumentFamily.PRESENTATION;
}
return false;
}
@Override
public void initialize() throws InitializationException
{
try {
LRUCacheConfiguration attachmentConfig = new LRUCacheConfiguration(MODULE_NAME + ".attachment", 50);
this.attachmentCache = this.cacheManager.createNewCache(attachmentConfig);
// We have no idea when to invalidate the cache so lets at least put a time to live
LRUCacheConfiguration exteralConfig = new LRUCacheConfiguration(MODULE_NAME + ".external", 50, 3600);
this.externalCache = this.cacheManager.createNewCache(exteralConfig);
} catch (CacheException e) {
throw new InitializationException("Failed to create caches.", e);
}
}
private OfficeDocumentView getView(ResourceReference reference, AttachmentReference attachmentReference,
Map<String, ?> parameters) throws Exception
{
// Search the cache.
String cacheKey =
getCacheKey(attachmentReference.getDocumentReference(), attachmentReference.getName(), parameters);
AttachmentOfficeDocumentView view = this.attachmentCache.get(cacheKey);
// It's possible that the attachment has been deleted. We need to catch such events and cleanup the cache.
DocumentReference documentReference = attachmentReference.getDocumentReference();
if (!this.documentAccessBridge.getAttachmentReferences(documentReference).contains(attachmentReference)) {
// If a cached view exists, flush it.
if (view != null) {
this.attachmentCache.remove(cacheKey);
}
throw new Exception(String.format("Attachment [%s] does not exist.", attachmentReference));
}
// Check if the view has expired.
String currentVersion = this.documentAccessBridge.getAttachmentVersion(attachmentReference);
if (view != null && !currentVersion.equals(view.getVersion())) {
// Flush the cached view.
this.attachmentCache.remove(cacheKey);
view = null;
}
// If a view in not available, build one and cache it.
if (view == null) {
XDOMOfficeDocument xdomOfficeDocument = createXDOM(attachmentReference, parameters);
String attachmentVersion = this.documentAccessBridge.getAttachmentVersion(attachmentReference);
XDOM xdom = xdomOfficeDocument.getContentDocument();
// We use only the file name from the resource reference because the rest of the information is specified by
// the owner document reference. This way we ensure the path to the temporary files doesn't contain
// redundant information and so it remains as small as possible (considering that the path length is limited
// on some environments).
Set<File> temporaryFiles = processImages(xdom, xdomOfficeDocument.getArtifacts(),
attachmentReference.getDocumentReference(), attachmentReference.getName(), parameters);
view = new AttachmentOfficeDocumentView(reference, attachmentReference, attachmentVersion, xdom,
temporaryFiles);
this.attachmentCache.set(cacheKey, view);
}
// We have to clone the cached XDOM to protect it from the rendering transformations. For instance, macro
// transformations must be executed even when the XDOM is taken from the cache.
return view;
}
private OfficeDocumentView getView(ResourceReference resourceReference, Map<String, ?> parameters) throws Exception
{
DocumentReference ownerDocument = getOwnerDocument(parameters);
String serializedResourceReference = this.resourceReferenceSerializer.serialize(resourceReference);
// Search the cache.
String cacheKey = getCacheKey(ownerDocument, serializedResourceReference, parameters);
OfficeDocumentView view = this.externalCache.get(cacheKey);
// If a view in not available, build one and cache it.
if (view == null) {
XDOMOfficeDocument xdomOfficeDocument = createXDOM(ownerDocument, resourceReference, parameters);
XDOM xdom = xdomOfficeDocument.getContentDocument();
Set<File> temporaryFiles = processImages(xdom, xdomOfficeDocument.getArtifacts(), ownerDocument,
serializedResourceReference, parameters);
view = new OfficeDocumentView(resourceReference, xdom, temporaryFiles);
this.externalCache.set(cacheKey, view);
}
return view;
}
@Override
public XDOM createView(ResourceReference reference, Map<String, ?> parameters) throws Exception
{
OfficeDocumentView view;
if (reference.getType().equals(ResourceType.ATTACHMENT) || reference.getType().equals(ResourceType.UNKNOWN)) {
AttachmentReference attachmentReference = this.attachmentResolver.resolve(reference.getReference());
view = getView(reference, attachmentReference, parameters);
} else {
view = getView(reference, parameters);
}
// We have to clone the cached XDOM to protect it from the rendering transformations. For instance, macro
// transformations must be executed even when the XDOM is taken from the cache.
return view.getXDOM().clone();
}
private String getCacheKey(DocumentReference ownerDocument, String resource, Map<String, ?> parameters)
{
return this.serializer.serialize(ownerDocument) + '/' + resource + '/' + parameters.hashCode();
}
private DocumentReference getOwnerDocument(Map<String, ?> parameters)
{
DocumentReference ownerDocument =
this.converter.convert(DocumentReference.class, parameters.get("ownerDocument"));
if (ownerDocument == null) {
this.documentAccessBridge.getCurrentDocumentReference();
}
return ownerDocument;
}
}