Creating A Custom XML Web Control

A complete walk through on the steps of creating a custom xml web control, using control designer, signing the assembly and implementing the control in a web form.

After searching the web for awhile looking for a quick fix to an issue I was having involving the XML web control, I stumbled a cross Peter Bromburg's article titled "Jazz Up the XML Control". Great, a clear an concise answer to a relatively annoying problem. I needed to set the DocumentSource of the XML control to a URL not a local path. I had my answer.

I opened C# Express Edition, created a class library project and began to write code. No sooner had I typed XSLTransform than the dreaded:

"System.Xml.Xsl.XslTransform' is obsolete: 'This class has been deprecated. Please use System.Xml.Xsl.XslCompiledTransform instead."

I could still use XSLTransform, but just as Peter mentioned in his article, why build something that will be obsolete in a short time. I decided I would create the control without inheriting from system.web.ui.webcontrol.xml but use system.web.ui.webcontrol.control instead. This way I can add some of the new items that come aong with XSLCompiledTransform. I used .NET Reflector to view a great deal of the code for the original XML control. and made necessary changes for the XSLCompiledTransform.

In order to shorten my long winded monologue, I will present some code:

I used C# Express Edition 2008 to create my control. I selected new project, class library and named it eCustomTools. Then added a new class "eXML" to my project.

eXml.cs

using System;
using System.Drawing;
using System.Drawing.Design;
using System.IO;
using System.Text;
using System.Collections;
using System.Collections.Specialized;

using System.Web;
using System.Web.Caching;
using System.Web.UI;
using System.Web.UI.Design;
using System.Web.UI.WebControls;

using System.Xml;
using System.Xml.Xsl;
using System.Xml.XPath;

using System.Globalization;
using System.ComponentModel;
using System.ComponentModel.Design;

using System.Security.Permissions;

using eCustomTools.eWeb;

namespace eCustomTools.eWeb.WebControls
{
    /// <summary>
    /// Displays an XML document without formatting or using Extensible Stylsheet Language Transformations (XSLT).
    /// </summary>
    [
    AspNetHostingPermission(SecurityAction.Demand, Level = AspNetHostingPermissionLevel.Minimal),
    AspNetHostingPermission(SecurityAction.InheritanceDemand, Level = AspNetHostingPermissionLevel.Minimal),
    DefaultProperty("DocumentSource"),
    Designer(typeof(eXmlDesigner)),
    PersistChildren(false, true),
    ToolboxData("<{0}:eXml runat=\"server\"/>"),
    ToolboxBitmap(typeof(ResourceLocator), "eXml.eXml.ico")
    ]
    public class eXml : Control
    {
        #region "Fields"

        private bool _enableDocumentFunction;
        private bool _enableScript;
        private string _documentSource;
        private XPathDocument _xpathDocument;
        private XPathNavigator _xpathNavigator;
        private static XslCompiledTransform _identityTransform;
        private XslCompiledTransform _transform;
        private string _transformSource;
        private XsltSettings _transformSettings;
        private XsltArgumentList _transformArgumentList;

        #endregion

        #region "Methods"

        /// <summary>
        /// 
        /// </summary>
        static eXml()
        {
            XmlTextReader stylesheet = new XmlTextReader(new StringReader("<xsl:stylesheet version='1.0' xmlns:xsl='http://www.w3.org/1999/XSL/Transform'><xsl:template match=\"/\"> <xsl:copy-of select=\".\"/> </xsl:template> </xsl:stylesheet>"));
            _identityTransform = new XslCompiledTransform();
            _identityTransform.Load(stylesheet, null, null);
        }

        /// <summary>
        /// 
        /// </summary>
        /// <returns></returns>
        protected override ControlCollection CreateControlCollection()
        {
            return new EmptyControlCollection(this);
        }

        /// <summary>
        /// 
        /// </summary>
        /// <param name="id"></param>
        /// <returns></returns>
        [EditorBrowsable(EditorBrowsableState.Never)]
        public override Control FindControl(string id)
        {
            return base.FindControl(id);
        }

        /// <summary>
        /// 
        /// </summary>
        [EditorBrowsable(EditorBrowsableState.Never)]
        public override void Focus()
        {
            throw new NotSupportedException(SR.GetString("NoFocusSupport", new object[] { base.GetType().Name }));
        }

