Submitting a page to workflow with associated components

I’ve recently inherited a project at a client where all content – both pages and datasource items – have been assigned a workflow, and the pages are comprised of numerous components with their own datasources.  The workflow assigned to the content is simple, but the issue the client was facing was that when they were using Experience Editor, they would go into a page and lock it for editing, then change some text – but the text was in the datasource of the component that had been included on the page! This meant when they clicked ‘submit’ to send their changes for approval, it would submit their page item, but not the datasource item that they had changed.  As authors who were new to Sitecore (some of whom were new to CMSs altogether) this was very confusing to them.

To be honest I was a little surprised that Sitecore doesn’t automatically submit datasource and other content items changed through Experience Editor with the page item, and even more surprised that I couldn’t find anything online on how others had tackled the issue.

Fortunately Sitecore makes this relatively painless to sort out through workflow actions.  Create a new /sitecore/templates/System/Workflow/Action item under your workflow’s Submit command item.  In the “Type string” field enter the name of your custom class (I called mine SubmitRelated).  Our class will find all the datasource items included  in presentation details of the current item (page) being submitted, and if these items are locked by the same user it will assume they should be submitted as well, so will “submit” (change the workflow state) and unlock them.

Getting the datasources in the presentation details of an item can be found in a few blog posts, but the easiest I’ve found is Brent Svac’s post.

public class SubmitRelated
{
  private const string AwaitingApproval = "{46DA5376-10DC-4B66-B464-AFDAA29DE84F}";

  /// <summary>
  /// Finds all related content locked by the current user and moves to approval state.
  /// </summary>
  /// <param name="args">Workflow arguments</param>
  public void Process(WorkflowPipelineArgs args)
  {
    Item workflowItem = args.DataItem;

    foreach (Item datasource in workflowItem.GetDataSourceItems().Where(i => i.Locking.IsLocked()))
    {
      string lockOwnerName = datasource.Locking.GetOwner();
      if (!string.IsNullOrEmpty(lockOwnerName) 
        && Security.Accounts.User.FromName(lockOwnerName, false) == Context.User)
      {
        using (new EditContext(datasource))
        {
          datasource[FieldIDs.WorkflowState] = AwaitingApproval;
          datasource.Locking.Unlock();
        }
      }
    }
  }
}

 

Sitecore PXM and ODG project (part 5 – PXM InDesign custom task)

  1. Introduction
  2. Non-editable fields
  3. Architecture
  4. Customising ODG emails
  5. PXM InDesign custom task

For the final post on this project I will run through how we can add some custom functionality to InDesign, as we have previously created a custom (non-editable) snippet which can be used in InDesign, however there is no way to create one except through Sitecore.

The plan is to create a custom PXM task that can be executed in InDesign, so that when a Page item is selected (in the project panel) and we execute our task, a Non-editable Snippet item will be created under that Page.

There are only a couple of posts that I’ve seen on how to create a custom task, however it’s more than enough to go off for our scenario which is not terribly complex.  Those posts cover how to create a custom task, so follow along and set up your custom task.  As is mentioned in the posts, our task is passed a dictionary of various values from InDesign; in our case the only relevant one is the ID of the item selected in the project panel, which is ci_projectPanel.  Once we know that we can get the relevant item, check that it is a Page, and create a new item beneath it.  Simple 🙂

I’ve also created  a gist for the full class, in a more legible format.  This code is untested but should work as described.

public string ExecuteTask(Dictionary<string, object> dictionary)
{
  Item page = Sitecore.Context.Database.GetItem(new ID(dictionary["ci_projectPanel"].ToString()));
  if (page.TemplateID != new ID("{6BFA47BA-F73C-48DB-9170-C0CC94179EC7}"))
  {
    return "Please select a page under which to add the non-editable snippet";
  }

  page.Add("Non-Editable", new TemplateID(new ID("{C0FD5401-A2A5-4205-831D-DF06120B389E}")));
  return $"Created custom snippet under page {page.Name}.";
}

 

Sitecore Custom Editor – Jump to Datasource

I recently worked with a client who was very new to Sitecore and CMSs in general, and was struggling to get their head around the concept of a component (with a datasource) being shown on the page, so that the content was not coming from the page item itself, but from the datasource content.  To help them out I decided to look into whether I could add a custom tab which would show a preview of the page (yes, there is already a preview editor) but with the components with datasources highlighted (in a coloured border) and a clickable link to the datasource itself.

The new editor

First things first, we’ll need a custom editor! There are a few blog entries already on how to do this, and it’s very straightforward.  Now let’s have a look at what we need to put in our new editor’s aspx file.

As I mentioned, there’s already a preview editor, but can we reuse or extend that?  Having a look at the item (in the core db) /sitecore/content/Applications/Content Editor/Editors/Layouts/Preview we can see the relevant page that is being shown is /sitecore/shell/~/xaml/Sitecore.Shell.Applications.ContentEditor.Editors.Preview.aspx which actually corresponds to the file sitecore\shell\Applications\Content Manager\Editors\Preview\Preview.xaml.xml which references the class Sitecore.Shell.Applications.ContentEditor.Editors.Preview.PreviewPage in the Sitecore.Client assembly.

Now I know how to read XML and XSLT, but I know I certainly prefer to write .NET, so the first thing I did was copy the necessary code over to C#.  I’ve created a gist for the CustomEditor.aspx and CustomEditor.aspx.cs files, which are based on the original PreviewPage, but not identical – I only included the code I deemed necessary, and on line 109 of the .cs file I have added a custom URL parameter so that we can identify that the page is being viewed in our custom editor: urlString["custom_editor"] = "true";

At this stage we can add our custom “editor” to the page and see a preview.

Highlighting the datasources

My plan to highlight the datasources and turn them into a link was to wrap the relevant renderings in a

div class="rendering" data-datasource-id="{the-datasource-id}" /div

and then use javascript/CSS to highlight and make the region clickable (opening the datasource item in the content editor).

Fortunately wrapping a rendering is quite simple.  A quick Google search sent me to a page on ctor.io where I found exactly what was needed, except that the Title field in that example was changed to ID so that it can be used in the data- attribute of the HTML above.  We also only want to wrap the renderings in this way when we’re in our custom editor, so we need to check the URL and ensure it contains our “custom_editor” parameter (above).

