///|
fn Parser::append_text(self : Parser, raw : StringView, start : Int) -> Unit {
if raw.is_empty() {
return
}
let mut text_start = start
let mut cleaned = if self.current_node_uses_foreign_content_for_text() {
self.clean_foreign_content_text(raw, start)
} else {
self.remove_null_characters(raw, start)
}
cleaned = normalize_tree_builder_text(cleaned)
if cleaned.is_empty() {
return
}
if text_has_non_ascii_whitespace(cleaned) {
self.before_html_phase = false
}
if self.current_is_disabled_head_noscript() &&
text_has_non_ascii_whitespace(cleaned) {
let whitespace = leading_ascii_whitespace(cleaned)
self.error_at("unexpected-start-tag", start + whitespace.length())
ignore(self.pop_open_element())
if !whitespace.is_empty() {
let data = if self.xml_coercion {
coerce_text_for_xml(whitespace)[:]
} else {
whitespace
}
self.current_node().append_child(self.text_at(data, start))
}
if self.current_is_document_head() {
ignore(self.pop_open_element())
}
cleaned = cleaned[whitespace.length():]
text_start += whitespace.length()
}
if self.current_is_document_head() && text_has_non_ascii_whitespace(cleaned) {
let whitespace = leading_ascii_whitespace(cleaned)
if !whitespace.is_empty() {
let data = if self.xml_coercion {
coerce_text_for_xml(whitespace)[:]
} else {
whitespace
}
self.current_node().append_child(self.text_at(data, text_start))
}
ignore(self.pop_open_element())
cleaned = cleaned[whitespace.length():]
text_start += whitespace.length()
}
if self.uses_document_frameset_rules() &&
!self.frameset_reprocesses_in_body_mode &&
(
self.after_frameset ||
self.detached_frameset_mode ||
self.has_open_element("frameset")
) {
let whitespace = ascii_whitespace_chars_only(cleaned)
if self.after_frameset && whitespace.length() != cleaned.length() {
self.error_at("unexpected-token-after-frameset", start + raw.length() - 1)
}
if !whitespace.is_empty() {
self.current_node().append_child(self.text_at(whitespace, text_start))
}
return
}
if self.uses_document_frameset_rules() && text_clears_frameset_ok(raw) {
self.frameset_ok = false
}
if self.handle_template_column_group_text(cleaned, text_start) {
return
}
if self.handle_fragment_context_colgroup_text(cleaned, text_start) {
return
}
if self.handle_column_group_text(cleaned, text_start) {
return
}
self.report_foreign_table_row_recovery_text(cleaned, text_start)
if self.foster_parent_table_text(cleaned, text_start) {
return
}
if self.foster_parent_template_table_text(cleaned, text_start) {
return
}
if self.template_table_context_mode_active() &&
text_has_non_ascii_whitespace(cleaned) {
ignore(self.report_foster_parenting_character_errors(cleaned, text_start))
}
self.report_foster_parented_table_context_text(cleaned, text_start)
let decoded_result = @tok.decode_entities_with_errors(
cleaned,
in_attribute=false,
)
for error in decoded_result.errors {
self.error_at(error.code, text_start + error.offset)
}
let decoded = normalize_tree_builder_text(decoded_result.data)
let mut data = if self.xml_coercion {
coerce_text_for_xml(decoded)[:]
} else {
decoded
}
if self.pending_initial_lf_skip {
self.pending_initial_lf_skip = false
if data.has_prefix("\n") {
data = data[1:]
if data.is_empty() {
// The text was only the skipped newline; nothing to insert.
return
}
// Advance the recorded origin past the consumed character
// reference so tracked locations point at the remaining text.
text_start = text_start + initial_newline_reference_length(cleaned)
}
}
if self.fragment_context_html_after_body &&
text_has_non_ascii_whitespace(data) {
// The whitespace decision uses the decoded characters: an entity
// that decodes to whitespace does not leave after-body mode.
self.fragment_context_html_after_body = false
self.fragment_context_html_entered_body = true
}
let table_parent = self.current_node()
if !text_has_non_ascii_whitespace(data) &&
table_parent.kind == Element &&
is_table_start_foster_target(table_parent.name) &&
self.last_open_table_index() >= 0 {
// Whitespace-only table text inserts at the current table position
// without reconstructing fostered formatting.
if self.merge_into_trailing_text(table_parent, data) {
return
}
table_parent.append_child(self.text_at(data, text_start))
return
}
self.reconstruct_active_formatting_elements()
let parent = self.current_node()
if self.document_after_body &&
!self.is_fragment_parser() &&
!self.in_frameset_document() &&
text_has_non_ascii_whitespace(data) {
// Non-whitespace after-body text reprocesses in body wherever the
// insertion point is; parsing stays in body afterwards.
self.document_entered_body = true
self.document_entered_body_from_after_body = true
self.document_after_body = false
self.document_after_html = false
}
if self.merge_into_trailing_text(parent, data) {
return
}
let text_node = self.text_at(data, text_start)
parent.append_child(text_node)
self.mark_forced_body_node(text_node)
if self.document_head_closed &&
!self.is_fragment_parser() &&
is_whitespace_text_node(text_node) &&
(
parent.kind == Document ||
(parent.kind == Element && parent.name == "html")
) &&
!document_children_contain_body_content(self.root) {
// Whitespace after belongs between the head and the body.
self.after_head_whitespace.push(text_node)
}
}
///|
/// Appends character data to an existing trailing text node, mirroring the
/// spec's "insert the characters into that text node" step. Keeps the
/// original node's source origin and never touches sanitizer escape text.
fn Parser::merge_into_trailing_text(
self : Parser,
parent : @dom.Node,
data : StringView,
) -> Bool {
match parent.children.last() {
Some(last) if last.kind == Text &&
!last.sanitize_escape_only &&
!self.merge_crosses_body_boundary(parent, last, data) => {
match self.pending_text_node {
Some(pending) if physical_equal(pending, last) => ()
_ => {
// Start (or restart) a batch on this node. The tree keeps the
// node with its current data; the builder accumulates the full
// text and flush_pending_text materializes it once, keeping
// merges amortized O(n) instead of O(n^2).
self.flush_pending_text()
self.pending_text_node = Some(last)
self.pending_text_builder = StringBuilder()
self.pending_text_builder.write_string(last.data)
self.pending_text_is_whitespace = is_whitespace_text_node(last)
}
}
self.pending_text_builder.write_view(data)
if self.pending_text_is_whitespace && text_has_non_ascii_whitespace(data) {
self.pending_text_is_whitespace = false
}
// Non-whitespace data is the after-body "anything else" case even
// when it merges into an existing forced text node: the mode
// switches back to in-body, so later comments insert normally.
if self.document_after_body &&
!self.is_fragment_parser() &&
!self.in_frameset_document() &&
text_has_non_ascii_whitespace(data) {
self.document_entered_body = true
self.document_entered_body_from_after_body = true
self.document_after_body = false
self.document_after_html = false
}
true
}
_ => false
}
}
///|
/// Materializes a batched trailing text node: replaces the stale node with
/// one carrying the accumulated data, preserving its position, origin, and
/// scaffolder markers. Runs at the end of parsing and whenever a batch
/// moves to a different node.
fn Parser::flush_pending_text(self : Parser) -> Unit {
let node = match self.pending_text_node {
Some(node) => node
None => return
}
self.pending_text_node = None
let full = self.pending_text_builder.to_string()
if full.length() == node.data.length() {
return
}
let merged = @dom.text(full)
merged.parsed_from_source = node.parsed_from_source
merged.origin_offset = node.origin_offset
merged.origin_line = node.origin_line
merged.origin_col = node.origin_col
match node.parent {
Some(parent) => {
let mut index = parent.children.length()
while index > 0 {
index -= 1
if physical_equal(parent.children[index], node) {
parent.children[index] = merged
merged.parent = Some(parent)
node.parent = None
break
}
}
}
None => ()
}
if self.remove_forced_body_node(node) {
self.forced_body_nodes.push(merged)
}
if self.remove_foster_parented_text(node) {
self.foster_parented_texts.push(merged)
}
if node_array_contains_identity(self.after_head_whitespace, node) {
remove_parser_node_identity(self.after_head_whitespace, node)
if is_whitespace_text_node(merged) {
self.after_head_whitespace.push(merged)
}
}
}
///|
/// True when appending would merge body text into a top-level
/// whitespace-only run that the scaffolder must keep between the head and
/// the body (for example ` X` keeps the space at the
/// html level and files only `X` into the body). Whitespace merges into
/// whitespace, non-whitespace text was already body content (XX /// yields "XX"), and runs already marked as body content always accept /// more data. fn Parser::merge_crosses_body_boundary( self : Parser, parent : @dom.Node, last : @dom.Node, data : StringView, ) -> Bool { // The html fragment context scaffolds head/body like a document, so the // boundary applies there too; other fragment contexts have no head/body // split. if self.is_fragment_parser() && !self.fragment_context_html { return false } if !(parent.kind == Document || (parent.kind == Element && parent.name == "html")) { return false } if !self.trailing_text_is_whitespace(last) || self.node_is_forced_body(last) { return false } // After the body has closed, even whitespace belongs to the body (the // in-body rules reprocess it), so it must not merge into an unmarked // pre-body run either. Similarly, whitespace after must not // merge into a pre-head run: it stays between the head and the body. text_has_non_ascii_whitespace(data) || self.document_after_body || ( self.document_head_closed && !node_array_contains_identity(self.after_head_whitespace, last) ) } ///| /// Whitespace check that accounts for batched data not yet materialized /// into the tree node. fn Parser::trailing_text_is_whitespace(self : Parser, node : @dom.Node) -> Bool { match self.pending_text_node { Some(pending) if physical_equal(pending, node) => self.pending_text_is_whitespace _ => is_whitespace_text_node(node) } } ///| fn Parser::node_is_forced_body(self : Parser, node : @dom.Node) -> Bool { self.forced_body_nodes[:].any(candidate => physical_equal(candidate, node)) } ///| fn Parser::remove_forced_body_node(self : Parser, node : @dom.Node) -> Bool { let mut index = 0 while index < self.forced_body_nodes.length() { if physical_equal(self.forced_body_nodes[index], node) { ignore(self.forced_body_nodes.remove(index)) return true } index += 1 } false } ///| fn Parser::remove_foster_parented_text(self : Parser, node : @dom.Node) -> Bool { let mut index = 0 while index < self.foster_parented_texts.length() { if physical_equal(self.foster_parented_texts[index], node) { ignore(self.foster_parented_texts.remove(index)) return true } index += 1 } false } ///| fn Parser::insert_body_mode_node(self : Parser, node : @dom.Node) -> Unit { let table_index = self.table_context_without_cell_or_caption() if table_index >= 0 { let current = self.current_node() if current.kind == Element && is_table_start_foster_target(current.name) { if self.table_index_is_fragment_context_table(table_index) { current.append_child(node) return } self.insert_node_before_open_table(node, table_index) return } } self.current_node().append_child(node) self.mark_forced_body_node(node) } ///| fn text_has_non_ascii_whitespace(value : StringView) -> Bool { value.any(ch => !ch.is_ascii_whitespace()) } ///| fn ascii_whitespace_chars_only(value : StringView) -> String { let out = StringBuilder(size_hint=value.length()) for ch in value { if ch.is_ascii_whitespace() { out.write_char(ch) } } out.to_string() } ///| fn normalize_tree_builder_text(value : StringView) -> StringView { // The common case has no form feeds: the input view passes through // unchanged and the per-character rebuild below is skipped. if !value.contains_char('\u{000C}') { return value } let out = StringBuilder(size_hint=value.length()) for ch in value { if ch == '\u{000C}' { out.write_char(' ') } else { out.write_char(ch) } } out.to_string() } ///| /// Input-stream preprocessing for raw character data (CDATA sections): /// CR and CRLF normalize to LF before any further handling. Decoded /// character references are exempt — they happen after preprocessing. fn normalize_raw_newlines(value : StringView) -> StringView { // The common case has no carriage returns: the input view passes // through unchanged. if !value.contains_char('\r') { return value } let out = StringBuilder(size_hint=value.length()) let mut last_was_cr = false for ch in value { if ch == '\r' { out.write_char('\n') last_was_cr = true } else if ch == '\n' && last_was_cr { last_was_cr = false } else { out.write_char(ch) last_was_cr = false } } out.to_string() } ///| fn decode_entities_for_tree_text(value : StringView) -> StringView { let decoded = @tok.decode_entities(value, in_attribute=false) normalize_tree_builder_text(decoded) } ///| fn Parser::remove_null_characters( self : Parser, raw : StringView, start : Int, ) -> StringView { // The common case has no nulls: the input view passes through // unchanged. if !raw.contains_char('\u{0000}') { return raw } let out = StringBuilder(size_hint=raw.length()) let mut pos = 0 while pos < raw.length() { let ch = raw.get_char(pos).unwrap() match ch { '\u{0000}' => { self.error_at("unexpected-null-character", start + pos) pos += 1 } _ => { out.write_char(ch) pos += ch.utf16_len() } } } out.to_string() } ///| fn Parser::replace_null_characters( self : Parser, raw : StringView, start : Int, ) -> StringView { // The common case has no nulls: the input view passes through // unchanged. if !raw.contains_char('\u{0000}') { return raw } let out = StringBuilder(size_hint=raw.length()) let mut pos = 0 while pos < raw.length() { let ch = raw.get_char(pos).unwrap() match ch { '\u{0000}' => { self.error_at("unexpected-null-character", start + pos) out.write_char('\u{FFFD}') pos += 1 } _ => { out.write_char(ch) pos += ch.utf16_len() } } } out.to_string() } ///| /// Length of the leading character reference that decoded to the skipped /// newline: '&' then either '#' digits / '#x' hex digits (numeric) or /// letters (named), with an optional terminating semicolon. fn initial_newline_reference_length(text : StringView) -> Int { let mut length = 0 let mut numeric = false let mut hex = false for ch in text { if length == 0 { length += 1 // the '&' continue } if ch == ';' { length += 1 break } if length == 1 && ch == '#' { numeric = true length += 1 continue } if numeric && length == 2 && (ch == 'x' || ch == 'X') { hex = true length += 1 continue } let in_body = if hex { ch.is_ascii_hexdigit() } else if numeric { ch.is_ascii_digit() } else { ch.is_ascii_alphabetic() || ch.is_ascii_digit() } if in_body { length += 1 } else { break } } length } ///| /// Character tokens clear frameset-ok unless they are whitespace; a NUL /// (replaced by U+FFFD in foreign content) takes the dedicated parse /// error path that leaves the flag alone. fn text_clears_frameset_ok(raw : StringView) -> Bool { raw.any(ch => !ch.is_ascii_whitespace() && ch != '\u{0}') }