Click here to Skip to main content
12,889,951 members (40,448 online)
Click here to Skip to main content
Add your own
alternative version

Tagged as


132 bookmarked
Posted 3 Dec 2011


, 22 Oct 2012 GPL3
Rate this:
Please Sign up or sign in to vote.
Comic and electronic publication reader with library management, extended file conversion, and devices support.


With the "success" of the first project and some spare time, I start a completely new version hosted on the same CodePlex project.

I decided to rewrite it to explore a bit more about the MVVM pattern and extend it to other ebook formats. I am also interested in 7 phone development, so I took the idea of dynamic books that I have seen on an iPhone application. The roadmap includes:

  • Better user interface and design: Ribbon...
  • Multiple format support and conversion: Images, PDF, XPS, CBR/RAR, CBZ/ZIP, ePUB...
  • Dynamic books format: CBZD, a complete zip format with additional XML files associated to pages that contain frame descriptions. CBR contains a designer to "edit" books to add frames on the different page zone with order and timing. This allows automatic reading usable on desktop, but mainly for phone application.
  • External devices support and a Windows Phone 7 application.

Related publications: CodePlex, "Product" site, Blog

Screenshot: New C.B.R. interface

Project Content

As a developer, what you can find helpful in this project, despite a lot of tricks that are not described here:

  • NEW : OPDS feed, function to localize by code, multi-document and toolboxes in MVVM design, re-designed frame editor in mvvm way for dynamic books, new tablet and 7 Phone simulators, 'Any CPU' and compatible with 32 & 64 bit plateforms, Image merger for PDF conversion, etc...
  • Two page book view based on Mitsu Futura "book"
  • Localization engine that works with resx, xml, bin and that you can extend to your needs...!
  • An explorer view with grid, simple and complex thumb view
  • A starting home page that display direct actions and "xml news" feeds
  • Quite complex MVVM pattern
  • Simple explorer like view in MVVM with tree and folder view
  • USB device detection with WMI
  • PDF image extraction
  • SevenZipSharp: extraction, compression, in memory usage
  • Office like: Ribbon control usage, backstage design like recent file lists
  • Controls: Magnify glass, 5 stars rating, Splitter expander, WaitSpin, ZoomFlyer, Extended ListView
  • XPS reading and writing, ePUB parsing and conversion
  • Single instance, parameter processing and file type registering



You can find more screenshots on the product web site :

The Code : Schemas and Principles

Has the project becomes a bit more complicated than the original and as it is always in beta stage, I split the article into several "high level" parts: core classes, MVVM pattern classes and coding tips... until the architecture becomes stable to give a more complete description.

Model Core Classes

The model core classes illustrated below contain a Catalog class that represent a library. It is mapped to the ExplorerViewModel and managed by a corresponding CatalogService. All books are represented by a Book entity, that extend (when possible) to Pages. Zone class is dedicated to my new format for dynamic books. When extra data is needed, it is stored in the Tag property of the Book (like for ePUB format).

Diagram: Model core classes

Service Core Classes

Below are the main service classes. FileService and FileExtension manage the data we need to associate a file (based on the extension) to data like the dialog filter, but also the associated model, service and viewmodel. This will be used by the BookServiceFactory to create the corresponding ViewModel that will also create the corresponding service using reflection. BookInfoService is a separated class that only manages the load/save operation on Book binary internal structure. CatalogService is the class that manages the library.

Update... !

Fileservice and FileInfo classes has been replaced by DocumentFactory, DocumentInfo and DocumentType that link to the Core model.

Note: About the internal file structure...C.B.R. does not use any database. It is a single binary file that you can store where you want which centralizes the catalog information (path to the book files). Underneath, in the current application folder, I store book information like rating or cover into a separate bin file, so that they can be shared among several libraries. You can lose you library, if the book binary is there, then nothing lost!

Diagram: Service core classes

Workspace Core Classes

These classes manage all data for the working environment. The singleton class WorkspaceService stores the settings in a WorkspaceInfo class that contains program options (that the user can change in the backstage), two lists of RecentFileInfo for book and library recent file list and the supported device list as a DeviceInfo collection. The settings class is serialized through the program properties. It is loaded a program start and automatically saved on closing.

Update... !

ExtendedInfo contains the data for the extended options. FeedInfo and FeedItemInfo the data for the backstage view FeedInfoView. ProxyInfo class has been added to manage any special internet settings.

Diagram: Workspace core classes

Convert Core Classes

My wish was to not include any "third party" that is not pure WPF... so I had to find a way to convert from unsupported format (like PDF) to the one I choose to support.

Based on the convert panel in the backstage, we have a ContractParameter class that groups all chosen options. It is given to the BookFileConverter class that supports threading through a BackgroundWorker (as it uses a report progress in the user interface). Then the conversion process goes through two interfaces that are based on input and output formats: IReaderContract and IWriterContract. Note that the conversion process does not manage multiple input/output formats.

The BookFileConverter is first calling the reader to extract the useful data that goes through the writer. The package includes ImageFileReader, RARImageReader, PDFImageReader, XPSImageReader for reading images in folder, Rar/Zip, PDF or XPS files (will be soon extended to ePUB). We can write to Image files, a XPS or CBZ/ZIP file through ImageFileWriter, XPSImageWriter and ZIPWriter classes.

Click to enlarge image

Diagram: Converter core classes

The standard way to transfer data from reader to writer is an array of bytes that represent images, but I take a shortcut for conversion like RAR to images where the reader is extracting images from the RAR directly to a folder without the need of a writer. Below is the transfer table mode used for the conversion:

SourceReaderTransfer modeWriterDestination
xdirectcompress folderCBZ/ZIP
extract to memmemwrite memXPS
PDFextract to memmemwrite filesImages
extract to memmemwrite files
compress folder
extract to memmemwrite memXPS
CBR/RARextract to folderdirectxImages
extract to folderto tempcompress folderCBZ/ZIP
extract to memmemwrite memXPS
CBZ/ZIPextract to folderdirectxImages
extract to memmemwrite memXPS
XPSextract to memmemwrite filesImages
extract to memmemwrite files
compress folder
extract to memmemwrite memXPS

MVVM Related Application Classes

This is the most complicated part... below are the classes from the application that participate in the pattern. I am not going to describe the helper classes like the ViewModelBase, the Mediator or the Messenger. It is cut in 5 parts. The views: main user interface, the backstage and out of the pattern classes. The viewmodels: main user interface, the backstage and some additional classes. Let's go through them.