public class RendererWrapper : GetRendererProcessor
{
  public override void Process(GetRendererArgs args)
  {
    // Make sure the page is being viewed in the correct place
    if (Context.GetSiteName() == "shell") return;
    if (!Context.RawUrl.Contains("custom_editor")) return;
    if (!(args.Result is ViewRenderer)) return;

    if (args.Rendering == null || !ID.IsID(args.Rendering.DataSource)) return;

    Item dataSourceItem = args.PageContext.Database.GetItem(args.Rendering.DataSource);
    if (dataSourceItem == null) return;

    // Add wrapper rendering
    WrapperModel model = new WrapperModel
    {
      Renderer = (ViewRenderer)args.Result,
      Id = dataSourceItem.ID
    };

    args.Result = new ViewRenderer
    {
      Model = model,
      Rendering = args.Rendering,
      ViewPath = "/Views/Common/RenderingWrapper.cshtml"
    };
  }
}
public class WrapperModel
{
  public ViewRenderer Renderer { get; set; }
  public ID Id { get; set; }
}

and our RenderingWrapper.cshtml:

@model Sitecore.Common.Website.Enhancements.WrapperModel

div class="rendering" data-datasource-id="@Model.Id"
    @Html.Partial(Model.Renderer.ViewPath, Model.Renderer.Model)
/div

and of course we need to include the processor in the pipeline:

<pipelines>
  <mvc.getRenderer>
    <processor 
      patch:after="processor[@type='Sitecore.Mvc.Pipelines.Response.GetRenderer.GetViewRenderer, Sitecore.Mvc']"
      type="Sitecore.Common.Website.Enhancements.RendererWrapper, Sitecore.Common.Website"/>
  </mvc.getRenderer>
</pipelines>

at this point when we preview the page in our custom editor, our renderings which have datasources should be wrapped in our new div tag.  A little CSS for divs with the class “rendering” can quickly add a coloured border and turn the cursor in to a pointer.

Linking it together

The final step is to make the divs clickable – ie. opening the selected datasource in the Content Editor.  Digging around I found that the way, in Content Editor, to open an item using javascript is scForm.postEvent("", "", "item:load(id={item-id},language=en)"), where item-id is the ID of the item to open.  The main issue is that our page is being displayed in an IFrame, which is displayed in an IFrame (the custom editor tab) which is in the Content Editor frame; so the event needs to propagate from the page up 2 levels to the Content Editor where it should call the aforementioned postEvent() method.  Not the biggest issue in the world, we can use the javascript parent variable to access the parent frame.

So on our page we have the click handler:

$(function () {
  $('.rendering').click(function (e) {
    if (parent != null) {
      e.preventDefault();
      parent.renderingClicked($(this).data('datasourceId'));
    }
  });
});

which calls the handler in our custom editor .aspx (in the gist above)

function renderingClicked(datasourceId) {
  parent.scForm.postEvent("", "", "item:load(id=" + datasourceId + ",language=en)");
}

which tells the Content Editor to display our item!

Sitecore PXM and ODG project (part 4 – customising email)

  1. Introduction
  2. Non-editable fields
  3. Architecture
  4. Customising ODG emails
  5. PXM InDesign custom task

In the next couple of posts I’d like to focus on some of the customisations that can be made to improve or build upon the functionality provided by PXM and ODG out of the box.

Let’s start with something simple: renaming the PDF file that’s output from ODG.  Fortunately for us, Sitecore has made life a little easier by checking whether the name has already been set before setting its own, which means all we have to do is insert our “naming” processor before the others.  If we look at Sitecore.PrintStudio.config, we can see in the <printPreview>, <printToDashboard> and <printToInDesign> pipelines it’s the Sitecore.PrintStudio.PublishingEngine.Pipelines.RenderProjectXml processor setting the args.PrintOptions.ResultFileName, so let’s insert our processor before this one.  In this case I only care about the <printToDashboard> pipeline, but the others will be the same.

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <pipelines>
      <printToDashboard>
        <processor
          patch:before="processor[@type='Sitecore.PrintStudio.PublishingEngine.Pipelines.RenderProjectXml, Sitecore.PrintStudio.PublishingEngine']"
          type="Custom.Pipelines.NameOutputFiles, Custom"/>
      </printToDashboard>
    </pipelines>
  </sitecore>
</configuration>

And the processor to set the name

public class NameOutputFiles : Sitecore.PrintStudio.PublishingEngine.Pipelines.IPrintProcessor
{
  public void Process(PrintPipelineArgs args)
  {
    args.PrintOptions.ResultFileName = Utils.GenerateFilename(Context.User, args.ProcessorItem.InnerItem.ID.Guid) + args.PrintOptions.ResultExtension;
  }

  public static string GenerateFilename(User user, Guid itemId)
  {
    return string.Concat(user.LocalName, "_", itemId, "_", DateTime.UtcNow.ToString("yyyy-MM-dd-HH-mm-ss-fff"));
  }
}

In this case I’ve separated the code generating the name into a separate method in case we need to re-use the same naming method elsewhere in the code (for example to generate a link to the document).

Now let’s look at how we can add additional recipients to the email that ODG sends out once the document has been exported.  By default it sends an email to notify the user (who generated the document) that their document is ready, with a link to the document.  What if we want this to be CCd or BCCd to for example their boss, or a support email?

For our example, let’s add a support email as a BCC to all emails sent.

The code which handles sending the email is in the Dashboard Service, however the code which tells the Dashboard Service which email(s) to use is in the printToDashboard pipeline, in the SendToDashboard class, so let’s extend that and switch it out in a patch:

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <pipelines>
      <printToDashboard>
        <processor 
          patch:instead="processor[@type='Sitecore.PrintStudio.PublishingEngine.Pipelines.Dashboard.SendToDashboard, Sitecore.PrintStudio.PublishingEngine']"
          type="Custom.Pipelines.SendToDashboard, Custom"/>
      </printToDashboard>
    </pipelines>
  </sitecore>
</configuration>

So taking a look at SendToDashboard in the Send method, we can see the message is being built by SoapHelper.SendSoapMessage() call, and taking a look in SoapHelper we find that the CreateGroupNode method takes a BCC field, to which SendSoapMessage is just passing a blank string.

Unfortunately here’s where things get a bit messy. We need to get BCC into SendSoapMessage, so ideally we would extend SoapHelper and add another override of SendSoapMessage which takes BCC as a parameter (with pretty much exactly the same code), however a lot of the methods that are used in SoapHelper are marked private (not protected) so we can’t re-use the existing code!  At the end of the day I found it easiest to just copy the SendSoapMessage code directly into the new SendToDashboard class and use reflection to call CreateGroupNode() and CreateJobNode().  It would be great if Sitecore made these protected in the future, so we can extend the SoapHelper class (or expose the CC and BCC fields).

