Report generator for Ruby and Rails.
Given a .rep.erb template and a data source (ActiveRecord exec_query,
Relation, array of models, or array of hashes), Repolho produces HTML with:
- Nested groupings (
<report-group>) - Page breaks (
<report-page rows="N">) - Headers and footers at every level
- Per-container statistics (
sum,avg,min,max,count) - Optional group-header reprint on page break
# Gemfile
gem "repolho"bundle installPlain Ruby:
require "repolho"
rows = Order.connection.exec_query(sql)
# or
# rows = Order.joins(...).select(...).order(...)
report = Repolho::Main.new("app/views/reports/sales.rep.erb", rows, { company: "Acme" })
report.run
html = report.content.joinOr with Rails:
render html: html.html_safe
# or File.write("report.html", html)Repolho::TemplateGenerator creates a .rep.erb file from a base template
and column metadata. The base template must already contain a <report-page>
element (the page is required, matching the runtime) and two markers:
<!-- REPORT:HEADERS -->— replaced by the generated<th>cells<!-- REPORT:ROW -->— replaced by the generated<report-row>
Column names are taken from the data source without executing the full query
when possible (ActiveRecord::Result#columns, a Relation's selected columns
or model column names, an array of hashes/models, or an explicit array of
column names).
# From a Relation or Result (columns only)
Repolho::TemplateGenerator.new(
"app/views/reports/base.rep.erb",
Order.where(status: "paid")
).write("app/views/reports/orders.rep.erb")
# Explicit columns + custom labels + numeric formatting
Repolho::TemplateGenerator.new(
"app/views/reports/base.rep.erb",
%w[id customer_name total],
labels: { "total" => "Total (R$)" },
numeric: %w[total]
).write("app/views/reports/orders.rep.erb")When running under Rails (or whenever I18n is available), header labels are
looked up case-insensitively under
activerecord.attributes.<model>.<column> (if a model: is given) or
repolho.attributes.<column>. The lookup tries the original key, the
downcase form and the upcase form. If no translation is found, a simple
humanized name is used.
Base template skeleton:
<report>
<report-page rows="40">
<report-header>
<tr>
<!-- REPORT:HEADERS -->
</tr>
</report-header>
<!-- REPORT:ROW -->
<report-footer>
<!-- optional page totals -->
</report-footer>
</report-page>
</report>Repolho uses a few custom elements inside a normal HTML document. Everything
outside <report>…</report> is copied as-is (the previous and after
sections). Everything inside the custom elements is processed as ERB.
The element names are intentionally generic (<report>, <report-page>,
<report-group>, etc.) rather than namespaced under the gem (<repolho>,
<repolho-page>, …).
Using repolho would tie the markup to this specific tool. With report,
the template describes a report structure, not a Ruby library. Someone
working in another language or with another report engine can reuse the same
markup (or a close subset of it) without carrying Repolho-specific names.
The goal is a small, readable convention for report templates — not a
proprietary dialect.
| Element | Purpose | Attributes |
|---|---|---|
<report> |
Root container of the report | pagination (optional, "true"): enables client-side page navigation. Also accepts pagination-first, pagination-prev, pagination-next, pagination-last for custom labels.orientation (optional, "portrait" or "landscape"): page orientation for print.paper (optional, e.g. "A4", "Letter"): paper size for print. If either is present, a @media print style is injected at the end of <body>.min-row-for-break (optional, integer): when a group has page-break-after="true", the forced break is skipped if the current page row number is less than or equal to this value. Prevents an extra break when a page has just started. |
<report-page> |
Defines a page (required, only one) | rows (required): max data rows per page |
<report-group> |
Nested grouping | id (required), attr (required), reprint-header-on-page-break (optional, "true"), page-break-after (optional, "true"): after the group ends and its footer is emitted on the current page, fill the remaining rows with blanks and force a page break so the next group starts on a fresh page. Respects min-row-for-break on <report>. |
<report-header> |
Header of the current container | — |
<report-footer> |
Footer of the current container | — |
<report-row> |
Template for each data row (inside the innermost group) | — |
<report-last-page> |
Optional block rendered only at the end of the report, as additional rows on the last page. Counts toward the page row limit and can force a page break. | — |
Optional element that is rendered only after all data rows have been processed.
Its content is injected as normal rows into the page flow, so:
- It counts toward the
rowslimit of<report-page>. - If it does not fit on the current page, a new page is opened automatically.
- The page footer of the final page is rendered after this block.
Because the block is only ever emitted at the end, you do not need to check last_page? inside it.
You can print the overall report total from this block by using the second argument of the statistics methods (see below):
<report-last-page>
<tr>
<td colspan="4">Grand total</td>
<td><%= format("%.2f", sum(:value, :report)) %></td>
</tr>
<tr>
<td colspan="5">Final notes and signature</td>
</tr>
</report-last-page>Every container keeps its own statistics. By default sum, avg, count, min and max operate on the current container.
All statistics methods accept an optional second argument that selects another context:
| Context | Meaning |
|---|---|
nil (default) |
Current container |
:page |
The current page |
:report |
The whole report (grand total) |
a Container object |
That specific container |
Examples:
<%= sum(:value) %> <!-- current group or page -->
<%= sum(:value, :page) %> <!-- current page total -->
<%= sum(:value, :report) %> <!-- overall report total -->
<%= report.sum(:value) %> <!-- same as above, via the report object -->The report object is available in every ERB context (headers, footers, rows and last-page content).
Each page is emitted as a <tbody class="repolho-page" data-page="N">.
Because of HTML table rules, only <tr> elements may appear as direct
children of that <tbody>.
| Allowed | Not allowed | Why |
|---|---|---|
<tr> (with <td> / <th>) |
<thead> |
<thead> cannot be a child of <tbody>. The browser moves it out and empties the page container. |
<tfoot> |
Same reason. | |
<div>, <p>, <section>, etc. |
Invalid inside a table. |
Use <tr> for headers and footers of pages and groups. Reserve <thead> /
<tfoot> only for content that lives outside the page bodies (for example
a report-level header that should appear before any page, if you place it
before the first <report-page>).
Repolho automatically wraps each page like this:
<tbody class="repolho-page" data-page="1" style="break-after: page; page-break-after: always;">
<!-- only <tr> elements here -->
</tbody>The data-page attribute and the break-after style are injected for you.
You do not need to write the <tbody> yourself.
If you set pagination="true" on the root <report> element, Repolho
appends a navigation bar just before the after section (still inside
the table) and a small pure-ES6 script at the end of the document
(inserted before </body> when present, otherwise appended). The bar
is omitted when the report has only one page.
Optional attributes let you customize the labels:
| Attribute | Default | Description |
|---|---|---|
pagination-first |
First |
First page link text |
pagination-prev |
< |
Previous page link text |
pagination-next |
> |
Next page link text |
pagination-last |
Last |
Last page link text |
<report pagination="true"
pagination-first="Primeira"
pagination-prev="«"
pagination-next="»"
pagination-last="Última">
...
</report>The generated controls look like:
<tbody id="report-pagination" data-max="N">
<tr>
<td colspan="200" id="report-pagination-links">
<a href="#" id="report-first-page">First</a>
<a href="#" id="report-prev-page"><</a>
<input type="number" id="report-current-page" min="1" max="N" value="1"/>
<a href="#" id="report-next-page">></a>
<a href="#" id="report-last-page">Last</a>
</td>
</tr>
</tbody>You can restyle the links and the input with normal CSS. The script is wrapped in try/catch so a failure does not break the rest of the page.
When pagination is enabled, page_number (available in ERB) holds the
total number of pages after the report has been fully generated.
<!DOCTYPE html>
<html>
<head>
<title>Sales report</title>
</head>
<body>
<table>
<report>
<report-header>
<tr><th colspan="100"><%= @company %></th></tr>
<tr><th colspan="100">Sales report</th></tr>
</report-header>
<report-page rows="40">
<report-header>
<tr>
<td>Id</td>
<td>Name</td>
<td class="text-end">Value</td>
</tr>
</report-header>
<report-footer>
<tr>
<td colspan="2">Page total</td>
<td><%= format("%.2f", sum(:value)) %></td>
</tr>
</report-footer>
<report-group id="state" attr="state" reprint-header-on-page-break="true">
<report-header>
<tr>
<td>State:</td>
<td colspan="100"><%= attr(:state) %></td>
</tr>
</report-header>
<report-footer>
<tr>
<td colspan="2"><%= attr(:state) %> total</td>
<td>
<%= format("%.2f", sum(:value)) %>
(max: <%= format("%.2f", max(:value)) %>)
</td>
</tr>
</report-footer>
<report-row>
<tr>
<td><%= attr(:id) %></td>
<td><%= attr(:name) %></td>
<td class="text-end"><%= format("%.2f", attr(:value)) %></td>
</tr>
</report-row>
</report-group>
</report-page>
<report-footer>
<tr>
<td colspan="2">Grand total</td>
<td><%= format("%.2f", sum(:value)) %></td>
</tr>
</report-footer>
</report>
</table>
</body>
</html>To preview the structure, rename the file to .html and open it in a browser
(the custom tags will be ignored).
| Method | Description |
|---|---|
attr(:field) |
Value of the field on the current row |
sum(:field) / sum(:field, context) |
Sum within the current container, or in the given context (:page, :report) |
avg(:field) / avg(:field, context) |
Average |
min(:field) / max(:field) (and with context) |
Minimum / maximum |
count(:field) / count(:field, context) |
Number of values |
number |
Current page or group number |
report |
The root Report container (useful to call report.sum(:field)) |
You can make Rails view helpers available inside the ERB templates by calling
Repolho.include_rails_helpers!. By default it includes
ActionView::Helpers::NumberHelper (so you get number_to_currency,
number_with_precision, etc.).
# config/initializers/repolho.rb
Repolho.include_rails_helpers!After that you can use the helpers normally in any template:
<td class="text-end"><%= number_to_currency(attr(:value)) %></td>
<td><%= number_with_precision(sum(:value), precision: 2) %></td>You can also pass extra modules:
Repolho.include_rails_helpers!(
ActionView::Helpers::NumberHelper,
ActionView::Helpers::TextHelper
)You need the corresponding gems. Directly requiring the individual helper file triggers an autoload problem in recent Rails versions, so load the helpers framework first:
# Gemfile
gem "actionview"
require "action_view"
require "action_view/helpers" # loads NumberHelper and the rest
Repolho.include_rails_helpers!Alternatively (and often simpler), use ActiveSupport::NumberHelper, which is
designed to be used outside of a view context. The method names are almost the
same (number_to_currency, number_to_rounded, etc.):
# Gemfile
gem "activesupport"
require "active_support/number_helper"
Repolho.include_rails_helpers!(ActiveSupport::NumberHelper)Note: some method names differ slightly between the two modules
(number_with_delimiter vs number_to_delimited). Prefer the ActiveSupport
version when you are not inside Rails.
Instance variables from the Rails view (such as @current_template) are not
automatically copied. Pass them explicitly through the locals hash:
Repolho::Main.new(path, data, current_template: @current_template, user: current_user)They become @current_template and @user inside the templates.
The project uses Minitest.
# From the gem root
bundle install
bundle exec rake test
# or
ruby -Ilib:test test/stats_test.rbTests cover:
Stats(sum, avg, min, max, count, reset, numeric coercion)Container(ERB rendering of headers/footers/rows, attribute access, stats delegation, status)Page(row counting via<tr>, page open/close, page breaks, raw append)Report(inheritance and composition)Main(data normalization for hashes / ActiveRecord-like objects / models, section extraction, full HTML generation, groupings, page breaks, pagination, print CSS for orientation/paper, previous/after ERB)
Integration tests assert on the generated HTML structure (tbody.repolho-page, data attributes, totals, labels).
This gem is cryptographically signed. To verify the signature on install:
# Trust the public certificate (once)
gem cert --add <(curl -Ls https://raw.githubusercontent.com/taq/repolho/main/certs/taq.pem)
# Install with signature verification
gem install repolho -P HighSecurityMIT