Diagram: MVVM application classes

View Layer

The main user interface is composed by a MainView (the whole window), an ExplorerView (the library explorer on the left) and some ribbon content (different book format and device views) that are hosted by a TabConbtrol (with no user interface displayed) binded to a collection of ViewModel.

  • InfoView, OptionsView, RecentFileView and DeviceConfigView are panels displayed in the ribbon backstage.
  • BookView (for comics), XpsBookView (for XPS documents) and ePUBBookView (for web like viewer) are hosted in a TabControl in the main window part
  • USBDeviceView and later PhoneDeviceView are also hosted in a TabControl in the main window part and but displayed in a contextual manner by the ribbon group "Devices"

The ConvertView and SimulateView are actually out of the pattern because of the BackgroundWorder/thread or a beta stage code.

ViewModel Layer

We have exactly the same for the view model layer, plus a BookViewModelBase that centralizes all common book functions and a DeviceViewModelBase that do the same for devices. Note that they are all inherited from ViewModelBase (that host a Model in a Data property) which comes from a BindableObject that implements the INotifyPropertyChanged interface for binding and MVVM support.

On the right side, we also have TreeviewItemViewModel class derivated into SysElementViewModel (that represents a file system element) and becomes specialized with SysDriveViewModel, SysDirectoryViewModel and SysFileViewModel. They are used in the USBDeviceViewModel which contains a treeview and a listview control to display the device content.

Update... !

The model has been completed with new classes like FeedView/Model to display OPDS feeds....

The main changes are due to the AvalonDock integration that brings PaneViewModel derivated into ToolViewModel (that represents documents and toolbox view models).

Exchange: How to Communicate between Layers

I will try to publish a graph about the exchange later. The way is MVVM based: Views call Commands on ViewModel, ViewModel communicate together with Mediator, ViewModel communicate with Views through Messages. I avoid a maximum of control event handling but sometimes it is not possible or too complicated.

Localization engine

This new part is based on the very nice article from CodeProject, WPF Localization using resx - (so I am not going to explain it...). But the solution was not satisfying me, because I don't like resx (too much work for the developer when it comes to update/copy/paste and rebuild new resources) and I consider C.B.R. being too small for having so many locale assemblies...There is also a big need for an editor so that any user can manage localization or open the resource storage to database for example.

Here under are the core classes to support the localization in the XAML. I just made some small changes to existing code, like a singleton pattern on the CultureManager or the LocalizationExtension renamed has got a ResModul property that can identify a resx file or a xml/bin file. To complete the core model represented by the CultureManager (single public class) I have added some management functions like GetAvailableCultures, GetAvailableModules, GetModuleResource, SaveResources or CreateCulture: this will answer the need for an editor. Note that finally, the resx method is very restrictive and cannot cover all this overload...

I also add a CultureItem class that extend the CultureInfo. This will be mapped to a view class to fill the language gallery in the ribbon.

Diagram: Localization core classes

To extend the resx model, I choose to add a provider pattern to the CultureManager. IResourceProvider is implemented directly by the ResxProvider class (existing one that does not manage cultures) and FileProviderBase that derive into BinProvider and XmlProvider (the solution I choose because file are human readable). The data model is very simple and will be serialize or deserialized directly to the resource files. It is composed of LocalizationFile that group all LocalizationDictionnary by culture code, each dictionary contains a collection of LocalizationItem that represent a resource set. See the programming extract to get more details about it's implementation and usage.

Diagram: Localization provider classes

ePUB and OPDS models

I had a complete rework on the previous model (that cannot extend to conversion or writing functionnalities). ePubManager is the parsing class and below are the model classes. This one is much better, even if ePUB has got a lot of specification must fit for nearly all reading. Note that the viewer is based on IE (need to register some key for better emulation mode - done by the setup - you will find them in the solution). In case of bad encoding detection, you can use the view context menu.

OPDS is a special feed format dedicated to electronical publication. CBR include a feed management view and a browser view that allow to navigate the Rss and download books. Below is the model that suit to my needs (not the OPDS specification) and the OpdsManager is the main class that parse the feed to create the objects for View and ViewModels

The Code: Programming Extracts

In the next chapter, I would like to point out the problems that I faced and the solutions as well the nice part of code that seems interesting to share.

Conversion: Extract PDF Images

I dig into a lots of solutions - read and parse PDF, image extraction tools - before hazard brings me to a code file on the internet. From some test file, I found that iTextSharp has a listener that you can plug onto the parser to get dedicated events and data during the parsing. Implement a class with the IRenderListener interface and the method RenderImage will be called on each image so you can get the bytes back.

Give it to the ProcessContent method while processing PDF pages through the PdFReader and PdfReaderContentParser classes. Note you can get text events too.

    reader = new PdfReader(inputFileorFolder);
    PdfReaderContentParser parser = new PdfReaderContentParser(reader);

    listener = new PDFImageListener();

    for (int i = 1; i <= reader.NumberOfPages; i++)
        parser.ProcessContent(i, listener);

Update... !

The RenderImage method has been improved with this new code. Before /flat and /lzw were not processed and GetImageAsBytes works better for image type that can be read by ITextSharp.

public void RenderImage(ImageRenderInfo renderInfo)
    PdfImageObject image = renderInfo.GetImage();
    PdfName filter = (PdfName)image.Get(PdfName.FILTER);

    if (PdfName.DCTDECODE.Equals(filter))
    _imageNames.Add(string.Format("{0:0000}_{1:0000}.{2}", PageIndex, _imageNames.Count, PdfImageObject.TYPE_JPG));
    else if (PdfName.JPXDECODE.Equals(filter))
    _imageNames.Add(string.Format("{0:0000}_{1:0000}.{2}", PageIndex, _imageNames.Count, PdfImageObject.TYPE_JP2));

    else if (PdfName.LZWDECODE.Equals(filter))
    _imageNames.Add(string.Format("{0:0000}_{1:0000}.{2}", PageIndex, _imageNames.Count, PdfImageObject.TYPE_TIF));
    else if (PdfName.FLATEDECODE.Equals(filter))

    _imageNames.Add(string.Format("{0:0000}_{1:0000}.{2}", PageIndex, _imageNames.Count, PdfImageObject.TYPE_PNG ));

New: Merge images by page (for PDF)

I have noticed in the past with some tests that PDF image extraction sometimes results in hundred of images because each page is cutted in several parts. I don't know where it comes from, but the solution is there. I have added a checkbox option in the conversion panel to merge images in case of a number difference with the page count.