        /// <summary>
        /// 
        /// </summary>
        /// <returns></returns>
        [SecurityPermission(SecurityAction.Demand, Unrestricted = true)]
        protected override IDictionary GetDesignModeState()
        {
            IDictionary dictionary = new HybridDictionary();
            dictionary["OriginalContent"] = this.ViewState["OriginalContent"];
            return dictionary;
        }

        /// <summary>
        /// 
        /// </summary>
        /// <returns></returns>
        [EditorBrowsable(EditorBrowsableState.Never)]
        public override bool HasControls()
        {
            return base.HasControls();
        }

        private void LoadXPathDocument()
        {
            //TODO: Cache Features
            if (!string.IsNullOrEmpty(this._documentSource))
            {
                if (this._xpathDocument == null)
                {
                    this._xpathDocument = new XPathDocument(ResolvePath(this._documentSource));
                }
            }
        }

        private void LoadTransformSource()
        {
            //TODO: Cache Features
            if ((this._transform == null) && (!string.IsNullOrEmpty(this._transformSource)))
            {
                this._transform = new XslCompiledTransform();
                this._transformSettings = new XsltSettings();

                this._transformSettings.EnableDocumentFunction = this._enableDocumentFunction;
                this._transformSettings.EnableScript = this._enableScript;

                this._transform.Load(ResolvePath(this._transformSource), _transformSettings, null);
            }
        }

        /// <summary>
        /// 
        /// </summary>
        /// <param name="writer"></param>
        protected override void Render(HtmlTextWriter writer)
        {
            if (this._xpathNavigator == null)
            {
                this.LoadXPathDocument();
            }

            this.LoadTransformSource();

            if ((this._xpathDocument != null) || (this._xpathNavigator != null))
            {
                if (this._transform == null)
                {
                    this._transform = _identityTransform;
                }

                if (this._xpathDocument != null)
                {

                    this._transform.Transform(this._xpathDocument, this._transformArgumentList, (TextWriter)writer);
                }
                else
                {
                    this._transform.Transform(this._xpathNavigator, this._transformArgumentList, (TextWriter)writer);
                }
            }
        }

        private string ResolvePath(string path)
        {
            CompareInfo _cmpUrl = CultureInfo.InvariantCulture.CompareInfo;
            return (_cmpUrl.IsPrefix(path, "http://")) ? path : base.MapPathSecure(path);
        }

        #endregion

        #region "Properties"

        /// <summary>
        /// 
        /// </summary>
        [EditorBrowsable(EditorBrowsableState.Never)]
        public override string ClientID
        {
            get 
            { 
                 return base.ClientID;
            }
        }

        /// <summary>
        /// 
        /// </summary>
        [EditorBrowsable(EditorBrowsableState.Never)]
        public override ControlCollection Controls
        {
            get
            {
                return base.Controls;
            }
        }

        /// <summary>
        /// 
        /// Gets or sets the path to an XML document to display in the eCustomTools.eXml control."
        /// </summary>
        [
        Browsable(true),
        WebCategory("Behavior"),
        UrlProperty(),
        WebSysDescription("Xml_DocumentSource"),
        Editor(typeof(XmlDataFileEditor), typeof(UITypeEditor))
        ]
        public string DocumentSource
        {
            get
            {
                return (this._documentSource == null) 
                    ? string.Empty : this._documentSource;
            }
            set
            {
                this._documentSource = value;
                this._xpathDocument = null;
                this._xpathNavigator = null;
            }
        }

        /// <summary>
        /// Gets or sets a value whether to enable support for the XSLT document() function.
        /// </summary>
        [
        Browsable(true),
        WebCategory("Behavior"),
        DefaultValue(false),
        WebSysDescription("Xml_EnableDocumentFunction")
        ]
        public bool EnableDocumentFunction
        {
            get
            {
                return this._enableDocumentFunction;
            }

            set
            {
                this._enableDocumentFunction = value;
            }
        }

        /// <summary>
        /// Gets or sets a value whether to enable support for embedded script blocks.
        /// </summary>
        [
        Browsable(true),
        WebCategory("Behavior"),
        DefaultValue(false),
        WebSysDescription("Xml_EnableScript")
        ]
        public bool EnableScript
        {
            get
            {
                return this._enableScript;
            }

            set
            {
                this._enableScript = value;
            }
        }

