Experience Making a Filter Service for Cocoa

This is a journal of my experience trying to get a filter service working on Cocoa. The goal is to be able to get TextEdit to load a WordPerfect document, roughly the way that it loads a Microsoft Word document. The reason I want to be able to do this is that I have quite a bit of mixed Tibetan and English text in WordPerfect 6 format. Back in the day, the DOS version of WordPerfect, with modifications and some lovely fonts from Tony Duff at the Tibetan Computer Company was the tool of choice for editing Tibetan texts. The Tibetan Machine font has since been open-sourced, although not yet converted to Unicode.

I was able to accomplish this by hacking wpd2text from libwpd to act as a unix-style filter, and then use the NSUnixStdio input mechanism. This causes Cocoa to stream the data to be translated to the standard input of the program, and then takes the standard output of the program as the contents of the file.

I will point out at this juncture that I am a unix geek, not a Mac geek. I'm sure there's a way to do this in Xcode, but I can't find it. There's no template for a Service, so it probably involves some serious Xcode geekery.

I have been unable to find any accurate documentation about setting up filter services online or in the XCode/ADC documentation, although people do make references to such documentation. Maybe I'm just not looking in the right place. If you know where I should have looked, I'd appreciate a pointer. I was able to piece this together by reading what documentation is available (some of which predates xml plist files) and also looking at another handy filter service that I was able to track down.

The ADC documentation that I located for filter services talks about them in reference to converting clipboards from one data format to another. However, the NSTextStorage class documentation refers to the idea of being able to add filter services that convert documents on loading - e.g., when you load a document using NSAttributedString:initWithUrl. It turns out that this is in fact possible.

There is also some documentation about System Services in general. These are a really nifty idea - I didn't realize what they did until reading up on them. These are those puzzling things in the Services-> selection off of the Applications menu. Services do transformations or operations on the selection - for example, speaking the selected text, or converting it from one representation to another.

The Filter Service Bundle

Like many smart objects in a MacOS X filesystem, system services are stored in bundles. A bundle is a special kind of directory that groups a bunch of stuff together so that it looks like a single object to the Finder. The contents of a bundle are stored in a subdirectory of the bundle called Contents. Since filters are just a special species of system service, they're stored in the same kind of bundle as a regular system service.

The structure of the bundle for the WordPerfect filter service is as follows:

wpd.services/
The top level directory.
Contents/
This is the only subdirectory of the top level directory.
Info.Plist
This is the file that tells Cocoa what this service does. Most of the interesting action is here.
version.plist
This describes the version of the service. I just copied a version.plist file from another system service and modified the fields to look right. I don't think this file really matters in this case, although it makes a big difference in some other cases (e.g., kexts).
PkgInfo
I think this corresponds to the Type/Creator field that's stored in an HFS directory entry. It's eight bytes, the first four of which are 'APPL' and the second four of which are '????'.
MacOS/
This contains the executable and, as far as I can tell, nothing else. The executable is anything that the exec() system call will run - it could be a shell script, for example. Don't ask me why I know this.
wpd2text
This is the actual program that converts from WordPerfect to text.

The interesting part of this is the Info.plist file. Here's what it looks like:

      <?xml version="1.0" encoding="UTF-8"?>
      <!DOCTYPE plist
		PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN"
		"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
      <plist version="1.0">
	<dict>
	  <key>CFBundleDevelopmentRegion</key>
	  <string>English</string>
	  <key>CFBundleExecutable</key>
	  <string>wpd2text</string>
	  <key>CFBundleGetInfoString</key>
	  <string>1.7 @2004 foo</string>
	  <key>CFBundleIdentifier</key>
	  <string>net.sourceforge.projects.libwpd</string>
	  <key>CFBundleInfoDictionaryVersion</key>
	  <string>6.0</string>
	  <key>CFBundleName</key>
	  <string>Wordperfect Converter Service 1.7</string>
	  <key>CFBundlePackageType</key>
	  <string>APPL</string>
	  <key>CFBundleShortVersionString</key>
	  <string>1.7</string>
	  <key>CFBundleSignature</key>
	  <string>????</string>
	  <key>CFBundleVersion</key>
	  <string>1.7</string>
	  <key>NSBGOnly</key>
	  <string>1</string>
	  <key>NSServices</key>
	  <array>
	    <dict>
	      <key>NSFilter</key>
	      <string></string>
	      <key>NSInputMechanism</key>
	      <string>NSUnixStdio</string>
	      <key>NSReturnTypes</key>
	      <array>
		<string>NSStringPboardType</string>
	      </array>
	      <key>NSSendTypes</key>
	      <array>
		<string>NSTypedFilenamesPboardType:END</string>
	      </array>
	    </dict>
	  </array>
	</dict>
      </plist>
    

Most of this is boilerplate - you can just copy it and edit it. The key things that I needed to tweak to get the service working were:

CFBundleExecutable

This is the name of the executable that Cocoa will run to do the translation. The actual executable is stored in the MacOS subdirectory of the Contents directory.

NSFilter