So now in our SendToDashboard class we have our new Send() method which calls the new SendSoapMessage() method with our BCC field.  Let’s take things a little further and say we want to pass new info into this email that’s being sent.  What if we want to pass the name of the user’s document into the email so that they know which one it was (sounds pretty standard, right)?

Unfortunately this is just as messy.  In this case it’s the CreateSoapMessageDetails() method we need to override to take the document name and pass it to SoapMessageDetails(), which should pass it into the string.Format() which builds the “Success” or “Failed” nodes.  Again none of these are protected, so we need to pretty  duplicate the code all the way through with our new parameter.  Don’t forget to update the email template to make use of this new parameter!

public class SendToDashboard : Sitecore.PrintStudio.PublishingEngine.Pipelines.IPrintProcessor
{
  public void Process(PrintPipelineArgs args)
  {
    if (!string.IsNullOrEmpty(args.XmlResultFile))
    {
      Item processingJobItem = args.ProcessorItem.Database.GetItem(args.PrintJobId);
      this.Send(args.XmlResultFile, Path.Combine(args.PrintOptions.ResultFolder, args.PrintOptions.ResultFileName), args.PrintOptions.ResultFolder, processingJobItem, args.ProcessorItem.InnerItem.Name);
    }
    else
    {
      Logger.Info("Empty args.XmlResultFile");
      args.AddMessage("Empty args.XmlResultFile", PipelineMessageType.Error);
      args.AbortPipeline();
    }
  }
  private void Send(string resultXmlFileName, string pdfFileName, string absoluteFilePath, Item processingJobItem, string docName)
  {
    if (string.IsNullOrEmpty(resultXmlFileName))
    {
      return;
    }
    string dbServerIpAddress = WebConfigHandler.PrintStudioEngineSettings.DbServerIpAddress;
    string dbServerPort = WebConfigHandler.PrintStudioEngineSettings.DbServerPort;
    Assert.IsNotNullOrEmpty(dbServerIpAddress, "Missing PrintStudio.DBServer.IPAddress configuration");
    Assert.IsNotNullOrEmpty(dbServerPort, "Missing PrintStudio.DBServer.Port configuration");
    Language userLanguage = SitecoreHelper.GetUserLanguage(Context.User, processingJobItem.Database);
    XmlDocument resultDocument = new XmlDocument();
    Logger.Info("Sending to Dashboard: " + Context.User.Name + " (" + Context.User.Profile.Email + ") file " + resultXmlFileName);
    SendSoapMessage(Context.User.Name,
      Context.User.Name,
      Context.User.Profile.Email,
      resultXmlFileName,
      WebConfigHandler.PrintStudioEngineSettings.ResponseType,
      WebConfigHandler.PrintStudioEngineSettings.DashboardQueueName,
      WebConfigHandler.PrintStudioEngineSettings.DashboardServiceMethod,
      SitecoreHelper.GetMessage(processingJobItem, userLanguage, "Mail Subject") + " " + TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, TimeZoneInfo.FindSystemTimeZoneById(Constants.General.Timezone)).ToString("g"),
 string.Empty,
     CreateSoapMessageDetails(processingJobItem, resultDocument, userLanguage, docName).ToString(),
     SitecoreHelper.GetPrintFilePath(processingJobItem, "relative filename path"),
     absoluteFilePath,
     pdfFileName,
     $"{dbServerIpAddress}:{dbServerPort}",
     processingJobItem.Database.GetItem(new ID(Constants.Items.SiteSettings))?.Fields[Constants.Fields.SupportEmail]?.Value);
  }
  private static string SendSoapMessage(string userName, string loginName, string email, string jobXml, string responseType, string serviceType, string serviceMethod, string subject, string body, string messageBrands, string relativePath, string absolutePath, string fileName, string serviceUrl, string bcc)
  {
     string startTime = DateTime.UtcNow.ToString("s", DateTimeFormatInfo.InvariantInfo);
     XmlDataDocument xmlDataDocument = new XmlDataDocument();
     XmlNode groupNode = (XmlNode)typeof(SoapHelper).GetMethod("CreateGroupNode", BindingFlags.Static | BindingFlags.NonPublic).Invoke(null, new object[] { xmlDataDocument, userName, userName, loginName, startTime, responseType, email, body, subject, string.Empty, bcc, string.Empty, "HTML" });
     XmlNode jobNode = (XmlNode)typeof(SoapHelper).GetMethod("CreateJobNode", BindingFlags.Static | BindingFlags.NonPublic).Invoke(null, new object[] { xmlDataDocument, userName, "1", serviceType, serviceMethod, string.Empty, fileName, relativePath, absolutePath, string.Empty, string.Empty, jobXml, string.Empty, serviceUrl });
     typeof(SoapHelper).GetMethod("AddJobNode", BindingFlags.Static | BindingFlags.NonPublic).Invoke(null, new object[] { xmlDataDocument, groupNode, jobNode });
     if (!string.IsNullOrEmpty(messageBrands))
     {
       XmlNode xmlNode = groupNode.SelectSingleNode("//Jobs/Job");
       if (xmlNode != null && groupNode.OwnerDocument != null)
       {
         XmlElement element = groupNode.OwnerDocument.CreateElement("ResultMail");
         element.InnerXml = messageBrands;
         xmlNode.AppendChild(element);
       }
    }
    return SoapHelper.DashBoardWebService.Send("<?xml version=\"1.0\" encoding=\"utf-16\" ?>" + groupNode.OuterXml); // returns "Success"
  }
  private static StringBuilder CreateSoapMessageDetails(Item processingJobItem, XmlDocument resultDocument, Language language, string jobName)
  {
    string message1 = SitecoreHelper.GetMessage(processingJobItem, language, "Mail Header");
    string message2 = SitecoreHelper.GetMessage(processingJobItem, language, "Mail Footer");
    string message3 = SitecoreHelper.GetMessage(processingJobItem, language, "Ready Message");
    string message4 = SitecoreHelper.GetMessage(processingJobItem, language, "Rejected Message");
    return SoapMessageDetails(resultDocument, message3, message4, message1, message2, jobName);
  }
  private static StringBuilder SoapMessageDetails(XmlDocument resultDocument, string jobSuccess, string jobFailure, string mailHeader, string mailFooter, string jobName)
  {
    StringBuilder stringBuilder = new StringBuilder();
    XmlNode xmlNode1 = resultDocument.CreateElement("Header");
    xmlNode1.AppendChild(resultDocument.CreateCDataSection(mailHeader));
    stringBuilder.Append(xmlNode1.OuterXml);
    XmlNode xmlNode2 = resultDocument.CreateElement("Footer");
    xmlNode2.AppendChild(resultDocument.CreateCDataSection(mailFooter));
    stringBuilder.Append(xmlNode2.OuterXml);
    XmlNode xmlNode3 = resultDocument.CreateElement("Success");
    xmlNode3.AppendChild(resultDocument.CreateCDataSection(string.Format(jobSuccess, Context.User.Profile.FullName, Context.User.LocalName, jobName)));
    stringBuilder.Append(xmlNode3.OuterXml);
    XmlNode xmlNode4 = resultDocument.CreateElement("Failed");
    xmlNode4.AppendChild(resultDocument.CreateCDataSection(string.Format(jobFailure, Context.User.Profile.FullName, Context.User.LocalName, jobName)));
    stringBuilder.Append(xmlNode4.OuterXml);
    return stringBuilder;
  }
}