        /// <summary>
        /// 
        /// </summary>
        [
        Browsable(false), 
        EditorBrowsable(EditorBrowsableState.Never),
        DefaultValue(false)
        ]
        public override bool EnableTheming
        {
            get
            {
                return false;
            }
            set
            {
                throw new NotSupportedException(SR.GetString("NoThemingSupport", new object[] {base.GetType().Name}));
            }
        }

        /// <summary>
        /// 
        /// </summary>
        [
        Browsable(false),
        EditorBrowsable(EditorBrowsableState.Never)
        ]
        public override string SkinID
        {
            get
            {
                return string.Empty;
            }
            set
            {
                throw new NotSupportedException(SR.GetString("NoThemingSupport", new object[] { base.GetType().Name }));
            }
        }
        /// <summary>
        /// Gets or sets the System.Xml.Xsl.XslCompiledTransform object that formats the XML document before it is written to the ouput stream.
        /// </summary>
        [
        DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden), 
        Browsable(false), 
        WebSysDescription("Xml_Transform")
        ]
        public XslCompiledTransform Transform
        {
            get
            {
                return this._transform;
            }
            set
            {
                this.TransformSource = null;
                this._transform = value;
            }
        }

        /// <summary>
        /// Gets or sets System.Xml.Xsl.XslArgumentList that contains a list of optional arguments passed
        /// to the stylesheet and used during the Extensible Stylesheet Language Transformation (XSLT).
        /// </summary>
        [ 
        DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden), 
        Browsable(false),
        WebSysDescription("Xml_TransformArgumentList")
        ]
        public XsltArgumentList TransformArgumentList
        {
            get
            {
                return this._transformArgumentList;
            }
            set
            {
                this._transformArgumentList = value;
            }
        }

        /// <summary>
        /// Gets or sets the path to an Extensible Stylesheet Language Transformation(XSLT) style sheet that
        /// formats the XML document before it is written to the output stream.
        /// </summary>
        [
        Browsable(true),
        WebCategory("Behavior"),
        UrlProperty(),
        WebSysDescription("Xml_TransformSource"),
        Editor(typeof(XslUrlEditor), typeof(UITypeEditor))
        ]
        public virtual string TransformSource
        {
            get
            {
                return (this._transformSource == null) ?
                    string.Empty : this._transformSource;
            }
            set
            {
                this._transform = null;
                this._transformSource = value;
            }
        }

        /// <summary>
        /// Gets or sets a cursor model for navigating and editing the data associated with the eCustomTools.Web.WebControls.eXml control.
        /// </summary>
        [
        DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden), 
        WebSysDescription("Xml_XPathNavigator"), 
        Browsable(false)
        ]
        public XPathNavigator XPathNavigator
        {
            get
            {
                return this._xpathNavigator;
            }
            set
            {
                this.DocumentSource = null;
                this._xpathDocument = null;
                this._xpathNavigator = value;
            }
        }

        #endregion
    }
}
Next I created a designer. When you drop your eXML control onto your web page, it will look like the XML control in the designer. This is optional,  but so easy, why not.

 eXmlDesigner.cs

using System.Web.UI;
using System.Web.UI.Design;
using System.Web.UI.WebControls;

namespace eCustomTools.eWeb.WebControls
{
    [TargetControlType(typeof(Panel))]
    class eXmlDesigner : ControlDesigner
    {
        public override string GetDesignTimeHtml()
        {
            return CreatePlaceHolderDesignTimeHtml("Use this control to perform XSL transforms.");
        }
    }
}
Great it's starting to look like something now.

In the eXML.cs code you will notice a few things. In my class MetaData you will see eXml.eXML.ico. This is the icon for the Toolbox in Web Developer. When I created my project, I actually have some other tools I have created and I use the same namespace conventions as Microsoft. I have the eXml.cs and eXml.ico sitting in a subfolder nameed eXml, so I have to describe the path from the root. Also when adding an .ico to a project make sure you set it's properties to embedded resource.

In order for this to work I created a file in my root directory:

ResourceLocator.cs
using System;
using System.Collections.Generic;
using System.Text;

