A header-only C++11 library that converts XML to JSON — and JSON back to XML.
xml2json is small, fast and has no external dependencies: RapidXML and
RapidJSON are bundled with it. It started as the XML/JSON converter of the
soft-subtitle pipeline at Tencent Video and is still used there.
- Header-only — copy
include/into your project and#include "xml2json.hpp". - XML → JSON (
xml2json) and JSON → XML (json2xml). - Returns either a JSON string or a
rapidjson::Documentyou can walk. - Optional pretty printing, numeric conversion, sibling-order recording, always-array output, qualified namespace names and configurable key names.
- Malformed input is reported as an exception instead of aborting the process.
- Requires C++11 or later; the test suite is built with GCC 16.2 (MinGW-w64).
#include <iostream>
#include "xml2json.hpp"
int main()
{
std::cout << xml2json("<a id=\"1\"><b>text</b></a>") << std::endl;
// {"a":{"@id":"1","b":"text"}}
}Compile with any C++11 compiler and the include directory on the include path:
c++ -std=c++11 -Iinclude my_app.cpp -o my_appWith CMake:
cmake -S . -B build
cmake --build build
ctest --test-dir build
cmake --install build --prefix /path/to/prefixor with GNU make / mingw32-make:
make # builds the ./xml2json tool
make test # builds and runs the regression suite
make install # copies the tool to $(PREFIX)/bin (default /usr/local)make install only installs the command line tool and needs the POSIX install
utility; use cmake --install to install the headers, the CMake package
configuration and the tool together.
Using the library from CMake:
# If xml2json is a subdirectory of your project:
add_subdirectory(xml2json)
target_link_libraries(my_app PRIVATE xml2json::xml2json)
# If xml2json was installed, point CMAKE_PREFIX_PATH at the prefix:
find_package(xml2json CONFIG REQUIRED)
target_link_libraries(my_app PRIVATE xml2json::xml2json)usage: xml2json [--json2xml] [input.xml]
--json2xml, -j convert JSON to XML instead of XML to JSON
(reads from standard input when no file is given)
./xml2json input.xml # XML -> JSON on stdout
./xml2json -j input.json # JSON -> XML on stdout
cat input.xml | ./xml2jsonThe tool exits with a non-zero status and prints error: ... on stderr when the
file cannot be opened or the input is malformed. It always uses the default
options; use the library API for numeric conversion, pretty printing, custom key
names and the other options.
| # | XML | JSON | Access |
|---|---|---|---|
| 1 | <e/> |
"e": null |
o.e |
| 2 | <e>text</e> |
"e": "text" |
o.e |
| 3 | <e name="value"/> |
"e": {"@name": "value"} |
o.e["@name"] |
| 4 | <e name="value">text</e> |
"e": {"#text": "text", "@name": "value"} |
o.e["#text"], o.e["@name"] |
| 5 | <e><a>text</a><b>text</b></e> |
"e": {"a": "text", "b": "text"} |
o.e.a, o.e.b |
| 6 | <e><a>text</a><a>text</a></e> |
"e": {"a": ["text", "text"]} |
o.e.a[0], o.e.a[1] |
| 7 | <e>text<a>text</a></e> |
"e": {"#text": "text", "a": "text"} |
o.e["#text"], o.e.a |
In short: attributes become @name members, text becomes a #text member, an
element that only has text collapses to that text, and elements that repeat
become an array. @, #text and #order are only the default names; the
text_name, attribute_prefix and order_name options change them.
All functions live in the single header and are inline.
// XML -> JSON
std::string xml2json(const char *xml_str);
std::string xml2json(const char *xml_str, int indent);
std::string xml2json(const char *xml_str, const xml2json_options &options);
// XML -> JSON, returning the parsed document instead of a string
rapidjson::Document xml2json_document(const char *xml_str,
const xml2json_options &options);
// JSON -> XML
std::string json2xml(const char *json_str);
std::string json2xml(const char *json_str, int indent);
std::string json2xml(const char *json_str, const xml2json_options &options);xml2json_document() returns a rapidjson::Document that owns its allocator;
keep the document alive while using its values. The conversion functions keep
no global state, so separate calls can run concurrently as long as they do not
share a Document or an xml2json_options object.
struct xml2json_options
{
int indent; // spaces per level for pretty printing; <= 0 is compact
bool numeric; // emit numeric looking text as JSON numbers
bool order; // add a "#order" array where sibling order would be lost
bool array; // always emit child elements as JSON arrays
bool qualify_element_names; // emit "prefix:local" element names instead of "local"
std::string text_name; // member name of an element's own text (default "#text")
std::string attribute_prefix; // prefix prepended to attribute names (default "@"; may be empty)
std::string order_name; // member name of the order array (default "#order")
xml2json_options(); // takes its defaults from the configuration block below
};The default constructor reproduces the historical behaviour, so existing code
keeps working. The defaults come from a compile-time configuration block at the
top of xml2json.hpp (xml2json_numeric_support, xml2json_keep_element_order,
xml2json_always_array, xml2json_qualify_element_names, all false, plus
xml2json_text_additional_name, xml2json_attribute_name_prefix and
xml2json_order_additional_name), which can be adjusted there or overridden per
call. With the default options the output is byte-for-byte identical to the
historical implementation; every new capability is opt-in.
xml2json_options options;
options.order = true; // record the original sibling order
options.indent = 2; // pretty print
std::cout << xml2json(xml, options);-
numericonly converts text that is entirely a decimal number (-42,+7,1.5,102.50). Anything else —99921-58-10-7,12 34,0x10,1e5— stays a string. Integers are parsed as 64-bit values; larger integers fall back todouble, and values that do not fit adoublestay strings instead of producing truncated JSON. Decimal parsing follows the current C locale, so setLC_NUMERIC=Cif your input uses.under another locale. -
orderadds"#order": ["b", "c", "b"]to the elements whose children had to be regrouped, and only to those, so document order is not lost. -
arraygives single children the same shape as repeated ones, which keeps look-ups simple. -
qualify_element_namesrestores the namespace prefix of element names (<ns:e>normally yields"e", with this option"ns:e"). Attribute names always keep their prefix. -
text_name,attribute_prefixandorder_namechange the three JSON key names that used to be compile-time constants only. Their defaults are exactly those constants, so the output does not change unless you set them. This is what a node/xml2js style consumer needs:xml2json_options options; options.text_name = "_"; options.attribute_prefix = "$"; xml2json("<a id=\"1\">x</a>", options); // {"a":{"_":"x","$id":"1"}}
-
attribute_prefixmay be empty, in which case attribute names are emitted without any prefix. They can then collide with child elements of the same name, so only use it when the schema makes that unambiguous.text_namemust not be empty; an emptyorder_namesuppresses the#orderarray entirely.
xml2json()andxml2json_document()throwrapidxml::parse_errorwhen the XML is malformed.json2xml()throwsstd::runtime_error, carrying the offset and message, when the JSON is malformed.
json2xml() implements the inverse of the rules above: members prefixed with
attribute_prefix (by default @) become attributes, the text_name member (by
default #text) becomes the text content, other members become child elements,
arrays repeat the element, and the order_name member (by default #order) is
ignored. A null value becomes a self-closing element:
json2xml("{\"a\":{\"@id\":\"9\",\"b\":[1,2]}}"); // <a id="9"><b>1</b><b>2</b></a>The json2xml(json, options) overload applies the same indent, text_name,
attribute_prefix and order_name as xml2json, so a customised conversion
stays symmetric. The remaining options (numeric, order, array and
qualify_element_names) only affect XML to JSON. When attribute_prefix is
empty there is no way to tell an attribute from a child element, so every member
is written as a child element rather than being dropped:
xml2json_options options;
options.attribute_prefix = ""; // no attribute prefix
json2xml("{\"a\":{\"id\":\"9\"}}", options); // <a><id>9</id></a>Text and attribute values are escaped (&, < and >; " is additionally
escaped in attributes), and no XML declaration is written. The output is best
effort: for JSON produced by xml2json() with the same options,
xml2json(json2xml(json)) reproduces the original JSON for the supported
structures. Arbitrary JSON is not guaranteed to round-trip:
- JSON numbers are rendered by RapidJSON's writer, so they use the shortest
round-trip form rather than the original spelling:
1e5becomes100000.0and1.2300becomes1.23. Integers that fitint64/uint64stay exact. - A top-level array or scalar is wrapped in
<root>; a top-levelnullproduces an empty string. #textarrays are concatenated and#order(or the configuredorder_name) is ignored.- Mixed content cannot round-trip because
xml2json()already loses the text position relative to the child elements.
- XML with mixed content (an element holding both text and child elements) does
not round-trip byte for byte:
xml2jsonalready collects the text nodes into a single#textmember, losing its position relative to the children. - Namespace prefixes are not resolved to their URIs in the output. RapidXML
exposes them (
xml_node::prefix(),xml_node::xmlns()), andqualify_element_nameskeeps the prefix, but noxmlnsresolution is performed. - A CDATA-only element is not collapsed like plain text:
<e><![CDATA[x]]></e>becomes{"e":{"#text":"x"}}, not{"e":"x"}. - On Windows the input is read in text mode, so CRLF inside text nodes is
normalised to LF, which makes the output differ from POSIX for such files
(this is why
test/windows_style_resultsexists). json2xml()does not validate XML names. Object keys that are not valid XML names (for example"1a", or a name containing a space) are copied verbatim and can produce malformed XML. Attributes are recognised purely by the configured prefix, so"@"alone yields an empty attribute name.- Only
&,<and>(plus"in attribute values) are escaped. Other characters are copied as-is, so JSON strings containing control characters that XML 1.0 forbids (such as\u0001) produce invalid XML. - An attribute value is converted to text: arrays are concatenated and objects become an empty string. Attributes therefore cannot represent structured values.
- Duplicate JSON object keys are not detected; each occurrence is written as a separate element (or attribute).
- Both conversions are recursive and have no explicit depth limit. On a 1 MB stack, roughly 4,000 nested XML elements (XML to JSON) or 5,000 nested JSON values (JSON to XML) can be processed; deeper input can overflow the stack.
| Library | Version |
|---|---|
| RapidXML | 1.13, namespace-aware variant with local patches (details) |
| RapidJSON | 1.1.0 plus post-1.1.0 master fixes, commit 24b5e7a (2024-12-22) (details) |
RapidJSON has not published a release since 1.1.0 (2016), so the bundled copy
tracks upstream master; the VERSION.md files record the exact commits and
local patches.
test/test.cpp is a self-contained regression suite (no test framework needed).
It checks the bundled documents against the expected output, plus numeric
detection, malformed input handling, the xml2json_document() API, every option
(including the configurable key names), json2xml and full round trips. It also
links a second translation unit that includes the header, which guards against
duplicate symbols.
Run it with make test, or ctest --test-dir build after a CMake build. Passing
--write regenerates the expected *.js.txt files instead of checking them.
The test suite is currently built and run with MinGW-w64 GCC 16.2 on Windows,
under C++11, C++14, C++17 and C++20 with -Wall -Wextra -Werror. There is no
CI configuration in the repository, so other platforms are expected to work but
are not continuously verified here.
MIT, see LICENSE.