This was a bit of a messy one.  Hopefully with newer releases of PXM and ODG Sitecore makes things a little more extensible!

Sitecore PXM and ODG project (part 3 – architecture)

  1. Introduction
  2. Non-editable fields
  3. Architecture
  4. Customising ODG email
  5. PXM InDesign custom task

There are a LOT of components to set up when you’re using PXM, ODG, and exporting your documents to PDF.  Adding even more complexity is the fact that this solution should really be running on a content delivery server accessible by the clients (or potentially multiple servers, in Azure IaaS/PaaS) whereas ODG natively runs in the Sitecore admin interface which would be on a content authoring server.

Here’s a list of things you’ll need for the full setup:

Sitecore PXM ODG architecture
PXM + ODG architecture in authoring / delivery setup

Note: Unfortunately this setup can run multiple delivery servers (IaaS/PaaS), but will only really support one authoring server (IaaS) since there isn’t really any way to balance the connections from delivery to the Dashboard/Processing/InDesign server side of things.  InDesign Server comes in a single or multi-instance licence, and if you need to scale then the multi-instance option on one high-spec’d authoring server would probably be best.

Firstly it’s important to set up and ensure that Sitecore on content authoring is working correctly with PXM, ODG, and InDesign Server.  This functionality is “out of the box”, but requires a lot of configuration which you can follow on the PXM youtube video series.  There has been an updated release of some of the PXM connectors to support CC 2015 so ensure you’re using the correct software version and connector version for your OS.  The video series refers to the folder holding the exports / config / logs as PXMPublishing so that’s what I will refer to it as here.

Once those pieces of software are setup, you should be able to:

  1. Import an InDesign document (through InDesign using the connector)
  2. Upload any media into the media library and replace the references in the PXM project so that they point to the media library rather than your hard drive (open the project through someone else’s InDesign connector to verify)
  3. Create a document in ODG, replace various content / media, and preview it
  4. Export your document to PDF and preview the output PDF in the PXMPublishing/PublishFolder folder

There are various logs that can help you if you can’t successfully complete all 4 steps:

  1. Sitecore logs
  2. PXM log (in with the rest of the Sitecore logs)
  3. Dashboard Server log (PXMPublishing/Logs/DashboardServer)
  4. Processing Server log (PXMPublishing/Logs/InDesignProcessingService)
  5. InDesign Server log (<InDesign Server directory>/Logs)

Once the content authoring server is running correctly, there are a few things that need to be setup so that everything works with the delivery server:

  • Ensure your PXMPublishing folder is accessible from both the authoring and delivery servers (through a file share or Azure File Storage works)
  • If you have a large amount of media (or very large media files) that you don’t want to upload, ensure they’re also in a shared location accessible from both servers (covered later in the blog)
  • Ensure the dashboard service port (by default 8070) on the CA server is open to the CD server (via both Azure and Windows firewall)
  • Ensure the InDesign server port (by default 8081) on the CA server is open to the CD server (via both Azure and Windows firewall)
  • Ensure the dashboard server in the web.config on the CD server is pointing to the URL / port of the dashboard server running on the CA server
  • Unfortunately a wrapper design controller will need to be created for the CD server to call the existing functionality. This wraps Sitecore.Odg.Controllers.DesignController.
  • If you’re going to be allowing users to grab the output PDFs from the server, ensure the virtual folder is mapped in IIS on the CD server

Once that’s done you should be able to call the GetCustomPreviewImage and ExportToPdf controller methods to get the preview image of your document, and export it to PDF, and view it via URL.

For example:

$.ajax({
  dataType: 'json',
  url: '/api/sitecore/CustomDesign/GetCustomPreviewImage?&itemId=' + currentPageId + '&lang=en&forceNew=true&useHighRes=false&saveInPage=true',
  cache: false,
  success: function (response) {
    if (response.mediaUrl != null && response.mediaUrl != "") {
      updatePageThumbnail(response.thumbnailUrl);
      loadImageToCanvas(response.mediaUrl);
    }
  }
});

and

$.ajax('/api/sitecore/CustomDesign/ExportToPdf', {
    type: 'POST',
    data: {itemId: odgDocumentId, processingJobId: processingJobId, lang: language},
    success: function (result) {
        alert('Successfully sent for conversion to PDF')
    },
    error: function (result) {
        alert('Error saving.  Please try again or contact us if the issue reoccurs.');
    }
});

In the next blog we’ll look into other customisations we can make to the process and pipelines to improve the user experience.

Sitecore PXM and ODG project (part 2 – non-editable fields)

  1. Introduction
  2. Non-editable fields
  3. Architecture
  4. Customising ODG email
  5. PXM InDesign custom task

Since we were using as much Sitecore-provided PXM and ODG functionality as possible (and ODG provides pretty much all of the functionality) the first task was to find a way to selectively make specific fields in the documents editable. ODG out of the box makes all text and image fields in the document editable, however images like the company logo, and text like terms and conditions should not be editable by the end user. There are (at least) two ways to go about this:

  1. Use security to restrict the user’s read/write access to the field
    This is a little risky, down the line when we go to export the document the read/write access may be required to export the text/image field to PDF
  2. Create a new template which inherits from the default snippet
    Digging into the Sitecore SPEAK code*, we can see that in ODG the snippets are loaded by doing a query on the template type (‘get all child items with the snippet template type’). By extending the existing snippet this query fill filter out new snippet type, so all the non-editable fields can be hidden in here, however when export to PDF is run it’s still treated as a snippet and the text is shown. The downside to this is that the custom snippet is not supported by the InDesign connector so even though the snippet will appear in InDesign, making items non-editable (creating the non-editable snippet and dragging items into this snippet) will have to be done in the content editor.

