Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,5 @@ body = html.create_element("body")
body.create_text("Hello Everyone ...")

print(root.serialize())
# <!DOCTYPE html>
# <html lang="en">
# <body>Hello Everyone ...</body>
# </html>
# <!DOCTYPE html><html lang="en"><body>Hello Everyone ...</body></html>
```
43 changes: 28 additions & 15 deletions docs/docs/parser.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,9 @@ title.text()
title.parent.name
# QualName(local="head", ns="http://www.w3.org/1999/xhtml", prefix=None)

ul = root.select_one("ul")
ul.serialize()
# <ul>
# <li><a href="https://www.example.com">Example Website</a></li>
# <li><a href="https://www.wikipedia.org">Wikipedia</a></li>
# <li><a href="https://www.bbc.com">BBC</a></li>
# <li><a href="https://www.microsoft.com">Microsoft</a></li>
# </ul>
li = root.select_one("li")
li.serialize()
# <li><a href="https://www.example.com">Example Website</a></li>
```

!!! tip "Common task"
Expand Down Expand Up @@ -128,6 +123,28 @@ root.serialize()
# </body></html>
```

!!! warning "Pretty-printing"

Pass `indent` to pretty-print the output, indenting each level of elements by that many spaces:
```python
root.serialize(indent=4)
# <!DOCTYPE html>
# <html>
# <head>
# <title>Incomplete Html</title>
# </head>
# <body>
# <ul>
# <li>
# <a href="https://www.example.com">Example Website</a>
# </li>
# ...
```

Pretty-printing adds and removes whitespace, which can change the meaning of the document: for example, it
changes the rendering of whitespace between inline elements, and the contents of `<pre>`, `<textarea>`, and
`<script>` elements. Use it only for display, and leave `indent` at its default of 0 to serialize the document exactly.

## Parsing XML
Imagine this **`file.xml`** file:

Expand Down Expand Up @@ -187,13 +204,9 @@ for i in root.select("mag|*"): # get all elements which has namespace 'mag'
# Element(name=QualName(local="publisher", ns="http://www.example.com/magazines", prefix=Some("mag")), attrs=[], template=false, mathml_annotation_xml_integration_point=false)
# Element(name=QualName(local="month", ns="http://www.example.com/magazines", prefix=Some("mag")), attrs=[], template=false, mathml_annotation_xml_integration_point=false)

book = root.select_one("book")
book.serialize()
# <bk:book xmlns:bk="http://www.example.com/books">
# <bk:title>Programming for Beginners</bk:title>
# <bk:author>Jane Doe</bk:author>
# <bk:year>2021</bk:year>
# </bk:book>
title = root.select_one("book title")
title.serialize()
# <bk:title xmlns:bk="http://www.example.com/books">Programming for Beginners</bk:title>
```

## Using Parser
Expand Down
18 changes: 12 additions & 6 deletions python/markupever/dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def select_one(self, expr: str, offset: int = 0) -> typing.Optional["Element"]:

