xsdvi 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "nokogiri"
5
+
6
+ module Xsdvi
7
+ module Comparison
8
+ # Orchestrates dual generation with Java and Ruby XsdVi
9
+ class DualGenerator
10
+ attr_reader :xsd_file, :output_dir
11
+
12
+ def initialize(xsd_file, options = {})
13
+ @xsd_file = xsd_file
14
+ @root = options[:root_node_name]
15
+ @output_dir = options[:output_path] || default_output_dir
16
+ @skip_java = options[:skip_java]
17
+ @skip_ruby = options[:skip_ruby]
18
+ end
19
+
20
+ # Generate comparison
21
+ # @return [Hash] Generation results with paths and metadata
22
+ def generate
23
+ validate_inputs
24
+ setup_directories
25
+
26
+ # Generate Java output
27
+ java_start = Time.now
28
+ generate_java unless @skip_java
29
+ java_time = Time.now - java_start
30
+
31
+ # Generate Ruby output
32
+ ruby_start = Time.now
33
+ generate_ruby unless @skip_ruby
34
+ ruby_time = Time.now - ruby_start
35
+
36
+ # Extract metadata
37
+ java_metadata = extract_metadata(java_dir)
38
+ ruby_metadata = extract_metadata(ruby_dir)
39
+
40
+ # Add timing info
41
+ java_metadata[:generation_time] = java_time.round(2) unless @skip_java
42
+ ruby_metadata[:generation_time] = ruby_time.round(2) unless @skip_ruby
43
+
44
+ # Generate HTML comparison
45
+ html_file = generate_html(java_metadata, ruby_metadata)
46
+
47
+ {
48
+ output_dir: @output_dir,
49
+ html_file: html_file,
50
+ java: java_metadata,
51
+ ruby: ruby_metadata,
52
+ }
53
+ end
54
+
55
+ private
56
+
57
+ # Validate inputs
58
+ def validate_inputs
59
+ raise "XSD file not found: #{@xsd_file}" unless File.exist?(@xsd_file)
60
+ raise "Must generate at least one implementation" if @skip_java && @skip_ruby
61
+ end
62
+
63
+ # Setup output directories
64
+ def setup_directories
65
+ FileUtils.mkdir_p(java_dir)
66
+ FileUtils.mkdir_p(ruby_dir)
67
+ end
68
+
69
+ # Get Java output directory
70
+ # @return [String] Java directory path
71
+ def java_dir
72
+ File.join(@output_dir, "java")
73
+ end
74
+
75
+ # Get Ruby output directory
76
+ # @return [String] Ruby directory path
77
+ def ruby_dir
78
+ File.join(@output_dir, "ruby")
79
+ end
80
+
81
+ # Generate Java XsdVi output
82
+ def generate_java
83
+ puts "Generating Java XsdVi output..."
84
+ manager = JavaManager.new
85
+
86
+ options = {}
87
+ options[:root] = @root if @root
88
+
89
+ manager.generate(@xsd_file, java_dir, options)
90
+ end
91
+
92
+ # Generate Ruby XsdVi output
93
+ def generate_ruby
94
+ puts "Generating Ruby XsdVi output..."
95
+
96
+ # Set up the generation pipeline (same as CLI)
97
+ builder = Tree::Builder.new
98
+ xsd_handler = XsdHandler.new(builder)
99
+ writer_helper = Utils::Writer.new
100
+ svg_generator = SVG::Generator.new(writer_helper)
101
+
102
+ # Configure handler
103
+ xsd_handler.root_node_name = @root
104
+ # one_node_only should only be true when generating all elements separately
105
+ xsd_handler.one_node_only = (@root == "all")
106
+
107
+ # Configure generator
108
+ svg_generator.hide_menu_buttons = (@root == "all")
109
+ svg_generator.embody_style = true
110
+
111
+ # Parse XSD
112
+ xsd_handler.process_file(@xsd_file)
113
+
114
+ # Generate output file(s)
115
+ if @root == "all"
116
+ generate_ruby_all_elements(xsd_handler, svg_generator, builder,
117
+ writer_helper)
118
+ else
119
+ generate_ruby_single(svg_generator, builder, writer_helper)
120
+ end
121
+ end
122
+
123
+ # Generate Ruby output for all elements
124
+ def generate_ruby_all_elements(xsd_handler, svg_generator, builder,
125
+ writer_helper)
126
+ doc = Nokogiri::XML(File.read(@xsd_file))
127
+ element_names = xsd_handler.get_elements_names(doc)
128
+
129
+ element_names.each do |elem_name|
130
+ # Reset for each element
131
+ builder = Tree::Builder.new
132
+ handler = XsdHandler.new(builder)
133
+ handler.root_node_name = elem_name
134
+ handler.one_node_only = true
135
+ handler.set_schema_namespace(doc, elem_name)
136
+ handler.process_file(@xsd_file)
137
+
138
+ output_file = File.join(ruby_dir, "#{elem_name}.svg")
139
+ writer_helper.new_writer(output_file)
140
+ svg_generator.draw(builder.root) if builder.root
141
+ end
142
+ end
143
+
144
+ # Generate Ruby output for single element
145
+ def generate_ruby_single(svg_generator, builder, writer_helper)
146
+ filename = if @root
147
+ "#{@root}.svg"
148
+ else
149
+ "#{File.basename(@xsd_file, '.xsd')}.svg"
150
+ end
151
+
152
+ output_file = File.join(ruby_dir, filename)
153
+ writer_helper.new_writer(output_file)
154
+ svg_generator.draw(builder.root) if builder.root
155
+ end
156
+
157
+ # Extract metadata from output directory
158
+ # @param dir [String] Directory path
159
+ # @return [Hash] Extracted metadata
160
+ def extract_metadata(dir)
161
+ extractor = MetadataExtractor.new
162
+ extractor.extract(dir)
163
+ end
164
+
165
+ # Generate HTML comparison page
166
+ # @param java_meta [Hash] Java metadata
167
+ # @param ruby_meta [Hash] Ruby metadata
168
+ # @return [String] Path to HTML file
169
+ def generate_html(java_meta, ruby_meta)
170
+ puts "Generating comparison HTML..."
171
+
172
+ generator = HtmlGenerator.new
173
+ generator.generate(
174
+ @output_dir,
175
+ java_meta,
176
+ ruby_meta,
177
+ schema_name: File.basename(@xsd_file, ".xsd"),
178
+ )
179
+ end
180
+
181
+ # Generate default output directory path
182
+ # @return [String] Default directory path
183
+ def default_output_dir
184
+ timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
185
+ schema_name = File.basename(@xsd_file, ".xsd")
186
+ File.join("comparisons", "#{schema_name}-#{timestamp}")
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Xsdvi
6
+ module Comparison
7
+ # Generates HTML comparison page
8
+ class HtmlGenerator
9
+ TEMPLATE_PATH = File.join(__dir__,
10
+ "../../../resources/comparison/template.html")
11
+
12
+ # Generate HTML comparison page
13
+ # @param output_dir [String] Output directory path
14
+ # @param java_metadata [Hash] Java generation metadata
15
+ # @param ruby_metadata [Hash] Ruby generation metadata
16
+ # @param options [Hash] Generation options
17
+ # @option options [String] :schema_name Schema name for display
18
+ # @return [String] Path to generated HTML file
19
+ def generate(output_dir, java_metadata, ruby_metadata, options = {})
20
+ template = File.read(TEMPLATE_PATH)
21
+
22
+ html = template
23
+ .gsub("{{SCHEMA_NAME}}", options[:schema_name] || "Schema")
24
+ .gsub("{{JAVA_METADATA}}", format_metadata_json(java_metadata))
25
+ .gsub("{{RUBY_METADATA}}", format_metadata_json(ruby_metadata))
26
+ .gsub("{{STATS_TABLE}}", generate_stats_table(java_metadata,
27
+ ruby_metadata))
28
+ .gsub("{{ELEMENT_SELECTOR}}", generate_element_selector(
29
+ java_metadata, ruby_metadata
30
+ ))
31
+ .gsub("{{IS_MULTI_FILE}}", (java_metadata[:file_count] > 1).to_s)
32
+
33
+ output_file = File.join(output_dir, "comparison.html")
34
+ File.write(output_file, html)
35
+
36
+ output_file
37
+ end
38
+
39
+ private
40
+
41
+ # Format metadata as JSON
42
+ # @param metadata [Hash] Metadata hash
43
+ # @return [String] Pretty-printed JSON
44
+ def format_metadata_json(metadata)
45
+ JSON.pretty_generate(metadata)
46
+ end
47
+
48
+ # Generate statistics comparison table HTML
49
+ # @param java_meta [Hash] Java metadata
50
+ # @param ruby_meta [Hash] Ruby metadata
51
+ # @return [String] HTML table
52
+ def generate_stats_table(java_meta, ruby_meta)
53
+ rows = []
54
+
55
+ # File counts
56
+ rows << table_row(
57
+ "Files",
58
+ java_meta[:file_count],
59
+ ruby_meta[:file_count],
60
+ java_meta[:file_count] == ruby_meta[:file_count],
61
+ )
62
+
63
+ # Total size
64
+ rows << table_row(
65
+ "Total Size",
66
+ "#{java_meta[:total_size_kb]} KB",
67
+ "#{ruby_meta[:total_size_kb]} KB",
68
+ (java_meta[:total_size_kb] - ruby_meta[:total_size_kb]).abs < 1,
69
+ )
70
+
71
+ # Generation time (if available)
72
+ if java_meta[:generation_time] && ruby_meta[:generation_time]
73
+ rows << table_row(
74
+ "Generation Time",
75
+ "#{java_meta[:generation_time]}s",
76
+ "#{ruby_meta[:generation_time]}s",
77
+ nil,
78
+ )
79
+ end
80
+
81
+ # Symbol counts (sum across all files)
82
+ symbol_types = [
83
+ ["Total Symbols", :total_symbols],
84
+ ["Elements", :elements],
85
+ ["Optional Elements", :optional_elements],
86
+ ["Attributes", :attributes],
87
+ ["Sequences", :sequences],
88
+ ["Choices", :choices],
89
+ ["All Compositors", :all_compositors],
90
+ ["Keys", :keys],
91
+ ["Key References", :keyrefs],
92
+ ["Unique Constraints", :uniques],
93
+ ["Loops", :loops],
94
+ ]
95
+
96
+ symbol_types.each do |label, key|
97
+ java_total = sum_symbol_count(java_meta[:files], key)
98
+ ruby_total = sum_symbol_count(ruby_meta[:files], key)
99
+
100
+ next if java_total.zero? && ruby_total.zero?
101
+
102
+ rows << table_row(label, java_total, ruby_total,
103
+ java_total == ruby_total)
104
+ end
105
+
106
+ <<~HTML
107
+ <table>
108
+ <thead>
109
+ <tr>
110
+ <th>Metric</th>
111
+ <th>Java XsdVi</th>
112
+ <th>Ruby XsdVi</th>
113
+ <th>Match</th>
114
+ </tr>
115
+ </thead>
116
+ <tbody>
117
+ #{rows.join("\n ")}
118
+ </tbody>
119
+ </table>
120
+ HTML
121
+ end
122
+
123
+ # Generate a table row
124
+ # @param label [String] Row label
125
+ # @param java_val [Object] Java value
126
+ # @param ruby_val [Object] Ruby value
127
+ # @param match [Boolean, nil] Whether values match (nil for N/A)
128
+ # @return [String] HTML table row
129
+ def table_row(label, java_val, ruby_val, match)
130
+ match_cell = if match.nil?
131
+ "<td>—</td>"
132
+ elsif match
133
+ "<td class='match'>✓</td>"
134
+ else
135
+ "<td class='mismatch'>✗</td>"
136
+ end
137
+
138
+ <<~HTML.strip
139
+ <tr>
140
+ <td><strong>#{label}</strong></td>
141
+ <td>#{java_val}</td>
142
+ <td>#{ruby_val}</td>
143
+ #{match_cell}
144
+ </tr>
145
+ HTML
146
+ end
147
+
148
+ # Sum symbol counts across files
149
+ # @param files [Array<Hash>] File metadata array
150
+ # @param key [Symbol] Symbol type key
151
+ # @return [Integer] Total count
152
+ def sum_symbol_count(files, key)
153
+ return 0 unless files
154
+
155
+ files.sum { |f| f[key] || 0 }
156
+ end
157
+
158
+ # Generate element selector dropdown
159
+ # @param java_meta [Hash] Java metadata
160
+ # @param ruby_meta [Hash] Ruby metadata
161
+ # @return [String] HTML select element or empty string
162
+ def generate_element_selector(java_meta, _ruby_meta)
163
+ return "" if java_meta[:file_count] <= 1
164
+
165
+ options = java_meta[:files].each_with_index.map do |file, index|
166
+ name = File.basename(file[:name], ".svg")
167
+ "<option value='#{index}'>#{name}</option>"
168
+ end
169
+
170
+ <<~HTML
171
+ <div class="element-selector">
172
+ <label for="element-select">Select Element:</label>
173
+ <select id="element-select" onchange="loadFile(this.value)">
174
+ #{options.join("\n ")}
175
+ </select>
176
+ </div>
177
+ HTML
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open-uri"
4
+ require "fileutils"
5
+
6
+ module Xsdvi
7
+ module Comparison
8
+ # Manages downloading, caching, and executing Java XsdVi
9
+ class JavaManager
10
+ JAR_URL = "https://github.com/metanorma/xsdvi/releases/download/v1.3/xsdvi-1.3.jar"
11
+ CACHE_DIR = File.expand_path("~/.xsdvi")
12
+ JAR_FILENAME = "xsdvi-1.3.jar"
13
+ JAR_PATH = File.join(CACHE_DIR, JAR_FILENAME)
14
+ EXPECTED_SIZE = 2_500_000 # ~2.5MB minimum
15
+
16
+ def initialize
17
+ @jar_path = JAR_PATH
18
+ end
19
+
20
+ # Ensure JAR is available, download if needed
21
+ # @return [Boolean] true if JAR is ready
22
+ def ensure_jar_available
23
+ return true if jar_valid?
24
+
25
+ puts "Downloading Java XsdVi v1.3..."
26
+ download_jar
27
+ verify_jar
28
+ true
29
+ end
30
+
31
+ # Generate SVG using Java XsdVi
32
+ # @param xsd_file [String] Path to XSD file
33
+ # @param output_dir [String] Output directory path
34
+ # @param options [Hash] Generation options
35
+ # @option options [String] :root Root element name
36
+ # @option options [Boolean] :all Generate all elements
37
+ # @return [Boolean] true if successful
38
+ def generate(xsd_file, output_dir, options = {})
39
+ ensure_java_available
40
+ ensure_jar_available
41
+
42
+ cmd = build_java_command(xsd_file, output_dir, options)
43
+ puts "Executing Java XsdVi..."
44
+
45
+ success = system(cmd)
46
+ raise "Java XsdVi execution failed" unless success
47
+
48
+ true
49
+ end
50
+
51
+ private
52
+
53
+ # Check if cached JAR is valid
54
+ # @return [Boolean] true if JAR exists and has valid size
55
+ def jar_valid?
56
+ File.exist?(@jar_path) && File.size(@jar_path) > EXPECTED_SIZE
57
+ end
58
+
59
+ # Download JAR from GitHub releases
60
+ def download_jar
61
+ FileUtils.mkdir_p(CACHE_DIR)
62
+
63
+ begin
64
+ URI.open(JAR_URL, "rb") do |remote|
65
+ File.binwrite(@jar_path, remote.read)
66
+ end
67
+ rescue StandardError => e
68
+ raise "Failed to download Java XsdVi: #{e.message}"
69
+ end
70
+ end
71
+
72
+ # Verify downloaded JAR integrity
73
+ def verify_jar
74
+ unless File.size(@jar_path) > EXPECTED_SIZE
75
+ File.delete(@jar_path)
76
+ raise "Downloaded JAR is invalid (size: #{File.size(@jar_path)} bytes)"
77
+ end
78
+
79
+ puts "✓ Java XsdVi cached at #{@jar_path}"
80
+ end
81
+
82
+ # Build Java command line
83
+ # @param xsd_file [String] Path to XSD file
84
+ # @param output_dir [String] Output directory path
85
+ # @param options [Hash] Generation options
86
+ # @return [String] Complete command string
87
+ def build_java_command(xsd_file, output_dir, options)
88
+ cmd_parts = ["java", "-jar", @jar_path]
89
+
90
+ # Input file is positional (no --in flag)
91
+ cmd_parts << File.expand_path(xsd_file)
92
+
93
+ # Root node name (including "all" for generating all elements)
94
+ if options[:root]
95
+ cmd_parts << "-rootNodeName" << options[:root]
96
+ end
97
+
98
+ # One node only flag (required when generating all elements separately)
99
+ cmd_parts << "-oneNodeOnly" if options[:root] == "all"
100
+
101
+ # Output path
102
+ cmd_parts << "-outputPath" << File.expand_path(output_dir)
103
+
104
+ cmd_parts.join(" ")
105
+ end
106
+
107
+ # Check if Java is available on system
108
+ # @raise [RuntimeError] if Java is not installed
109
+ def ensure_java_available
110
+ return if system("java -version", out: File::NULL, err: File::NULL)
111
+
112
+ raise <<~ERROR
113
+ Error: Java is required but not installed.
114
+
115
+ Please install Java from: https://java.com
116
+ Or use your system package manager:
117
+ - macOS: brew install openjdk
118
+ - Ubuntu/Debian: sudo apt-get install default-jdk
119
+ - Fedora: sudo dnf install java-latest-openjdk
120
+ ERROR
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Xsdvi
6
+ module Comparison
7
+ # Extracts metadata from SVG files for comparison
8
+ class MetadataExtractor
9
+ # Extract metadata from all SVG files in a directory
10
+ # @param svg_dir [String] Directory containing SVG files
11
+ # @return [Hash] Metadata including file count, sizes, and symbol counts
12
+ def extract(svg_dir)
13
+ files = Dir.glob(File.join(svg_dir, "*.svg"))
14
+
15
+ {
16
+ file_count: files.length,
17
+ total_size: files.sum { |f| File.size(f) },
18
+ total_size_kb: (files.sum { |f| File.size(f) } / 1024.0).round(1),
19
+ files: files.map { |f| analyze_file(f) },
20
+ }
21
+ end
22
+
23
+ private
24
+
25
+ # Analyze a single SVG file
26
+ # @param svg_file [String] Path to SVG file
27
+ # @return [Hash] File metadata and symbol counts
28
+ def analyze_file(svg_file)
29
+ content = File.read(svg_file)
30
+ doc = Nokogiri::XML(content)
31
+ # Remove namespaces to simplify XPath queries
32
+ doc.remove_namespaces!
33
+
34
+ {
35
+ name: File.basename(svg_file),
36
+ size: File.size(svg_file),
37
+ size_kb: (File.size(svg_file) / 1024.0).round(1),
38
+ elements: count_by_class(doc, "boxelement"),
39
+ optional_elements: count_by_class(doc, "boxelementoptional"),
40
+ attributes: count_by_class(doc, "boxattribute1", "boxattribute2"),
41
+ sequences: count_by_class(doc, "boxsequence"),
42
+ choices: count_by_class(doc, "boxchoice"),
43
+ all_compositors: count_by_class(doc, "boxall"),
44
+ compositors: count_by_class(doc, "boxcompositor"),
45
+ any: count_by_class(doc, "boxany"),
46
+ any_attribute: count_by_class(doc, "boxanyAttribute"),
47
+ keys: count_by_class(doc, "boxkey"),
48
+ keyrefs: count_by_class(doc, "boxkeyref"),
49
+ uniques: count_by_class(doc, "boxunique"),
50
+ selectors: count_by_class(doc, "boxselector"),
51
+ fields: count_by_class(doc, "boxfield"),
52
+ loops: count_by_class(doc, "boxloop"),
53
+ schemas: count_by_class(doc, "boxschema"),
54
+ total_symbols: count_all_boxes(doc),
55
+ }
56
+ end
57
+
58
+ # Count rectangles with specific CSS classes
59
+ # @param doc [Nokogiri::XML::Document] Parsed SVG document
60
+ # @param classes [Array<String>] CSS class names to match
61
+ # @return [Integer] Count of matching rectangles
62
+ def count_by_class(doc, *classes)
63
+ xpath = classes.map { |c| "@class='#{c}'" }.join(" or ")
64
+ doc.xpath("//rect[#{xpath}]").count
65
+ end
66
+
67
+ # Count all box elements (symbols)
68
+ # @param doc [Nokogiri::XML::Document] Parsed SVG document
69
+ # @return [Integer] Total count of box elements
70
+ def count_all_boxes(doc)
71
+ doc.xpath("//rect[starts-with(@class, 'box')]").count
72
+ end
73
+ end
74
+ end
75
+ end
@@ -50,6 +50,7 @@ module Xsdvi
50
50
  print(script)
