From 91bc9bb29b6a1cc6d7ac06ee9ae68f878ed26181 Mon Sep 17 00:00:00 2001 From: Rui Zhao Date: Sun, 11 Oct 2026 00:34:38 +0800 Subject: [PATCH] Preserve spaces after leading text in XML content On older libxml2, areBlanks() preserves whitespace when the parent's first child is text. xmlParseInNodeContext() inserts a comment before the parsed nodes, so that check no longer sees the leading text. XMLSERIALIZE ... INDENT then removes the space in "abc " that the previous parser retained. When whitespace removal is enabled and the fragment starts with nonblank text or a character reference, add an empty text child before parsing and remove it afterward. Leave leading literal whitespace alone to preserve its existing handling. Add regression tests for these inputs and for indentation of element-only content. --- src/backend/utils/adt/xml.c | 30 ++++++++++++++++++++++++++++++ src/test/regress/expected/xml.out | 18 ++++++++++++++++++ src/test/regress/sql/xml.sql | 10 ++++++++++ 3 files changed, 58 insertions(+) diff --git a/src/backend/utils/adt/xml.c b/src/backend/utils/adt/xml.c index 3b28070843b..0067ff954d0 100644 --- a/src/backend/utils/adt/xml.c +++ b/src/backend/utils/adt/xml.c @@ -1913,14 +1913,44 @@ xml_parse(text *data, XmlOptionType xmloption_arg, /* for some reason we need "char *" not "xmlChar *" here */ const char *content = (const char *) (utf8string + count); xmlNodePtr nodelist = NULL; + xmlNodePtr text_context = NULL; xmlParserErrors res_code; + /* + * Older libxml2's areBlanks() checks whether the first child + * is text before discarding whitespace. + * xmlParseInNodeContext() puts a comment there instead, + * hiding the fragment's leading text. Supply an empty text + * child for that check, then remove it. Leave leading + * whitespace alone to preserve its handling. + */ + if (!preserve_whitespace && *content != '<' && + !xmlIsBlank_ch(*content)) + { + text_context = xmlNewDocText(doc, (const xmlChar *) ""); + if (text_context == NULL || xmlerrcxt->err_occurred) + xml_ereport(xmlerrcxt, ERROR, ERRCODE_OUT_OF_MEMORY, + "could not allocate xml node"); + if (xmlAddChild(root, text_context) == NULL) + { + xmlFreeNode(text_context); + xml_ereport(xmlerrcxt, ERROR, ERRCODE_INTERNAL_ERROR, + "could not append xml node"); + } + } + res_code = xmlParseInNodeContext(root, content, strlen(content), options, &nodelist); + if (text_context) + { + xmlUnlinkNode(text_context); + xmlFreeNode(text_context); + } + /* immediately attach the resulting nodelist to the root */ if (nodelist) { diff --git a/src/test/regress/expected/xml.out b/src/test/regress/expected/xml.out index 7576a84b7d8..c7aecdda98d 100644 --- a/src/test/regress/expected/xml.out +++ b/src/test/regress/expected/xml.out @@ -1918,3 +1918,21 @@ SELECT xmltext('x'|| '

73

'::xml || .42 || true || 'j'::char); x<P>73</P>0.42truej (1 row) +-- Keep spaces in mixed content while still indenting element-only content. +SELECT xmlserialize(CONTENT xmlparse(CONTENT input) AS text INDENT) = expected AS matches +FROM (VALUES + ('abc
', E'abc\n \n'), + (' ', E' \n \n'), + (' ', E'\n'), + (' abc ', E' abc\n \n'), + (' ', E'\n \n') +) AS cases(input, expected); + matches +--------- + t + t + t + t + t +(5 rows) + diff --git a/src/test/regress/sql/xml.sql b/src/test/regress/sql/xml.sql index edd5f5e7022..11d06d0d42a 100644 --- a/src/test/regress/sql/xml.sql +++ b/src/test/regress/sql/xml.sql @@ -708,3 +708,13 @@ SELECT xmltext(' '); SELECT xmltext('foo `$_-+?=*^%!|/\()[]{}'); SELECT xmltext('foo & <"bar">'); SELECT xmltext('x'|| '

73

'::xml || .42 || true || 'j'::char); + +-- Keep spaces in mixed content while still indenting element-only content. +SELECT xmlserialize(CONTENT xmlparse(CONTENT input) AS text INDENT) = expected AS matches +FROM (VALUES + ('abc ', E'abc\n \n'), + (' ', E' \n \n'), + (' ', E'\n'), + (' abc ', E' abc\n \n'), + (' ', E'\n \n') +) AS cases(input, expected); -- 2.43.7