Implementing Wordle in LibreOffice with JavaScript Macros

2 hours ago 2

It is the Month of LibreOffice—time to be awesome with LibreOffice, whether that's spreading the word, supporting others, translating, documenting, bugfixing, or coding new features!

Given that LibreOffice is looking for developers to improve the scripting support and change their current JavaScript runtime (Rhino), I wondered...

What's scripting LibreOffice in JavaScript like, today?

(Spoilers: it's hard to start using JavaScript macros, but they work surprisingly well! )

A game of Wordle inside of LibreOffice A game of Wordle inside of LibreOffice
Video demonstration of playing Wordle in a LibreOffice Writer document

To answer that, I experimented: can I make a simple game inside LibreOffice Writer?

I decided to make a Wordle clone, as the input method was very fitting: player enters words one at a time, and whenever they press "Enter", the game scores their guess. Scoring could be done by highlighting the letters of a word, which is already a feature of Writer!
In that respect, I'm quite happy with the final result that you can see above; my initial idea translated very well into LibreOffice's scripting API.

(You can even try the final result for yourself in the Codeberg repository!)

Starting out

Coming up with an idea is easy.

Getting code to execute is much harder.

LibreOffice's documentation is sorely lacking when it comes to writing macros. There is a page on Scripting LibreOffice which tells you that it is possible to use JavaScript, and directs you to the LibreOffice API where you... won't find anything about JavaScript, except a single support class.

The Document Foundation Wiki is more useful, with the Developer's Guide on Scripting Frameworks, which at least points you to the "Organize Macros → JavaScript" menu option

However, the Developer's Guide lies to you, saying that "[The Organizer dialog for JavaScript] allows you to run macros and edit macros, and create, delete and rename macros and macro libraries."

Here's what the dialog looks like, when you try to edit a JavaScript macro:

A dialog listing JavaScript macros; a library called "Library1" is selected, but both the Edit and Create buttons on the side are grayed out. A dialog listing JavaScript macros; a library called "Library1" is selected, but both the Edit and Create buttons on the side are grayed out.

Note the grayed-out Edit and Create buttons. 😅

Further down, the Developer's Guide gives a JavaScript macro example. To its credit, the macro works... except again, there is not a hint of how to properly embed it into a document.

Is an .odt file with a Wordle macro too much to ask? 😂


After exploring an unzipped .odt file, looking at the Developer's Guide's Java example, and finally finding the JavaScript examples in LibreOffice's source code, I understood how to make an embedded JavaScript macro:

First, I unzip my .odt file, renaming it to a .zip and using an archival tool. Inside of it, there is a folder structure like the following:

root |- META-INF | |- manifest.xml |- mimetype, content.xml, ...

In here folder, I need to add my JavaScript macro. It goes in Scripts/javascript/<Library name>/<function.js>, with a matching Scripts/javascript/<Library name>/parcel-descriptor.xml file:

root |- META-INF | |- manifest.xml |- Scripts | |- javascript | | |- MyLibraryName | | | |- MyFile.js | | | |- parcel-descriptor.xml |- mimetype, content.xml, ...

The manifest.xml must be updated with the new files:

<?xml version="1.0" encoding="UTF-8"?> <manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"> <!-- ... rest of the file ... --> <manifest:file-entry manifest:full-path="Scripts/javascript/MyLibraryName/parcel-descriptor.xml" manifest:media-type=""/> <manifest:file-entry manifest:full-path="Scripts/javascript/MyLibraryName/MyFile.js" manifest:media-type="application/javascript"/> <manifest:file-entry manifest:full-path="Scripts/javascript/MyLibraryName/" manifest:media-type="application/binary"/> <manifest:file-entry manifest:full-path="Scripts/javascript/" manifest:media-type="application/binary"/> <manifest:file-entry manifest:full-path="Scripts/" manifest:media-type="application/binary"/> <!-- ... rest of the file ... --> </manifest:manifest>

Then, I need a parcel-descriptor.xml describing the JavaScript macro:

<?xml version="1.0" encoding="UTF-8" standalone="no"?> <parcel xmlns:parcel="scripting.dtd" language="JavaScript"> <script language="JavaScript"> <locale lang="en"> <displayname value="My Library Name"/> <description> Description of the whole library goes here! </description> </locale> <functionname value="MyFile.js"/> <logicalname value="MyFile.JavaScript"/> <!-- (logicalname doesn't have to match functionname) --> </script> </parcel>

And then, I want to add my JavaScript code in MyFile.js. Here's a simplified version of the official Hello World example:

importClass(Packages.com.sun.star.uno.UnoRuntime); importClass(Packages.com.sun.star.text.XTextDocument); var doc = XSCRIPTCONTEXT.getDocument(); var text = UnoRuntime.queryInterface(XTextDocument, doc).getText(); var endRange = text.getEnd(); endRange.setString("Hello World (in JavaScript)");

Finally, with all of that done, I can re-zip the ODT file (zipping the files, then renaming the archive to .odt), and with some luck, it'll all be working!

(Note: When re-compressing your ODT file, the ZIP archive must not have a root directory. You can do that by compressing the files in the unzipped directory instead of the directory itself.)

Now, I can navigate to "Tools → Macros → Run Macro...: and select my new macro from the list.

_The Hello World text printed out by our macro The Hello World text printed out by our macro

Wohoo!

The zipping and unzipping process so far is the the worst part of JavaScript in LibreOffice. Everything else is much simpler! This will hopefully change with better documentation and a JavaScript editor built into LibreOffice.

I made myself a short "zip everything and run" commandline to make testing changes to the macro easier:

rm ./wordle-test.odt; zip -r ./wordle-test.odt * && soffice --norestore ./wordle-test.odt

Feel free to use something similar yourself.

Now what?

Now that I can run a macro, the sky is the limit! I have the whole power of the UNO LibreOffice API, as exposed to a Java API, and made accessible through the Rhino engine, together with the whole Java standard library!

I just.. need a way to trigger the macro in the first place. 😁

The original Scripting LibreOffice help page explains all the different places in which we can attach macros to events. These include document loading, hyperlinks, form controls, and keyboard shortcuts—feel free to explore!

Basic Wordle input

For my game, I wanted to have a way of detecting when the player has entered a new line and a way of highlighting the letters of the player's guess.

I looked at shortcuts, but those are not saved with the document, and I didn't want to ask the player to register their own shortcut. Instead, I went with a button that would attach an event listener. Totally missing that I could have used a document load event instead of the button.

But what event ("event broadcaster", in the UNO nomenclature) could I listen to?

After a few false leads, like com.sun.star.document.Events, I finally ended up at XModifyBroadcaster, an interface implemented by the TextDocument service we already use in the script above as doc.

XModifyBroadcaster requires an XModifyListener. I worried that I can't to create one through the script API, but Rhino had me covered: new Interface({member: function() { ... }}) creates new objects implementing a given interface! 🎉

It's now a matter of combining all of that together:

importClass(Packages.com.sun.star.uno.UnoRuntime); importClass(Packages.com.sun.star.text.XTextDocument); importClass(Packages.com.sun.star.util.XModifyBroadcaster); importClass(Packages.com.sun.star.util.XModifyListener); var doc = XSCRIPTCONTEXT.getDocument() var modifyBroadcaster = UnoRuntime.queryInterface(XModifyBroadcaster, doc) modifyBroadcaster.addModifyListener(new XModifyListener({ modified: function() { var text = UnoRuntime.queryInterface(XTextDocument, doc).getText(); var endRange = text.getEnd(); endRange.setString("Hello World (in JavaScript)"); } }))

Except... it doesn't work. LibreOffice crashes 😅

Modifying the document inside the modified event handler sounded like endless recursion, so I used a variable to ignore the changes caused by my script, but, it still crashed!

I assume that XModifyListener is in a critical section which does not allow modifications, so I caved and used a java.util.Timer, as inspired by a StackOverflow question on setTimeout in Rhino:

// ...same as before, import classes var recursionGuard = false modifyBroadcaster.addModifyListener(new XModifyListener({ modified: function(ev) { if (recursionGuard) return; recursionGuard = true var timer = new java.util.Timer() timer.schedule(new java.util.TimerTask({ run: function() { // ...same as before, modify the document recursionGuard = false } }), 100) } }))

As a bonus, the timer debounces inputs!

And now it works! When I modify the document, a new "Hello World (in JavaScript)" text appears 🎉

%Screenshot of me fighting the Hello world macro for attention Screenshot of me fighting the Hello world macro for attention

I wrap that in a function and move on to highlights.

Basic Wordle output

I need to highlight the previous line once the user submits it as a guess.

Actually, scratch the "submits" part. I can re-highlight the previous line on every modification, and it will work the same, as the highlights won't change.

I just need to highlight specific characters of a document...

Again, the UNO LibreOffice API reference comes handy. An XText lets me create a "cursor" for navigating the text and selecting the guessed word.
However, the interface I have, XTextCursor can only move character-by-character and I need to move up a paragraph to get to the player's last guess.
Thankfully, createTextCursor links to the TextCursor service, which also implements other interfaces, like XParagraphCursor. Nifty!

I cast the cursor I get to the paragraph cursor interface, and it works!

A bit of tinkering yields the following script:

importClass(Packages.com.sun.star.uno.UnoRuntime); importClass(Packages.com.sun.star.text.XTextDocument); importClass(Packages.com.sun.star.text.XParagraphCursor); var doc = XSCRIPTCONTEXT.getDocument(); var text = UnoRuntime.queryInterface(XTextDocument, doc).getText(); var cursor = text.createTextCursorByRange(text.getEnd()); var paragraphCursor = UnoRuntime.queryInterface(XParagraphCursor, cursor); paragraphCursor.gotoPreviousParagraph(false); // false - do not expand selection paragraphCursor.gotoPreviousParagraph(true); // true - expand selection (like holding Shift) paragraphCursor.setString("This text replaces the whole previous paragraph!")

Now I need to change the background color. I see that TextCursor implements CharacterProperties. These properties can be accessed through XPropertySet, as I've learned from the wiki:

// ... code as before, without the last line var cursorProps = UnoRuntime.queryInterface(XPropertySet, cursor) cursorProps.setPropertyValue("CharBackColor", new java.lang.Integer(0xFF5500)) // RR GG BB

And, voila: we have highlights!

Also, back to inputs, I can use getString on an XTextCursor to read the text, so that's solved too!

var guessText = paragraphCursor.getString()

The rest of the owl

With input and output sorted, the rest of the Wordle game is a matter of a few JavaScript functions that you can find in the final Wordle.js script.

Rather than explain the code in detail, I would like to highlight a few difficulties I encountered while programming it:

Rhino's JavaScript support

Rhino does not fully support newer ECMAScript features, though work is underway. The lack of let, especially, was a constant annoyance as my muscle memory kept getting in the way.

Java strings

The getString function I used for getting the user's guess returns a java.lang.String—not a JavaScript String.
This confused me for over half an hour, because == compares Java strings by reference, before I realized I had the wrong type.

To convert the Java string to a JavaScript string, I can cast it:

var paragraphText = String(paragraphCursor.getString()) // "last paragraph as String" // Or: var paragraphText = paragraphCursor.getString() + "" // "last paragraph as String"

UNO long-s

On the note of types, I thought that the CharBackColor property is a long, and attempted to pass a java.lang.Long instead of java.lang.Integer when I was initially set it.

It throws an IllegalArgumentException.

var props = UnoRuntime.queryInterface(XPropertySet, cursor) //Wrong: props.setPropertyValue("CharBackColor", new java.lang.Long(0xFF5500)) props.setPropertyValue("CharBackColor", new java.lang.Integer(0xFF5500))

Reading the chapter 2 of the Developer Guide, I learned that the UNO API does indeed specify long, but UNO's long maps to Java's int. (And for a 64 bit value, UNO uses hyper instead of long.) Wish I read that earlier!

Printing to the console

Making progress is hard without a way to debug programs, and I often needed to display a few values to make sense of what is happening.

Fortunately, I have the whole Java API available:

java.lang.System.err.println(typeof paragraphText) // "object"?!

Handling Undo/Redo

The macro script created individual undo/redo actions for each modification it did. This was especially bad as I was re-creating those actions on any modification, including undo.

To bundle the actions together, I used XUndoManagerSupplier and its enterHiddenUndoContext function, so that the macro's modifications would be undone/redone together with the user's own action.

importClass(Packages.com.sun.star.document.XUndoManagerSupplier) registerModifyListener(function() { var undoManager = UnoRuntime.queryInterface(XUndoManagerSupplier, doc).getUndoManager() undoManager.enterHiddenUndoContext() try { // Modify the document } finally { undoManager.leaveUndoContext() } })

Spellchecking

In Wordle, you are not allowed to use made-up words. After I saw the Python spellchecking example, I thought it would be cool if I used LibreOffice's own spellchecker to do limit the player to English words.

Creating an object in UNO involves the service manager factory as explained in the wiki:

importClass(Packages.com.sun.star.linguistic2.XSpellChecker) var context = XSCRIPTCONTEXT.getComponentContext(); var spellcheckerService = context.getServiceManager().createInstanceWithContext( "com.sun.star.linguistic2.SpellChecker", context ) var spellchecker = UnoRuntime.queryInterface(XSpellChecker, spellcheckerService)

For spellchecking, you need to specify the exact Locale you want, and the API silently ignores missing locales:

importClass(Packages.com.sun.star.lang.Locale) // Incorrect: ~~new Locale("en", "", "")~~, ~~new Locale("en", "us", "")~~ // Correct: new Locale("en", "US", "") var valid = spellchecker.isValid(guess, new Locale("en", "US", ""), []);

Printing animated messages

Finally, you might have noticed I added a bit of animation to the start and end of a game.

It's implemented as a list of strings that get displayed one after another, with another Timer:

var message = [ "∴ ∴ ∴", "∴ ∴ ∴ WORDLE ∴ ∴ ∴", "∴ ∴ ∴ WORDLE in LibreOffice ∴ ∴ ∴", ] var i = 0 var timer = new java.util.Timer() timer.schedule(new java.util.TimerTask({ run: function() { if (i >= message.length) { return timer.cancel() } var cursor = UnoRuntime.queryInterface(XParagraphCursor, text.createTextCursor()) cursor.gotoEnd(false) cursor.gotoStartOfParagraph(true) cursor.setString(message[i]) i ++ } }), 0, 450)

Conclusion

That's how I made a Wordle clone with LibreOffice's JavaScript/Rhino bindings!

I undertook this project to figure out the current state of JavaScript in LibreOffice.
Overall, it is not very user friendly, as making a JavaScript macro requires manually modifying OpenDocument files.
However, I'm surprised at the stability of a working JavaScript macro: despite the UNO API being bridged from C++ to Java and then to JavaScript, I encountered no bridge-related bugs, and the macro was working even with Java's multithreaded Timers!

Working with LibreOffice's macro layer, I saw more of the UNO object model than in BugsDoneQuick.
I love how it allows me to access every part of LibreOffice and has APIs for anything an office suite needs.
In that regard, I find UNO a bit similar to Godot's Node system that is used by the Godot Editor itself, in that both are very "practical". That's in stark contrast to the Web's DOM or UI libraries like Jetpack, that all revolve around potential needs of a potential user.

Feel free to try wordle-in-libreoffice for yourself, play around with the LibreOffice JavaScript API, or even contact me with any questions or comments you might have about this article.


This has been my 33rd article for #100DaysToOffload.

← Simple CI/CD with bare git repositories Articles tagged technology (16/16) →|

← Learning to trust with Hanabi Articles tagged 100DaysToOffload (33/33) →|

← Learning to trust with Hanabi Articles on this blog (40/40) →|

Comments?

Read Entire Article