diff --git a/source/java/org/alfresco/repo/action/executer/ContentMetadataExtracter.java b/source/java/org/alfresco/repo/action/executer/ContentMetadataExtracter.java index 023501108b..615f8dc9dd 100644 --- a/source/java/org/alfresco/repo/action/executer/ContentMetadataExtracter.java +++ b/source/java/org/alfresco/repo/action/executer/ContentMetadataExtracter.java @@ -19,6 +19,8 @@ package org.alfresco.repo.action.executer; import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -26,6 +28,7 @@ import java.util.Map; import java.util.Set; import org.alfresco.model.ContentModel; +import org.alfresco.repo.content.metadata.AbstractMappingMetadataExtracter; import org.alfresco.repo.content.metadata.MetadataExtracter; import org.alfresco.repo.content.metadata.MetadataExtracterRegistry; import org.alfresco.service.cmr.action.Action; @@ -37,6 +40,8 @@ import org.alfresco.service.cmr.repository.ContentReader; import org.alfresco.service.cmr.repository.ContentService; import org.alfresco.service.cmr.repository.NodeRef; import org.alfresco.service.cmr.repository.NodeService; +import org.alfresco.service.cmr.repository.datatype.DefaultTypeConverter; +import org.alfresco.service.cmr.tagging.TaggingService; import org.alfresco.service.namespace.QName; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -60,8 +65,10 @@ public class ContentMetadataExtracter extends ActionExecuterAbstractBase private NodeService nodeService; private ContentService contentService; private DictionaryService dictionaryService; + private TaggingService taggingService; private MetadataExtracterRegistry metadataExtracterRegistry; private boolean carryAspectProperties = true; + private boolean enableStringTagging = false; public ContentMetadataExtracter() { @@ -91,6 +98,14 @@ public class ContentMetadataExtracter extends ActionExecuterAbstractBase this.dictionaryService = dictService; } + /** + * @param taggingService The TaggingService to set. + */ + public void setTaggingService(TaggingService taggingService) + { + this.taggingService = taggingService; + } + /** * @param metadataExtracterRegistry The metadataExtracterRegistry to set. */ @@ -110,7 +125,92 @@ public class ContentMetadataExtracter extends ActionExecuterAbstractBase { this.carryAspectProperties = carryAspectProperties; } + + /** + * Whether or not to enable mapping of simple strings to cm:taggable tags + * + * @param enableStringTagging true find or create tags for each string + * mapped to cm:taggable. false (default) + * ignore mapping strings to tags. + */ + public void setEnableStringTagging(boolean enableStringTagging) + { + this.enableStringTagging = enableStringTagging; + } + /** + * Iterates the values of the taggable property which the metadata + * extractor should have already attempted to convert values to {@link NodeRef}s. + *

+ * If conversion by the metadata extracter failed due to a MalformedNodeRefException + * the taggable property should still contain raw string values. + *

+ * Mixing of NodeRefs and string values is permitted so each raw value is + * checked for a valid NodeRef representation and if so, converts to a NodeRef, + * if not, adds as a tag via the {@link TaggingService}. + * + * @param actionedUponNodeRef The NodeRef being actioned upon + * @param propertyDef the PropertyDefinition of the taggable property + * @param rawValue the raw value from the metadata extracter + */ + @SuppressWarnings("unchecked") + protected void addTags(NodeRef actionedUponNodeRef, PropertyDefinition propertyDef, Serializable rawValue) + { + List tags = new ArrayList(); + if (logger.isDebugEnabled()) + { + logger.debug("converting " + rawValue.toString() + " of type " + + rawValue.getClass().getCanonicalName() + " to tags"); + } + if (rawValue instanceof Collection) + { + for (Object singleValue : (Collection) rawValue) + { + if (singleValue instanceof String) + { + if (NodeRef.isNodeRef((String) singleValue)) + { + // Convert to a NodeRef + Serializable convertedPropertyValue = (Serializable) DefaultTypeConverter.INSTANCE.convert( + propertyDef.getDataType(), + (String) singleValue); + String tagName = (String) nodeService.getProperty((NodeRef) convertedPropertyValue, ContentModel.PROP_NAME); + if (logger.isTraceEnabled()) + { + logger.trace("found tag '" + tagName + "' from tag nodeRef '" + (String) singleValue + "', " + + "adding to " + actionedUponNodeRef.toString()); + } + tags.add(tagName); + } + else + { + // Must be a simple string + if (logger.isTraceEnabled()) + { + logger.trace("adding string tag '" + (String) singleValue + "' to " + actionedUponNodeRef.toString()); + } + tags.add((String) singleValue); + + } + } + else if (singleValue instanceof NodeRef) + { + String tagName = (String) nodeService.getProperty((NodeRef) singleValue, ContentModel.PROP_NAME); + tags.add(tagName); + } + } + } + else if (rawValue instanceof String) + { + if (logger.isTraceEnabled()) + { + logger.trace("adding tag '" + (String) rawValue + "' to " + actionedUponNodeRef.toString()); + } + tags.add((String) rawValue); + } + taggingService.addTags(actionedUponNodeRef, tags); + } + /** * @see org.alfresco.repo.action.executer.ActionExecuter#execute(org.alfresco.service.cmr.repository.NodeRef, * NodeRef) @@ -144,6 +244,10 @@ public class ContentMetadataExtracter extends ActionExecuterAbstractBase // There is no extracter to use return; } + if (enableStringTagging && (extracter instanceof AbstractMappingMetadataExtracter)) + { + ((AbstractMappingMetadataExtracter) extracter).setEnableStringTagging(enableStringTagging); + } // Get all the node's properties Map nodeProperties = nodeService.getProperties(actionedUponNodeRef); @@ -212,11 +316,22 @@ public class ContentMetadataExtracter extends ActionExecuterAbstractBase ClassDefinition propertyContainerDef = propertyDef.getContainerClass(); if (propertyContainerDef.isAspect()) { - QName aspectQName = propertyContainerDef.getName(); - requiredAspectQNames.add(aspectQName); - // Get all properties associated with the aspect - Set aspectProperties = propertyContainerDef.getProperties().keySet(); - aspectPropertyQNames.addAll(aspectProperties); + if (enableStringTagging && propertyContainerDef.getName().equals(ContentModel.ASPECT_TAGGABLE)) + { + Serializable oldValue = nodeProperties.get(propertyQName); + addTags(actionedUponNodeRef, propertyDef, oldValue); + // Replace the raw value with the created tag NodeRefs + nodeProperties.put(ContentModel.PROP_TAGS, + nodeService.getProperty(actionedUponNodeRef, ContentModel.PROP_TAGS)); + } + else + { + QName aspectQName = propertyContainerDef.getName(); + requiredAspectQNames.add(aspectQName); + // Get all properties associated with the aspect + Set aspectProperties = propertyContainerDef.getProperties().keySet(); + aspectPropertyQNames.addAll(aspectProperties); + } } } diff --git a/source/java/org/alfresco/repo/action/executer/ContentMetadataExtracterTagMappingTest.java b/source/java/org/alfresco/repo/action/executer/ContentMetadataExtracterTagMappingTest.java new file mode 100644 index 0000000000..7e86ba72b4 --- /dev/null +++ b/source/java/org/alfresco/repo/action/executer/ContentMetadataExtracterTagMappingTest.java @@ -0,0 +1,385 @@ +/* + * Copyright (C) 2005-2012 Alfresco Software Limited. + * + * This file is part of Alfresco + * + * Alfresco 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 3 of the License, or + * (at your option) any later version. + * + * Alfresco 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 Alfresco. If not, see . + */ +package org.alfresco.repo.action.executer; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; + +import junit.framework.TestCase; + +import org.alfresco.model.ContentModel; +import org.alfresco.repo.action.ActionImpl; +import org.alfresco.repo.action.ActionModel; +import org.alfresco.repo.action.AsynchronousActionExecutionQueuePolicies; +import org.alfresco.repo.content.MimetypeMap; +import org.alfresco.repo.content.metadata.MetadataExtracterRegistry; +import org.alfresco.repo.content.metadata.TikaPoweredMetadataExtracter; +import org.alfresco.repo.content.transform.AbstractContentTransformerTest; +import org.alfresco.repo.policy.Behaviour.NotificationFrequency; +import org.alfresco.repo.policy.JavaBehaviour; +import org.alfresco.repo.policy.PolicyComponent; +import org.alfresco.repo.security.authentication.AuthenticationComponent; +import org.alfresco.repo.tagging.TaggingServiceImplTest; +import org.alfresco.repo.tagging.TaggingServiceImplTest.AsyncOccurs; +import org.alfresco.repo.tagging.UpdateTagScopesActionExecuter; +import org.alfresco.repo.transaction.AlfrescoTransactionSupport; +import org.alfresco.repo.transaction.AlfrescoTransactionSupport.TxnReadState; +import org.alfresco.repo.transaction.RetryingTransactionHelper.RetryingTransactionCallback; +import org.alfresco.service.cmr.audit.AuditService; +import org.alfresco.service.cmr.repository.ContentReader; +import org.alfresco.service.cmr.repository.ContentService; +import org.alfresco.service.cmr.repository.ContentWriter; +import org.alfresco.service.cmr.repository.NodeRef; +import org.alfresco.service.cmr.repository.NodeService; +import org.alfresco.service.cmr.repository.StoreRef; +import org.alfresco.service.cmr.tagging.TaggingService; +import org.alfresco.service.namespace.NamespaceService; +import org.alfresco.service.namespace.QName; +import org.alfresco.service.transaction.TransactionService; +import org.alfresco.util.ApplicationContextHelper; +import org.alfresco.util.GUID; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.parser.Parser; +import org.apache.tika.parser.jpeg.JpegParser; +import org.springframework.context.ConfigurableApplicationContext; + +import com.google.common.collect.Sets; + +/** + * Test of the ActionExecuter for extracting metadata, specifically for + * the mapping to cm:taggable tags which requires different transaction + * mechanisms than the existing {@link ContentMetadataExtracterTest}. + * + * @author Roy Wetherall + * @author Nick Burch + * @author Ray Gauss II + */ +public class ContentMetadataExtracterTagMappingTest extends TestCase +{ + private static ConfigurableApplicationContext ctx = + (ConfigurableApplicationContext)ApplicationContextHelper.getApplicationContext(); + + protected static final String TAGGING_AUDIT_APPLICATION_NAME = "Alfresco Tagging Service"; + protected static final String QUICK_FILENAME = "quickIPTC.jpg"; + protected static final String QUICK_KEYWORD = "fox"; + protected static final String TAG_1 = "tag one"; + protected static final String TAG_2 = "tag two"; + protected static final String TAG_3 = "Tag Three"; + + /** Services */ + private TaggingService taggingService; + private NodeService nodeService; + private ContentService contentService; + private AuditService auditService; + private TransactionService transactionService; + private AuthenticationComponent authenticationComponent; + private AsyncOccurs asyncOccurs; + + private static StoreRef storeRef; + private static NodeRef rootNode; + private NodeRef folder; + private NodeRef document; + + private ContentMetadataExtracter executer; + private TagMappingMetadataExtracter extractor; + + private static boolean init = false; + + private final static String ID = GUID.generate(); + + + @Override + protected void setUp() throws Exception + { + // Detect any dangling transactions as there is a lot of direct UserTransaction manipulation + if (AlfrescoTransactionSupport.getTransactionReadState() != TxnReadState.TXN_NONE) + { + throw new IllegalStateException( + "There should not be any transactions when starting test: " + + AlfrescoTransactionSupport.getTransactionId() + " started at " + + new Date(AlfrescoTransactionSupport.getTransactionStartTime())); + } + + // Get services + this.taggingService = (TaggingService)ctx.getBean("TaggingService"); + this.nodeService = (NodeService) ctx.getBean("NodeService"); + this.contentService = (ContentService) ctx.getBean("ContentService"); + + this.transactionService = (TransactionService)ctx.getBean("transactionComponent"); + this.auditService = (AuditService)ctx.getBean("auditService"); + this.authenticationComponent = (AuthenticationComponent)ctx.getBean("authenticationComponent"); + + this.executer = (ContentMetadataExtracter) ctx.getBean("extract-metadata"); + executer.setEnableStringTagging(true); + executer.setTaggingService(taggingService); + + if (init == false) + { + this.transactionService.getRetryingTransactionHelper().doInTransaction(new RetryingTransactionCallback(){ + + @Override + public Void execute() throws Throwable + { + // Authenticate as the system user + authenticationComponent.setSystemUserAsCurrentUser(); + + // Create the store and get the root node + ContentMetadataExtracterTagMappingTest.storeRef = nodeService.createStore(StoreRef.PROTOCOL_WORKSPACE, "Test_" + System.currentTimeMillis()); + ContentMetadataExtracterTagMappingTest.rootNode = nodeService.getRootNode(ContentMetadataExtracterTagMappingTest.storeRef); + + // Create the required tagging category + NodeRef catContainer = nodeService.createNode(ContentMetadataExtracterTagMappingTest.rootNode, ContentModel.ASSOC_CHILDREN, QName.createQName(NamespaceService.CONTENT_MODEL_1_0_URI, "categoryContainer"), ContentModel.TYPE_CONTAINER).getChildRef(); + NodeRef catRoot = nodeService.createNode( + catContainer, + ContentModel.ASSOC_CHILDREN, + QName.createQName(NamespaceService.CONTENT_MODEL_1_0_URI, "categoryRoot"), + ContentModel.TYPE_CATEGORYROOT).getChildRef(); + nodeService.createNode( + catRoot, + ContentModel.ASSOC_CATEGORIES, + ContentModel.ASPECT_TAGGABLE, + ContentModel.TYPE_CATEGORY).getChildRef(); + + MetadataExtracterRegistry registry = (MetadataExtracterRegistry) ctx.getBean("metadataExtracterRegistry"); + extractor = new TagMappingMetadataExtracter(); + extractor.setRegistry(registry); + extractor.register(); + + init = true; + return null; + }}); + + } + + // We want to know when tagging actions have finished running + asyncOccurs = (new TaggingServiceImplTest()).new AsyncOccurs(); + ((PolicyComponent)ctx.getBean("policyComponent")).bindClassBehaviour( + AsynchronousActionExecutionQueuePolicies.OnAsyncActionExecute.QNAME, + ActionModel.TYPE_ACTION, + new JavaBehaviour(asyncOccurs, "onAsyncActionExecute", NotificationFrequency.EVERY_EVENT) + ); + + // We do want action tracking whenever the tag scope updater runs + UpdateTagScopesActionExecuter updateTagsAction = + (UpdateTagScopesActionExecuter)ctx.getBean("update-tagscope"); + updateTagsAction.setTrackStatus(true); + + // Create the folders and documents to be tagged + createTestDocumentsAndFolders(); + } + + @Override + protected void tearDown() throws Exception + { + removeTestDocumentsAndFolders(); + if (AlfrescoTransactionSupport.getTransactionReadState() != TxnReadState.TXN_NONE) + { + fail("Test is not transaction-safe. Fix up transaction handling and re-test."); + } + } + + private void createTestDocumentsAndFolders() throws Exception + { + this.transactionService.getRetryingTransactionHelper().doInTransaction(new RetryingTransactionCallback(){ + + @Override + public Void execute() throws Throwable + { + + // Authenticate as the system user + authenticationComponent.setSystemUserAsCurrentUser(); + + String guid = GUID.generate(); + + // Create a folder + Map folderProps = new HashMap(1); + folderProps.put(ContentModel.PROP_NAME, "testFolder" + guid); + folder = nodeService.createNode( + ContentMetadataExtracterTagMappingTest.rootNode, + ContentModel.ASSOC_CHILDREN, + QName.createQName(NamespaceService.CONTENT_MODEL_1_0_URI, "testFolder" + guid), + ContentModel.TYPE_FOLDER, + folderProps).getChildRef(); + + // Create a node + Map docProps = new HashMap(1); + docProps.put(ContentModel.PROP_NAME, "testDocument" + guid + ".jpg"); + document = nodeService.createNode( + folder, + ContentModel.ASSOC_CONTAINS, + QName.createQName(NamespaceService.CONTENT_MODEL_1_0_URI, "testDocument" + guid + ".jpg"), + ContentModel.TYPE_CONTENT, + docProps).getChildRef(); + + try + { + ContentWriter cw = contentService.getWriter(document, ContentModel.PROP_CONTENT, true); + cw.setMimetype(MimetypeMap.MIMETYPE_IMAGE_JPEG); + cw.putContent(AbstractContentTransformerTest.loadNamedQuickTestFile(QUICK_FILENAME)); + } + catch (Exception e) + { + fail(e.getMessage()); + } + + return null; + } + }); + } + + private void removeTestDocumentsAndFolders() throws Exception + { + this.transactionService.getRetryingTransactionHelper().doInTransaction(new RetryingTransactionCallback(){ + @Override + public Void execute() throws Throwable + { + // Authenticate as the system user + authenticationComponent.setSystemUserAsCurrentUser(); + + // If anything is a tag scope, stop it being + NodeRef[] nodes = new NodeRef[] { document, folder }; + for(NodeRef nodeRef : nodes) + { + if(taggingService.isTagScope(nodeRef)) + { + taggingService.removeTagScope(nodeRef); + } + } + + // Remove the sample nodes + for(NodeRef nodeRef : nodes) + { + nodeService.deleteNode(nodeRef); + } + + // Tidy up the audit component, now all the nodes have gone + auditService.clearAudit( + TAGGING_AUDIT_APPLICATION_NAME, + 0l, System.currentTimeMillis()+1 + ); + return null; + } + }); + } + + private static class TagMappingMetadataExtracter extends TikaPoweredMetadataExtracter + { + + private String existingTagNodeRef; + + public TagMappingMetadataExtracter() + { + super(Sets.newHashSet(MimetypeMap.MIMETYPE_IMAGE_JPEG)); + Properties mappingProperties = new Properties(); + // TODO move to new keyword once tika is upgraded + mappingProperties.put(Metadata.KEYWORDS, ContentModel.PROP_TAGS.toString()); + mappingProperties.put(Metadata.DESCRIPTION, ContentModel.PROP_DESCRIPTION.toString()); + setMappingProperties(mappingProperties); + } + + public void setExistingTagNodeRef(String existingTagNodeRef) + { + this.existingTagNodeRef = existingTagNodeRef; + } + + @Override + protected Map> getDefaultMapping() + { + // No need to give anything back as we have explicitly set the mapping already + return new HashMap>(0); + } + @Override + public boolean isSupported(String sourceMimetype) + { + return sourceMimetype.equals(MimetypeMap.MIMETYPE_IMAGE_JPEG); + } + + @Override + protected Parser getParser() + { + return new JpegParser(); + } + + @SuppressWarnings("unchecked") + public Map extractRaw(ContentReader reader) throws Throwable + { + Map rawMap = super.extractRaw(reader); + + // Add some test keywords to those actually extracted from the file including a nodeRef + List keywords = new ArrayList(Arrays.asList(new String[] { existingTagNodeRef, TAG_2, TAG_3 })); + Serializable extractedKeywords = rawMap.get(Metadata.KEYWORDS); + if (extractedKeywords != null && extractedKeywords instanceof String) + { + keywords.add((String) extractedKeywords); + } + else if (extractedKeywords != null && extractedKeywords instanceof Collection) + { + keywords.addAll((Collection) extractedKeywords); + } + putRawValue(Metadata.KEYWORDS, (Serializable) keywords, rawMap); + return rawMap; + } + } + + /** + * Test execution of mapping strings to tags + */ + public void testTagMapping() + { + this.transactionService.getRetryingTransactionHelper().doInTransaction(new RetryingTransactionCallback(){ + + @Override + public Void execute() throws Throwable + { + NodeRef existingTagNodeRef = taggingService.createTag(storeRef, TAG_1); + extractor.setExistingTagNodeRef(existingTagNodeRef.toString()); + + ActionImpl action = new ActionImpl(document, ID, ContentMetadataExtracter.EXECUTOR_NAME, null); + executer.execute(action, document); + + // Test extracted properties + assertEquals(ContentMetadataExtracterTest.QUICK_DESCRIPTION, + nodeService.getProperty(document, ContentModel.PROP_DESCRIPTION)); + assertTrue("storeRef tags should contain '" + QUICK_KEYWORD + "'", + taggingService.getTags(storeRef).contains(QUICK_KEYWORD)); + assertTrue("document's tags should contain '" + QUICK_KEYWORD + "'", + taggingService.getTags(document).contains(QUICK_KEYWORD)); + + // Test manually added keyword + assertTrue("tags should contain '" + TAG_2 + "'", + taggingService.getTags(document).contains(TAG_2)); + + // Test manually added nodeRef keyword + assertTrue("tags should contain '" + TAG_1 + "'", + taggingService.getTags(document).contains(TAG_1)); + + return null; + } + }); + } + +} diff --git a/source/java/org/alfresco/repo/content/metadata/AbstractMappingMetadataExtracter.java b/source/java/org/alfresco/repo/content/metadata/AbstractMappingMetadataExtracter.java index 959524e234..e829e19e9f 100644 --- a/source/java/org/alfresco/repo/content/metadata/AbstractMappingMetadataExtracter.java +++ b/source/java/org/alfresco/repo/content/metadata/AbstractMappingMetadataExtracter.java @@ -38,11 +38,13 @@ import java.util.Set; import java.util.StringTokenizer; import org.alfresco.error.AlfrescoRuntimeException; +import org.alfresco.model.ContentModel; import org.alfresco.service.cmr.dictionary.DataTypeDefinition; import org.alfresco.service.cmr.dictionary.DictionaryService; import org.alfresco.service.cmr.dictionary.PropertyDefinition; import org.alfresco.service.cmr.repository.ContentReader; import org.alfresco.service.cmr.repository.ContentWriter; +import org.alfresco.service.cmr.repository.MalformedNodeRefException; import org.alfresco.service.cmr.repository.MimetypeService; import org.alfresco.service.cmr.repository.datatype.DefaultTypeConverter; import org.alfresco.service.cmr.repository.datatype.TypeConversionException; @@ -114,6 +116,7 @@ abstract public class AbstractMappingMetadataExtracter implements MetadataExtrac private Map> embedMapping; private boolean inheritDefaultMapping; private boolean inheritDefaultEmbedMapping; + private boolean enableStringTagging; /** * Default constructor. If this is called, then {@link #isSupported(String)} should @@ -351,6 +354,18 @@ abstract public class AbstractMappingMetadataExtracter implements MetadataExtrac this.inheritDefaultMapping = inheritDefaultMapping; } + /** + * Whether or not to enable the pass through of simple strings to cm:taggable tags + * + * @param enableStringTagging true find or create tags for each string + * mapped to cm:taggable. false (default) + * ignore mapping strings to tags. + */ + public void setEnableStringTagging(boolean enableStringTagging) + { + this.enableStringTagging = enableStringTagging; + } + /** * Set if the embed property mappings augment or override the mapping generically provided by the * extracter implementation. The default is false, i.e. any mapping set completely @@ -1298,6 +1313,38 @@ abstract public class AbstractMappingMetadataExtracter implements MetadataExtrac propertyValue); } } + catch (MalformedNodeRefException e) + { + if (propertyQName.equals(ContentModel.PROP_TAGS)) + { + if (enableStringTagging) + { + // We must want to map tag string values instead of nodeRefs + // ContentMetadataExtracter will take care of tagging by string + ArrayList list = new ArrayList(1); + for (Object value : (Object[]) propertyValue) + { + list.add(value); + } + convertedProperties.put(propertyQName, list); + } + else + { + if (logger.isInfoEnabled()) + { + logger.info("enableStringTagging is false and could not convert " + + propertyQName.toString() + ": " + e.getMessage()); + } + } + } + else + { + if (failOnTypeConversion) + { + throw e; + } + } + } } // Done return convertedProperties; diff --git a/source/test-resources/quick/quickIPTC.jpg b/source/test-resources/quick/quickIPTC.jpg new file mode 100644 index 0000000000..8711d8f86c Binary files /dev/null and b/source/test-resources/quick/quickIPTC.jpg differ