Implement nested lists
All checks were successful
CI Pipeline / build (pull_request) Successful in 13s
All checks were successful
CI Pipeline / build (pull_request) Successful in 13s
This commit is contained in:
39
README.md
39
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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user