diff --git a/packages/core/src/layout/LayoutTree.zig b/packages/core/src/layout/LayoutTree.zig index 0394015..f64263a 100644 --- a/packages/core/src/layout/LayoutTree.zig +++ b/packages/core/src/layout/LayoutTree.zig @@ -50,6 +50,7 @@ pub fn getNodePtr(self: *Self, id: LayoutNode.Id) *LayoutNode { pub const LayoutNode = struct { id: Id, + parent: ?Id = null, data: Data, pub const Id = u32; pub const Data = union(enum) { @@ -77,8 +78,10 @@ pub const TextNode = struct { pub const InlineNode = struct { ref: DocRef, - is_atomic: bool, + is_atomic: bool = false, children: Array(LayoutNode.Id) = .{}, + continuation: ?LayoutNode.Id = null, + continuationOf: ?LayoutNode.Id = null, pub fn deinit(self: *InlineNode, allocator: std.mem.Allocator) void { self.children.deinit(allocator); } @@ -106,6 +109,8 @@ pub const InlineContainerNode = struct { ref: DocRef, children: Array(LayoutNode.Id) = .{}, line_boxes: Array(LineBox) = .{}, + continuation: ?LayoutNode.Id = null, + continuationOf: ?LayoutNode.Id = null, pub fn deinit(self: *InlineContainerNode, allocator: std.mem.Allocator) void { self.children.deinit(allocator); self.line_boxes.deinit(allocator); @@ -136,107 +141,211 @@ fn nodeIsInline(tree: *DocTree, node_id: DocNodeId) bool { if (kind == .text) return true; return tree.getStyle(node_id).display.outside == .@"inline"; } +fn isOnlyInlineSubtree(tree: *DocTree, node_id: DocNodeId) bool { + const kind = tree.getNodeKind(node_id); + if (kind == .text) return true; + const style = tree.getStyle(node_id); + if (style.display.outside != .@"inline") return false; + if (isAtomicInline(tree, node_id)) return true; + for (tree.getNodeChildren(node_id)) |child| { + if (!isOnlyInlineSubtree(tree, child)) return false; + } + return true; +} +pub fn isDisplayNone(tree: *DocTree, node_id: DocNodeId) bool { + return tree.getStyle(node_id).display.outside == .none; +} +pub fn isInlineFlow(tree: *DocTree, node_id: DocNodeId) bool { + const style = tree.getStyle(node_id); + return style.display.outside == .@"inline" and style.display.inside == .flow; +} +pub fn isAtomicInline(tree: *DocTree, node_id: DocNodeId) bool { + const style = tree.getStyle(node_id); + return style.display.outside == .@"inline" and style.display.inside != .flow; +} + +const BuildError = error{ + OutOfMemory, + InvalidParent, +}; +const MixedContextBuilder = struct { + layout_tree: *Self, + doc_tree: *DocTree, + root_container_id: LayoutNode.Id, + current_container_id: LayoutNode.Id, + allocator: std.mem.Allocator, + stack: Array(LayoutNode.Id) = .{}, + pub fn isCurrentContainerInline(self: *MixedContextBuilder) bool { + const current_container = self.layout_tree.getNodePtr(self.current_container_id); + return switch (current_container.data) { + .inline_container_node => true, + .block_container_node => false, + else => unreachable, + }; + } + pub fn init(allocator: std.mem.Allocator, layout_tree: *Self, doc_tree: *DocTree, root_container_id: LayoutNode.Id) !MixedContextBuilder { + return MixedContextBuilder{ + .allocator = allocator, + .layout_tree = layout_tree, + .doc_tree = doc_tree, + .root_container_id = root_container_id, + .current_container_id = root_container_id, + }; + } + pub fn getCurrentParent(self: *MixedContextBuilder) LayoutNode.Id { + return if (self.stack.items.len > 0) self.stack.items[self.stack.items.len - 1] else self.current_container_id; + } + pub fn createBlockContainer(self: *MixedContextBuilder) !LayoutNode.Id { + const id = try self.layout_tree.createNode(.{ .block_container_node = .{ .ref = .anonymous } }); + try self.layout_tree.appendNode(self.root_container_id, id); + self.current_container_id = id; + return id; + } + pub fn createInlineContainer(self: *MixedContextBuilder, parent_id: LayoutNode.Id) !LayoutNode.Id { + const id = try self.layout_tree.createNode(.{ .inline_container_node = .{ .ref = .anonymous } }); + try self.layout_tree.appendNode(parent_id, id); + self.current_container_id = id; + return id; + } + pub fn appendNode(self: *MixedContextBuilder, child_id: LayoutNode.Id) !void { + if (!self.isCurrentContainerInline()) { + try self.splitStack(); + } + try self.layout_tree.appendNode(self.getCurrentParent(), child_id); + } + pub fn splitStack(self: *MixedContextBuilder) !void { + var parent = try self.createInlineContainer(self.root_container_id); + for (0..self.stack.items.len) |i| { + const id = self.stack.items[i]; + var node = self.layout_tree.getNodePtr(id); + switch (node.data) { + .inline_container_node => { + const clone_inline_container_node_id = try self.layout_tree.createNode(.{ .inline_container_node = .{ .ref = .anonymous } }); + node = self.layout_tree.getNodePtr(id); + node.data.inline_container_node.continuation = clone_inline_container_node_id; + const clone_node = self.layout_tree.getNodePtr(clone_inline_container_node_id); + clone_node.data.inline_container_node.continuationOf = id; + + try self.layout_tree.appendNode(parent, clone_inline_container_node_id); + parent = clone_inline_container_node_id; + self.stack.items[i] = clone_inline_container_node_id; + }, + .inline_node => { + const clone_inline_node_id = try self.layout_tree.createNode(.{ .inline_node = .{ .ref = .{ .doc_node = node.data.inline_node.ref.doc_node } } }); + node = self.layout_tree.getNodePtr(id); + node.data.inline_node.continuation = clone_inline_node_id; + const clone_node = self.layout_tree.getNodePtr(clone_inline_node_id); + clone_node.data.inline_node.continuationOf = id; + try self.layout_tree.appendNode(parent, clone_inline_node_id); + parent = clone_inline_node_id; + self.stack.items[i] = clone_inline_node_id; + }, + else => unreachable, + } + } + } + pub fn build(self: *MixedContextBuilder) BuildError!void { + const children = self.doc_tree.getNodeChildren(self.root_container_id); + for (children) |child| { + if (isDisplayNone(self.doc_tree, child)) continue; + try self.buildFromNode(child); + } + } + pub fn buildFromNode(self: *MixedContextBuilder, node_id: DocNodeId) BuildError!void { + const kind = self.doc_tree.getNodeKind(node_id); + if (kind == .text) { + const text = self.doc_tree.getText(node_id).bytes.items; + const id = try self.layout_tree.createTextNode(text); + try self.appendNode(id); + + // return id; + return; + } + if (isAtomicInline(self.doc_tree, node_id)) { + const id = try self.layout_tree.buildInsideBlock(self.doc_tree, node_id); + try self.appendNode(id); + return; + } + if (isInlineFlow(self.doc_tree, node_id)) { + const id = try self.layout_tree.createNode(.{ .inline_node = .{ .ref = .{ .doc_node = node_id } } }); + try self.layout_tree.appendNode(self.getCurrentParent(), id); + // push to the stack + try self.stack.append(self.allocator, id); + defer _ = self.stack.pop(); + const children = self.doc_tree.getNodeChildren(node_id); + for (children) |child| { + if (isDisplayNone(self.doc_tree, child)) continue; + try self.buildFromNode(child); + } + return; + } + + // otherwise it's a block + const block_container_id = if (self.isCurrentContainerInline()) try self.createBlockContainer() else self.current_container_id; + const block_node = try self.layout_tree.buildInsideBlock(self.doc_tree, node_id); + try self.layout_tree.appendNode(block_container_id, block_node); + } + pub fn deinit(self: *MixedContextBuilder) void { + self.stack.deinit(self.allocator); + } +}; /// Recursively convert the DOM starting at `node_id` into layout nodes. /// Returns the id of the created layout node or `null` if the DOM node should /// not produce a layout representation. -fn build(self: *Self, tree: *DocTree, node_id: DocNodeId) !?LayoutNode.Id { +fn build(self: *Self, tree: *DocTree, node_id: DocNodeId) BuildError!LayoutNode.Id { const kind = tree.getNodeKind(node_id); - // 1. Text DOM nodes map directly to layout text nodes. Empty text nodes are - // ignored. + // 1. Text DOM nodes map directly to layout text nodes. if (kind == .text) { const text = tree.getText(node_id).bytes.items; - if (text.len == 0) return null; const id = try self.createTextNode(text); return id; } - const style = tree.getStyle(node_id); + if (style.display.inside != .flow) { + return self.buildInsideBlock(tree, node_id); + } - // 2. Nodes with `display: none` do not participate in layout. - if (style.display.outside == .none) return null; - - // 3. Inline-level elements produce an `InlineNode` and simply convert all of - // their children. - if (style.display.outside == .@"inline") { - const id = try self.createNode(.{ .inline_node = .{ .ref = .{ .doc_node = node_id }, .is_atomic = false } }); + // 2. Atomic inline elements produce an `InlineNode`, right now we dont have other types of atomic inline elements besides inline-block or inline-flex + if (isInlineFlow(tree, node_id)) { + const id = try self.createNode(.{ .inline_node = .{ .ref = .{ .doc_node = node_id } } }); for (tree.getNodeChildren(node_id)) |child| { - if (try self.build(tree, child)) |child_id| { - try self.appendNode(id, child_id); - } + if (isDisplayNone(tree, child)) continue; + const child_layout_node_id = try self.build(tree, child); + try self.appendNode(id, child_layout_node_id); } return id; } - + unreachable; +} +pub fn buildInsideBlock(self: *Self, tree: *DocTree, node_id: DocNodeId) !LayoutNode.Id { const children = tree.getNodeChildren(node_id); - var only_inline = true; - - // Determine whether every visible child is inline-level so we know what - // kind of container to create. + var only_inline_children = true; for (children) |child| { - if (!nodeIsInline(tree, child)) { - if (tree.getStyle(child).display.outside != .none) { - only_inline = false; - break; - } + if (isDisplayNone(tree, child)) continue; + if (!isOnlyInlineSubtree(tree, child)) { + only_inline_children = false; } } - - if (only_inline) { - // 4. If all children are inline, wrap them in an `InlineContainerNode` - // so they participate in the inline formatting context. - const id = try self.createNode(.{ .inline_container_node = .{ .ref = .{ .doc_node = node_id } } }); + if (only_inline_children) { + const inline_container_id = try self.createNode(.{ .inline_container_node = .{ .ref = .{ .doc_node = node_id } } }); for (children) |child| { - if (try self.build(tree, child)) |child_id| { - try self.appendNode(id, child_id); - } + if (isDisplayNone(tree, child)) continue; + const child_layout_node_id = try self.build(tree, child); + try self.appendNode(inline_container_id, child_layout_node_id); } - return id; + return inline_container_id; } - // 5. Otherwise we create a `BlockContainerNode` and insert anonymous inline - // containers around contiguous inline children to preserve block model - // invariants. const container_id = try self.createNode(.{ .block_container_node = .{ .ref = .{ .doc_node = node_id } } }); - var inline_seq: Array(LayoutNode.Id) = .{}; - defer inline_seq.deinit(self.allocator); - - for (children) |child| { - const child_is_inline = nodeIsInline(tree, child); - const maybe_child = try self.build(tree, child); - if (maybe_child == null) continue; - const l_id = maybe_child.?; - - if (child_is_inline) { - // Accumulate inline children so they can be wrapped together. - try inline_seq.append(self.allocator, l_id); - } else { - // Flush any collected inline children before appending the block. - if (inline_seq.items.len > 0) { - const anon = try self.createNode(.{ .inline_container_node = .{ .ref = .anonymous } }); - for (inline_seq.items) |iid| { - try self.appendNode(anon, iid); - } - try self.appendNode(container_id, anon); - inline_seq.clearRetainingCapacity(); - } - try self.appendNode(container_id, l_id); - } - } - - // Flush trailing inline children. - if (inline_seq.items.len > 0) { - const anon = try self.createNode(.{ .inline_container_node = .{ .ref = .anonymous } }); - for (inline_seq.items) |iid| { - try self.appendNode(anon, iid); - } - try self.appendNode(container_id, anon); - } - + var mixed_context_builder = try MixedContextBuilder.init(self.allocator, self, tree, container_id); + try mixed_context_builder.build(); + mixed_context_builder.deinit(); + // try self.build(node_id); + // const container_id = try self.appendNode(parent_id: LayoutNode.Id, child_id: LayoutNode.Id) return container_id; } - fn writeDocRef(writer: std.io.AnyWriter, ref: DocRef) !void { switch (ref) { .anonymous => try writer.writeAll("{anon}"), @@ -276,6 +385,13 @@ fn printNodeInternal(self: *Self, node_id: LayoutNode.Id, writer: std.io.AnyWrit if (inline_node.is_atomic) { try writer.print(" atomic", .{}); } + if (inline_node.continuationOf) |continuation_of| { + try writer.print(" continuationOf={{#{d}}}", .{continuation_of}); + } + if (inline_node.continuation) |continuation| { + try writer.print(" continuation={{#{d}}}", .{continuation}); + } + try writer.print(" ref=", .{}); try writeDocRef(writer, inline_node.ref); try writer.print(" children={{{d}}}]", .{inline_node.children.items.len}); @@ -283,11 +399,18 @@ fn printNodeInternal(self: *Self, node_id: LayoutNode.Id, writer: std.io.AnyWrit .block_container_node => |block| { try writer.print("[{s} #{d} ref=", .{ @tagName(node.data), node.id }); try writeDocRef(writer, block.ref); + try writer.print(" children={{{d}}}]", .{block.children.items.len}); }, .inline_container_node => |container| { try writer.print("[{s} #{d} ref=", .{ @tagName(node.data), node.id }); try writeDocRef(writer, container.ref); + if (container.continuationOf) |continuation_of| { + try writer.print(" continuationOf={{#{d}}}", .{continuation_of}); + } + if (container.continuation) |continuation| { + try writer.print(" continuation={{#{d}}}", .{continuation}); + } try writer.print(" children={{{d}}} lines={{{d}}}]", .{ container.children.items.len, container.line_boxes.items.len }); }, } @@ -349,36 +472,13 @@ test "LayoutTree" { \\ zzz \\ , - \\[block_container_node #0 ref={anon} children={2}] - \\├── [inline_container_node #1 ref={anon} children={1} lines={0}] - \\│ └── [inline_node #2 atomic={false} ref={anon} children={2}] - \\│ ├── [text_node #3] "abc" - \\│ └── [text_node #4] "def" - \\└── [text_node #5] "zzz" - ); -} - -test "fromTree inline only" { - const allocator = std.testing.allocator; - var doc = try docFromXml(allocator, "
abcdef
", .{}); - defer doc.deinit(); - - var lt = try fromTree(allocator, &doc); - defer lt.deinit(); - - var buf = std.ArrayList(u8).init(allocator); - defer buf.deinit(); - try lt.printRoot(buf.writer().any()); - - const expected = \\[inline_container_node #0 ref={doc#0} children={2} lines={0}] - \\├── [inline_node #1 ref={doc#1} children={1}] - \\│ └── [text_node #2] "abc" - \\└── [inline_node #3 ref={doc#3} children={1}] - \\ └── [text_node #4] "def" + \\├── [inline_node #1 ref={doc#1} children={2}] + \\│ ├── [text_node #2] "abc" + \\│ └── [text_node #3] "def" + \\└── [text_node #4] "zzz" \\ - ; - try std.testing.expectEqualStrings(buf.items, expected); + ); } test "deep formatting context break" { @@ -400,22 +500,66 @@ test "deep formatting context break" { // More italic and bold text More italic text // try expectLayoutTree("deep formatting context break", - \\Italic only italic and bold
Wow, a block!
Wow, another block!
More italic and bold text
More italic text
+ \\ + \\ Italic only + \\ + \\ italic and bold + \\
Wow, a block!
+ \\
Wow, another block!
+ \\ More italic and bold text + \\
+ \\ More italic text + \\
+ \\ , - \\[block_container_node #0 ref={anon} children={2}] - \\├── [inline_container_node #1 ref={doc#0} children={1} lines={0}] - \\│ ├── [text_node #2] "Italic only " - \\│ └── [inline_node #3 ref={doc#1} children={2}] + \\[block_container_node #0 ref={doc#0} children={3}] + \\├── [inline_container_node #2 ref={anon} children={2} lines={0}] + \\│ ├── [text_node #1] "Italic only" + \\│ └── [inline_node #3 continuation={#12} ref={doc#2} children={1}] \\│ └── [text_node #4] "italic and bold" \\├── [block_container_node #5 ref={anon} children={2}] - \\│ ├── [block_container_node #6 ref={anon} children={1}] + \\│ ├── [inline_container_node #6 ref={doc#4} children={1} lines={0}] \\│ │ └── [text_node #7] "Wow, a block!" - \\│ └── [block_container_node #8 ref={anon} children={1}] + \\│ └── [inline_container_node #8 ref={doc#6} children={1} lines={0}] \\│ └── [text_node #9] "Wow, another block!" - \\└── [inline_container_node #9 ref={doc#0} children={1} lines={0}] - \\ └── [inline_node #10 ref={doc#2} children={2}] - \\ ├── [text_node #11] "More italic and bold text" - \\ └── [text_node #12] "More italic text" + \\└── [inline_container_node #11 ref={anon} children={2} lines={0}] + \\ ├── [inline_node #12 continuationOf={#3} ref={doc#2} children={1}] + \\ │ └── [text_node #10] "More italic and bold text" + \\ └── [text_node #13] "More italic text" + \\ + ); + + try expectLayoutTree("deep formatting context break 2", + \\ + \\ Italic only + \\ + \\ italic and bold + \\
Wow, a block!
+ \\ + \\
Wow, another block!
+ \\ More italic and bold text + \\
+ \\
+ \\ More italic text + \\
+ \\ + , + \\[block_container_node #0 ref={doc#0} children={3}] + \\├── [inline_container_node #2 ref={anon} children={2} lines={0}] + \\│ ├── [text_node #1] "Italic only" + \\│ └── [inline_node #3 continuation={#13} ref={doc#2} children={2}] + \\│ ├── [text_node #4] "italic and bold" + \\│ └── [inline_node #8 continuation={#14} ref={doc#6} children={0}] + \\├── [block_container_node #5 ref={anon} children={2}] + \\│ ├── [inline_container_node #6 ref={doc#4} children={1} lines={0}] + \\│ │ └── [text_node #7] "Wow, a block!" + \\│ └── [inline_container_node #9 ref={doc#7} children={1} lines={0}] + \\│ └── [text_node #10] "Wow, another block!" + \\└── [inline_container_node #12 ref={anon} children={2} lines={0}] + \\ ├── [inline_node #13 continuationOf={#3} ref={doc#2} children={1}] + \\ │ └── [inline_node #14 continuationOf={#8} ref={doc#6} children={1}] + \\ │ └── [text_node #11] "More italic and bold text" + \\ └── [text_node #15] "More italic text" \\ ); } diff --git a/packages/core/src/layout/doc-from-xml.zig b/packages/core/src/layout/doc-from-xml.zig index 03bcf2d..6a1ba9c 100644 --- a/packages/core/src/layout/doc-from-xml.zig +++ b/packages/core/src/layout/doc-from-xml.zig @@ -1,13 +1,23 @@ +// Helper functions to convert a small XML subset into the document tree used by +// the layout tests. The parser is intentionally simple and exists only so the +// layout code can be tested without pulling in a full HTML parser. const xml = @import("../xml.zig"); const std = @import("std"); const Tree = @import("../tree/Tree.zig"); +/// Simple configuration options used when converting XML into a test DOM tree. pub const Options = struct { + /// Discard text nodes that only contain whitespace. ignore_empty_text: bool = true, + /// Remove leading and trailing whitespace from text nodes. trim_text: bool = true, + /// Split text nodes on newline characters into multiple nodes. split_lines: bool = true, }; +/// Parse a string of XML into the document tree representation understood by +/// the layout code. The resulting DOM tree is independent of the XML parser +/// after this function returns. pub fn docFromXml(allocator: std.mem.Allocator, xml_string: []const u8, options: Options) !Tree { var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); @@ -45,12 +55,16 @@ fn trimText(text: []const u8) []const u8 { return std.mem.trim(u8, text, " \n\t\r"); } +/// Recursively build the Tree representation from a parsed XML element. fn nodeFromXmlElement(tree: *Tree, element: *xml.Element, options: Options) TreeFromXmlError!Tree.Node.NodeId { + // Create a DOM node corresponding to this element. const node_id = try tree.createNode(); + // Walk all children of the element and create corresponding DOM nodes. for (element.children) |child| { switch (child) { .char_data => { + // Text nodes may be dropped or split according to the options. if (options.ignore_empty_text and isEmpty(child.char_data)) { continue; } @@ -58,7 +72,9 @@ fn nodeFromXmlElement(tree: *Tree, element: *xml.Element, options: Options) Tree var iter = std.mem.splitScalar(u8, child.char_data, '\n'); while (iter.next()) |line| { - const text_node_id = try tree.createTextNode(if (options.trim_text) trimText(line) else line); + const text = if (options.trim_text) trimText(line) else line; + if (text.len == 0) continue; + const text_node_id = try tree.createTextNode(text); _ = try tree.appendChild(node_id, text_node_id); } } else { @@ -67,9 +83,12 @@ fn nodeFromXmlElement(tree: *Tree, element: *xml.Element, options: Options) Tree } }, .comment => { - // ignore + // Comments are ignored entirely. }, .element => { + // Recursively build the subtree for the child element and + // assign inline style hints for some HTML-like tags used in the + // tests. const child_id = try nodeFromXmlElement(tree, child.element, options); var child_node = tree.getNode(child_id); if (std.mem.eql(u8, child.element.tag, "span")) { @@ -88,6 +107,7 @@ fn nodeFromXmlElement(tree: *Tree, element: *xml.Element, options: Options) Tree return node_id; } test "treeFromXml" { + // Basic sanity test to print the generated tree to stderr during testing. var tree = try docFromXml(std.testing.allocator, "
Hello, world!
", .{}); defer tree.deinit(); const stderr = std.io.getStdErr().writer().any();