def serialize_bytes(
self,
indent: int = 4,
indent: int = 0,
is_html: bool | None = None,
include_self: bool = True,
) -> bytes:
Expand All @@ -52,7 +52,7 @@ def serialize_bytes(

def serialize(
self,
indent: int = 4,
indent: int = 0,
is_html: bool | None = None,
include_self: bool = True,
) -> str:
Expand Down Expand Up @@ -356,29 +356,35 @@ def text(self, separator: str = "", strip: bool = False) -> str:

def serialize_bytes(
self,
indent: int = 4,
indent: int = 0,
is_html: bool | None = None,
include_self: bool = True,
) -> bytes:
"""
Serialize the current node and its subtree to bytes.

- indent (int, optional): Number of spaces for indentation. Defaults to 4.
- indent (int, optional): If nonzero, pretty-print the output, indenting each level of elements by this
many spaces. Pretty-printing adds and removes whitespace, which can change the meaning of the document:
for example, it changes the rendering of whitespace between inline elements, and the contents of
`<pre>`, `<textarea>`, and `<script>` elements. Defaults to 0, which serializes the document exactly.
- is_html (bool, optional): Whether to serialize as HTML. Defaults to None.
- include_self (bool, optional): Whether to include the current node in serialization. Defaults to True.
"""
return _rustlib.serialize(self._raw, indent, include_self, is_html)

def serialize(
self,
indent: int = 4,
indent: int = 0,
is_html: bool | None = None,
include_self: bool = True,
) -> str:
"""
Serialize the current node and its subtree to string.

- indent (int, optional): Number of spaces for indentation. Defaults to 4.
- indent (int, optional): If nonzero, pretty-print the output, indenting each level of elements by this
many spaces. Pretty-printing adds and removes whitespace, which can change the meaning of the document:
for example, it changes the rendering of whitespace between inline elements, and the contents of
`<pre>`, `<textarea>`, and `<script>` elements. Defaults to 0, which serializes the document exactly.
- is_html (bool, optional): Whether to serialize as HTML. Defaults to None.
- include_self (bool, optional): Whether to include the current node in serialization. Defaults to True.
"""
Expand Down
25 changes: 24 additions & 1 deletion python/tests/test_dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,7 @@ def test_serializer():
'<!DOCTYPE html>\n<html lang="en">\n<head>\n <meta charset="UTF-8">\n <meta name="viewport" content="width=device-width, initial-scale=1.0">\n <title>Complex Test</title>\n <style>\n body { font-family: Arial, sans-serif; }\n .highlight { background-color: yellow; }\n </style>\n</head>\n<body>\n <header>\n <h1>Welcome to the Test Page</h1>\n </header>\n <nav>\n <ul>\n <li><a href="#home">Home</a></li>\n <li><a href="#about">About</a></li>\n <li><a href="#contact">Contact</a></li>\n </ul>\n </nav>\n <main>\n <section id="home">\n <p>Home content goes here.</p>\n </section>\n <section id="about">\n <p>About content goes here.</p>\n </section>\n <section id="contact">\n <p>Contact content goes here.</p>\n </section>\n </main>\n <footer>\n <p>&copy; 2025 Test Page. All rights reserved.</p>\n </footer>\n</body>\n</html>\n',
False,
0,
'<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Complex Test</title><style>\n body { font-family: Arial, sans-serif; }\n .highlight { background-color: yellow; }</style></head><body><header><h1>Welcome to the Test Page</h1></header><nav><ul><li><a href="#home">Home</a></li><li><a href="#about">About</a></li><li><a href="#contact">Contact</a></li></ul></nav><main><section id="home"><p>Home content goes here.</p></section><section id="about"><p>About content goes here.</p></section><section id="contact"><p>Contact content goes here.</p></section></main><footer><p>© 2025 Test Page. All rights reserved.</p></footer></body></html>',
'<!DOCTYPE html><html lang="en"><head>\n <meta charset="UTF-8">\n <meta name="viewport" content="width=device-width, initial-scale=1.0">\n <title>Complex Test</title>\n <style>\n body { font-family: Arial, sans-serif; }\n .highlight { background-color: yellow; }\n </style>\n</head>\n<body>\n <header>\n <h1>Welcome to the Test Page</h1>\n </header>\n <nav>\n <ul>\n <li><a href="#home">Home</a></li>\n <li><a href="#about">About</a></li>\n <li><a href="#contact">Contact</a></li>\n </ul>\n </nav>\n <main>\n <section id="home">\n <p>Home content goes here.</p>\n </section>\n <section id="about">\n <p>About content goes here.</p>\n </section>\n <section id="contact">\n <p>Contact content goes here.</p>\n </section>\n </main>\n <footer>\n <p>© 2025 Test Page. All rights reserved.</p>\n </footer>\n\n\n</body></html>',
),
_SerializerIndent(
'<!DOCTYPE html>\n<html lang="en">\n<head>\n <meta charset="UTF-8">\n <meta name="viewport" content="width=device-width, initial-scale=1.0">\n <title>Complex Test</title>\n <style>\n body { font-family: Arial, sans-serif; }\n .highlight { background-color: yellow; }\n </style>\n</head>\n<body>\n <header>\n <h1>Welcome to the Test Page</h1>\n </header>\n <nav>\n <ul>\n <li><a href="#home">Home</a></li>\n <li><a href="#about">About</a></li>\n <li><a href="#contact">Contact</a></li>\n </ul>\n </nav>\n <main>\n <section id="home">\n <p>Home content goes here.</p>\n </section>\n <section id="about">\n <p>About content goes here.</p>\n </section>\n <section id="contact">\n <p>Contact content goes here.</p>\n </section>\n </main>\n <footer>\n <p>&copy; 2025 Test Page. All rights reserved.</p>\n </footer>\n</body>\n</html>\n',
Expand All @@ -493,6 +493,29 @@ def test_serializer():
assert dom.serialize(case.indent) == case.expected


def test_serializer_preserves_whitespace():
for content, options in [
(
"<p><b>a</b> <i>b</i></p>\n<p>c </p>",
markupever.HtmlOptions(full_document=False),
),
(
"<pre> x\n\n</pre><textarea> y\n</textarea>",
markupever.HtmlOptions(full_document=False),
),
(
"<script> if (a < b) {}\n</script><style> p { }\n</style>",
markupever.HtmlOptions(full_document=False),
),
("<a> <b>x </b>\n <c></c>\t</a>", markupever.XmlOptions()),
]:
dom = markupever.parse(content, options)
assert dom.serialize(indent=0) == content
# indent=0 is the default.
assert dom.serialize() == content
assert dom.serialize_bytes() == content.encode()


def test_add_itself():
tag = markupever.parse("<p>").root().first_child

Expand Down
2 changes: 1 addition & 1 deletion src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,7 @@ impl PyParser {
}

#[pyo3::pyfunction]
#[pyo3(signature=(node, indent=4, include_self=true, is_html=None))]
#[pyo3(signature=(node, indent=0, include_self=true, is_html=None))]
pub fn serialize(
node: crate::nodes::PyNodeRef,
indent: usize,
Expand Down
5 changes: 4 additions & 1 deletion treedom/src/dom.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,10 @@ impl<'a> Serializer<'a> {
serializer.write_processing_instruction(&pi.target, &pi.data)?
}
interface::Interface::Text(text) => {
if !text.contents.trim_ascii().is_empty() {
// Without indentation, whitespace is significant: write it unchanged.
if self.indent == 0 {
serializer.write_text(&text.contents)?;
} else if !text.contents.trim_ascii().is_empty() {
serializer.write_text(text.contents.trim_ascii_end())?;
}
}
Expand Down
21 changes: 21 additions & 0 deletions treedom/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,27 @@ mod tests {
assert_eq!(HTML, String::from_utf8_lossy(&buf));
}

#[cfg(feature = "html5ever")]
#[test]
fn html_serializer_preserves_whitespace() {
use crate::Serializer;

const HTML: &str = "<!DOCTYPE html><html><head>\n</head>\n<body><p><b>a</b> <i>b</i></p>\n<pre> x\n\n</pre>\n</body></html>";

let parser = ParserSink::parse_html(true, Default::default(), Default::default());
let dom = parser.one(HTML).into_dom();

let mut buf: Vec<u8> = Vec::new();
html5ever::serialize::serialize(
&mut buf,
&Serializer::new(&dom, dom.root().id(), 0),
Default::default(),
)
.unwrap();

assert_eq!(HTML, String::from_utf8_lossy(&buf));
}

#[cfg(feature = "xml5ever")]
#[test]
fn xml_serializer() {
Expand Down
Loading