4 Commits

Author SHA1 Message Date
67a60c8c6e Update readme
All checks were successful
CI Pipeline / build (push) Successful in 12s
2025-12-03 11:57:02 +01:00
52d715a6de 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
2025-12-02 14:00:57 +00:00
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
8 changed files with 238 additions and 30 deletions

View File

@@ -105,6 +105,21 @@ Notare includes built-in styles and supports custom style definitions.
#### Built-in Styles
| Style | Properties |
|-------|------------|
| `:title` | 26pt, bold, centered |
| `:subtitle` | 15pt, italic, gray (#666666) |
| `:quote` | italic, gray (#666666), indented |
| `:code` | Courier New, 10pt |
| `:heading1` | 24pt, bold |
| `:heading2` | 18pt, bold |
| `:heading3` | 14pt, bold |
| `:heading4` | 12pt, bold |
| `:heading5` | 11pt, bold, italic |
| `:heading6` | 10pt, bold, italic |
Note: `h1` through `h6` methods use the corresponding heading styles automatically.
```ruby
Notare::Document.create("output.docx") do |doc|
doc.p "This is a title", style: :title
@@ -195,6 +210,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 +449,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,16 +132,32 @@ module Notare
def list(type, &block)
@num_id_counter ||= 0
@list_level ||= 0
@list_type_stack ||= []
previous_list = @current_list
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)
previous_list = @current_list
@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)
@format_stack ||= []

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

@@ -1,5 +1,5 @@
# frozen_string_literal: true
module Notare
VERSION = "0.0.2"
VERSION = "0.0.3"
end

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
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(list.type))
xml["w"].lvlText("w:val" => lvl_text(list.type))
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
xml["w"].ind("w:left" => "720", "w:hanging" => "360")
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