2 Commits

Author SHA1 Message Date
75b3a163c7 Implement nested lists
All checks were successful
CI Pipeline / build (pull_request) Successful in 13s
2025-12-02 15:00:39 +01:00
6a54f9f8da Merge pull request 'Implement many more nodes' (#6) from feature/add-more-elements into main
All checks were successful
CI Pipeline / build (push) Successful in 14s
Reviewed-on: #6
2025-12-02 13:44:53 +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
```
#### 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 |

View File

@@ -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
# ============================================================================

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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