Merge pull request 'Implement nested lists' (#7) from feature/nested-lists into main
All checks were successful
CI Pipeline / build (push) Successful in 12s

Reviewed-on: #7
This commit is contained in:
2025-12-02 14:00:57 +00:00
7 changed files with 222 additions and 29 deletions

View File

@@ -195,6 +195,40 @@ Notare::Document.create("output.docx") do |doc|
end 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 ### Tables
```ruby ```ruby
@@ -400,10 +434,11 @@ end
| `link(url, text)` | Hyperlink with custom text | | `link(url, text)` | Hyperlink with custom text |
| `link(url) { }` | Hyperlink with block content | | `link(url) { }` | Hyperlink with block content |
| `define_style(name, **props)` | Define a custom style | | `define_style(name, **props)` | Define a custom style |
| `ul { }` | Bullet list | | `ul { }` | Bullet list (can be nested) |
| `ol { }` | Numbered list | | `ol { }` | Numbered list (can be nested) |
| `li(text)` | List item with text | | `li(text)` | List item with text |
| `li { }` | List item with block content | | `li { }` | List item with block content |
| `li(text) { }` | List item with text and nested content |
| `table { }` | Table | | `table { }` | Table |
| `tr { }` | Table row | | `tr { }` | Table row |
| `td(text)` | Table cell with text | | `td(text)` | Table cell with text |

View File

@@ -151,6 +151,37 @@ Notare::Document.create(OUTPUT_FILE) do |doc|
doc.li "Step three" doc.li "Step three"
end 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 # 8. Hyperlinks
# ============================================================================ # ============================================================================
@@ -238,6 +269,11 @@ Notare::Document.create(OUTPUT_FILE) do |doc|
doc.td { doc.text "Complete", style: :success } doc.td { doc.text "Complete", style: :success }
doc.td "PNG and JPEG" doc.td "PNG and JPEG"
end end
doc.tr do
doc.td "Nested Lists"
doc.td { doc.text "Complete", style: :success }
doc.td "Multi-level with mixed types"
end
end end
# ============================================================================ # ============================================================================

View File

@@ -93,12 +93,10 @@ module Notare
end end
def li(text = nil, &block) def li(text = nil, &block)
item = Nodes::ListItem.new([], list_type: @current_list.type, num_id: @current_list.num_id) current_type = @list_type_stack.last
if block item = Nodes::ListItem.new([], list_type: current_type, num_id: @current_list.num_id, level: @list_level)
with_target(item, &block) item.add_run(Nodes::Run.new(text, **current_formatting)) if text
elsif text with_target(item, &block) if block
item.add_run(Nodes::Run.new(text, **current_formatting))
end
@current_list.add_item(item) @current_list.add_item(item)
end end
@@ -134,15 +132,31 @@ module Notare
def list(type, &block) def list(type, &block)
@num_id_counter ||= 0 @num_id_counter ||= 0
@num_id_counter += 1 @list_level ||= 0
mark_has_lists! @list_type_stack ||= []
list_node = Nodes::List.new(type: type, num_id: @num_id_counter)
previous_list = @current_list previous_list = @current_list
@current_list = list_node nested = !previous_list.nil?
block.call
@current_list = previous_list if nested
@nodes << list_node # 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 end
def with_format(format, &block) def with_format(format, &block)

View File

@@ -3,13 +3,14 @@
module Notare module Notare
module Nodes module Nodes
class ListItem < Base 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() super()
@runs = runs @runs = runs
@list_type = list_type @list_type = list_type
@num_id = num_id @num_id = num_id
@level = level
end end
def add_run(run) def add_run(run)

View File

@@ -69,7 +69,7 @@ module Notare
xml["w"].p do xml["w"].p do
xml["w"].pPr do xml["w"].pPr do
xml["w"].numPr 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) xml["w"].numId("w:val" => item.num_id.to_s)
end end
end end

View File

@@ -4,6 +4,8 @@ module Notare
module Xml module Xml
class Numbering class Numbering
NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
BULLET_CHARS = ["", "", ""].freeze
NUMBER_FORMATS = %w[decimal lowerLetter lowerRoman].freeze
def initialize(lists) def initialize(lists)
@lists = lists @lists = lists
@@ -28,13 +30,16 @@ module Notare
def render_abstract_num(xml, list) def render_abstract_num(xml, list)
xml["w"].abstractNum("w:abstractNumId" => list.num_id.to_s) do xml["w"].abstractNum("w:abstractNumId" => list.num_id.to_s) do
xml["w"].lvl("w:ilvl" => "0") do 9.times do |level|
xml["w"].start("w:val" => "1") xml["w"].lvl("w:ilvl" => level.to_s) do
xml["w"].numFmt("w:val" => num_format(list.type)) xml["w"].start("w:val" => "1")
xml["w"].lvlText("w:val" => lvl_text(list.type)) xml["w"].numFmt("w:val" => num_format_for_level(list.type, level))
xml["w"].lvlJc("w:val" => "left") xml["w"].lvlText("w:val" => lvl_text_for_level(list.type, level))
xml["w"].pPr do xml["w"].lvlJc("w:val" => "left")
xml["w"].ind("w:left" => "720", "w:hanging" => "360") xml["w"].pPr do
left = 720 * (level + 1)
xml["w"].ind("w:left" => left.to_s, "w:hanging" => "360")
end
end end
end end
end end
@@ -46,12 +51,20 @@ module Notare
end end
end end
def num_format(type) def num_format_for_level(type, level)
type == :bullet ? "bullet" : "decimal" if type == :bullet
"bullet"
else
NUMBER_FORMATS[level % NUMBER_FORMATS.length]
end
end end
def lvl_text(type) def lvl_text_for_level(type, level)
type == :bullet ? "" : "%1." if type == :bullet
BULLET_CHARS[level % BULLET_CHARS.length]
else
"%#{level + 1}."
end
end end
end end
end end

View File

@@ -144,4 +144,98 @@ class ListTest < Minitest::Test
refute xml_files.key?("word/numbering.xml"), "numbering.xml should not exist without lists" refute xml_files.key?("word/numbering.xml"), "numbering.xml should not exist without lists"
end 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 end