namespace eCustomTools
{
    internal class ResourceLocator
    {
        //Use this to locate icons outside of the root namespace for ToolboxBitmap;
    }
}
This is an empty class and always brings me back to my root namespace. I don't know where I got the trick from, but have been using it for quite sometime.

Ok, now it's time for some fluff. In the code you will also notice WebSysDescription and WebCategory. A resource file was added to my project when I created it, So im going to use it to hold some strings. Mostly, descriptions for the property designer and error messages. I need a way to access these strings so I create an eWeb class. Just the same way as Microsoft.

eWeb.cs
using System;
using System.Web;
using System.Text;
using System.Collections;
using System.Resources;
using System.Threading;
using System.Reflection;
using System.ComponentModel;
using System.Globalization;

namespace eCustomTools.eWeb
{
    #region "SR"
    internal sealed class SR
    {
        internal const string Xml_DocumentSource = "Xml_DocumentSource";
        internal const string Xml_TransformSource = "Xml_TransformSource";
        internal const string NoThemingSupport = "NoThemingSupport";
        internal const string NoFocusSupport = "NoFocusSupport";
        internal const string Xml_EnableDocumentFunction = "Xml_EnableDocumentFunction";
        internal const string Xml_EnableScript = "Xml_EnableScript";
        internal const string Xml_Transform = "Xml_Transform";
        internal const string Xml_TransformArgumentList = "Xml_TransformArgumentList";
        internal const string Xml_XPathNavigator = "Xml_XPathNavigator";
        
        private ResourceManager _resources;

        private static SR _loader;
        private static object _internalSyncObject;

        private static CultureInfo Culture
        {
            get
            {
                return null;
            }
        }

        internal SR()
        {
            this._resources = new ResourceManager("eCustomTools.Properties.Resources", Assembly.GetExecutingAssembly());
        }

        private static SR GetLoader()
        {
            if (_loader == null)
            {
                lock (InternalSyncObject)
                {
                    if (_loader == null)
                    {
                        _loader = new SR();
                    }
                }
            }
            return _loader;
        }

        public static string GetString(string name)
        {
            SR loader = GetLoader();
            if (loader == null)
            {
                return null;
            }
            return loader._resources.GetString(name, Culture);
        }

        public static string GetString(string name, params object[] args)
        {
            SR loader = GetLoader();
            if (loader == null)
            {
                return null;
            }
            string format = loader._resources.GetString(name, Culture);
            if ((args == null) || (args.Length <= 0))
            {
                return format;
            }
            for (int i = 0; i < args.Length; i++)
            {
                string str2 = args[i] as string;
                if ((str2 != null) && (str2.Length > 0x400))
                {
                    args[i] = str2.Substring(0, 0x3fd) + "...";
                }
            }
            return string.Format(CultureInfo.CurrentCulture, format, args);
        }

        private static object InternalSyncObject
        {
            get
            {
                if (_internalSyncObject == null)
                {
                    object obj2 = new object();
                    Interlocked.CompareExchange(ref _internalSyncObject, obj2, null);
                }
                return _internalSyncObject;
            }
        }

        public static ResourceManager Resources
        {
            get
            {
                return GetLoader()._resources;
            }
        }
    }
    #endregion

    #region "WebCategoryAttribute"

    [AttributeUsage(AttributeTargets.All)]
    internal sealed class WebCategoryAttribute : CategoryAttribute
    {
        internal WebCategoryAttribute(string category)
            : base(category)
        {
        }
        
        protected override string  GetLocalizedString(string value)
        {
             string localizedString = base.GetLocalizedString(value);
        if (localizedString == null)
        {
            localizedString = SR.GetString("Category_" + value);
        }
            return localizedString;
        }

         public override object TypeId
        {
            get
            {
                return typeof(CategoryAttribute);
            }
        }
    }

    #endregion

    #region "WebSysDefaultValueAttribute"

    [AttributeUsage(AttributeTargets.All)]
    internal sealed class WebSysDefaultValueAttribute : DefaultValueAttribute
    {
        // Fields
        private bool _localized;
        private Type _type;

        // Methods
        internal WebSysDefaultValueAttribute(string value)
            : base(value)
        {
        }

        internal WebSysDefaultValueAttribute(Type type, string value)
            : base(value)
        {
            this._type = type;
        }