We ended up going with option 2, though option 1 may still work, I did not get a chance to investigate.

So it’s easy as finding the P_Snippet template (at /sitecore/templates/Print Studio Templates/Publishing Engine/P_Snippet), and making a custom template which contains that as a base template.

custom_snippet
Custom non-editable snippet made from P_Snippet

Sitecore PXM and ODG project (part 1 – introduction)

  1. Introduction
  2. Non-editable fields
  3. Architecture
  4. Customising ODG email
  5. PXM InDesign custom task

This series of posts will cover my discoveries while using Print Exprience Manager (PXM) and Online Document Generator (ODG) with Sitecore 8.1 initial release. It won’t go into any detail on the setup of PXM or ODG as there are great resources and videos for getting these set up.  Unfortunately because it is an internal client project I can’t post screenshots, but by the time the project went live it allowed the business to import InDesign documents (created by marketing) into Sitecore, which then displayed these to the end user for selection. Once a document was seelcted the user could then edit specified text and imagery in the document, save their document, export it as a PDF, and allow it to be sent to print. Although similar in functionality to out-of-the-box ODG, it was decided that the user should not be allowed access to the Sitecore admin interface to use ODG. We did, however, use as much existing functionality in PXM and ODG as possible; it was effectively a content-delivery based re-skin/wrapper of ODG.

While the majority of the documentation around PXM focuses on how to import Sitecore content into an InDesign document and format it, our project was the reverse: given a number of already-created InDesign documents, we were to import these into Sitecore (PXM) and allow them to be editable in ODG for export as high quality PDF to be used by the printing companies. Unfortunately our marketing team had little interest in learning Sitecore, and little time to explain the workings of InDesign. Coming from a Sitecore background (with minimal Photoshop knowledge and no InDesign knowledge) this meant that I certainly tought myself a lot in a very short time.

InDesign

Edit: just after I wrote this an excellent article on how to use the Sitecore connector with InDesign was posted

InDesign itself is based primarily on XML and Sitecore takes advantage of this using the connector (an InDesign plugin) to easily create or import the InDesign elements into Sitecore as items in various sections of the in the content tree. I’d highly recommend watching the series of PXM videos linked above (or at least the first few) to get an idea of how it all works together.   The elements in the InDesign document (shapes, images, text, etc.) are imported into PXM (sub-items in Print Studio projects); whereas the layers, character styles, paragraph styles, and object styles, are saved in the master document (in the media library) which is then referenced by the InDesign project(s). Images are not automatically imported into Sitecore; rather, a reference to the image location on your hard drive is saved in a field (meaning unless others have the image in the same location on the same drive letter, it will not appear for them). If you later choose to import the image into Sitecore, a second field (media reference) overrides this hard drive location reference.

Similar to those familiar with CSS, InDesign has what I’ll call “inline” styles (like style="" in HTML), and then defined character and paragraph styles (saved groups of styles given a name, like a CSS class). If you simply select a bunch of text, and hit “bold” it will save it inline, or you can save your styles as a character/paragraph style and apply that to your text (some styles, like centering text, can only be applied to a paragraph). Unfortunately I found out the hard way that the Sitecore connector does not recognise these “inline” styles, but rather relies on character/paragraph styles having been created, which when imported are saved in the master document. I say unfortunate, because none of the InDesign documents we were provided had any character or paragraph styles applied. If you’re importing an InDesign document you’ll certainly want to ensure these exist first or your document is going to look very strange when you load it from Sitecore. We also discovered that you shouldn’t use an ampersand (&) in the style name, as this is misinterpreted by InDesign (as least for TextFrames) and your TextFrame will not be positioned (or even appear) correctly.

Sitecore PXM and ODG

master_documents
Master documents (where styles are stored)
PXM project
PXM Projects (where shapes are stored)
ODG
ODG items
(where user documents are stored)

Installing ODG adds a couple of new icons to the dashboard (in this case the useful one is Document Publisher) and a new ODG section to the content tree.  The SPEAK applications are simply a pretty frontend for manipulating the items in the content tree, which can be done manually by more advanced users.  This new content tree section allows the creation of Collections, which were used to categorise our documents and are stored in the Design Collections bucket; Projects which are a clone of the original PXM project specific to the user who creates it and for some reason aren’t stored in a bucket (the fact that they are a clone is useful as it means updates to the original PXM project are automatically applied to all ODG documents cloned from it, but also can be problematic if your user wants to keep a version of the original); Design Templates which specify which formats the PXM projects can be exported as; and Design Documents which link them all together and say “the user has created a document referencing this project (clone) belonging to this collection which can be exported as PDF/other”.

odg_pxm

The Document Publisher app allows the creation of Collections to group documents, as well as creating a user-specific version of a PXM project, edit the text and image fields (field types configurable in the config) in this document, and export it to specified format (through the processing job field which links to the PXM Publishing Settings) PDF high / low quality and Flash out of the box.  These correspond with InDesign Server settings which can be found in <install_directory>\Adobe InDesign CC Server 2014\Resources\Adobe PDF\settings\mul\*.joboptions

Part 2 will cover our first customisation for ODG: specifying which fields should be editable.

Extending an Image field (pt 2) – adding a SPEAK dialog

This is the second part of a 2-part post about how to extend the Sitecore image field, and add an image overlaid on top of the main image. If you haven’t already, check out part 1 to see how to extend the image field. This post will focus on the SPEAK component which will allow the user to set the top/left coordinates that specify where our overlay will be placed over the main image.

So if you remember back to when we set up the Overlay button on our new field, we updated the Message field to use “overlay”. Let’s handle this message in our ImageWithOverlay.cs class.

/// The overlay message.
private const string OverlayMessage = "overlay";

/// The overlay application location.
private const string OverlayAppLocation = "/sitecore/client/Your Apps/OverlaySelector";

public override void HandleMessage(Message message)
{
    if (message["id"] != ID)
    {
        return;
    }

    string[] command = message.Name.Split(':');
    Assert.IsTrue(command.Length > 1, "Expected message format is control:message");

    if (command[1] == OverlayMessage)
    {
        Sitecore.Context.ClientPage.Start(this, "Overlay");
        return;
    }

    base.HandleMessage(message);
}