As my conversion engine is based on a reader (or extractor), a pipe with the image bytes, then a writer to finalize the choosen destination format, i put in the middle a ImageJoiner class that combine the original bytes into real pages.

I first modify the ImageListener to be sure that page index is allways included in the image file name. When I detect a difference in the Read method of PDFImageReader class, I give the array to the ImageJoiner class and take his array as the result

if (settings.CheckResult && reader.NumberOfPages != listener.ImageNames.Count)
    if (settings.JoinImages)
        progress(string.Format("Extracting {0} : {1} images for {2} pages - Try to merge !", inputFileorFolder, listener.ImageNames.Count, reader.NumberOfPages));

        ImageJoiner cp = new ImageJoiner();

        cp.Merge(listener.ImageBytes, listener.ImageNames);

        progress(string.Format("Merge to {0} new images...", cp.NewImageNames.Count));


The ImageJoiner is quite simple and the trick is in the second Merge method : it takes indexes to process as parameters. I first create a list of the corresponding

and compute the size of the destination bitmap. Then create a
that match the destination. With a
, I open is context and draw all the bitmap in it. Then I ask the RenderTargetBitmap to draw the visual and after that extract the bytes to the new it is !

public void MergeGroup(List<byte[]> imageBytes, List<string> imageNames, int start, int end, int index)

        List<BitmapImage> bmps = new List<BitmapImage>();
        double maxWidth = 0, maxHeight = 0, position = 0;

        for (int i = start; i <= end; i++)

            MemoryStream ms = new MemoryStream(imageBytes[i]);
            BitmapImage myImage = new BitmapImage();
            myImage.StreamSource = ms;
            myImage.CacheOption = BitmapCacheOption.None;

            maxWidth = Math.Max(myImage.Width, maxWidth);
            maxHeight += myImage.Height;


        RenderTargetBitmap temp = new RenderTargetBitmap((int)maxWidth, (int)maxHeight, 96d, 96d, PixelFormats.Pbgra32);

        DrawingVisual dv = new DrawingVisual();
        using (DrawingContext ctx = dv.RenderOpen())
            foreach (BitmapImage bi in bmps)
                ctx.DrawImage(bi, new System.Windows.Rect(0, position, bi.Width, bi.Height));
                position += bi.Height;



        NewImageNames.Add(string.Format("{0:0000}.jpg", index));


XPS: Fit Image on Fixed Page

How to fit images on XPS document pages? Seems like quite an easy question, but I had to test a lot before finding my Achilles heel... Do not mix pixel image size and WPF units!

Have a look at the class XPSHelper in CBR.Core. The method WriteDocument has all the mechanics to write the array of images into a fixed document. It calls a WritePageContent method used to write the XAML page structure. Here, I specify the viewbox to fit the image into a A4 formatted page.