        // Properties
        public override object TypeId
        {
            get
            {
                return typeof(DefaultValueAttribute);
            }
        }

        public override object Value
        {
            get
            {
                if (!this._localized)
                {
                    this._localized = true;
                    string str = (string)base.Value;
                    if (!string.IsNullOrEmpty(str))
                    {
                        object obj2 = SR.GetString(str);
                        if (this._type != null)
                        {
                            try
                            {
                                obj2 = TypeDescriptor.GetConverter(this._type).ConvertFromInvariantString((string)obj2);
                            }
                            catch (NotSupportedException)
                            {
                                obj2 = null;
                            }
                        }
                        base.SetValue(obj2);
                    }
                }
                return base.Value;
            }
        }
    }

    #endregion

    #region "WebSysDescriptionAttribute"

    [AttributeUsage(AttributeTargets.All)]
    internal class WebSysDescriptionAttribute : DescriptionAttribute
    {
        private bool replaced;

        internal WebSysDescriptionAttribute(string description)
            : base(description)
        {
        }

        public override string Description
        {
            get
            {
                if (!this.replaced)
                {
                    this.replaced = true;
                    base.DescriptionValue = SR.GetString(base.Description);
                }
                return base.Description;
            }
        }

        public override object TypeId
        {
            get
            {
                return typeof(DescriptionAttribute);
            }
        }
    }

    #endregion

    #region "WebSysDisplayNameAttribute"

    [AttributeUsage(AttributeTargets.Event | AttributeTargets.Property | AttributeTargets.Class)]
    internal sealed class WebSysDisplayNameAttribute : DisplayNameAttribute
    {
        // Fields
        private bool replaced;

        // Methods
        internal WebSysDisplayNameAttribute(string DisplayName)
            : base(DisplayName)
        {
        }

        // Properties
        public override string DisplayName
        {
            get
            {
                if (!this.replaced)
                {
                    this.replaced = true;
                    base.DisplayNameValue = SR.GetString(base.DisplayName);
                }
                return base.DisplayName;
            }
        }

        public override object TypeId
        {
            get
            {
                return typeof(DisplayNameAttribute);
            }
        }
    }

    #endregion
}
Then I added the proper strings:

NoFocusSupport     Control of type '{0}' does not support the Focus operation.    
NoThemingSupport    Control of type '{0}' does not support theming.    
Xml_DocumentSource    The XSL file used to transform the data.    
Xml_EnableDocumentFunction    Gets or sets a value whether to enable support for the XSLT document() function.    
Xml_EnableScript    Gets or sets a value whether to enable support for embedded script blocks.    
Xml_Transform    The XSL transform used on the Xml data.    
Xml_TransformArgumentList    The argument list used by the XSL stylesheet.    
Xml_TransformSource    The XML file the transform is applied to.    
Xml_XPathNavigator    The XPathNavigator that the transform is applied to.
I also signed my control by going to my Project Properties and selecting the signing tab. For "Choose a string name key file", select new and enter what ever. I use eCustomTools. This allows me to have a PublicKeyToken when I drop the control on a page and register my control in the web.config.

<pages>
   <controls>
      <add tagPrefix="asp" namespace="System.Web.UI" assembly="System.Web.Extensions, Version=3.5.0.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35"/>
      <add tagPrefix="asp" namespace="System.Web.UI.WebControls" assembly="System.Web.Extensions, Version=3.5.0.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35"/>
      <add tagPrefix="ect" namespace="eCustomTools.eWeb.WebControls" assembly="eCustomTools, Version=1.0.0.0, Culture=neutral, PublicKeyToken=cf74ac745801e44d" />
   </controls>
</pages>
Build the project, and lets use it.

Open up Web Developer Express Editon, and drop your control on Default.aspx. In the property designer you will see DocumentSource and TransformSource. For this example I am going to use: Microsoft at Home Rss Feed. http://go.microsoft.com/fwlink/?LinkId=68928 as my DocumentSource and a TransformSource I will use an XSLT file I created.