public void Overlay(ClientPipelineArgs args)
{
    if (args.IsPostBack)
    {
        if (!args.HasResult)
        {
            return;
        }

        XmlValue.SetAttribute(Models.Constants.CoordinatesAttribute, args.Result);
        Update();
        SetModified();
        SheerResponse.Refresh(this);
    }
    else
    {
        UrlString urlString = new UrlString(OverlayAppLocation);

        Item selectedImage = GetMediaItem();
        if (selectedImage != null)
        {
            urlString["fo"] = selectedImage.Uri.ToString();
        }

        string coords = XmlValue.GetAttribute(Models.Constants.CoordinatesAttribute);
        if (!string.IsNullOrEmpty(coords))
        {
            urlString["coords"] = coords;
        }

        SheerResponse.ShowModalDialog(new ModalDialogOptions(urlString.ToString()) { Width = "800px", Height = "275px", Response = true, ForceDialogSize = true });
        args.WaitForPostBack();
    }
}

This code handles the message and will open our SPEAK application that we’re about to make. We’re passing the existing coordinates (if they’re set) and the Uri of the main image (so that we can display it in our SPEAK dialog).

Ok let’s build our SPEAK app. This will show our main image with our overlay image over top, and allow the user to drag the overlay around to set its coordinates. It’ll also have a ‘save’ and ‘cancel’ button which sets (or not) the coordinates on the main image field.

In Sitecore Explorer, expand core/sitecore/client/Your Apps. Right click it and create a /sitecore/client/Business Component Library/Templates/Pages/Speak-DialogPage called “OverlaySelector”. Set the following properties:

  • Theme: Oxford
  • Subthemes: Dialogs

Open the design layout (right click -> tasks -> Design Layout, or Ctrl+U) and set

  • Layout: /sitecore/client/Speak/Layouts/Layouts/Speak-Layout

Add the following renderings:


Type ID Location Other
PageCode Page.Code PageCodeScriptFileName: /Scripts/Speak/Overlay.js
Dialog Page.Body
DialogHeader DialogHeader
DialogFooter DialogFooter
DialogContentM DialogContent
Text HeaderTitle DialogHeader.Title Text: Select a teardrop position
Section MainImage DialogContent.Main
Image OverlayImage MainImage.Content Alt: Overlay, Height: 300, Width: 245, ImageUrl: /Content/Images/overlay.png
Text Coordinates DialogContent.Main Text: 100,100
Button SaveButton DialogFooter.Button ButtonType: Primary, Text: Select
Button CancelButton DialogFooter.Button ButtonType: Primary, Text: Cancel
Rule SaveButtonRule Page.Body Field: Rule, RuleItemId: (see below), TargetControl: SaveButton, Trigger: click
Rule CancelButtonRule Page.Body Field: Rule, RuleItemId: (see below), TargetControl: CancelButton, Trigger: click

Under your OverlaySelector item, create a /sitecore/cilent/Speak/Templates/Pages/PageSettings item which will have the settings for our dialog (for now, just the 2 rules for our buttons).

Under the PageSettings item, create:
/sitecore/client/Speak/Layouts/Renderings/Resources/Rule Definition called CancelButtonRuleDefinition with rule: where always close the dialog
/sitecore/client/Speak/Layouts/Renderings/Resources/Rule Definition called SaveButtonRuleDefinition with rule: where always the dialog return value to component Coordinates text

The final step is to add our javascript for the component. We’ll use jQuery UI to make the overlay draggable.

define(["sitecore", "jquery", "jqueryui"], function (_sc, $, ui) {
    var overlaySelectorDialog = _sc.Definitions.App.extend({
        initialized: function () {
            var app = this;
            var scale = 1;

            var itemUriString = _sc.Helpers.url.getQueryParameters(window.location.href)['fo'];
            var itemPath = null;
            try {
                var itemUri = new URL(itemUriString);
                itemPath = itemUri.pathname;
                if (itemPath == "" || itemPath.indexOf("?") > -1) throw "Invalid URL";
            } catch (e) {
                // Doesn't support URL (IE and pretty much FF as well)
                var slashes = itemUriString.indexOf("//");
                var query = itemUriString.indexOf("?");
                if (slashes > -1) itemPath = itemUriString.substring(slashes, query > -1 ? query : itemUriString.length);
            }

            var mainImage = document.querySelector('[data-sc-id="MainImage"]');
            if (itemPath == null || itemPath == "") {
                alert("Couldn't parse item URL for your background image");
            } else {
                var itemUriSplit = itemPath.substring(2).split("/");
                var database = new _sc.Definitions.Data.Database(new _sc.Definitions.Data.DatabaseUri(itemUriSplit[0]));
                database.getItem(itemUriSplit[1], function (item) {
                    if (item == null) alert("Couldn't find background image item in database for unknown reason");
                    else {
                        var imgWidth = parseInt(item.Width);
                        var imgHeight = parseInt(item.Height);
                        if (imgWidth > imgHeight) {
                            scale = 500 / imgWidth;
                            mainImage.style.height = Math.round(scale * imgHeight) + "px";
                        } else {
                            scale = 500 / imgHeight;
                            mainImage.style.width = Math.round(scale * imgWidth) + "px";
                        }
                        
                        mainImage.style.backgroundImage = "url('" + item.$mediaurl.replace("thn=1", "") + "&w=500')";

                        var coords = _sc.Helpers.url.getQueryParameters(window.location.href)['coords'];
                        if (coords != null && coords != "") {
                            app.Coordinates.set('text', coords);
                            var coordsSplit = coords.split(",");
                            jQuery('[data-sc-id="OverlayImage"]').css({ "left": (parseInt(coordsSplit[0]) * scale) + "px", "top": (parseInt(coordsSplit[1]) * scale) + "px" });
                        }
                    }
                });
            }

            mainImage.style.height = "500px";
            mainImage.style.width = "500px";
            mainImage.style.backgroundSize = "cover";

            jQuery('[data-sc-id="OverlayImage"]').draggable({
                containment: '[data-sc-id="MainImage"]',
                scroll: false,
                start: function (e, ui) {
                    app.Coordinates.set('text', Math.round(ui.position.left / scale) + "," + Math.round(ui.position.top / scale));
                },
                drag: function (e, ui) {
                    app.Coordinates.set('text', Math.round(ui.position.left / scale) + "," + Math.round(ui.position.top / scale));
                },
                stop: function (e, ui) {
                    app.Coordinates.set('text', Math.round(ui.position.left / scale) + "," + Math.round(ui.position.top / scale));
                }
            });
        }
    });
    return overlaySelectorDialog;
});

I’ve uploaded the full code to Github if you’d like to try it out for yourself.

Extending an Image field in Sitecore (part 1)

