From 75b3a163c7371bdea4839e3f37e4ad6807bca4d4 Mon Sep 17 00:00:00 2001 From: mathias234 Date: Tue, 2 Dec 2025 15:00:39 +0100 Subject: [PATCH] Implement nested lists --- README.md | 39 +++++++++++++- examples/full_demo.rb | 36 +++++++++++++ lib/notare/builder.rb | 40 ++++++++++----- lib/notare/nodes/list_item.rb | 5 +- lib/notare/xml/document_xml.rb | 2 +- lib/notare/xml/numbering.rb | 35 +++++++++---- test/list_test.rb | 94 ++++++++++++++++++++++++++++++++++ 7 files changed, 222 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 20d7ca5..55375ef 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,40 @@ Notare::Document.create("output.docx") do |doc| end ``` +#### Nested Lists + +Lists can be nested inside list items. Mixed types (bullets inside numbered or vice versa) are supported. + +```ruby +Notare::Document.create("output.docx") do |doc| + doc.ol do + doc.li "First item" + doc.li "Second item" do + doc.ul do + doc.li "Nested bullet A" + doc.li "Nested bullet B" + end + end + doc.li "Third item" + end + + # Deeply nested + doc.ul do + doc.li "Level 0" + doc.li "Has children" do + doc.ul do + doc.li "Level 1" + doc.li "Goes deeper" do + doc.ul do + doc.li "Level 2" + end + end + end + end + end +end +``` + ### Tables ```ruby @@ -400,10 +434,11 @@ end | `link(url, text)` | Hyperlink with custom text | | `link(url) { }` | Hyperlink with block content | | `define_style(name, **props)` | Define a custom style | -| `ul { }` | Bullet list | -| `ol { }` | Numbered list | +| `ul { }` | Bullet list (can be nested) | +| `ol { }` | Numbered list (can be nested) | | `li(text)` | List item with text | | `li { }` | List item with block content | +| `li(text) { }` | List item with text and nested content | | `table { }` | Table | | `tr { }` | Table row | | `td(text)` | Table cell with text | diff --git a/examples/full_demo.rb b/examples/full_demo.rb index 4502a24..af2ffb0 100644 --- a/examples/full_demo.rb +++ b/examples/full_demo.rb @@ -151,6 +151,37 @@ Notare::Document.create(OUTPUT_FILE) do |doc| doc.li "Step three" end + doc.h3 "Nested Lists" + doc.ol do + doc.li "Main topic one" + doc.li "Main topic two" do + doc.ul do + doc.li "Supporting point A" + doc.li "Supporting point B" do + doc.ul do + doc.li "Detail 1" + doc.li "Detail 2" + end + end + doc.li "Supporting point C" + end + end + doc.li "Main topic three" + end + + doc.p "Mixed nested lists with formatting:" + doc.ul do + doc.li do + doc.b { doc.text "Bold parent item" } + end + doc.li "Item with nested numbered list" do + doc.ol do + doc.li "First sub-step" + doc.li "Second sub-step" + end + end + end + # ============================================================================ # 8. Hyperlinks # ============================================================================ @@ -238,6 +269,11 @@ Notare::Document.create(OUTPUT_FILE) do |doc| doc.td { doc.text "Complete", style: :success } doc.td "PNG and JPEG" end + doc.tr do + doc.td "Nested Lists" + doc.td { doc.text "Complete", style: :success } + doc.td "Multi-level with mixed types" + end end # ============================================================================ diff --git a/lib/notare/builder.rb b/lib/notare/builder.rb index 9fee3c2..7c7479f 100644 --- a/lib/notare/builder.rb +++ b/lib/notare/builder.rb @@ -93,12 +93,10 @@ module Notare end def li(text = nil, &block) - item = Nodes::ListItem.new([], list_type: @current_list.type, num_id: @current_list.num_id) - if block - with_target(item, &block) - elsif text - item.add_run(Nodes::Run.new(text, **current_formatting)) - end + current_type = @list_type_stack.last + item = Nodes::ListItem.new([], list_type: current_type, num_id: @current_list.num_id, level: @list_level) + item.add_run(Nodes::Run.new(text, **current_formatting)) if text + with_target(item, &block) if block @current_list.add_item(item) end @@ -134,15 +132,31 @@ module Notare def list(type, &block) @num_id_counter ||= 0 - @num_id_counter += 1 - mark_has_lists! + @list_level ||= 0 + @list_type_stack ||= [] - list_node = Nodes::List.new(type: type, num_id: @num_id_counter) previous_list = @current_list - @current_list = list_node - block.call - @current_list = previous_list - @nodes << list_node + nested = !previous_list.nil? + + if nested + # Nested list: reuse parent list, push new type, increment level + @list_level += 1 + @list_type_stack.push(type) + block.call + @list_type_stack.pop + @list_level -= 1 + else + # Top-level list: new List node + @num_id_counter += 1 + mark_has_lists! + list_node = Nodes::List.new(type: type, num_id: @num_id_counter) + @list_type_stack.push(type) + @current_list = list_node + block.call + @current_list = previous_list + @list_type_stack.pop + @nodes << list_node + end end def with_format(format, &block) diff --git a/lib/notare/nodes/list_item.rb b/lib/notare/nodes/list_item.rb index 78d73d9..c159433 100644 --- a/lib/notare/nodes/list_item.rb +++ b/lib/notare/nodes/list_item.rb @@ -3,13 +3,14 @@ module Notare module Nodes class ListItem < Base - attr_reader :runs, :list_type, :num_id + attr_reader :runs, :list_type, :num_id, :level - def initialize(runs = [], list_type:, num_id:) + def initialize(runs = [], list_type:, num_id:, level: 0) super() @runs = runs @list_type = list_type @num_id = num_id + @level = level end def add_run(run) diff --git a/lib/notare/xml/document_xml.rb b/lib/notare/xml/document_xml.rb index ec808ed..f83f50a 100644 --- a/lib/notare/xml/document_xml.rb +++ b/lib/notare/xml/document_xml.rb @@ -69,7 +69,7 @@ module Notare xml["w"].p do xml["w"].pPr do xml["w"].numPr do - xml["w"].ilvl("w:val" => "0") + xml["w"].ilvl("w:val" => item.level.to_s) xml["w"].numId("w:val" => item.num_id.to_s) end end diff --git a/lib/notare/xml/numbering.rb b/lib/notare/xml/numbering.rb index 70498a2..c9ad5f4 100644 --- a/lib/notare/xml/numbering.rb +++ b/lib/notare/xml/numbering.rb @@ -4,6 +4,8 @@ module Notare module Xml class Numbering NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + BULLET_CHARS = ["•", "○", "■"].freeze + NUMBER_FORMATS = %w[decimal lowerLetter lowerRoman].freeze def initialize(lists) @lists = lists @@ -28,13 +30,16 @@ module Notare def render_abstract_num(xml, list) xml["w"].abstractNum("w:abstractNumId" => list.num_id.to_s) do - xml["w"].lvl("w:ilvl" => "0") do - xml["w"].start("w:val" => "1") - xml["w"].numFmt("w:val" => num_format(list.type)) - xml["w"].lvlText("w:val" => lvl_text(list.type)) - xml["w"].lvlJc("w:val" => "left") - xml["w"].pPr do - xml["w"].ind("w:left" => "720", "w:hanging" => "360") + 9.times do |level| + xml["w"].lvl("w:ilvl" => level.to_s) do + xml["w"].start("w:val" => "1") + xml["w"].numFmt("w:val" => num_format_for_level(list.type, level)) + xml["w"].lvlText("w:val" => lvl_text_for_level(list.type, level)) + xml["w"].lvlJc("w:val" => "left") + xml["w"].pPr do + left = 720 * (level + 1) + xml["w"].ind("w:left" => left.to_s, "w:hanging" => "360") + end end end end @@ -46,12 +51,20 @@ module Notare end end - def num_format(type) - type == :bullet ? "bullet" : "decimal" + def num_format_for_level(type, level) + if type == :bullet + "bullet" + else + NUMBER_FORMATS[level % NUMBER_FORMATS.length] + end end - def lvl_text(type) - type == :bullet ? "•" : "%1." + def lvl_text_for_level(type, level) + if type == :bullet + BULLET_CHARS[level % BULLET_CHARS.length] + else + "%#{level + 1}." + end end end end diff --git a/test/list_test.rb b/test/list_test.rb index 9fc914e..b6facd4 100644 --- a/test/list_test.rb +++ b/test/list_test.rb @@ -144,4 +144,98 @@ class ListTest < Minitest::Test refute xml_files.key?("word/numbering.xml"), "numbering.xml should not exist without lists" end + + # + # Nested List Tests + # + + def test_nested_bullet_list + xml = create_doc_and_read_xml do |doc| + doc.ul do + doc.li "Parent item" + doc.li "Item with nested list" do + doc.ul do + doc.li "Nested item 1" + doc.li "Nested item 2" + end + end + doc.li "Another parent item" + end + end + + assert_includes xml, "Parent item" + assert_includes xml, "Nested item 1" + assert_includes xml, "Nested item 2" + assert_includes xml, "Another parent item" + # Check for level 0 and level 1 + assert_includes xml, 'w:ilvl w:val="0"' + assert_includes xml, 'w:ilvl w:val="1"' + end + + def test_nested_numbered_list + xml = create_doc_and_read_xml do |doc| + doc.ol do + doc.li "First" + doc.li "Second with nested" do + doc.ol do + doc.li "Nested 1" + doc.li "Nested 2" + end + end + doc.li "Third" + end + end + + assert_includes xml, "First" + assert_includes xml, "Nested 1" + assert_includes xml, "Third" + assert_includes xml, 'w:ilvl w:val="0"' + assert_includes xml, 'w:ilvl w:val="1"' + end + + def test_mixed_nested_list + xml_files = create_doc_and_read_all_xml do |doc| + doc.ol do + doc.li "Numbered item 1" + doc.li "Numbered item 2" do + doc.ul do + doc.li "Bullet inside numbered" + end + end + doc.li "Numbered item 3" + end + end + + doc_xml = xml_files["word/document.xml"] + assert_includes doc_xml, "Numbered item 1" + assert_includes doc_xml, "Bullet inside numbered" + assert_includes doc_xml, "Numbered item 3" + assert_includes doc_xml, 'w:ilvl w:val="0"' + assert_includes doc_xml, 'w:ilvl w:val="1"' + end + + def test_deeply_nested_list + xml = create_doc_and_read_xml do |doc| + doc.ul do + doc.li "Level 0" + doc.li "Has nested" do + doc.ul do + doc.li "Level 1" + doc.li "Has deeper nested" do + doc.ul do + doc.li "Level 2" + end + end + end + end + end + end + + assert_includes xml, "Level 0" + assert_includes xml, "Level 1" + assert_includes xml, "Level 2" + assert_includes xml, 'w:ilvl w:val="0"' + assert_includes xml, 'w:ilvl w:val="1"' + assert_includes xml, 'w:ilvl w:val="2"' + end end