rss.xslt
<?xml version="1.0" encoding="utf-8" ?>
<xsl:stylesheet version="1.0"
    xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
    xmlns:wfw="http://wellformedweb.org/CommentAPI/"
    xmlns:ect="urn:custom-extension-lib:date">
    <xsl:output method="html" />
    <xsl:template match="/rss/channel">
        <link href="rss.css" type="text/css" rel="stylesheet" />
        <div id="content">
            <div id="channel_content">
                <div id="channel_left">
                    <div id="channel_title">
                        <xsl:element name="a">
                            <xsl:attribute name="href">
                                <xsl:value-of select="link" />
                            </xsl:attribute>
                            <xsl:value-of select="title" disable-output-escaping="yes" />
                        </xsl:element>
                    </div>
                    <div id="channel_pubdate">
                        <xsl:value-of select="ect:GetFormattedDate(pubDate, 'MMM dd, yyyy, hh:mm:ss tt')" />
                    </div>
                </div>
                <div id="channel_right">
                    <xsl:element name="a">
                        <xsl:attribute name="href">
                            <xsl:value-of select="image/link" />
                        </xsl:attribute>
                        <xsl:element name="img">
                            <xsl:attribute name="src">
                                <xsl:value-of select="image/url" />
                            </xsl:attribute>
                            <xsl:attribute name="alt">Feed Image</xsl:attribute>
                        </xsl:element>
                    </xsl:element>
                </div>
            </div>
            <br/>
            <div id="copyright">
                <xsl:value-of select="copyright"/>
            </div>
            <br/>
            <xsl:for-each select="item">
                <div id="item_content">
                    <div id="item_title">
                        <xsl:element name="a">
                            <xsl:attribute name="href">
                                <xsl:value-of select="link" />
                            </xsl:attribute>
                            <xsl:value-of select="title" disable-output-escaping="yes" />
                        </xsl:element>
                    </div>
                    <div id="item_pubdate">
                        <xsl:value-of select="ect:GetFormattedDate(pubDate, 'MMM dd, yyyy, hh:mm:ss tt')"/>
                    </div>
                    <div id="item_description">
                        <xsl:value-of select="description" disable-output-escaping="yes"/>
                    </div>
                </div>
            </xsl:for-each>
            <div id="copyright">
                <xsl:value-of select="copyright"/>
            </div>
        </div>
    </xsl:template>
</xsl:stylesheet>
And it references this stylesheet:

rss.css
#content
{
    display: block;
    font-family: sans-serif, tahoma, arial; } #channel_content { display: block; padding: 20px; height: 35px; border: solid 1px #EEEEEE; background-color: #F9F9F9; } #channel_left { float: left; width: auto; } #channel_right { float:right; width: auto; } #channel_right img { border-width: 0px; } #channel_title { font-size: 13.5pt; font-weight: bold; } #channel_title a:link, #channel_title a:visited { color: #0D5CA7; text-decoration: none; } #channel_title a:hover { text-decoration: underline; } #channel_pubdate { color: #999; font-size: 8pt; } #item_content { display: block; padding: 20px 0px 20px 0px; } #item_title { font-size: 11pt; font-weight: 600; padding-bottom: 5px; border-bottom: solid 1px #999; } #item_title a:link, #item_title a:visited { color: #0D5CA7; text-decoration: none; } #item_title a:hover { text-decoration: underline; } #item_pubdate { color: #999; font-size: 8pt; margin-top: 3px; } #item_description { margin-top: 10px; } #item_description img { margin-right: 10px; max-width: 150px; }
If you try to run this, you get an error. You will see getFormattedDate for the pubDate

In my Page_Load event, I added an argument to transform the dates;

this.eXml1.TransformArgumentList = new XsltArgumentList();
this.eXml1.TransformArgumentList.AddExtensionObject("urn:custom-extension-lib:date", new DateLib());
And created this class in my Default.aspx.cs

public class DateLib
    {
        private  bool IsDate(string date)
        {
            DateTime dt;
            bool isDate = true;
            try
            {
                dt = DateTime.Parse(date);
            }
            catch
            {
                isDate = false;
            }
            return isDate;
        }
        public string GetFormattedDate(string date, string format)
        {
            if (IsDate(date))
            {
                return (DateTime.Parse(date).ToString(format));
            }
            else
            {
                return date;
            }
        }
    }
Now your ready to build your project and view your document.

Download source code
By Graham Underwood   Popularity  (3027 Views)