On my last project one of the more fun things I got to play with was the SPEAK framework, and today’s posts (which will be quite large) will outline how to extend the default Sitecore Image field. We’re simply going to add an additional icon which is overlaid on top of our main image (like a watermark), and use a simple SPEAK interface to allow it to be positioned (by being dragged, and setting x,y coordintes in pixels) by the content author.

So, first things first: since we’re extending the Image field, let’s go into the core database and make a duplicate of that field, which you can find at /sitecore/system/Field types/Simple Types/Image. I’m going to call mine “Image With Overlay”. Let’s also add a new button to open our SPEAK interface, so expand the Menu folder under the new item, and duplicate the Browse button; call it “Overlay”, and change the Display Name field to “Overlay” as well. Now there are 2 more things we need to change: in the “Image With Overlay” item we just created, we need to update the Control field, and put overlay:ImageWithOverlay (we’ll get back to this later). In the “Overlay” button item, update the Message field so that “open” is now “overlay”. It should look like contentimage:overlay(id=$Target).

Ok now that we’ve got our field defined in Sitecore, let’s add the code side of things. Let’s extend the default Sitecore.Data.Fields.ImageField and add a new property called OverlayCoordinates to store the coordinates at which to display our overlay. Hopefully you’re familiar with what Sitecore Image fields look like as raw values (if not, turn on raw values and have a look with an image with a custom alt tag and title) – we’re going to store our coordinates as a new xml attribute in the same way, and this OverlayCoordinates property does just that.

using Sitecore.Data.Fields;

public class ImageWithOverlayField : ImageField
{
    public ImageWithOverlayField(Field innerField)
        : base(innerField)
    {
    }
    
    public ImageWithOverlayField(Field innerField, string runtimeValue)
        : base(innerField, runtimeValue)
    {
    }
    
    public string OverlayCoordinates
    {
        get
        {
            return GetAttribute(Constants.CoordinatesAttribute) ?? Constants.OverlayDefaultCoordinates;
        }

        set
        {
            SetAttribute(Constants.CoordinatesAttribute, value ?? Constants.OverlayDefaultCoordinates);
        }
    }
    
    public static implicit operator ImageWithOverlayField(Field field)
    {
        return field == null ? null : new ImageWithOverlayField(field);
    }
}

Ok now that we’ve extended our field to store the coordinates attribute, let’s extend the actual class which renders our Image field, and show our overlay over top in the preview. The easiest way is to decompile Sitecore.Shell.Applications.ContentEditor.Image and extend+reuse what we can, and copy+update what we cannot reuse (ie. if it’s a private method).

using System;
using System.Linq;
using System.Text;
using System.Web.UI;
using Sitecore;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.Globalization;
using Sitecore.Resources.Media;
using Sitecore.Shell.Applications.ContentEditor;
using Sitecore.Text;
using Sitecore.Web;
using Sitecore.Web.UI.Sheer;
using Convert = System.Convert;