51
51
 
52
52
  print_defs(embody_style, true)
53
+ print("")
53
54
  print(load_resource("svg/menu_buttons.svg")) unless hide_menu_buttons
54
55
  end
55
56
 
@@ -155,10 +155,11 @@ module Xsdvi
155
155
  wrapped_string = word_utils_wrap(desc_string, wrap_length)
156
156
  wrapped_lines = wrapped_string.split("\n")
157
157
  strings_with_breaks.concat(wrapped_lines)
158
+ # Match Java bug: accumulate TOTAL size, not just new lines
159
+ @additional_height += y_shift * strings_with_breaks.size
158
160
  end
159
161
 
160
162
  @description_string_array = strings_with_breaks
161
- @additional_height = y_shift * strings_with_breaks.size
162
163
 
163
164
  prev_y = Symbol.prev_y_position || 0
164
165
  curr_y = y_position || 0
@@ -187,47 +188,55 @@ module Xsdvi
187
188
 
188
189
  # Apache Commons WordUtils.wrap(str, wrapLength, newLineStr, wrapLongWords)
189
190
  # Wraps text at wrapLength, inserting newLineStr, optionally breaking long words
191
+ # Java WordUtils processes multi-line input by handling each line separately
190
192
  def word_utils_wrap(input, wrap_length)
