An Asciidoctor extension that renders protection-relay
time-current grading studies (TCC sheets), written in the tc-curves
language (.ptc source), into SVG, PNG, or PDF at conversion time,
driven by AsciiDoc attributes.
You write a study — voltage levels, relays, faults, the pairs to grade — in an AsciiDoc block; the extension parses, validates, grades and draws it, and embeds the result as an image — no manual placement, no separate build step.
[ptc]
....
system {
voltages { MV { V = 11 kV; } }
}
faults {
BOARD_MAX { I = 6.2 kA; type = three_phase; voltage = MV; }
}
relay R_FDR {
voltage = MV; ct_ratio = 400/5;
element 51 { function = "phase_oc"; curve = iec.si; I_pickup = 480 A; tms = 0.10; }
}
relay R_INC {
voltage = MV; ct_ratio = 1200/5;
element 51 { function = "phase_oc"; curve = iec.si; I_pickup = 960 A; tms = 0.25; }
}
grade {
primary = R_FDR:51; backup = R_INC:51; fault = BOARD_MAX; margin = 0.30 s;
}
....
It ships in two forms that share a single rendering core, so a study and its options produce byte-identical output either way:
-
a Ruby gem,
asciidoctor-prot-time-curves(the default examples below), and -
an Asciidoctor.js extension,
@openpowershift/asciidoctor-prot-time-curveson npm — see Use with Asciidoctor.js.
Both accept the same attributes and emit the same roles.
How it works
Rendering is delegated to the official @openpowershift/time-current-grading-language
npm package (tc-curves — the same library used by the
tc-curves playground and CLI). The gem ships a tiny Node helper that
imports the package, parses and grades the study, and returns the rendered
artifact; the Ruby side handles attributes, output paths and caching.
-
SVG output is produced by the tc-curves renderer directly (isomorphic — pure JS/Node). It embeds cleanly in
asciidoctor-pdf, scales without loss, and stays small. -
PNG output rasterises the SVG. Unlike a renderer with no export step of its own, tc-curves ships PNG rasterisation (
@resvg/resvg-js) as a real dependency of its own package — installing tc-curves is enough; there is nothing extra to install for PNG.
The dependency footprint is deliberately small: Ruby, Node, and the tc-curves npm package.
A .ptc study is more than a diagram source: it can fail to parse, and
even a well-formed study can fail to grade (a margin comes up short). The
extension distinguishes the two exactly as the tc-curves CLI does — see
Error handling.
Prerequisites
-
Ruby (works on older versions — 2.5+) with Asciidoctor 2.0+.
-
Node.js 20 or newer, on
PATH. -
The tc-curves npm package, installed where you build your documents:
$ npm install @openpowershift/time-current-grading-language
The extension resolves the npm package from your project’s node_modules
(your build directory), or from a location you point it at with the
ptc-package-dir attribute or the PTC_PACKAGE_DIR environment variable.
Installation
Add the gem to your Gemfile:
gem 'asciidoctor-prot-time-curves'
or install it directly:
$ gem install asciidoctor-prot-time-curves
Registering the extension
On the command line, require it when converting:
$ asciidoctor -r asciidoctor-prot-time-curves document.adoc
$ asciidoctor-pdf -r asciidoctor-prot-time-curves document.adoc
From Ruby, requiring the gem auto-registers it with the global registry:
require 'asciidoctor'
require 'asciidoctor-prot-time-curves'
Asciidoctor.convert_file 'document.adoc', safe: :safe
To register against a specific registry instead of globally:
registry = Asciidoctor::Extensions.create
Asciidoctor::Ptc.register(registry)
Usage
Inline block
Put .ptc source in a block named ptc. Use a literal (….) delimiter so
the language’s own punctuation is never interpreted as AsciiDoc:
[ptc]
....
relay R_FDR { ... }
....
Optional positional attributes are target (a stable output file name) and
format:
[ptc,feeder-grading,svg]
....
relay R_FDR { ... }
....
From a file
Use the ptc:: block macro to render an external .ptc file (path relative
to the document):
ptc::studies/feeder-grading.ptc[format=png,scale=2]
A study that declares several sheets needs to say which one to draw — see the
view attribute below:
ptc::studies/tee-point.ptc[view=Residual]
Use with Asciidoctor.js
The extension is also published to npm as @openpowershift/asciidoctor-prot-time-curves,
for Asciidoctor.js 4. It
shares the same rendering core as the gem, so the same study and options
yield byte-identical SVG and the same generated file names. It ships as a
single bundled file (Node ESM + CommonJS, plus a browser build) with
TypeScript types.
$ npm install @openpowershift/asciidoctor-prot-time-curves @openpowershift/time-current-grading-language
The renderer (@openpowershift/time-current-grading-language) is a runtime
dependency; it brings its own PNG rasteriser, so there is nothing optional to
add for format=png.
Programmatic use (Node)
Rendering is asynchronous, so convert returns a promise — always await it.
import { Extensions, convert } from '@asciidoctor/core'
import ptc from '@openpowershift/asciidoctor-prot-time-curves'
const registry = Extensions.create()
ptc.register(registry) // or: ptc.register(registry, { packageDir })
const html = await convert(source, {
extension_registry: registry,
safe: 'safe',
})
Images are written under imagesdir (or imagesoutdir) exactly as the gem
does, with identical content-hash file names and metadata-sidecar caching.
Command line
$ npx asciidoctor -r @openpowershift/asciidoctor-prot-time-curves document.adoc
Browser
A browser build is exported at @openpowershift/asciidoctor-prot-time-curves/browser.
Unlike a renderer whose raster path is Node-only, tc-curves' own PNG export
falls back to <canvas> in the browser, so both svg and png work here,
embedded inline; your bundler or import map must provide
@openpowershift/time-current-grading-language.
import { Extensions, convert } from '@asciidoctor/core'
import ptc from '@openpowershift/asciidoctor-prot-time-curves/browser'
const registry = Extensions.create()
ptc.register(registry)
const html = await convert(source, { extension_registry: registry })
Asciidoctor VS Code
The AsciiDoc
extension loads Asciidoctor.js extensions from any .js file under
.asciidoctor/lib/ (searched recursively) in the workspace: it require`s
each file and calls its `register(registry). Enable
Enable Asciidoctor.js extensions registration
(asciidoc.extensions.registerWorkspaceExtensions) and trust the workspace when
prompted. Studies render as inline SVG in the preview (PNG is not available
there).
Option A — zero install (recommended). Download
asciidoctor-prot-time-curves-standalone.js from the
latest release and
save it as <workspace>/.asciidoctor/lib/ptc.js. It is a single self-contained
file with the tc-curves library bundled in — nothing to npm install, which is
ideal when you cannot add packages. Reload the window; [ptc] blocks and
ptc:: macros render.
Option B — via npm. Install into the workspace and point a loader file at the bundled standalone entry:
$ npm install @openpowershift/asciidoctor-prot-time-curves @openpowershift/time-current-grading-language
module.exports = require('@openpowershift/asciidoctor-prot-time-curves/standalone')
Use the /standalone subpath: it renders synchronously and inline, so it works
regardless of which Asciidoctor.js version the editor bundles. (The default
entry uses asynchronous rendering, which older bundled engines may not await.)
|
Note
|
If your workspace package.json sets "type": "module", also drop a
.asciidoctor/lib/package.json containing { "type": "commonjs" } so the .js
loader file is treated as CommonJS.
|
Attributes
Every option can be set on the individual block/macro, or document-wide using
the ptc- prefixed form (block/macro wins). Set document attributes in the
header or on the command line (-a ptc-format=png).
| Block attribute | Document attribute | Default | Description |
|---|---|---|---|
|
|
|
Output format: |
|
|
|
Scale multiplier applied to the sheet’s intrinsic size (explicit
|
|
|
(intrinsic) |
Explicit output width in pixels. Wins over |
|
|
(the study’s own |
Colour theme: |
|
|
(the sheet marked |
Which declared sheet to draw, by its |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
(the sheet’s own paper colour) |
Background colour, or |
|
|
(tc-curves' own export font stack) |
Font family baked into the exported SVG root (the export has no host page
to inherit fonts from). Only needed to override the library’s default.
|
|
|
|
Render the sheet even when the study has errors, instead of showing an error block. The sheet is stamped as invalid (see Error handling). |
|
|
(hash) |
Output file basename (without extension). When omitted, a content-addressed
name ( |
|
|
|
Path to the Node executable. |
|
|
(auto) |
Directory from which to resolve the tc-curves npm package. Overrides
auto-detection; the |
|
|
|
Set to |
Standard image attributes — alt, title, width, height, pdfwidth,
scaledwidth, align, float, id, link, role — pass straight through to
the generated image. To control the rendered size, use width for HTML and
pdfwidth (or scaledwidth) for asciidoctor-pdf, e.g.:
ptc::studies/feeder-grading.ptc[pdfwidth=90%]
|
Note
|
The block attribute width sets the rendered pixel width baked into
the file (see the table above); the image attribute width sets the HTML
<img width> and can be given independently.
|
Without a sizing attribute, a sheet renders at its intrinsic size and
asciidoctor-pdf scales an over-wide one down to the content width.
For format=pdf, two more image attributes pass through: page and pages,
read by asciidoctor-pdf’s own PDF-page-import (not by this extension) to
pick which page(s) of the generated PDF to place here — see
PDF output, and embedding it with asciidoctor-pdf.
Error handling
A .ptc study goes through more than parsing: it is also validated and
graded. The extension mirrors the tc-curves CLI’s own distinction between
these:
-
Parse or validation errors (an unknown curve id, an unresolved reference, a value out of range, …) refuse the render: the extension emits a visible error block (role
ptc-error) listing the diagnostics, and logs a warning, rather than aborting the whole conversion or drawing a sheet built on broken defaults. Passforce=trueto draw it anyway — the sheet is stamped as invalid and gets theptc-forcedrole, so it cannot be mistaken for a clean one. -
A grading failure (the study parses and validates cleanly, but a margin comes up short) is not a render error — the CLI draws that sheet too, because the failing margin is exactly what the study exists to show. The extension does the same, and adds the
ptc-grading-failrole so the document can flag it in CSS or a PDF theme without re-deriving it from the drawing.
This keeps a single broken study from failing a large book, while still making an invalid or failing sheet impossible to mistake for a passing one.
Output formats
Choosing a format:
| Format | When to use |
|---|---|
|
Default and recommended for |
|
When a raster image is required (e.g. a target that cannot embed SVG). Use
|
|
To embed the sheet as a vector page via asciidoctor-pdf’s own PDF-page-import, or to hand a study off as a standalone file. See below. |
For HTML output, use svg or png — a browser cannot display a .pdf file
through an <img> tag, so format=pdf is only useful when the document is
itself being converted with asciidoctor-pdf.
PDF output, and embedding it with asciidoctor-pdf
asciidoctor-pdf can import
a page from a PDF file through the ordinary image macro — no other extension
needed. Point it at a sheet this extension rendered:
ptc::studies/feeder-grading.ptc[format=pdf]
That imports page 1 as a dedicated page sized and laid out to match the imported PDF — not scaled inline like a raster or SVG image, so no manual page break is needed around it. Two consequences follow directly from that:
-
The macro must be a direct child of the document or a section — never nested inside a delimited block, a table cell, or a sidebar. asciidoctor-pdf refuses a PDF import there, because "insert a whole page" has no meaning inside a constrained region.
-
It only makes sense with
asciidoctor-pdfitself. Converting the same document to HTML with plainasciidoctorstill writes the.pdffile, but emits a plain (non-functional)<img>reference to it — usesvg/pngfor documents you also convert to HTML.
A study with several sheets renders as a multi-page PDF with
all-views=true, one page per declared view, in declaration order:
ptc::studies/tee-point.ptc[format=pdf,all-views=true]
Pick a specific sheet out of it with the page attribute (1-based), or a
range/list with pages — both are asciidoctor-pdf’s own PDF-import
attributes, passed straight through:
== Phase sheet
ptc::studies/tee-point.ptc[format=pdf,all-views=true,page=1]
== Residual sheet
ptc::studies/tee-point.ptc[format=pdf,all-views=true,page=3]
Because rendering is cached by content and options, both macros
above resolve to the same generated file (same source, same all-views) and
the study is only rendered once; only which page each image:: imports
differs. A study with only one sheet needs no page — all-views=true on it
still produces a coherent one-page PDF (the CLI’s own fallback), so requesting
every view never fails just because there is only one.
Paper size, orientation and margins come from size, orientation and
margin (see Attributes) rather than from the study’s own page { size }
— a PDF meant for import into another document has its own layout to fit,
which is not necessarily the study’s declared print size.
|
Note
|
A study with grading errors is refused for PDF exactly as for svg/png
— see Error handling. force=true still stamps every page it draws.
|
Styling with roles
Every generated image is tagged with roles so it can be targeted from CSS or
an asciidoctor-pdf theme without touching each block:
-
ptc— on every tc-curves sheet. -
ptc-svg/ptc-png/ptc-pdf— the output format. -
ptc-light/ptc-dark/ptc-monochrome/ptc-print— the resolved theme (the study’s own, unless thethemeattribute overrides it). -
ptc-forced— the study had errors and was rendered anyway (force=true). -
ptc-grading-fail— the study is valid but at least one graded pair fails its margin.
Any role you set on the block is preserved and appended.
In HTML the roles appear as classes on the image block, so you can style them with CSS:
.imageblock.ptc { margin-block: 1.5rem; }
.imageblock.ptc img { max-width: 100%; }
.imageblock.ptc-dark { background: #1b1b1f; padding: 0.5rem; border-radius: 6px; }
.imageblock.ptc-grading-fail { outline: 2px solid #b33; }
In asciidoctor-pdf, target the same roles from your theme (roles map to
role_<name> keys). For example, to flag a failing sheet:
role:
ptc-grading-fail:
border-color: 'B33333'
border-width: 1
Because the roles are stable, a document-wide look needs no per-block markup.
Caching
Rendered files are content-addressed and cached: the source plus the resolved
options (format, scale, width, theme, view, background, font family,
all-views, size, orientation, margin, force, and the gem version) are hashed
into a small JSON sidecar (<image>.ptccache) that also carries the resolved
verdict (theme, dimensions, page count, whether the study had errors or
failed grading) — so a cache hit needs neither Node nor a re-parse to answer
those. On the next build, an unchanged study is reused. Editing the study or
changing an option transparently regenerates it. Disable with
-a ptc-cache=false.
Generated images are written to the usual Asciidoctor image output location —
imagesoutdir if set, otherwise imagesdir under the output directory — so
they interoperate with the rest of your image pipeline.
Example document
= Northgate 33/11 kV Coordination Study
:ptc-format: svg
:imagesdir: images
== Phase overcurrent cascade
[ptc,northgate-cascade]
....
system {
voltages {
HV { V = 33.0 kV; }
MV { V = 11.0 kV; }
}
}
faults {
BUS_MAX { I = 8.2 kA; voltage = MV; }
}
relay R_INC {
voltage = HV; ct_ratio = 600/5;
element 51 { function = "phase_oc"; curve = iec.si; I_pickup = 700 A; tms = 0.40; }
}
relay R_TIE {
voltage = MV; ct_ratio = 800/5;
element 51 { function = "phase_oc"; curve = iec.si; I_pickup = 900 A; tms = 0.30; }
}
grade {
primary = R_TIE:51; backup = R_INC:51; fault = BUS_MAX; margin = 0.30 s;
}
....
== Sequence sheet (from a file)
ptc::studies/tee-point.ptc[view=Residual]
== Appendix: every sheet, embedded as PDF pages
ptc::studies/tee-point.ptc[format=pdf,all-views=true,page=1]
ptc::studies/tee-point.ptc[format=pdf,all-views=true,page=2]
Convert to HTML and PDF:
$ asciidoctor -r asciidoctor-prot-time-curves protection-study.adoc
$ asciidoctor-pdf -r asciidoctor-prot-time-curves protection-study.adoc
Development
The repository holds both implementations, sharing one rendering core
(lib/asciidoctor/ptc/js/render-core.mjs):
-
the Ruby gem (
lib/,test/), and -
the Asciidoctor.js extension (
js/, TypeScript, bundled with esbuild).
# Ruby gem
$ bundle install
$ (cd test/js && npm install) # tc-curves package for the test suite
$ bundle exec rake test # Ruby unit + end-to-end tests
$ bundle exec rake jstest # Node helper tests
# Asciidoctor.js extension
$ (cd js && npm install)
$ (cd js && npm run build) # single bundled file per target → js/dist
$ (cd js && npm test) # extension tests
Releasing
Gem and npm package are versioned in lockstep and released together by
release-please: Conventional-Commit
pushes to main maintain a release PR; merging it tags vX.Y.Z, then the
Release workflow publishes the gem (RubyGems) and npm package — both via
trusted publishing (OIDC, no stored secrets) — and attaches the gem, npm
tarball and standalone build to the GitHub Release. See
RELEASING.adoc for the one-time trusted-publisher setup
and the full release process.
Contributing
Contributions are welcome — see CONTRIBUTING.adoc for the repository layout and how to run the Ruby and JavaScript test suites.
License
MIT © Daniel Mulholland. See LICENSE.