public class ImageWithOverlay : Image
{
    protected override void DoRender(HtmlTextWriter output)
    {
        Assert.ArgumentNotNull(output, "output");
        Item mediaItem = GetMediaItem();

        string src = GetSrc();
        string str1 = " src=\"" + src + "\"";
        string str2 = " id=\"" + ID + "_image\"";
        string str3 = " alt=\"" + (mediaItem != null ? WebUtil.HtmlEncode(mediaItem["Alt"]) : string.Empty) + "\"";
        string coordinates = XmlValue.GetAttribute(Common.Constants.CoordinatesAttribute);

        if (string.IsNullOrEmpty(coordinates))
        {
            coordinates = Common.Constants.OverlayDefaultCoordinates;
        }

        int[] coords = coordinates.Split(',').Select(int.Parse).ToArray();

        // base.DoRender(output);
        output.Write("
"); output.Write("
"); string dimensions = ""; string overlayDimensions = ""; int[] padding = { 12, 8 }; if (mediaItem != null) { int width = Convert.ToInt32(mediaItem["Width"]); int height = Convert.ToInt32(mediaItem["Height"]); double scale = 128.0 / height; dimensions = "width=\"" + Math.Round(scale * width) + "px\" height=\"" + Math.Round(scale * height) + "px\""; overlayDimensions = "left:" + (Math.Round(coords[0] * scale) + padding[0]) + "px;top:" + (Math.Round(coords[1] * scale) + padding[1]) + "px;"; } else { overlayDimensions = "left:" + padding[0] + "px;top:" + padding[1] + "px;"; } output.Write(""); output.Write(""); output.Write("
"); output.Write("
"); string details = GetDetails(); output.Write(details); output.Write("
"); output.Write("</div>"); } private Item GetMediaItem() { string attribute = XmlValue.GetAttribute("mediaid"); if (attribute.Length <= 0) { return null; } Language language = Language.Parse(ItemLanguage); return Client.ContentDatabase.GetItem(attribute, language); } private string GetSrc() { string src = string.Empty; MediaItem mediaItem = GetMediaItem(); if (mediaItem != null) { MediaUrlOptions thumbnailOptions = MediaUrlOptions.GetThumbnailOptions(mediaItem); int result; if (!int.TryParse(mediaItem.InnerItem["Height"], out result)) { result = 128; } thumbnailOptions.Height = Math.Min(128, result); thumbnailOptions.MaxWidth = 640; thumbnailOptions.UseDefaultIcon = true; src = MediaManager.GetMediaUrl(mediaItem, thumbnailOptions); } return src; } private string GetDetails() { string str1 = string.Empty; MediaItem mediaItem = GetMediaItem(); if (mediaItem != null) { Item innerItem = mediaItem.InnerItem; StringBuilder stringBuilder = new StringBuilder(); XmlValue xmlValue = XmlValue; stringBuilder.Append("
"); string str2 = innerItem["Dimensions"]; string str3 = WebUtil.HtmlEncode(xmlValue.GetAttribute("width")); string str4 = WebUtil.HtmlEncode(xmlValue.GetAttribute("height")); if (!string.IsNullOrEmpty(str3) || !string.IsNullOrEmpty(str4)) { stringBuilder.Append(Translate.Text("Dimensions: {0} x {1} (Original: {2})", str3, str4, str2)); } else { stringBuilder.Append(Translate.Text("Dimensions: {0}", str2)); } stringBuilder.Append("
"); stringBuilder.Append("
"); string str5 = WebUtil.HtmlEncode(innerItem["Alt"]); string str6 = WebUtil.HtmlEncode(xmlValue.GetAttribute("alt")); if (!string.IsNullOrEmpty(str6) && !string.IsNullOrEmpty(str5)) { stringBuilder.Append(Translate.Text("Alternate Text: \"{0}\" (Default Alternate Text: \"{1}\")", str6, str5)); } else if (!string.IsNullOrEmpty(str6)) { stringBuilder.Append(Translate.Text("Alternate Text: \"{0}\"", str6)); } else if (!string.IsNullOrEmpty(str5)) { stringBuilder.Append(Translate.Text("Default Alternate Text: \"{0}\"", str5)); } else { stringBuilder.Append(Translate.Text("Warning: Alternate Text is missing.")); } stringBuilder.Append("
"); stringBuilder.Append("
"); string str7 = WebUtil.HtmlEncode(xmlValue.GetAttribute(Common.Constants.CoordinatesAttribute)); stringBuilder.Append(!string.IsNullOrEmpty(str7) ? Translate.Text("Overlay coordinates: {0}", str7) : Translate.Text("Overlay coordinates: No coordinates set, using 100,100.")); stringBuilder.Append("
"); str1 = stringBuilder.ToString(); } if (str1.Length == 0) { str1 = Translate.Text("This media item has no details."); } return str1; } }

You’ll notice the DoRender() method is where we’re now including our overlay.png over top of the main image using relative and absolute CSS positioning. I’ve also added an extra line in the GetDetails() method which shows the user the currently set coordinates, like the “dimensions” and “alt” are currently shown below the image preview.

Ok so now that we’ve got our classes, let’s hook up the Sitecore side of things with the code. For this, we’ll need a new include file (which it’s always best to put in a “zzz” folder so that it’s included last).

<?xml version="1.0" encoding="utf-8" ?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <fieldTypes>
      <fieldType name="ImageWithOverlay" type="SpeakImageOverlay.Models.Fields.ImageWithOverlayField, SpeakImageOverlay" />
    </fieldTypes>
    <controlSources>
      <source mode="on" namespace="SpeakImageOverlay.Models.Controls" assembly="SpeakImageOverlay" prefix="overlay"/>
    </controlSources>
  </sitecore>
</configuration>

The fieldType you’ll notice is the ImageWithOverlayField class we created. The source namespace/assembly is where the ImageWithOverlay class lives. Do you remember back to the first step in the core database where we set our Control field to overlay:ImageWithOverlay? Hopefully you’ll see now that “overlay” is the source prefix, and “ImageWithOverlay” is the field name.

Ok at this point you should be able to work with your new field in Sitecore! The Overlay button won’t do anything, but you should see your overlay image and be able to set the main image as usual. Let’s bind everything up using Glass Mapper so we can use it on the frontend as well.

I’m just using a basic Glass Mapper setup, so in GlassMapperScCustom.cs, in CreateResolver() where the container is being created, let’s add the line:
container.Register(Component.For().ImplementedBy().LifeStyle.Transient);. If you don’t have a container set up, have a look into using the Glass.Mapper.Sc.CastleWindsor (make sure it’s a compatible version). This will use our ImageOverlayMapper where possible to map our images. The code for this is pretty straightforward. I found it easier to copy some code from SitecoreFieldImageMapper.cs rather than extending it.

public class ImageWithOverlay : Glass.Mapper.Sc.Fields.Image
    {
        public virtual string OverlayCoordinates { get; set; }
    }
using System;
using Fields;
using Glass.Mapper.Sc;
using Glass.Mapper.Sc.Configuration;
using Glass.Mapper.Sc.DataMappers;
using Sitecore.Data;
using Sitecore.Data.Fields;
using Sitecore.Data.Items;

public class ImageOverlayMapper : AbstractSitecoreFieldMapper
{
    public ImageOverlayMapper()
        : base(typeof(ImageWithOverlay))
    {
    }
    
    public override object GetField(Field field, SitecoreFieldConfiguration config, SitecoreDataMappingContext context)
    {
        ImageWithOverlay img = new ImageWithOverlay();
        ImageWithOverlayField sitecoreImage = new ImageWithOverlayField(field);

        SitecoreFieldImageMapper.MapToImage(img, sitecoreImage);
        img.OverlayCoordinates = sitecoreImage.OverlayCoordinates;

        return img;
    }
    
    public override void SetField(Field field, object value, SitecoreFieldConfiguration config, SitecoreDataMappingContext context)
    {
        ImageWithOverlay img = value as ImageWithOverlay;
        if (field == null || img == null)
        {
            return;
        }

        var item = field.Item;

        ImageWithOverlayField sitecoreImage = new ImageWithOverlayField(field);

        SitecoreFieldImageMapper.MapToField(sitecoreImage, img, item);
        sitecoreImage.OverlayCoordinates = img.OverlayCoordinates;
    }
    
    public override string SetFieldValue(object value, SitecoreFieldConfiguration config, SitecoreDataMappingContext context)
    {
        throw new NotImplementedException();
    }
    
    public override object GetFieldValue(string fieldValue, SitecoreFieldConfiguration config, SitecoreDataMappingContext context)
    {
        Item item = context.Service.Database.GetItem(new ID(fieldValue));

        if (item == null)
        {
            return null;
        }

        MediaItem imageItem = new MediaItem(item);
        ImageWithOverlay image = new ImageWithOverlay();
        SitecoreFieldImageMapper.MapToImage(image, imageItem);
        image.OverlayCoordinates = Constants.OverlayDefaultCoordinates;
        return image;
    }
}

Now we can just use our ImageWithOverlay (the one which extends Glass.Mapper.Sc.Fields.Image) in our model and on our page!

@using Glass.Mapper.Sc.Web.Mvc
@model SpeakImageOverlay.Models.IPage

@{
 string[] coordinates = SpeakImageOverlay.Models.Constants.OverlayDefaultCoordinates.Split(',');
 if (Model != null &amp;&amp; Model.PageImage != null &amp;&amp; !string.IsNullOrEmpty(Model.PageImage.OverlayCoordinates))
 {
 coordinates = Model.PageImage.OverlayCoordinates.Split(',');
 }
}

&lt;div style="position:relative;"&gt;
 @Html.Glass().RenderImage(Model, m =&gt; m.PageImage, new { style = "position: absolute; max-width: 100%;" }, true)
 &lt;img src="~/Content/images/overlay.png" style="position:absolute;width:50px;top:@coordinates[1]px;left:@coordinates[0]px;" /&gt;
&lt;/div&gt;

In the next post I’ll run through how to add the SPEAK component to set the coordinates of our overlay image.