191
193
  return input if input.nil? || wrap_length < 1
192
194
 
193
- input_line_length = input.length
194
- return input if input_line_length <= wrap_length
195
+ # Split by existing newlines first (like Java WordUtils does)
196
+ input_lines = input.split("\n", -1) # -1 to preserve trailing empty strings
197
+ result_lines = []
195
198
 
196
- result = []
197
- offset = 0
198
-
199
- while offset < input_line_length
200
- # Handle existing newline in input
201
- space_idx = input.index("\n", offset)
202
- if space_idx && space_idx < wrap_length + offset
203
- # There's a newline before wrap point
204
- result << input[offset...space_idx]
205
- offset = space_idx + 1
199
+ input_lines.each do |line|
200
+ # Handle empty lines
201
+ if line.empty?
202
+ result_lines << ""
206
203
  next
207
204
  end
208
205
 
209
- # Find wrap point
210
- if input_line_length - offset <= wrap_length
211
- # Rest of string fits
212
- result << input[offset..]
213
- break
206
+ # Skip wrapping if line is strictly shorter than wrap_length
207
+ # Apache Commons only skips if length < wrap_length, not <=
208
+ if line.length < wrap_length
209
+ result_lines << line
210
+ next
214
211
  end
215
212
 
216
- # Need to wrap - find last space before wrap_length
217
- space_idx = input.rindex(" ", offset + wrap_length)
218
-
219
- if space_idx && space_idx >= offset
220
- # Found space to break at
221
- result << input[offset...space_idx]
222
- offset = space_idx + 1
223
- else
224
- # No space found - break at wrap_length (wrapLongWords=true)
225
- result << input[offset...(offset + wrap_length)]
226
- offset += wrap_length
213
+ # Wrap this line
214
+ offset = 0
215
+ line_length = line.length
216
+
217
+ while offset < line_length
218
+ # Rest of line fits?
219
+ if line_length - offset <= wrap_length
220
+ result_lines << line[offset..]
221
+ break
222
+ end
223
+
224
+ # Find last space before wrap point
225
+ space_idx = line.rindex(" ", offset + wrap_length)
226
+
227
+ if space_idx && space_idx >= offset
228
+ # Found space to break at
229
+ result_lines << line[offset...space_idx]
230
+ offset = space_idx + 1
231
+ else
232
+ # No space found - break at wrap_length (wrapLongWords=true)
233
+ result_lines << line[offset...(offset + wrap_length)]
234
+ offset += wrap_length
235
+ end
227
236
  end
228
237
  end
229
238
 
230
- result.join("\n")
239
+ result_lines.join("\n")
231
240
  end
232
241
  end
233
242
  end
@@ -51,7 +51,7 @@ module Xsdvi
51
51
  calc.new_width(15, namespace)
52
52
  calc.new_width(15, type)
53
53
  calc.new_width(15, 13)
54
- calc.new_width(15, constraint)
54
+ calc.new_width(15, constraint) # Constraint includes "default: " or "fixed: " prefix
55
55
  calc.width
56
56
  end
57
57