webapi: spec algorithms for Range.cloneContents and extractContents

Fixes all remaining failures of WPT /dom/ranges/Range-cloneContents.html
(171/187 -> 187/187) and Range-extractContents.html (153/187 ->
187/187):

- cloneContents only handled same-container and sibling-boundary
  ranges. It now implements the DOM "clone the contents" algorithm on
  explicit boundary points: partially contained CharacterData
  boundaries are cloned with the substring, partially contained
  elements are cloned shallow with the subrange recursed into the
  clone, contained nodes are cloned deep, and a contained doctype
  throws HierarchyRequestError.
- extractContents was cloneContents + deleteContents, which recreated
  the extracted nodes ("you created or detached nodes when you weren't
  supposed to"). It now implements the "extract" algorithm: contained
  nodes MOVE into the fragment preserving identity; only the boundary
  CharacterData substrings and the partially contained element shells
  are cloned, with the originals truncated via replaceData. Children
  are classified before anything moves, since moving a contained child
  shifts the indices the boundary comparisons rely on. The range then
  collapses to the same new node/new offset rule as deleteContents.

Coverage:
- /dom/ranges/Range-cloneContents.html 171/187 -> 187/187 (fully green)
- /dom/ranges/Range-extractContents.html 153/187 -> 187/187 (fully green)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Francis Bouvier
2026-07-11 22:10:02 +02:00
committed by Karl Seguin
parent 41823f06c4
commit 7e97e77bab

View File

@@ -470,97 +470,204 @@ fn collectContained(self: *const Range, node: *Node, list: *std.ArrayList(*Node)
}
fn nodeContained(self: *const Range, node: *Node) bool {
const after_start = AbstractRange.compareBoundaryPoints(
return containedBetween(
node,
0,
self._proto._start_container,
self._proto._start_offset,
) == .after;
if (!after_start) return false;
return AbstractRange.compareBoundaryPoints(
node,
node.getLength(),
self._proto._end_container,
self._proto._end_offset,
) == .before;
);
}
pub fn cloneContents(self: *const Range, frame: *Frame) !*DocumentFragment {
const fragment = try DocumentFragment.init(frame);
if (self._proto.getCollapsed()) return fragment;
// Simple case: same container
if (self._proto._start_container == self._proto._end_container) {
if (self._proto._start_container.is(Node.CData)) |_| {
// Clone part of text node
const text_data = self._proto._start_container.getData().str();
const byte_start = byteOffset(text_data, self._proto._start_offset);
const byte_end = byteOffset(text_data, self._proto._end_offset);
if (byte_start < text_data.len and byte_end <= text_data.len) {
const cloned_text = text_data[byte_start..byte_end];
const text_node = try Frame.node_factory.createTextNode(frame, cloned_text);
_ = try fragment.asNode().appendChild(text_node, frame);
}
} else {
// Clone child nodes in range
var offset = self._proto._start_offset;
while (offset < self._proto._end_offset) : (offset += 1) {
if (self._proto._start_container.getChildAt(offset)) |child| {
if (try child.cloneNodeForAppending(true, frame)) |cloned| {
_ = try fragment.asNode().appendChild(cloned, frame);
}
}
}
}
} else {
// Complex case: different containers
// Clone partial start container
if (self._proto._start_container.is(Node.CData)) |_| {
const text_data = self._proto._start_container.getData().str();
const byte_start = byteOffset(text_data, self._proto._start_offset);
if (byte_start < text_data.len) {
// Clone from start_offset to end of text
const cloned_text = text_data[byte_start..];
const text_node = try Frame.node_factory.createTextNode(frame, cloned_text);
_ = try fragment.asNode().appendChild(text_node, frame);
}
}
// Clone nodes between start and end containers (siblings case)
if (self._proto._start_container.parentNode() == self._proto._end_container.parentNode()) {
var current = self._proto._start_container.nextSibling();
while (current != null and current != self._proto._end_container) {
const next = current.?.nextSibling();
if (try current.?.cloneNodeForAppending(true, frame)) |cloned| {
_ = try fragment.asNode().appendChild(cloned, frame);
}
current = next;
}
}
// Clone partial end container
if (self._proto._end_container.is(Node.CData)) |_| {
const text_data = self._proto._end_container.getData().str();
const byte_end = byteOffset(text_data, self._proto._end_offset);
if (byte_end > 0 and byte_end <= text_data.len) {
// Clone from start to end_offset
const cloned_text = text_data[0..byte_end];
const text_node = try Frame.node_factory.createTextNode(frame, cloned_text);
_ = try fragment.asNode().appendChild(text_node, frame);
}
}
}
try cloneContentsBetween(
frame,
fragment.asNode(),
self._proto._start_container,
self._proto._start_offset,
self._proto._end_container,
self._proto._end_offset,
);
return fragment;
}
// The DOM "clone the contents of a range" algorithm, on explicit boundary
// points so the partially-contained recursion doesn't need Range objects.
fn cloneContentsBetween(frame: *Frame, out: *Node, start_node: *Node, start_offset: u32, end_node: *Node, end_offset: u32) !void {
if (start_node == end_node) {
if (start_node.is(Node.CData)) |cdata| {
const data = cdata.getData().str();
const cloned = (try start_node.cloneNodeForAppending(false, frame)) orelse return;
try cloned.setData(data[byteOffset(data, start_offset)..byteOffset(data, end_offset)], frame);
_ = try out.appendChild(cloned, frame);
return;
}
}
// The closest common ancestor of the two boundary points.
var common = start_node;
while (common != end_node and !common.contains(end_node)) {
common = common.parentNode() orelse break;
}
var child = common.firstChild();
while (child) |c| : (child = c.nextSibling()) {
const contains_start = c == start_node or c.contains(start_node);
const contains_end = c == end_node or c.contains(end_node);
if (contains_start) {
// First partially contained child.
if (c.is(Node.CData)) |cdata| {
// c is the start node itself.
const data = cdata.getData().str();
const cloned = (try c.cloneNodeForAppending(false, frame)) orelse continue;
try cloned.setData(data[byteOffset(data, start_offset)..], frame);
_ = try out.appendChild(cloned, frame);
} else {
const cloned = (try c.cloneNodeForAppending(false, frame)) orelse continue;
_ = try out.appendChild(cloned, frame);
try cloneContentsBetween(frame, cloned, start_node, start_offset, c, c.getLength());
}
} else if (contains_end) {
// Last partially contained child.
if (c.is(Node.CData)) |cdata| {
// c is the end node itself.
const data = cdata.getData().str();
const cloned = (try c.cloneNodeForAppending(false, frame)) orelse continue;
try cloned.setData(data[0..byteOffset(data, end_offset)], frame);
_ = try out.appendChild(cloned, frame);
} else {
const cloned = (try c.cloneNodeForAppending(false, frame)) orelse continue;
_ = try out.appendChild(cloned, frame);
try cloneContentsBetween(frame, cloned, c, 0, end_node, end_offset);
}
} else if (containedBetween(c, start_node, start_offset, end_node, end_offset)) {
if (c._type == .document_type) {
return error.HierarchyError;
}
const cloned = (try c.cloneNodeForAppending(true, frame)) orelse continue;
_ = try out.appendChild(cloned, frame);
}
}
}
fn containedBetween(node: *Node, start_node: *Node, start_offset: u32, end_node: *Node, end_offset: u32) bool {
const after_start = AbstractRange.compareBoundaryPoints(node, 0, start_node, start_offset) == .after;
if (!after_start) return false;
return AbstractRange.compareBoundaryPoints(node, node.getLength(), end_node, end_offset) == .before;
}
pub fn extractContents(self: *Range, frame: *Frame) !*DocumentFragment {
const fragment = try self.cloneContents(frame);
try self.deleteContents(frame);
const fragment = try DocumentFragment.init(frame);
if (self._proto.getCollapsed()) return fragment;
frame.domChanged();
const start_node = self._proto._start_container;
const start_offset = self._proto._start_offset;
const end_node = self._proto._end_container;
const end_offset = self._proto._end_offset;
// Where the range collapses to afterwards; same rule as deleteContents.
var new_node = start_node;
var new_offset = start_offset;
if (start_node != end_node and !start_node.contains(end_node)) {
var reference = start_node;
while (reference.parentNode()) |parent| {
if (parent == end_node or parent.contains(end_node)) break;
reference = parent;
}
new_node = reference.parentNode().?;
new_offset = (new_node.getChildIndex(reference) orelse 0) + 1;
}
try extractContentsBetween(frame, fragment.asNode(), start_node, start_offset, end_node, end_offset);
try self.setStart(new_node, new_offset);
try self.setEnd(new_node, new_offset);
return fragment;
}
// The DOM "extract" algorithm: contained nodes MOVE into the output
// (preserving identity); only the partially contained boundary
// CharacterData nodes and the partially contained element shells are cloned.
// Children are classified before anything moves, since moving a contained
// child shifts the indices the boundary comparisons rely on.
fn extractContentsBetween(frame: *Frame, out: *Node, start_node: *Node, start_offset: u32, end_node: *Node, end_offset: u32) !void {
if (start_node == end_node) {
if (start_node.is(Node.CData)) |cdata| {
const data = cdata.getData().str();
const cloned = (try start_node.cloneNodeForAppending(false, frame)) orelse return;
try cloned.setData(data[byteOffset(data, start_offset)..byteOffset(data, end_offset)], frame);
_ = try out.appendChild(cloned, frame);
try cdata.replaceData(start_offset, end_offset - start_offset, "", frame);
return;
}
}
var common = start_node;
while (common != end_node and !common.contains(end_node)) {
common = common.parentNode() orelse break;
}
var first_partial: ?*Node = null;
var last_partial: ?*Node = null;
var contained: std.ArrayList(*Node) = .empty;
var child = common.firstChild();
while (child) |c| : (child = c.nextSibling()) {
if (c == start_node or c.contains(start_node)) {
first_partial = c;
} else if (c == end_node or c.contains(end_node)) {
last_partial = c;
} else if (containedBetween(c, start_node, start_offset, end_node, end_offset)) {
if (c._type == .document_type) {
return error.HierarchyError;
}
try contained.append(frame.call_arena, c);
}
}
if (first_partial) |c| {
if (c.is(Node.CData)) |cdata| {
// c is the start node itself.
const data = cdata.getData().str();
const byte_start = byteOffset(data, start_offset);
const cloned = (try c.cloneNodeForAppending(false, frame)) orelse return;
try cloned.setData(data[byte_start..], frame);
_ = try out.appendChild(cloned, frame);
const length: u32 = @intCast(cdata.getLength());
try cdata.replaceData(start_offset, length - start_offset, "", frame);
} else {
const cloned = (try c.cloneNodeForAppending(false, frame)) orelse return;
_ = try out.appendChild(cloned, frame);
try extractContentsBetween(frame, cloned, start_node, start_offset, c, c.getLength());
}
}
for (contained.items) |c| {
_ = try out.appendChild(c, frame);
}
if (last_partial) |c| {
if (c.is(Node.CData)) |cdata| {
// c is the end node itself.
const data = cdata.getData().str();
const cloned = (try c.cloneNodeForAppending(false, frame)) orelse return;
try cloned.setData(data[0..byteOffset(data, end_offset)], frame);
_ = try out.appendChild(cloned, frame);
try cdata.replaceData(0, end_offset, "", frame);
} else {
const cloned = (try c.cloneNodeForAppending(false, frame)) orelse return;
_ = try out.appendChild(cloned, frame);
try extractContentsBetween(frame, cloned, c, 0, end_node, end_offset);
}
}
}
pub fn surroundContents(self: *Range, new_parent: *Node, frame: *Frame) !void {
// Extract contents
const contents = try self.extractContents(frame);