This is the method in the filter's service provider object that's supposed to be invoked to convert the data. This has to be here, and has to be blank.

NSInputMechanism

This determines how Cocoa will use the filter application. We're using the NSUnixStdio input mechanism, which means that Cocoa will just stuff the data into the standard input of the program specified in CFBundleExecutable, and will expect the converted text to appear on its standard output.

NSReturnTypes

Right now this is set to NSStringPboardType. This tells Cocoa that we're just going to feed it a stream of ASCII or UTF-8 text (it turns out to be important that we can feed it UTF-8, because Tibetan characters are obviously _not_ ASCII!). BTW, I said "right now" because I want to convert to RTF - this is more useful in general.

NSSendTypes
This determines what formats the converter can accept. The format can be determined by the file extension, or by the document type. Since WordPerfect never had a document type, we don't do that here. If your service needs to be able to determine file type from the HFS file type rather than the extension, you can put the type in single quotes - for example, Microsoft Word documents have a creator of 'WDBN'. You would express this as:
	  <string>NSTypedFilenamesPboardType:'WDBN'<string>
	

The program

This is the least exciting part, because I don't really have the time to document it. It's a unix filter, so it just reads standard input and spits the converted text back out on standard output. Nothing to see here.

Installing it

The filter service has to be installed in either /System/Library/Services or ~/Library/Services, depending on whether you want it to work for everybody on your computer or just for you. After you've installed it, and anytime you change the Info.plist file, you have to log out and log back in again to get your user environment to recognize that it's there. When you are debugging the Info.plist, try not to have too much stuff running, because you're going to log out and back in again more than once unless you're a lot more anal-retentive than I was. Which brings us to...

You'd probably like to be able to double-click on a document of the appropriate type and have it come up in TextEdit or something like that. In order to make that happen, find a document of the right type in the Finder and select it. Then select Get Info (command I) from the file menu. The fourth section from the top (as of this writing, of course) is the Open with section. There should be a widget from which you can select the application - change it to TextEdit, or whatever. If you want all documents with the same extension or type to be opened by TextEdit, click on Change All...

Now you're going to be wanting to change the icon. I think to do this you have to hack the Info.plist file in the TextEdit application bundle (or whatever application you're going to have open the file) to add an icon for the END document type. I have (thus far) restrained myself from doing so, simply because I'd have to redo it every time I upgraded TextEdit. Maybe there's a better way to do this - I don't know. Feel free to clue me in!

Trials and Tribulations

Nothing is ever easy. I didn't even know if this would work at all until I found AntiWordFilter . Having found that, I ran into quite a few problems, which I will confess here in embarrassing detail, just in case you run into one of the same problems yourself.

No feedback

There is absolutely no feedback about what's going on with the service. If you get it wrong, it just doesn't work. What to do? I was testing my filter with TextEdit, so I used ktrace to see if it was even referencing my service. First, start TextEdit. Then use ps or top to find its process ID (PID). Then ktrace -i -p <pid> to start the trace. This traces to ktrace.out in the current directory. Then, in TextEdit, try to load a file that you expect the filter to operate on. Then once whatever's happened has happened, quit out of TextEdit.

Then dump the trace into a text file so you can search it easily: kdump >kdump.txt. Now edit it in your favorite text editor. Search for the name of your filter program. If you don't find it, there's something wrong enough with your Info.plist file that Cocoa didn't know to run your filter. More on this later.

If you did find the filter program, you can look at what it did. Maybe there was some output to stderr, or an exit code. You need to know how to use ktrace to figure this out, but it's not difficult - read the man page if you don't already know how.

CFBundleExecutable, not NSPortName

The documentation from Apple says to use NSPortName for the name of the excutable, but this is no longer true. Instead, you need to use CFBundleExecutable . See the sample Info.plist for details.

Capitalization counts

I accidentally typed in NSTypedFilenamesPBoardType instead of NSTypedFilenamesPboardType. There's no indication of any error in the system logs, but Cocoa doesn't recognize this, and therefore doesn't know to invoke the filter. Your best bet is to copy a known working file (you're welcome to use the one included here - there's nothing special about it). Otherwise, be very careful in checking over all the sTuDLy cApS in your Info.plist file.

Make sure the filter is filtering

The wpd2text executable in the libwpd build directory is actually a shell script that invokes an executable in the .libs subdirectory. If you copy it somewhere else, it doesn't work. You're probably smart enough not to make this mistake, but also be aware of shared libraries that might be required but not accessible, and stuff like that. You can always debug this sort of thing with ktrace - you just have to pore over the output to see what's going on. What clued me in was seeing IO for #!/bin/sh in the ktrace output right after the NAMI for my program. Ouch.

Info.plist is only read on login

Early on I didn't realize that the UI server caches the information in the service Info.plist files. This means that whenever you update Info.plist, you need to log out and in again. I mentioned this earlier, but just in case you're skimming...

I'm sure there are lots of other ways to screw this up, but these are the ones I was able to find for you. Best of luck!

--Ted

Copyright (C) 2004 Edward W. Lemon III All Rights Reserved

This page was last updated on January 24, 2004.