poml 0.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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +239 -0
- data/TUTORIAL.md +987 -0
- data/bin/poml +80 -0
- data/examples/101_explain_character.poml +30 -0
- data/examples/102_render_xml.poml +40 -0
- data/examples/103_word_todos.poml +27 -0
- data/examples/104_financial_analysis.poml +33 -0
- data/examples/105_write_blog_post.poml +48 -0
- data/examples/106_research.poml +36 -0
- data/examples/107_read_report_pdf.poml +4 -0
- data/examples/201_orders_qa.poml +50 -0
- data/examples/202_arc_agi.poml +36 -0
- data/examples/301_generate_poml.poml +46 -0
- data/examples/README.md +50 -0
- data/examples/_generate_expects.py +35 -0
- data/examples/assets/101_jerry_mouse.jpg +0 -0
- data/examples/assets/101_tom_and_jerry.docx +0 -0
- data/examples/assets/101_tom_cat.jpg +0 -0
- data/examples/assets/101_tom_introduction.txt +9 -0
- data/examples/assets/103_prompt_wizard.docx +0 -0
- data/examples/assets/104_chart_normalized_price.png +0 -0
- data/examples/assets/104_chart_price.png +0 -0
- data/examples/assets/104_mag7.xlsx +0 -0
- data/examples/assets/107_usenix_paper.pdf +0 -0
- data/examples/assets/201_order_instructions.json +7 -0
- data/examples/assets/201_orderlines.csv +2 -0
- data/examples/assets/201_orders.csv +3 -0
- data/examples/assets/202_arc_agi_data.json +1 -0
- data/examples/expects/101_explain_character.txt +117 -0
- data/examples/expects/102_render_xml.txt +28 -0
- data/examples/expects/103_word_todos.txt +121 -0
- data/examples/expects/104_financial_analysis.txt +86 -0
- data/examples/expects/105_write_blog_post.txt +41 -0
- data/examples/expects/106_research.txt +29 -0
- data/examples/expects/107_read_report_pdf.txt +151 -0
- data/examples/expects/201_orders_qa.txt +44 -0
- data/examples/expects/202_arc_agi.txt +64 -0
- data/examples/expects/301_generate_poml.txt +153 -0
- data/examples/ruby_expects/101_explain_character.txt +17 -0
- data/examples/ruby_expects/102_render_xml.txt +28 -0
- data/examples/ruby_expects/103_word_todos.txt +14 -0
- data/examples/ruby_expects/104_financial_analysis.txt +0 -0
- data/examples/ruby_expects/105_write_blog_post.txt +57 -0
- data/examples/ruby_expects/106_research.txt +5 -0
- data/examples/ruby_expects/107_read_report_pdf.txt +403 -0
- data/examples/ruby_expects/201_orders_qa.txt +41 -0
- data/examples/ruby_expects/202_arc_agi.txt +17 -0
- data/examples/ruby_expects/301_generate_poml.txt +17 -0
- data/lib/poml/components/base.rb +132 -0
- data/lib/poml/components/content.rb +156 -0
- data/lib/poml/components/data.rb +346 -0
- data/lib/poml/components/examples.rb +55 -0
- data/lib/poml/components/instructions.rb +93 -0
- data/lib/poml/components/layout.rb +50 -0
- data/lib/poml/components/lists.rb +82 -0
- data/lib/poml/components/styling.rb +36 -0
- data/lib/poml/components/text.rb +8 -0
- data/lib/poml/components/workflow.rb +63 -0
- data/lib/poml/components.rb +47 -0
- data/lib/poml/components_new.rb +297 -0
- data/lib/poml/components_old.rb +1096 -0
- data/lib/poml/context.rb +53 -0
- data/lib/poml/parser.rb +153 -0
- data/lib/poml/renderer.rb +147 -0
- data/lib/poml/template_engine.rb +66 -0
- data/lib/poml/version.rb +5 -0
- data/lib/poml.rb +53 -0
- data/media/logo-16-purple.png +0 -0
- data/media/logo-64-white.png +0 -0
- metadata +149 -0
@@ -0,0 +1,156 @@
|
|
1
|
+
module Poml
|
2
|
+
# Document component (reads and includes external files)
|
3
|
+
class DocumentComponent < Component
|
4
|
+
def initialize(element, context)
|
5
|
+
super
|
6
|
+
@src = element.attributes['src']
|
7
|
+
@selected_pages = element.attributes['selectedpages'] || element.attributes['selectedPages']
|
8
|
+
@syntax = element.attributes['syntax'] || 'text'
|
9
|
+
end
|
10
|
+
|
11
|
+
def render(context = nil)
|
12
|
+
return "[Document: no src specified]" unless @src
|
13
|
+
|
14
|
+
begin
|
15
|
+
# Resolve file path - try relative to current working directory first
|
16
|
+
file_path = @src
|
17
|
+
unless File.exist?(file_path)
|
18
|
+
# Try relative to examples directory
|
19
|
+
examples_dir = File.expand_path('examples')
|
20
|
+
file_path = File.join(examples_dir, @src)
|
21
|
+
end
|
22
|
+
|
23
|
+
unless File.exist?(file_path)
|
24
|
+
# Try relative to project root examples directory
|
25
|
+
project_root = File.expand_path('..', File.dirname(__dir__))
|
26
|
+
examples_dir = File.join(project_root, 'examples')
|
27
|
+
file_path = File.join(examples_dir, @src)
|
28
|
+
end
|
29
|
+
|
30
|
+
# Check if file exists
|
31
|
+
unless File.exist?(file_path)
|
32
|
+
return "[Document: #{@src} (not found)]"
|
33
|
+
end
|
34
|
+
|
35
|
+
# Check file type and extract content
|
36
|
+
if file_path.downcase.end_with?('.pdf')
|
37
|
+
read_pdf_content(file_path)
|
38
|
+
elsif file_path.downcase.end_with?('.docx')
|
39
|
+
read_docx_content(file_path)
|
40
|
+
else
|
41
|
+
File.read(file_path)
|
42
|
+
end
|
43
|
+
rescue => e
|
44
|
+
"[Document: #{@src} (error reading: #{e.message})]"
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def read_pdf_content(file_path)
|
51
|
+
if @selected_pages
|
52
|
+
# Parse Python-style slice notation
|
53
|
+
start_page, end_page = parse_python_style_slice(@selected_pages, get_pdf_page_count(file_path))
|
54
|
+
|
55
|
+
# Convert 0-indexed to 1-indexed for pdftotext (-f and -l are 1-indexed)
|
56
|
+
start_page_1indexed = start_page + 1
|
57
|
+
# For Python slice "1:3" -> start=1, end=3 (0-indexed, end exclusive)
|
58
|
+
# This means we want pages 1,2 (0-indexed) = pages 2,3 (1-indexed)
|
59
|
+
# So pdftotext should use -f 2 -l 3
|
60
|
+
last_page_1indexed = start_page_1indexed + (end_page - start_page) - 1
|
61
|
+
|
62
|
+
if end_page > start_page + 1
|
63
|
+
# Extract range of pages
|
64
|
+
command = "pdftotext -f #{start_page_1indexed} -l #{last_page_1indexed} \"#{file_path}\" -"
|
65
|
+
result = `#{command}`
|
66
|
+
else
|
67
|
+
# Single page
|
68
|
+
command = "pdftotext -f #{start_page_1indexed} -l #{start_page_1indexed} \"#{file_path}\" -"
|
69
|
+
result = `#{command}`
|
70
|
+
end
|
71
|
+
else
|
72
|
+
# Extract all pages
|
73
|
+
result = `pdftotext "#{file_path}" -`
|
74
|
+
end
|
75
|
+
|
76
|
+
if $?.success?
|
77
|
+
result
|
78
|
+
else
|
79
|
+
"[Document: #{@src} (error extracting PDF)]"
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def parse_python_style_slice(slice, total_length)
|
84
|
+
# Handle different slice formats: "1:3", ":3", "3:", "3", ":"
|
85
|
+
if slice == ':'
|
86
|
+
[0, total_length]
|
87
|
+
elsif slice.end_with?(':')
|
88
|
+
[slice[0..-2].to_i, total_length]
|
89
|
+
elsif slice.start_with?(':')
|
90
|
+
[0, slice[1..-1].to_i]
|
91
|
+
elsif slice.include?(':')
|
92
|
+
parts = slice.split(':')
|
93
|
+
[parts[0].to_i, parts[1].to_i]
|
94
|
+
else
|
95
|
+
index = slice.to_i
|
96
|
+
[index, index + 1]
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def get_pdf_page_count(file_path)
|
101
|
+
# Get page count using pdfinfo (if available) or default to large number
|
102
|
+
result = `pdfinfo "#{file_path}" 2>/dev/null | grep "Pages:" | awk '{print $2}'`
|
103
|
+
if $?.success? && !result.strip.empty?
|
104
|
+
result.strip.to_i
|
105
|
+
else
|
106
|
+
# Fallback: try to extract and count pages
|
107
|
+
100 # Default fallback
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
def read_docx_content(file_path)
|
112
|
+
# Try to extract text from .docx file using antiword or textutil (macOS)
|
113
|
+
# First try textutil (available on macOS)
|
114
|
+
result = `textutil -convert txt -stdout "#{file_path}" 2>/dev/null`
|
115
|
+
if $?.success? && !result.strip.empty?
|
116
|
+
return result
|
117
|
+
end
|
118
|
+
|
119
|
+
# Try pandoc if available
|
120
|
+
result = `pandoc "#{file_path}" -t plain 2>/dev/null`
|
121
|
+
if $?.success? && !result.strip.empty?
|
122
|
+
return result
|
123
|
+
end
|
124
|
+
|
125
|
+
# Try unzip to extract document.xml and parse it
|
126
|
+
begin
|
127
|
+
require 'zip'
|
128
|
+
content = ""
|
129
|
+
Zip::File.open(file_path) do |zip_file|
|
130
|
+
entry = zip_file.find_entry("word/document.xml")
|
131
|
+
if entry
|
132
|
+
xml_content = entry.get_input_stream.read
|
133
|
+
# Simple XML text extraction (not perfect but better than binary)
|
134
|
+
content = xml_content.gsub(/<[^>]*>/, ' ').gsub(/\s+/, ' ').strip
|
135
|
+
end
|
136
|
+
end
|
137
|
+
return content unless content.empty?
|
138
|
+
rescue => e
|
139
|
+
# Zip gem not available or error occurred
|
140
|
+
end
|
141
|
+
|
142
|
+
# Fallback: indicate that document could not be processed
|
143
|
+
"[Document: #{File.basename(file_path)} (Word document text extraction not available)]"
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
# Paragraph component for basic text content
|
148
|
+
class ParagraphComponent < Component
|
149
|
+
def render
|
150
|
+
apply_stylesheet
|
151
|
+
|
152
|
+
content = @element.content.empty? ? render_children : @element.content
|
153
|
+
"#{content}\n\n"
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
@@ -0,0 +1,346 @@
|
|
1
|
+
module Poml
|
2
|
+
# Table component for displaying tabular data
|
3
|
+
class TableComponent < Component
|
4
|
+
require 'csv'
|
5
|
+
require 'json'
|
6
|
+
|
7
|
+
def render
|
8
|
+
apply_stylesheet
|
9
|
+
|
10
|
+
src = get_attribute('src')
|
11
|
+
records_attr = get_attribute('records')
|
12
|
+
columns_attr = get_attribute('columns')
|
13
|
+
parser = get_attribute('parser', 'auto')
|
14
|
+
syntax = get_attribute('syntax')
|
15
|
+
selected_columns = get_attribute('selectedColumns')
|
16
|
+
selected_records = get_attribute('selectedRecords')
|
17
|
+
max_records = get_attribute('maxRecords')
|
18
|
+
max_columns = get_attribute('maxColumns')
|
19
|
+
|
20
|
+
# Load data from source or use provided records
|
21
|
+
data = if src
|
22
|
+
load_table_data(src, parser)
|
23
|
+
elsif records_attr
|
24
|
+
parse_records_attribute(records_attr)
|
25
|
+
else
|
26
|
+
{ records: [], columns: [] }
|
27
|
+
end
|
28
|
+
|
29
|
+
# Apply column and record selection
|
30
|
+
data = apply_selection(data, selected_columns, selected_records, max_records, max_columns)
|
31
|
+
|
32
|
+
# Check syntax preference
|
33
|
+
if syntax == 'tsv' || syntax == 'csv'
|
34
|
+
render_table_raw(data, syntax)
|
35
|
+
elsif xml_mode?
|
36
|
+
render_table_xml(data)
|
37
|
+
else
|
38
|
+
render_table_markdown(data)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def load_table_data(src, parser)
|
45
|
+
# Resolve relative paths
|
46
|
+
file_path = if src.start_with?('/')
|
47
|
+
src
|
48
|
+
else
|
49
|
+
base_path = if @context.source_path
|
50
|
+
File.dirname(@context.source_path)
|
51
|
+
else
|
52
|
+
Dir.pwd
|
53
|
+
end
|
54
|
+
File.join(base_path, src)
|
55
|
+
end
|
56
|
+
|
57
|
+
unless File.exist?(file_path)
|
58
|
+
return { records: [], columns: [] }
|
59
|
+
end
|
60
|
+
|
61
|
+
# Determine parser from file extension if auto
|
62
|
+
if parser == 'auto'
|
63
|
+
ext = File.extname(file_path).downcase
|
64
|
+
parser = case ext
|
65
|
+
when '.csv' then 'csv'
|
66
|
+
when '.tsv' then 'tsv'
|
67
|
+
when '.json' then 'json'
|
68
|
+
when '.jsonl' then 'jsonl'
|
69
|
+
else 'csv'
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
case parser
|
74
|
+
when 'csv'
|
75
|
+
parse_csv_file(file_path)
|
76
|
+
when 'tsv'
|
77
|
+
parse_tsv_file(file_path)
|
78
|
+
when 'json'
|
79
|
+
parse_json_file(file_path)
|
80
|
+
when 'jsonl'
|
81
|
+
parse_jsonl_file(file_path)
|
82
|
+
else
|
83
|
+
{ records: [], columns: [] }
|
84
|
+
end
|
85
|
+
rescue => e
|
86
|
+
{ records: [], columns: [] }
|
87
|
+
end
|
88
|
+
|
89
|
+
def parse_csv_file(file_path)
|
90
|
+
data = CSV.read(file_path, headers: true)
|
91
|
+
columns = data.headers.map { |header| { field: header, header: header } }
|
92
|
+
records = data.map(&:to_h)
|
93
|
+
{ records: records, columns: columns }
|
94
|
+
end
|
95
|
+
|
96
|
+
def parse_tsv_file(file_path)
|
97
|
+
data = CSV.read(file_path, headers: true, col_sep: "\t")
|
98
|
+
columns = data.headers.map { |header| { field: header, header: header } }
|
99
|
+
records = data.map(&:to_h)
|
100
|
+
{ records: records, columns: columns }
|
101
|
+
end
|
102
|
+
|
103
|
+
def parse_json_file(file_path)
|
104
|
+
content = File.read(file_path)
|
105
|
+
records = JSON.parse(content)
|
106
|
+
|
107
|
+
# Extract columns from first record if it's an array of objects
|
108
|
+
columns = if records.is_a?(Array) && !records.empty? && records.first.is_a?(Hash)
|
109
|
+
records.first.keys.map { |key| { field: key, header: key } }
|
110
|
+
else
|
111
|
+
[]
|
112
|
+
end
|
113
|
+
|
114
|
+
{ records: records.is_a?(Array) ? records : [records], columns: columns }
|
115
|
+
end
|
116
|
+
|
117
|
+
def parse_jsonl_file(file_path)
|
118
|
+
records = []
|
119
|
+
File.readlines(file_path).each do |line|
|
120
|
+
records << JSON.parse(line.strip) unless line.strip.empty?
|
121
|
+
end
|
122
|
+
|
123
|
+
# Extract columns from first record
|
124
|
+
columns = if !records.empty? && records.first.is_a?(Hash)
|
125
|
+
records.first.keys.map { |key| { field: key, header: key } }
|
126
|
+
else
|
127
|
+
[]
|
128
|
+
end
|
129
|
+
|
130
|
+
{ records: records, columns: columns }
|
131
|
+
end
|
132
|
+
|
133
|
+
def parse_records_attribute(records_attr)
|
134
|
+
# Handle string records (JSON) or already parsed arrays
|
135
|
+
records = if records_attr.is_a?(String)
|
136
|
+
JSON.parse(records_attr)
|
137
|
+
else
|
138
|
+
records_attr
|
139
|
+
end
|
140
|
+
|
141
|
+
columns = if records.is_a?(Array) && !records.empty? && records.first.is_a?(Hash)
|
142
|
+
records.first.keys.map { |key| { field: key, header: key } }
|
143
|
+
else
|
144
|
+
[]
|
145
|
+
end
|
146
|
+
|
147
|
+
{ records: records.is_a?(Array) ? records : [records], columns: columns }
|
148
|
+
end
|
149
|
+
|
150
|
+
def apply_selection(data, selected_columns, selected_records, max_records, max_columns)
|
151
|
+
records = data[:records]
|
152
|
+
columns = data[:columns]
|
153
|
+
|
154
|
+
# Apply column selection
|
155
|
+
if selected_columns && columns
|
156
|
+
if selected_columns.is_a?(Array)
|
157
|
+
# Array of column names
|
158
|
+
new_columns = selected_columns.map do |col_name|
|
159
|
+
columns.find { |col| col[:field] == col_name } || { field: col_name, header: col_name }
|
160
|
+
end
|
161
|
+
columns = new_columns
|
162
|
+
records = records.map do |record|
|
163
|
+
selected_columns.each_with_object({}) { |col, new_record| new_record[col] = record[col] }
|
164
|
+
end
|
165
|
+
elsif selected_columns.is_a?(String) && selected_columns.include?(':')
|
166
|
+
# Python-style slice
|
167
|
+
start_idx, end_idx = parse_slice(selected_columns, columns.length)
|
168
|
+
columns = columns[start_idx...end_idx]
|
169
|
+
column_fields = columns.map { |col| col[:field] }
|
170
|
+
records = records.map do |record|
|
171
|
+
column_fields.each_with_object({}) { |field, new_record| new_record[field] = record[field] }
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
# Apply record selection
|
177
|
+
if selected_records
|
178
|
+
if selected_records.is_a?(Array)
|
179
|
+
records = selected_records.map { |idx| records[idx] }.compact
|
180
|
+
elsif selected_records.is_a?(String) && selected_records.include?(':')
|
181
|
+
start_idx, end_idx = parse_slice(selected_records, records.length)
|
182
|
+
records = records[start_idx...end_idx]
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
# Apply max records
|
187
|
+
if max_records && records.length > max_records
|
188
|
+
# Show top half and bottom half with ellipsis
|
189
|
+
top_rows = (max_records / 2.0).ceil
|
190
|
+
bottom_rows = max_records - top_rows
|
191
|
+
ellipsis_record = columns.each_with_object({}) { |col, record| record[col[:field]] = '...' }
|
192
|
+
records = records[0...top_rows] + [ellipsis_record] + records[-bottom_rows..-1]
|
193
|
+
end
|
194
|
+
|
195
|
+
# Apply max columns
|
196
|
+
if max_columns && columns && columns.length > max_columns
|
197
|
+
columns = columns[0...max_columns]
|
198
|
+
column_fields = columns.map { |col| col[:field] }
|
199
|
+
records = records.map do |record|
|
200
|
+
column_fields.each_with_object({}) { |field, new_record| new_record[field] = record[field] }
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
{ records: records, columns: columns }
|
205
|
+
end
|
206
|
+
|
207
|
+
def parse_slice(slice_str, total_length)
|
208
|
+
# Parse Python-style slice notation like "1:3"
|
209
|
+
parts = slice_str.split(':')
|
210
|
+
start_idx = parts[0].to_i
|
211
|
+
end_idx = parts[1] ? parts[1].to_i : total_length
|
212
|
+
[start_idx, end_idx]
|
213
|
+
end
|
214
|
+
|
215
|
+
def render_table_markdown(data)
|
216
|
+
records = data[:records]
|
217
|
+
columns = data[:columns]
|
218
|
+
|
219
|
+
return '' if records.empty?
|
220
|
+
|
221
|
+
# If no columns specified, infer from first record
|
222
|
+
if columns.empty? && records.first.is_a?(Hash)
|
223
|
+
columns = records.first.keys.map { |key| { field: key, header: key } }
|
224
|
+
end
|
225
|
+
|
226
|
+
return '' if columns.empty?
|
227
|
+
|
228
|
+
# Build markdown table
|
229
|
+
result = []
|
230
|
+
|
231
|
+
# Header row
|
232
|
+
headers = columns.map { |col| col[:header] || col[:field] }
|
233
|
+
result << "| #{headers.join(' | ')} |"
|
234
|
+
|
235
|
+
# Separator row
|
236
|
+
result << "| #{headers.map { '---' }.join(' | ')} |"
|
237
|
+
|
238
|
+
# Data rows
|
239
|
+
records.each do |record|
|
240
|
+
row_values = columns.map do |col|
|
241
|
+
value = record[col[:field]]
|
242
|
+
value.nil? ? '' : value.to_s
|
243
|
+
end
|
244
|
+
result << "| #{row_values.join(' | ')} |"
|
245
|
+
end
|
246
|
+
|
247
|
+
result.join("\n")
|
248
|
+
end
|
249
|
+
|
250
|
+
def render_table_raw(data, syntax)
|
251
|
+
records = data[:records]
|
252
|
+
columns = data[:columns]
|
253
|
+
|
254
|
+
return '' if records.empty?
|
255
|
+
|
256
|
+
# If no columns specified, infer from first record
|
257
|
+
if columns.empty? && records.first.is_a?(Hash)
|
258
|
+
columns = records.first.keys.map { |key| { field: key, header: key } }
|
259
|
+
end
|
260
|
+
|
261
|
+
return '' if columns.empty?
|
262
|
+
|
263
|
+
# Determine separator
|
264
|
+
separator = syntax == 'tsv' ? "\t" : ","
|
265
|
+
|
266
|
+
# Build raw table
|
267
|
+
result = []
|
268
|
+
|
269
|
+
# Header row
|
270
|
+
headers = columns.map { |col| col[:header] || col[:field] }
|
271
|
+
result << headers.join(separator)
|
272
|
+
|
273
|
+
# Data rows
|
274
|
+
records.each do |record|
|
275
|
+
row_values = columns.map do |col|
|
276
|
+
value = record[col[:field]]
|
277
|
+
value.nil? ? '' : value.to_s
|
278
|
+
end
|
279
|
+
result << row_values.join(separator)
|
280
|
+
end
|
281
|
+
|
282
|
+
result.join("\n")
|
283
|
+
end
|
284
|
+
|
285
|
+
def render_table_xml(data)
|
286
|
+
records = data[:records]
|
287
|
+
columns = data[:columns]
|
288
|
+
|
289
|
+
return '' if records.empty?
|
290
|
+
|
291
|
+
# If no columns specified, infer from first record
|
292
|
+
if columns.empty? && records.first.is_a?(Hash)
|
293
|
+
columns = records.first.keys.map { |key| { field: key, header: key } }
|
294
|
+
end
|
295
|
+
|
296
|
+
return '' if columns.empty?
|
297
|
+
|
298
|
+
# Build XML table structure
|
299
|
+
result = []
|
300
|
+
result << '<table>'
|
301
|
+
result << ' <thead>'
|
302
|
+
result << ' <trow>'
|
303
|
+
columns.each do |col|
|
304
|
+
result << " <tcell>#{escape_xml(col[:header] || col[:field])}</tcell>"
|
305
|
+
end
|
306
|
+
result << ' </trow>'
|
307
|
+
result << ' </thead>'
|
308
|
+
result << ' <tbody>'
|
309
|
+
|
310
|
+
records.each do |record|
|
311
|
+
result << ' <trow>'
|
312
|
+
columns.each do |col|
|
313
|
+
value = record[col[:field]]
|
314
|
+
result << " <tcell>#{escape_xml(value.nil? ? '' : value.to_s)}</tcell>"
|
315
|
+
end
|
316
|
+
result << ' </trow>'
|
317
|
+
end
|
318
|
+
|
319
|
+
result << ' </tbody>'
|
320
|
+
result << '</table>'
|
321
|
+
|
322
|
+
result.join("\n")
|
323
|
+
end
|
324
|
+
|
325
|
+
def escape_xml(text)
|
326
|
+
text.to_s.gsub('&', '&').gsub('<', '<').gsub('>', '>')
|
327
|
+
end
|
328
|
+
end
|
329
|
+
|
330
|
+
# Image component
|
331
|
+
class ImageComponent < Component
|
332
|
+
def render
|
333
|
+
apply_stylesheet
|
334
|
+
|
335
|
+
src = get_attribute('src')
|
336
|
+
alt = get_attribute('alt', '')
|
337
|
+
syntax = get_attribute('syntax', 'text')
|
338
|
+
|
339
|
+
if syntax == 'multimedia'
|
340
|
+
"[Image: #{src}]#{alt.empty? ? '' : " (#{alt})"}"
|
341
|
+
else
|
342
|
+
alt.empty? ? "[Image: #{src}]" : alt
|
343
|
+
end
|
344
|
+
end
|
345
|
+
end
|
346
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
module Poml
|
2
|
+
# Example component
|
3
|
+
class ExampleComponent < Component
|
4
|
+
def render
|
5
|
+
apply_stylesheet
|
6
|
+
|
7
|
+
content = @element.content.empty? ? render_children : @element.content
|
8
|
+
"#{content}\n\n"
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
# Input component (for examples)
|
13
|
+
class InputComponent < Component
|
14
|
+
def render
|
15
|
+
apply_stylesheet
|
16
|
+
|
17
|
+
content = @element.content.empty? ? render_children : @element.content
|
18
|
+
"#{content}\n\n"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
# Output component (for examples)
|
23
|
+
class OutputComponent < Component
|
24
|
+
def render
|
25
|
+
apply_stylesheet
|
26
|
+
|
27
|
+
content = @element.content.empty? ? render_children : @element.content
|
28
|
+
"#{content}\n\n"
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# Output format component
|
33
|
+
class OutputFormatComponent < Component
|
34
|
+
def render
|
35
|
+
apply_stylesheet
|
36
|
+
|
37
|
+
caption = get_attribute('caption', 'Output Format')
|
38
|
+
caption_style = get_attribute('captionStyle', 'header')
|
39
|
+
content = @element.content.empty? ? render_children : @element.content
|
40
|
+
|
41
|
+
case caption_style
|
42
|
+
when 'header'
|
43
|
+
"# #{caption}\n\n#{content}\n\n"
|
44
|
+
when 'bold'
|
45
|
+
"**#{caption}:** #{content}\n\n"
|
46
|
+
when 'plain'
|
47
|
+
"#{caption}: #{content}\n\n"
|
48
|
+
when 'hidden'
|
49
|
+
"#{content}\n\n"
|
50
|
+
else
|
51
|
+
"# #{caption}\n\n#{content}\n\n"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,93 @@
|
|
1
|
+
module Poml
|
2
|
+
# Role component
|
3
|
+
class RoleComponent < Component
|
4
|
+
def render
|
5
|
+
apply_stylesheet
|
6
|
+
|
7
|
+
content = @element.content.empty? ? render_children : @element.content
|
8
|
+
|
9
|
+
if xml_mode?
|
10
|
+
render_as_xml('role', content)
|
11
|
+
else
|
12
|
+
caption = apply_text_transform(get_attribute('caption', 'Role'))
|
13
|
+
caption_style = get_attribute('captionStyle', 'header')
|
14
|
+
|
15
|
+
case caption_style
|
16
|
+
when 'header'
|
17
|
+
"# #{caption}\n\n#{content}\n\n"
|
18
|
+
when 'bold'
|
19
|
+
"**#{caption}:** #{content}\n\n"
|
20
|
+
when 'plain'
|
21
|
+
"#{caption}: #{content}\n\n"
|
22
|
+
when 'hidden'
|
23
|
+
"#{content}\n\n"
|
24
|
+
else
|
25
|
+
"# #{caption}\n\n#{content}\n\n"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# Task component
|
32
|
+
class TaskComponent < Component
|
33
|
+
def render
|
34
|
+
apply_stylesheet
|
35
|
+
|
36
|
+
# For mixed content (text + elements), preserve spacing
|
37
|
+
content = if @element.children.empty?
|
38
|
+
@element.content.strip
|
39
|
+
else
|
40
|
+
# Don't strip when there are children to preserve spacing between text and elements
|
41
|
+
render_children
|
42
|
+
end
|
43
|
+
|
44
|
+
if xml_mode?
|
45
|
+
render_as_xml('task', content)
|
46
|
+
else
|
47
|
+
caption = apply_text_transform(get_attribute('caption', 'Task'))
|
48
|
+
caption_style = get_attribute('captionStyle', 'header')
|
49
|
+
|
50
|
+
case caption_style
|
51
|
+
when 'header'
|
52
|
+
# Don't add extra newlines if content already ends with newlines
|
53
|
+
content_ending = content.end_with?("\n\n") ? "" : "\n\n"
|
54
|
+
"# #{caption}\n\n#{content}#{content_ending}"
|
55
|
+
when 'bold'
|
56
|
+
"**#{caption}:** #{content}\n\n"
|
57
|
+
when 'plain'
|
58
|
+
"#{caption}: #{content}\n\n"
|
59
|
+
when 'hidden'
|
60
|
+
content_ending = content.end_with?("\n\n") ? "" : "\n\n"
|
61
|
+
"#{content}#{content_ending}"
|
62
|
+
else
|
63
|
+
content_ending = content.end_with?("\n\n") ? "" : "\n\n"
|
64
|
+
"# #{caption}\n\n#{content}#{content_ending}"
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
# Hint component
|
71
|
+
class HintComponent < Component
|
72
|
+
def render
|
73
|
+
apply_stylesheet
|
74
|
+
|
75
|
+
caption = get_attribute('caption', 'Hint')
|
76
|
+
caption_style = get_attribute('captionStyle', 'header')
|
77
|
+
content = @element.content.empty? ? render_children : @element.content
|
78
|
+
|
79
|
+
case caption_style
|
80
|
+
when 'header'
|
81
|
+
"# #{caption}\n\n#{content}\n\n"
|
82
|
+
when 'bold'
|
83
|
+
"**#{caption}:** #{content}\n\n"
|
84
|
+
when 'plain'
|
85
|
+
"#{caption}: #{content}\n\n"
|
86
|
+
when 'hidden'
|
87
|
+
"#{content}\n\n"
|
88
|
+
else
|
89
|
+
"# #{caption}\n\n#{content}\n\n"
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
module Poml
|
2
|
+
# CP component (custom component with caption)
|
3
|
+
class CPComponent < Component
|
4
|
+
def render
|
5
|
+
apply_stylesheet
|
6
|
+
|
7
|
+
caption = get_attribute('caption', '')
|
8
|
+
caption_serialized = get_attribute('captionSerialized', caption)
|
9
|
+
|
10
|
+
# Render children with increased header level for nested CPs
|
11
|
+
content = if @element.content.empty?
|
12
|
+
@context.with_increased_header_level { render_children }
|
13
|
+
else
|
14
|
+
@element.content
|
15
|
+
end
|
16
|
+
|
17
|
+
if xml_mode?
|
18
|
+
# Use captionSerialized for XML tag name, fallback to caption
|
19
|
+
tag_name = caption_serialized.empty? ? caption : caption_serialized
|
20
|
+
return render_as_xml(tag_name, content) unless tag_name.empty?
|
21
|
+
# If no caption, just return content
|
22
|
+
return "#{content}\n\n"
|
23
|
+
else
|
24
|
+
caption_style = get_attribute('captionStyle', 'header')
|
25
|
+
# Use captionSerialized for the actual header if provided
|
26
|
+
display_caption = caption_serialized.empty? ? caption : caption_serialized
|
27
|
+
|
28
|
+
# Apply stylesheet text transformation
|
29
|
+
display_caption = apply_text_transform(display_caption)
|
30
|
+
|
31
|
+
return content + "\n\n" if display_caption.empty?
|
32
|
+
|
33
|
+
case caption_style
|
34
|
+
when 'header'
|
35
|
+
header_prefix = '#' * @context.header_level
|
36
|
+
"#{header_prefix} #{display_caption}\n\n#{content}\n\n"
|
37
|
+
when 'bold'
|
38
|
+
"**#{display_caption}:** #{content}\n\n"
|
39
|
+
when 'plain'
|
40
|
+
"#{display_caption}: #{content}\n\n"
|
41
|
+
when 'hidden'
|
42
|
+
"#{content}\n\n"
|
43
|
+
else
|
44
|
+
header_prefix = '#' * @context.header_level
|
45
|
+
"#{header_prefix} #{display_caption}\n\n#{content}\n\n"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|