A Rust implementation of pyxform: converts XLSForm spreadsheets into ODK XForm XML, as used by ODK Collect, Enketo, KoboToolbox and friends. Ships as a CLI and as a library crate.
Output is validated against pyxform 4.5.0: for every form in
tests/fixtures/ — the pyxform example forms plus one form per advanced
feature — rxform's output is identical to pyxform's after XML canonicalization
(attribute order aside). The one exception is the ordering of <text> entries
inside <translation> blocks in heavily multilingual forms, which has no
semantic effect.
# writes survey.xml next to the input
rxform survey.xlsx
# explicit output path, or stdout
rxform survey.xlsx -o out/form.xml
rxform survey.xlsx --stdoutAs a library:
let xml = rxform::convert_file(std::path::Path::new("survey.xlsx"))?;Input formats: .xlsx, .xls, .ods
(via calamine).
The workbook is read and normalized (xls.rs), parsed into a tree of
questions, groups and choice lists (parser.rs + model.rs), validated with
precise locations (parser/validate.rs), and finally serialized into the
XForm document (xform.rs + xmlwriter.rs) using pyxform's output
conventions, so existing ODK tooling sees familiar XML.
The second diagram shows where each piece of the spreadsheet ends up in the generated document:
Broken forms fail with the sheet, row and column of the problem and a probable cause — never a stack trace or a silently wrong form:
$ rxform broken.xlsx
error: [sheet 'survey', row 3, column 'type'] unknown question type 'integr'
probable cause: did you mean 'integer'?
Checks include, among others:
- unknown question types and misspelled select prefixes ("did you mean 'select_one'?");
${references}that don't match any field, with the closest name suggested;- selects pointing at nonexistent choice lists (closest list suggested, or the available lists shown);
- duplicate or invalid (non-XML) names, with both offending rows;
- unclosed
begin group/begin repeat(points at thebeginrow) and mismatched or orphanendrows; - ambiguous
${references}(the same name in two different sections) at the place of use — duplicated names between sections are otherwise legal, as loops rely on them; - entities-sheet mistakes (
update_ifwithoutentity_id, creation without a label, unknown columns) andsave_toinside repeats; - loops over nonexistent choice lists;
triggervalues that aren't a${question}reference, point at nothing, or point at a non-user-visible question (calculations can't fire triggers);background-geopointwithout a trigger;- choice names with spaces in
select_multiplelists; - duplicate choice names in a list (unless
allow_choice_duplicatesis set); - visible questions with no label, hint or media;
or_othercombined withchoice_filteror from-file selects;- two external data sources claiming the same instance id with different files.
survey,choicesandsettingssheets (case-insensitive names).- Question types: text/string, integer, decimal, range, date, time, dateTime, note, trigger/acknowledge, geopoint/geotrace/geoshape, photo/image, audio, video, file, osm, barcode, calculate, hidden, audit, rank, and the metadata preloads (start, end, today, deviceid, phonenumber, username, email, etc.).
select_one/select_multiple/rankwith choice lists, rendered as secondary instances +<itemset>, includingchoice_filterpredicates, extra choice columns, andrandomize/seedparameters.select_one_from_file/select_multiple_from_file(csv, xml, geojson;value=/label=parameter overrides),xml-external/csv-externalrows, andpulldata()calls — each becomes an<instance id src="jr://file…"/>with pyxform's dedup rules.select_one_external: thechoice_filterbecomes the control'squeryattribute and theexternal_choicessheet is exported asitemsets.csvnext to the output file.or_other: appends the Other choice (translated when the list is) and generates the "Specify other." follow-up question with its relevance.- Groups and repeats (
begin/end group|repeat), nested arbitrarily;repeat_count(a generated<name>_countcalculate node is created for non-trivial expressions, exactly like pyxform). - The
table-listappearance transformation (generated label note + header select +list-nolabel). - Bind columns:
relevant,constraint,constraint_message,required,required_message,read_only,calculation, plus passthroughbind::*columns and the common aliases (bind:jr:constraintMsg,control:appearance,repeat_count,rows,autoplay,noAppErrorString, ...). - Generic column prefixes:
body::attr(any control attribute),instance::attr(attributes on the question's instance node), and in settingsattribute::x(primary-instance root attributes) andnamespaces(extraxmlns:declarations). - Capture parameters: image
max-pixels(orx:max-pixelsbind), audioquality(odk:qualityon the bind, or on<odk:recordaudio>for background-audio), geopointcapture-accuracy/warning-accuracy(accuracyThreshold/unacceptableAccuracyThresholdbody attributes). audit, placed atmeta/auditwith itslocation-priority,location-min-intervalandlocation-max-ageparameters asodk:*bind attributes.- The
entitiessheet: create, update and conditional create+update declarations (list_name,entity_id,create_if,update_if,label), thesave_tocolumn (entities:savetobinds), themeta/entityblock with offlinebaseVersion/trunkVersion/branchIdtracking, and theentities:entities-versionmodel attribute. - The
search()appearance: choices become inline<item>column templates and the list is excluded from the secondary instances. ${last-saved#question}references, expanding to thejr://instance/last-savedsecondary instance.- Compact/SMS record representation: settings
prefix/delimiter(odk:prefix/odk:delimiter) and thecompact_tagcolumn (odk:tag). flatforms (settingsflat=yes): groups keep their body element but vanish from the instance and xpaths, with group relevance "and"-ed down onto their questions; plusomit_instanceID.begin loop over <list>: expands into one sub-group per choice with%(name)s/%(label)ssubstituted per language.clean_text_values(default on, like pyxform): collapses runs of spaces and trims every cell;clean_text_values=nopreserves spacing.- Defaults: static values go into the primary instance; dynamic expressions
become
<setvalue event="odk-instance-first-load">actions (plusodk-new-repeatinside repeats), like pyxform. - The
triggercolumn:<setvalue event="xforms-value-changed">fired from the source question, carrying the target's calculation (or clearing it). - odk actions:
start-geopoint(<odk:setgeopoint>on first load),background-audio(<odk:recordaudio>),background-geopoint(geopoint captured when its trigger changes). ${name}reference expansion in expressions and labels (labels get inline<output value="..."/>elements).- Multiple languages via
column::Language(and the legacycolumn:lang),media::image/audio/video/big-image,guidance_hint, all routed through<itext>with pyxform's rules (untranslated content stays inline; missing translations become-). - Notes without a
nameget pyxform's auto-generated names. - Settings:
form_title,form_id,name(instance root, defaultdata),version,default_language,instance_name,style,submission_url,public_key,auto_send,auto_delete. - Smart-quote straightening in all cell values.
meta/instanceID(andinstanceNamewheninstance_nameis set).
- OSM tag lists (the
osmupload type itself is supported). - The legacy J2ME
sms_*columns (the modern compact/odk:tagrepresentation is supported).
Prebuilt binaries for Linux (static musl + .deb), macOS and Windows are
attached to every GitHub release.
# macOS (Homebrew)
brew install milkway/tap/rxform
# Debian/Ubuntu — grab the .deb for your arch from the latest release
sudo dpkg -i rxform_*.deb
# Windows (Scoop)
scoop bucket add milkway https://github.com/milkway/scoop-bucket
scoop install rxform
# anywhere with a Rust toolchain
cargo install rxform # CLI
cargo add rxform # librarycargo testtests/fixtures/ contains example forms copied from the pyxform test suite
(BSD 2-Clause, © the pyxform contributors) plus small feature-specific forms.
tests/expected/ holds rxform's output for them, validated against pyxform
4.5.0 by canonical XML comparison; the integration tests pin rxform to those
snapshots, and tests/errors_test.rs locks in the diagnostics (location +
probable cause) for common authoring mistakes.
rxform is open source under the BSD 2-Clause License, the same license as pyxform, whose behavior it reimplements and whose test forms it reuses. Contributions — issues, example forms that convert differently from pyxform, and pull requests — are welcome at github.com/milkway/rxform.
To cite rxform in academic work, use the metadata in CITATION.cff or the Zenodo archive: doi:10.5281/zenodo.21894015 (concept DOI — always resolves to the latest release).