private void WritePageContent(System.Xml.XmlWriter xmlWriter, 
    XpsResource res, double wpfWidth, double wpfHeight)
        xmlWriter.WriteAttributeString("Width", "794");
        xmlWriter.WriteAttributeString("Height", "1123");
        xmlWriter.WriteAttributeString("xml:lang", "en-US");


        if (res is XpsImage)
                "M 0,0 L 794,0 794,1123 0,1123 z");
                ("Viewbox", string.Format("0,0,{0},{1}",

That's why I create a WPF BitmapImage to get the image size in WPF Units...!

//this is just to get the real WPF image size as WPF display units and 
//not image pixel size !!
using (MemoryStream ms = new MemoryStream(images[i]))
    BitmapImage myImage = new BitmapImage();
    myImage.CacheOption = BitmapCacheOption.OnLoad;
    myImage.StreamSource = ms;

    //write the page
    WritePageContent(xmlWriter, xpsImage, myImage.Width, myImage.Height);

Dynamic Properties

CBR has no database to store information about the books, so I had to find a way to add properties dynamically to my objects so that they can be extended. Let's says you manage all your books by series... it is not an existing property of my Book model object. So you are not able to define the data, nor to group or to sort them. Another requisite was that they need to be properties to work and take advantage of GroupDescriptions and SortDescription on an ObservableCollection of book. See below in "context menu" chapter.

.NET 4 brings a fantastic but not well known feature: dynamic or Expando objects. First, add a new property to our model like below based on them. What you will add to it will be seen like a property by WPF and reflection, but in code we generally manipulate it like a Dictionary.

private dynamic _dynamics = new ExpandoObject();
public dynamic Dynamics
    get { return _dynamics; }
    set { _dynamics = value; RaisePropertyChanged("Dynamics"); }

After defining the backstage panel that allows to manage the dynamic property reference list, I am able to use it and refresh the Book model with a synchronized method. I am looping on both dictionaries to update the book properties.

public void SynchronizeProperties(Book bk)
        IDictionary<string, object> dict = 
            (IDictionary<string, object>)bk.Dynamics;

        // add the properties from the settings if missing
        foreach( string k in WorkspaceService.Instance.Settings.Dynamics )
            if (!dict.Keys.Contains(k))
                dict.Add(k, string.Empty);

        // remove old properties that were removed from settings
        foreach (string k in dict.Keys)
            if (!WorkspaceService.Instance.Settings.Dynamics.Contains(k))

Filling the backstage file information panel was hard, because WPF sees it as a dictionary when binding as a list of PropertyName/PropertyValue. So I had to convert it to a collection of KeyValueProperty view-model class (derived from BindableObject) that has PropertyChanged event so I can get the value back in the dynamic property.

I let you go through the code in InfoViewModel and KeyValueProperty but also in ExplorerViewModel and PropertyViewModel which uses dynamic to fill the sort and group dropdown button menus.

Context Menu Binding

Everywhere on internet, it is pointed out that the context menu does not share the same logical tree view... and it's easy to get the parent context... But in my case, for the context menus in the explorer, I had extra need:

  • Use the commands from the MainViewModel because commands like Read/Bookmark or Delete were already implemented
  • Fill sort and group dropdown menus with properties from fixed and dynamic and all calling the same command

Context menus: Sort and book commands

The Book Contextual Menu

As it was not easy to get up in the logical tree view to get the parent main window and to bind to these commands, I choose to implement an intermediary command in my ExplorerViewModel that will forward it to the MainViewModel. Menu items are defined in the XAML like below:

        <MenuItem Header="Read" Command="{Binding ForwardCommand}" 


It was then easy to implement a command in the ExplorerViewModel that uses the Mediator to inform other views that a command occurs through the CommandContext class that contains the CommandName as a string and the Book item as a parameter.

private ICommand forwardCommand;
public ICommand ForwardCommand
        if (forwardCommand == null)
            forwardCommand = new DelegateCommand<string>(
                delegate(string param)
                        new CommandContext()
                        { CommandName = param, 
                        CommandParameter = 
                            this.Books.CurrentItem } );
                delegate(string param)
                    if (CatalogData != null && 
                        Books.CurrentItem != null)
                        return true;
                    return false;
        return forwardCommand;

On the MainViewModel, I have a method that calls the original command by reflection each time the Mediator invokes the registered delegate.

internal void ExecuteDistantCommand(CommandContext context)
    if (context != null)
        new ReflectionHelper().ExecuteICommand
        ( this, context.CommandName, context.CommandParameter );

Sort and Group Menus

I faced the same problems with sort and group menu, plus I had to find a way to populate them with Book model class properties, some were dynamic ones. To fill it up, I define two properties in the ExplorerViewModel like below that gives me the menu item lists. I also use reflection to get the properties out of the book... but I quickly found that I need a view model for menu items because I need more than just a property name to continue.

public List<PropertyViewModel> SortProperties
    get { return GetSortProperties(); }

PropertyViewModel below contains necessary data to fully identify the properties and a command based on the same model as in previous chapter, that will notify the ExplorerViewModel.

public class PropertyViewModel : ViewModelBase
    #region ----------------PROPERTIES----------------

    private object CatalogData { get; set; }
    public string Prefix { get; set; }
    public string ToDisplay { get; set; }
    public string Name { get; set; }
    #region generic command
    private ICommand genericCommand;
    public ICommand GenericCommand
            if (genericCommand == null)
                genericCommand = new DelegateCommand<string>

    void ExecCommand(string param)
        if (param.Equals("group"))
            (ViewModelMessages.ExplorerGroup, this.Name);
        if (param.Equals("sort"))
                   ViewModelMessages.ExplorerSort, this.Name);

Note that in the ExplorerViewModel, we subscribe to both messages and after that, we can modify the SortDescription and GroupDescription on our collection. Group command was a bit special, because it needs to forward the changes to the View to remove the GroupStyle if needed, that's why I call the Messenger.

    (Object o) =>
        Group( o as string );

internal void Group(string propertyName)
    PropertyViewModel prop = GetGroupProperties().Find(p => p.Name == propertyName);

    IEnumerable<PropertyGroupDescription> result =
            Where(p => p.PropertyName == prop.Prefix + prop.Name);

    if (result != null && result.Count() == 1)
        Books.GroupDescriptions.Add(new PropertyGroupDescription
                (prop.Prefix + prop.Name));

    Messenger.Default.Send<MessageBase>( new MessageBase(this) );


In the XAML, mainly have to define the right template for menu items with the display and the command bindings.

<!-- style for menu items in sort/group dropdown buttons -->
<Style x:Key="PropertyViewModel">
    <Setter Property="MenuItem.Header" Value="{Binding ToDisplay}"/>
    <Setter Property="MenuItem.Command" Value="{Binding GenericCommand}" />

    <Setter Property="MenuItem.IsCheckable" Value="true" />

<Style x:Key="GroupMenuItemStyle" BasedOn="{StaticResource PropertyViewModel}" >

    <Setter Property="MenuItem.CommandParameter" Value="group" />

<Style x:Key="SortMenuItemStyle" BasedOn="{StaticResource PropertyViewModel}" >

    <Setter Property="MenuItem.CommandParameter" Value="sort" />

Update... !

Based on the same principe, i have implemented an automatic property discovering based on an Attribute class that allow me to define which property can be sorted, grouped along with the localize code...below is the schema : The PropertyHelper class provides me directly with the PropertyViewModel collection needed to fill up the menus.

I extend my book model like below to define that this property is not groupable, but sortable and that the localize key is Core.Properties.FilePath. Advantage is that my menus are now automatically updated if i changes properties or the language !

[UserPropertyAttribute(false, true, "Core.Properties.FilePath")]
public string FilePath

Implement Drag and Drop

On the application, from outside

You can drop a comic file or library to the application to open it directly. Easy to implement, add the drop event handler, check if we support the file extension and execute open command in the MainViewModel. Do not forget to allow drop on you main window through the AllowDrop property.

private void RibbonWindow_Drop(object sender, DragEventArgs e)
        if (e.Data.GetDataPresent(DataFormats.FileDrop))
            MainViewModel mvm = DataContext as MainViewModel;

            string[] droppedFilePaths = 
            e.Data.GetData(DataFormats.FileDrop, true) as string[];

            if (FileService.Instance.FindCatalogFilterByExt
            (System.IO.Path.GetExtension(droppedFilePaths[0])) != null)
            if (FileService.Instance.FindBookFilterByExt
            (System.IO.Path.GetExtension(droppedFilePaths[0])) != null)
    catch (Exception err)
        ExceptionHelper.Manage("MainView:RibbonWindow_Drop", err);

Explorer and Device View

When drag & drop comes from the internal controls, so we have to manage MouseDown, MouseMove events and initiate a drag & drop operation on each possible source control. To make it easier, I develop a DragHelper that you plug on each control that needs to be a source - so you don't care anymore about mouse handling and item preview. The drop destination needs to be managed as usual depending your needs. The DragHelper attaches itself to the control given in the constructor.

 public DragHelper( Control attachedView )
    _Attached = attachedView;
    _Attached.PreviewMouseLeftButtonDown += 
        new MouseButtonEventHandler(PreviewMouseLeftButtonDown);
    _Attached.PreviewMouseMove += new MouseEventHandler(PreviewMouseMove);

void PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
    _startPoint = e.GetPosition(null);

void PreviewMouseMove(object sender, MouseEventArgs e)
    if (e.LeftButton == MouseButtonState.Pressed && !IsDragging)
        Point position = e.GetPosition(null);

        if (Math.Abs(position.X - _startPoint.X) > 
            SystemParameters.MinimumHorizontalDragDistance ||
            Math.Abs(position.Y - _startPoint.Y) > 

If we meet the drag distances, we capture the mouse in the application, and send a StartDragEvent to the attached control - so it can prepare data and callback DoDragDrop below. Shortly, it allows drop, attaches itself to the main window DragOver event, creates an adorner that contains the source element preview, and then calls the real DoDragDrop function. When it is released, we clean up all.

 public void DoDragDrop( DataObject data, UIElement source )
    // Let's define our DragScope .. In this case it is every thing inside 
    // our main window ..
    DragScope = Application.Current.MainWindow.Content as FrameworkElement;

    // We enable Drag & Drop in our scope ... We are not implementing Drop, 
    // so it is OK, but this allows us to get DragOver
    bool previousDrop = DragScope.AllowDrop;
    DragScope.AllowDrop = true;

    // The DragOver event ...
    DragEventHandler draghandler = new DragEventHandler(ScopeDragOver);
    DragScope.PreviewDragOver += draghandler;

    //Here we create our adorner..
    _DragAdorner = new DragAdorner(DragScope, source, true, 0.5);
    AdornerLayer layer = AdornerLayer.GetAdornerLayer(DragScope as Visual);

    DragDropEffects de = DragDrop.DoDragDrop(_Attached, data, DragDropEffects.Move);

    // Clean up
    DragScope.AllowDrop = previousDrop;
    _DragAdorner = null;

    DragScope.PreviewDragOver -= draghandler;

    IsDragging = false;

How to Use It

To use it is very easy, just instantiate a DragHelper on a control like below:

_drager = new DragHelper(CatalogListBox);
_drager.OnStartDrag += new StartDragEventHandler(drag_OnStartDrag);

Then, create the StartDragEventHandler that will be called by the helper when a drag & drop operation is validated. Find your control item and create the data you need to manage the drop. Call the DragHelper.DoDragDrop function to validate and continue the operation.

void drag_OnStartDrag(object sender, MouseEventArgs e)
    // Get the dragged ListViewItem
    ListBoxItem item = VisualHelper.FindAnchestor<ListBoxItem>

    if (item != null)
        Book bk = (Book)CatalogListBox.ItemContainerGenerator.

        // Find the data behind the item + 
        // Initialize the drag & drop operation
        DataObject dragData = new DataObject("CBR.Book.Path", bk.FilePath);

        _drager.DoDragDrop(dragData, item);

In any destination control, handle the _DragOver, _DragLeave and _Drop handler as needed. Check whether the data comes from you, then execute any business code like below to make a file copy.

private void listViewContent_Drop(object sender, DragEventArgs e)
    if (e.Data.GetDataPresent("CBR.Book.Path"))
        string path = e.Data.GetData("CBR.Book.Path") as string;

        string destFile = Path.Combine( 
        (this.FolderTree.SelectedItem as SysElementViewModel).FullPath,
             Path.GetFileName(path) );

        FileService.Instance.CopyToDevice(path, destFile, 
            this.cbDiskType.SelectedItem as DeviceInfo);

Implement USB and Device Detection

WMI Event Watcher

WMI as Windows Management Instrumentation is a nice (but complicated feature) that I discover when a friend of mine says "what about dragging my book directly to my reader ?"...ha ha so simple it is? and what about e-book reader supported formats? This drives me to two new features: DeviceConfigView in the backstage that holds devices/manufacturers and supported formats as a referential, and the new USBDeviceView to display USB drives that can be associated to devices.

The idea comes to simply detect USB peripherals for most e-book reader (and later 7 phones). After hours of internet digging and test, no way to get it straight because WMI provides a lot of information but none in the same structure. Here is the solution I implement by writing a helper class that first search for existing devices at the connection time and then watch over usb events.

WMI provides helpful classes and support a query language called WQL: ObjectQuery - to define a query, ManagementObjectSearcher - like an Execute and return collection of ManagementBaseObject - the result that contains asked information.

First, I need two ManagementEventWatcher to get WMI events onto the Win32_USBControllerDevice structure for __InstanceCreationEvent and __InstanceDeletionEvent. Note that the WITHIN clause is like a timer and allow pooling events.

addedWatcher = new ManagementEventWatcher
    ("SELECT * FROM __InstanceCreationEvent WITHIN 5 WHERE TargetInstance 
    ISA \"Win32_USBControllerDevice\"");
addedWatcher.EventArrived += new EventArrivedEventHandler(HandleAddedEvent);
// Stop listening for events
if (addedWatcher != null)

Handling the creation event is a bit complicated, because a lot of things are created when plugging a device. First we get that:

Antecedent: \\FR-L25676\root\cimv2:Win32_USBController.DeviceID=

Dependent: \\FR-L25676\root\cimv2:Win32_PnPEntity.DeviceID="USBSTOR\\

I extract from that two pieces of information that allow me to query the Win32_PnPEntity:

  • the PNP Device ID that is the Win32_PnPEntity key => USBSTOR\\DISK&VEN_USB&PROD_FLASH_DISK&REV_1100\\AA04012700076941&0
  • the Device ID => AA04012700076941&0

and the result is a multirow result set.

I only create an USB drive if "USBSTOR" is founded. Then I parse the line with the service = "disk" to get more information and call GetDiskInformation that is the same as GetExistingDevices.

ManagementBaseObject targetInstance =
    e.NewEvent["TargetInstance"] as ManagementBaseObject;


// get the device name in the dependent property Dependent: extract the last part
string PNP_deviceID = Convert.ToString(targetInstance["Dependent"]).
Split('=').Last().Replace("\"", "").Replace("\\", "");
string device_name = Convert.ToString(targetInstance["Dependent"]).
Split('\\').Last().Replace("\"", "");

// query that device entity
ObjectQuery query = new ObjectQuery(string.Format("Select *
from Win32_PnPEntity Where DeviceID like \"%{0}%\"", device_name));

// check if match usb removable disk
using (ManagementObjectSearcher searcher = new ManagementObjectSearcher(query))
    ManagementObjectCollection entities = searcher.Get();

    //first loop to check USBSTOR
    foreach (var entity in entities)
        string service = Convert.ToString(entity["Service"]);

        if (service == "USBSTOR")
        device = new USBDiskInfo();

    if (device != null)
        foreach (var entity in entities)
            string service = Convert.ToString(entity["Service"]);
            if (service == "disk")
                GetDiskInformation(device, device_name);

                if (EventArrived != null)
                    EventArrived(this, new WMIEventArgs() 
                { Disk = device, EventType = WMIActions.Added });

To handle the device removal, I implemented this method: get the instance fired by event, extract the PNP device id and if we remove the device from our collection, fire an internal event targeting the application.

private void HandleRemovedEvent(object sender, EventArrivedEventArgs e)
        ManagementBaseObject targetInstance = 
            e.NewEvent["TargetInstance"] as ManagementBaseObject;


        string PNP_deviceID = Convert.ToString(targetInstance
        ("\"", "").Replace("\\", "");

        USBDiskInfo device = Devices.Find(x => x.PNPDeviceID == PNP_deviceID);
        if( device != null )
            Devices.Remove( device );
            if (EventArrived != null)
                EventArrived(this, new WMIEventArgs() 
                { Disk = device, EventType = WMIActions.Removed });

For existing devices, I go from Win32_DiskDrive through Win32_DiskDriveToDiskPartition through Win32_LogicalDiskToPartition to reach Win32_LogicalDisk. This makes a cascade of query and loop like below:

ObjectQuery diskQuery = new ObjectQuery
    ("Select * from Win32_DiskDrive where InterfaceType='USB'");

foreach (ManagementObject drive in new ManagementObjectSearcher(diskQuery).Get())
    ObjectQuery partQuery = new ObjectQuery(
    String.Format("associators of {{Win32_DiskDrive.DeviceID='{0}'}}
    where AssocClass = Win32_DiskDriveToDiskPartition", drive["DeviceID"])


    foreach (ManagementObject partition in new ManagementObjectSearcher
        // associate partitions with logical disks (drive letter volumes)
        ObjectQuery logicalQuery = new ObjectQuery(
        String.Format("associators of {{Win32_DiskPartition.DeviceID='{0}'}}
        where AssocClass = Win32_LogicalDiskToPartition", partition["DeviceID"])


        foreach (ManagementObject logical in new ManagementObjectSearcher

            USBDiskInfo disk = new USBDiskInfo();

            ParseDiskDriveInfo(disk, drive);
            ParseDiskLogicalInfo(disk, logical);


Use it in the Application

First, create an instance of the watcher in the MainWindowView, start it and get events with a new handler. Do not forget to manage existing devices that were plugged before the application start.

private WMIEventWatcher wmi = new WMIEventWatcher();

private void RibbonWindow_Loaded(object sender, RoutedEventArgs e)
    wmi.EventArrived += new WMIEventArrived(wmi_EventArrived);

    MainViewModel mvm = DataContext as MainViewModel;

    if (mvm != null)
        //add all existing disks
        foreach (USBDiskInfo disk in wmi.Devices)

In the event handler, as it is called on another thread, we use the application dispatcher to call the commands to add or remove a device. This will update/create the device view. Do not forget to close the watcher in the OnClose event of the main window.

void wmi_EventArrived(object sender, WMIEventArgs e)
        (DispatcherPriority.DataBind, (ThreadStart)delegate {
        MainViewModel mvm = DataContext as MainViewModel;

        if (mvm != null)
            if (e.EventType == WMIActions.Added)
            if (e.EventType == WMIActions.Removed)

Localization engine

How it works?

To complete the core model explanation, everything happens in the GetValue method of the LocalizationExtension. I extend this existing method to ask the configured provider. ConvertValue is the same, GetObject has been overridden in all provider to search the asked key. More changes occurs in the GetDefaultValue, because the provider will create the file, dictionary and resource item.

/// <summary>

/// Return the value associated with the key from the resource manager
/// </summary>
/// <returns>The value from the resources if possible otherwise the default value</returns>
protected override object GetValue()
    if (string.IsNullOrEmpty(Key))
        throw new ArgumentException("Key cannot be null");

    object result = null;
    IResourceProvider provider = null;

        object resource = null;

        //allow resource trapping by calling the handler
        if (GetResource != null)
            resource = GetResource(ResModul, Key, CultureManager.Instance.UICulture);

        if (resource == null)
            //get the provider
            if (provider == null)
                provider = CultureManager.Instance.Provider;

            //get the localized resource
            if (provider != null)
                resource = provider.GetObject(this, CultureManager.Instance.UICulture);

        //and then convert it to desired type
        result = provider.ConvertValue(this, resource);
    catch (Exception err)
        // if it does not work, we ask the default value
        if (result == null)
            result = provider.GetDefaultValue(this, CultureManager.Instance.UICulture);
    catch (Exception err)
    return result;

Use it in the Application

Configure the provider you want through settings LocalizeProvider and LocalizeFolder, then just replace the actual content with something like below: ResModul is the destination file, Key is the unique resource identifier in the file and DefaultValue is the displayed text. There is no need to add an extra namespace because the resource extension is associated with standard XAML namespace.

Text="{LocalizationExtension ResModul=CBR.Backstage, Key=ConvertView.Title, DefaultValue=Convert}"

Note that a localize version will be created and displayed in the designer and the program until you work on it. This helps identifying quickly what you have missed. At the runtime, all resources for the running culture a going to be added automatically to the files, no need to create one!

Implement Language Choice and Editor

I design a dropdown button associated with a Gallery that display the CultureInfoItem provided by the GetAvailableCulture method of CultureManager. They are mapped to a LanguageMenuItemViewModel which derive from MenuItemViewModel that is the generic class for all CBR menus items in the MVVM pattern.

The associated XAML below define it:

<Fluent:Gallery ItemsSource="{Binding Languages}"

    GroupBy="Tag" x:Name="languageGallery" MinItemsInRow="1" MaxItemsInRow="2"

    Orientation="Horizontal" SelectionChanged="languageGallery_SelectionChanged">
                <Image Source="{Binding Icon}" Width="16" Height="16" />

                <TextBlock Text="{Binding ToDisplay}" />

        <Style TargetType="{x:Type Fluent:GalleryItem}" 

                  BasedOn="{StaticResource {x:Type Fluent:GalleryItem}}">
            <Setter Property="Fluent:GalleryItem.IsSelected" Value="{Binding IsChecked}"/>


The editor is a very simple tool window. In the source pane, you choose the language and module you want to work on. Click the Select button to use the language in the application. Note that the window is amodal, so you can continue to use C.B.R. to check your work. The grid display the selected resources: Key, Default is the original text from development, Translated is the one you need to work on and that will be displayed.

In the New pane, you can select a culture that does not exist actually, add an icon file name and then click Create button to add a new language in "memory". Use the Save button to commit your changes to the files.

Update 1... !

This bloomy engine was not thinked for localizing from code ! Before the tabs for multi-document support, i had no resource out of XAML. But when it comes to messages or tab titles like "home" and "devices"...

fails because it is for XAML only ! So i found a turn arround solution. For messages to be displayed in message boxes, just ask the resource. Tabs need to be updated via the ViewModel when the language is changed.

I wrote a new function in CultureManager to ask a localization based on the same principle...GetLocalization(string modul, string key, string defaultValue) will go through the provider to find if an existing LocalizationExtension or

exist. If not it will manage everything like the original engine do.

To put it in a ViewModel like my "Home" tab, I add to subscribe to the CultureManager event when culture change...and unsubscribe in the Dispose method. That it's....hours and hours to find an elegant solution !

public HomeViewModel()
    CultureManager.Instance.UICultureChanged += new CultureEventArrived(Instance_UICultureChanged);
    DisplayName = CultureManager.Instance.GetLocalization("ByCode", "DocumentTitle.Home", "Home");


void Instance_UICultureChanged(object sender, CultureEventArgs e)
    DisplayName = CultureManager.Instance.GetLocalization("ByCode", "DocumentTitle.Home", "Home");

protected override void OnDispose()


    CultureManager.Instance.UICultureChanged -= new CultureEventArrived(Instance_UICultureChanged);

Update 2... !

Cultures are now identified by the IETF code from the net class

. It combines language and country code like fr-FR : french in FRANCE. fr-CA is french in CANADA. CultureItem class has been removed and the icon is now named based on IETF code too.

Update 3... !

The localize editor has been improved and display now an additionnal column about unused resources...Then it has been completed with WPF spellcheck. Note that you will have to install net language packs ! and by the way, fluent gallery are now displaying correctly with the manage button at the bottom.

Extend the engine

If you want to extend it with a database provider for example, write your model and provider and feel free to provide us feedback ! I need it for another project Smile | <img src= " src="" />

Note that CBR is not fully localized on beta items and tooltips...

3D and 2 pages flip viewer

For a long time, I had a look to Mistu Futura "2 pages book", but I was not sure about the performance of an ItemControl binded to more than 50 bitmap pages...and recently I discover after short tests that binding only occurs when turning the pages! So I decided to go further and adapt this control to my needs. Original code is not very well documented and content is based on "full fit" and xaml user controls, so no scaling I had to found a solution where bitmaps allways fit the pages the best they can. I also need it to be in a scrollview and answers my actual book commands.

How it works ?

First, take all original code into a folder, change the class names to be more clear, group, and transform the user control into a control. This lead us to LinearGradiantHelper, PageParameters,

and PageStatus (no changes) - and
(the new main control) with TripleSheet control that represent the 3 page sides on the right or left part of the book. When animating a page we can see the side in front of us, the one behind (when it rolls the page) and the front side on the page before (or after).

I modify the control to have his default style and wrap it into a scrollviewer like the template below. I remove a few things I do not need like zoom on current page...

<Style x:Key="{x:Type local:TwoPageBook}" TargetType="{x:Type local:TwoPageBook}">
    <Setter Property="VerticalAlignment" Value="Stretch" />

    <Setter Property="HorizontalAlignment" Value="Stretch" />
    <Setter Property="Margin" Value="0" />

    <Setter Property="ClipToBounds" Value="False" />
    <Setter Property="Template">
        <ControlTemplate TargetType="{x:Type local:TwoPageBook}">

            <ScrollViewer Name="PART_ScrollViewer" Focusable="True"

            VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto">
                <Grid Name="PART_Content">

                    <ColumnDefinition Width="50*" />
                    <ColumnDefinition Width="50*" />

                <local:TripleSheet Grid.Column="0" x:Name="sheet0" HorizontalAlignment="Right"

                    IsTopRightCornerEnabled="false" IsBottomRightCornerEnabled="false" />

                <local:TripleSheet Grid.Column="1" x:Name="sheet1" HorizontalAlignment="Left"

                    IsTopLeftCornerEnabled="false" IsBottomLeftCornerEnabled="false" />


Then, I complete the class OnApplyTemplate method to grap my parts to be sure _Content will be scaled and the

will be used later to be able to fit into the document view. I also add Scale, FitMode and a
because original code manage a sheet index (index divided by 2). Then I change the TripleSheet template to have the content to fit and colors to fill the gap when bitmap cannot shrink correctly. Note that the Fit method make an entorse to the genericity of the control by using the Page model class...that was the only way to find the content size ! -Frown | <img src= src="" /> /p>

public override void OnApplyTemplate()

    _ScrollContainer = (ScrollViewer)GetTemplateChild("PART_ScrollViewer");
    _Content = (FrameworkElement)GetTemplateChild("PART_Content");

    _scaleTransform.CenterX = 0.5;
    _scaleTransform.CenterY = 0.5;
    _Content.LayoutTransform = _scaleTransform;


private void Fit()
    if (FitMode == DisplayFitMode.Height)
        Scale = (this._ScrollContainer.ViewportHeight - FIT_BORDER) / (Items[CurrentSheetIndex] as CBR.Core.Models.Page).Image.Height;
    else if (FitMode == DisplayFitMode.Width)
        Scale = (this._ScrollContainer.ViewportWidth - FIT_BORDER) / ((Items[CurrentSheetIndex] as CBR.Core.Models.Page).Image.Width * 2);

For performance reasons, I split my book view into two separate classes : simple page view and 2 pages view. OOppps ! I have to manage the swap between the 2 modes and passing parameters ! As the book is loaded, I just need the current page index. The book views communicate through the main view that receive a SwapTwoPageView message and convert the view into the desired one like illustrated below.

    (BookViewModelBase o) =>
internal void SwapTwoPageMode(BookViewModelBase o)

    BookViewModelBase newModel = null;
    BookViewModelBase oldModel = null;
    if (o is ComicViewModel)
        ComicViewModel comic = o as ComicViewModel;
        newModel = new TwoPageViewModel(o.Data, comic.CurrentPage.Index, comic.FitMode, comic.PreviousScale);
        TwoPageViewModel comic = o as TwoPageViewModel;
        newModel = new ComicViewModel(o.Data, comic.CurrentPageIndex, comic.FitMode, comic.PreviousScale);

    oldModel = o;


Note that many of the books I found are not really compliant with this view because of page ratio that are never the same or contains double scan. Don't be surprised, but as I do not process the images and read them in memory, I can't make any adjustments.


Many thanks to SevenZip (which allows me to uncompress in memory), Fluent Ribbon for this excellent control library and also for all the internet contributors I read along this project.

Your feedback

As the last version was pretty long to come out and 0.6 was downloaded around 60 000 times, I think you are perhaps interested to be able to give some more precise feed back, I put all planned features in the "Issue tracker" page of the project. So now you are able to give me feedback about your needs priority by voting or create new items. This will help me to plan next version content.

Deliverables - Installation notes

With version 0.7 and installer, always remove the previous version or some files are not going to be replaced and big troubles will occurs. I will work on a better solution.

There is no more download on CodeProject, because there 4 deliverables of consequent sizes : 9 Mo for installers, 16 Mo of sources and 7Mo of direct binaries. Please visit the link to get the needed one.


I hope you will enjoy it as much as I do and that you will find your needs in my code or in my software. If you get any better solution for the problems I faced, please forward...

History (simplified...)

  • v0.7
    • Ribbon tabs and commands have been re-organized
    • ePUB : Complete refactoring
    • Add a new dedicated feed viewer for opds stream
    • Localize Engine improved: add functions to manage resource by code, now based on ietf language/country code, remove image from xml (based on code), remove CultureItem class. Better copy, management view and gallery, spell check
    • Dynamic Books: Designer for frames is now MVVM and much better. It is based in ItemControl and templated with a Canvas. Also added 7Phone and Tablet simulators
    • Release mode are now "Any CPU" to be compliant with the running plateform. Added 32 and 64 bits versions of 7z.dll. Installer for x32 and x64, registry keys for ie emulation mode for epub/html view
    • Multi-Doc: C.B.R. has now an option for multi document display with tabs - Integrate customized AvalonDock 2 library.
    • PDF image extraction improved and new image merging conversion option
    • New BrowseForControl
    • Customized xps viewer to suppress toolbars and bind it to cbr commands
    • Add quick start manual and button in the home page, manage internal CBR feed language


This article, along with any associated source code and files, is licensed under The GNU General Public License (GPLv3)


About the Author

Guillaume Waser
Architect XEROX
France France
WPF and MVVM fan, I practice C # in all its forms from the beginning of the NET Framework without mentioning C ++ / MFC and other software packages such as databases, ASP, WCF, Web & Windows services, Application, and now Core and UWP.
In my wasted hours, I am guilty of having fathered C.B.R. and its cousins C.B.R. for WinRT and UWP on the Windows store.
But apart from that, I am a great handyman ... the house, a rocket stove to heat the jacuzzi and the last one: a wood oven for pizza, bread, and everything that goes inside

You may also be interested in...

Comments and Discussions

GeneralRe: This certainly deserves a 5 Pin
Guillaume Waser4-Apr-13 6:52
memberGuillaume Waser4-Apr-13 6:52 
GeneralRe: This certainly deserves a 5 Pin
Espen Harlinn4-Apr-13 8:57
mvpEspen Harlinn4-Apr-13 8:57 
GeneralRe: This certainly deserves a 5 Pin
Guillaume Waser4-Apr-13 9:55
memberGuillaume Waser4-Apr-13 9:55 
GeneralRe: This certainly deserves a 5 Pin
Espen Harlinn4-Apr-13 9:58
mvpEspen Harlinn4-Apr-13 9:58 
GeneralMy vote of 5 Pin
loizzi11-Feb-13 5:01
memberloizzi11-Feb-13 5:01 
GeneralRe: My vote of 5 Pin
Guillaume Waser11-Feb-13 6:47
memberGuillaume Waser11-Feb-13 6:47 
GeneralWow! This is excellent work. Pin
loizzi11-Feb-13 4:59
memberloizzi11-Feb-13 4:59 
GeneralRe: Wow! This is excellent work. Pin
Guillaume Waser11-Feb-13 6:47
memberGuillaume Waser11-Feb-13 6:47 
QuestionThe current version for download is exactly the same as the one dated Sept. 25, 2012 ? Pin
BillWoodruff29-Oct-12 0:19
memberBillWoodruff29-Oct-12 0:19 
AnswerRe: The current version for download is exactly the same as the one dated Sept. 25, 2012 ? Pin
Guillaume Waser29-Oct-12 0:50
memberGuillaume Waser29-Oct-12 0:50 
GeneralMy vote of 5 Pin
manoj kumar choubey8-Jul-12 20:27
membermanoj kumar choubey8-Jul-12 20:27 
GeneralRe: My vote of 5 Pin
Guillaume Waser10-Jul-12 3:51
memberGuillaume Waser10-Jul-12 3:51 
QuestionMMCE Pin
J. Wijaya19-Jun-12 22:14
memberJ. Wijaya19-Jun-12 22:14 
AnswerRe: MMCE Pin
Guillaume Waser20-Jun-12 1:17
memberGuillaume Waser20-Jun-12 1:17 
GeneralMy vote of 5 Pin
JF201511-May-12 4:22
memberJF201511-May-12 4:22 
GeneralRe: My vote of 5 Pin
Guillaume Waser11-May-12 6:08
memberGuillaume Waser11-May-12 6:08 
GeneralExcellent article Pin
Espen Harlinn20-Mar-12 13:02
mvpEspen Harlinn20-Mar-12 13:02 
GeneralRe: Excellent article Pin
Guillaume Waser20-Mar-12 21:06
memberGuillaume Waser20-Mar-12 21:06 
GeneralMy vote of 5 Pin
Abinash Bishoyi15-Jan-12 13:03
memberAbinash Bishoyi15-Jan-12 13:03 
GeneralRe: My vote of 5 Pin
Guillaume Waser16-Jan-12 21:52
memberGuillaume Waser16-Jan-12 21:52 
QuestionVery Cool Pin
Dave Kerr9-Jan-12 22:44
mvpDave Kerr9-Jan-12 22:44 
AnswerRe: Very Cool Pin
Guillaume Waser9-Jan-12 23:04
memberGuillaume Waser9-Jan-12 23:04 
GeneralMy vote of 4 Pin
Slacker00716-Dec-11 8:55
memberSlacker00716-Dec-11 8:55 
GeneralRe: My vote of 4 Pin
Guillaume Waser16-Dec-11 21:43
memberGuillaume Waser16-Dec-11 21:43 
GeneralRe: My vote of 4 Pin
Guillaume Waser16-Dec-11 22:06
memberGuillaume Waser16-Dec-11 22:06 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

Permalink | Advertise | Privacy | Terms of Use | Mobile
Web02 | 2.8.170424.1 | Last Updated 22 Oct 2012
Article Copyright 2011 by Guillaume Waser
Everything else Copyright © CodeProject, 1999-2017
Layout: fixed | fluid