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-curves on 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
<workspace>/.asciidoctor/lib/ptc.js
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

format

ptc-format

svg

Output format: svg, png, or pdf (see Output formats).

scale

ptc-scale

1

Scale multiplier applied to the sheet’s intrinsic size (explicit width/height; the viewBox is preserved, so it still scales crisply). Ignored when width is set; ignored for format=pdf (a PDF’s page comes from size/orientation instead).

width

ptc-width

(intrinsic)

Explicit output width in pixels. Wins over scale; height follows the sheet’s own aspect ratio. Ignored for format=pdf.

theme

ptc-theme

(the study’s own page { theme }, else light; always light for format=pdf unless set)

Colour theme: light, dark, monochrome, or print. Overrides what the study itself declares. A PDF is printed and filed, so — like the tc-curves CLI — it defaults to light regardless of the study’s own page { theme }; svg/png honour the declared theme.

view

ptc-view

(the sheet marked default = true, else the first)

Which declared sheet to draw, by its id or printed name. Naming a sheet the study does not declare is an error, not a silent fall-back. Ignored when all-views=true.

all-views

ptc-all-views

false

format=pdf only: every declared sheet, one page each, in declaration order, instead of just view. A svg/png is one image by definition, so all-views=true with either is an error rather than a silent single page.

size

ptc-size

A4

format=pdf only: paper size — A0A5, Letter, Legal, or Tabloid.

orientation

ptc-orientation

landscape

format=pdf only: portrait or landscape. Landscape by default — a TCC’s x axis is logarithmic and wide.

margin

ptc-margin

10

format=pdf only: page margin in millimetres, applied on all four sides.

background

ptc-background

(the sheet’s own paper colour)

Background colour, or transparent. svg/png only.

font-family

ptc-font-family

(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. svg/png only.

force

ptc-force

false

Render the sheet even when the study has errors, instead of showing an error block. The sheet is stamped as invalid (see Error handling).

target

(hash)

Output file basename (without extension). When omitted, a content-addressed name (ptc-<hash>.<ext>) is used so an identical study+options shares a file.

(n/a)

ptc-node

node

Path to the Node executable.

(n/a)

ptc-package-dir

(auto)

Directory from which to resolve the tc-curves npm package. Overrides auto-detection; the PTC_PACKAGE_DIR env var does the same.

(n/a)

ptc-cache

true

Set to false to always re-render, ignoring the cache.

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. Pass force=true to draw it anyway — the sheet is stamped as invalid and gets the ptc-forced role, 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-fail role 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

svg

Default and recommended for asciidoctor-pdf. Vector, so it is sharp at any zoom or print resolution, embeds via prawn-svg with no rasterisation, and compresses to a fraction of an equivalent PNG. Also ideal for HTML.

png

When a raster image is required (e.g. a target that cannot embed SVG). Use scale or width to control resolution.

pdf

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-pdf itself. Converting the same document to HTML with plain asciidoctor still writes the .pdf file, but emits a plain (non-functional) <img> reference to it — use svg/png for 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 pageall-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 the theme attribute 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.