asciidoctor-ldl 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d37e7971f951167055c3491ecfeb5490b04b57871428bedb4cdd9e1184ddda18
4
+ data.tar.gz: 66950750f09e748e71f498678f0d305beb92ce31b04e8aa5cb2a47bc0f344069
5
+ SHA512:
6
+ metadata.gz: 5bb1d501146cece951dbc5268ec36d73b058af6aceaaf3124bef4c870594fbc7cac86041f5b6fa342b18b3ba4037e3cc07ccb21f3d7ec52f9c23bc34eb623dd3
7
+ data.tar.gz: a07923b23d094eabfff664acf3d95b4451802e5c3263616eba076cbd11b86b71d32baedbec57fdf5f552f7a897f677431431e7145eefe0e515953fd555cbd674
data/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is based on
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-07-23
10
+
11
+ ### Added
12
+
13
+ - Initial release.
14
+ - `[ldl]` delimited block and `ldl::file.ldl[]` block macro that render
15
+ [Logic Diagram Language](https://github.com/OpenPowerShift/logic-diagram-language)
16
+ source to an image at conversion time.
17
+ - SVG output (vector, isomorphic, no extra dependency) and PNG output (via
18
+ `@resvg/resvg-js`).
19
+ - `format`, `scale`, `theme`, `show-ids` and `show-labels` attributes, settable
20
+ per block or document-wide with the `ldl-` prefix.
21
+ - Every generated image carries `ldl`, `ldl-<format>` and `ldl-<theme>` roles for
22
+ CSS / asciidoctor-pdf theme targeting.
23
+ - Content-addressed caching with a digest sidecar so unchanged diagrams are not
24
+ re-rendered.
25
+
26
+ [Unreleased]: https://github.com/OpenPowerShift/asciidoctor-ldl/compare/v0.1.0...HEAD
27
+ [0.1.0]: https://github.com/OpenPowerShift/asciidoctor-ldl/releases/tag/v0.1.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Mulholland
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.adoc ADDED
@@ -0,0 +1,365 @@
1
+ = asciidoctor-ldl
2
+ :idprefix:
3
+ :idseparator: -
4
+ :source-language: asciidoc
5
+ :ldl-url: https://github.com/OpenPowerShift/logic-diagram-language
6
+ :npm-url: https://www.npmjs.com/package/@openpowershift/logic-diagram-language
7
+ :toc: macro
8
+ :toclevels: 3
9
+
10
+ An https://asciidoctor.org[Asciidoctor] extension that renders *logic and
11
+ protection-relay diagrams* written in the {ldl-url}[Logic Diagram Language (LDL)]
12
+ into *SVG* or *PNG* at conversion time, driven by AsciiDoc attributes.
13
+
14
+ You write boolean equations in an AsciiDoc block; the extension renders a clean,
15
+ laid-out diagram and embeds it as an image — no manual placement, no separate
16
+ build step.
17
+
18
+ [source]
19
+ ----
20
+ [ldl]
21
+ ....
22
+ TRIP.Name = "Trip"
23
+ TRIP = OVERCURRENT AND NOT BLOCK
24
+ OR (EARTH AND MANUAL)
25
+ ....
26
+ ----
27
+
28
+ toc::[]
29
+
30
+ == How it works
31
+
32
+ Rendering is delegated to the official {npm-url}[`@openpowershift/logic-diagram-language`]
33
+ npm package (the same renderer used by the
34
+ {ldl-url}[LDL playground]). The gem ships a tiny Node helper that imports the
35
+ package and returns the rendered artifact; the Ruby side handles attributes,
36
+ output paths and caching.
37
+
38
+ * *SVG* output is produced by the LDL renderer directly (isomorphic — pure
39
+ Node). It needs only Node and the npm package. Being vector, it embeds
40
+ cleanly in `asciidoctor-pdf`, scales without loss, and stays small.
41
+ * *PNG* output additionally rasterises the SVG with
42
+ https://github.com/thx/resvg-js[`@resvg/resvg-js`] — a single prebuilt,
43
+ dependency-free native module (no system libraries such as ImageMagick,
44
+ Cairo or a headless browser required).
45
+
46
+ The dependency footprint is deliberately small: *Ruby*, *Node*, and the *LDL npm
47
+ package* (plus `@resvg/resvg-js` only if you want PNG).
48
+
49
+ == Prerequisites
50
+
51
+ * Ruby (works on older versions — 2.5+) with Asciidoctor 2.0+.
52
+ * https://nodejs.org[Node.js] 20 or newer, on `PATH`.
53
+ * The LDL npm package, installed where you build your documents:
54
+ +
55
+ [source,console]
56
+ ----
57
+ $ npm install @openpowershift/logic-diagram-language
58
+ $ npm install @resvg/resvg-js # only needed for PNG output
59
+ ----
60
+
61
+ The extension resolves the npm package from your project's `node_modules`
62
+ (your build directory), or from a location you point it at with the
63
+ `ldl-package-dir` attribute or the `LDL_PACKAGE_DIR` environment variable.
64
+
65
+ == Installation
66
+
67
+ Add the gem to your `Gemfile`:
68
+
69
+ [source,ruby]
70
+ ----
71
+ gem 'asciidoctor-ldl'
72
+ ----
73
+
74
+ or install it directly:
75
+
76
+ [source,console]
77
+ ----
78
+ $ gem install asciidoctor-ldl
79
+ ----
80
+
81
+ == Registering the extension
82
+
83
+ On the command line, require it when converting:
84
+
85
+ [source,console]
86
+ ----
87
+ $ asciidoctor -r asciidoctor-ldl document.adoc
88
+ $ asciidoctor-pdf -r asciidoctor-ldl document.adoc
89
+ ----
90
+
91
+ From Ruby, requiring the gem auto-registers it with the global registry:
92
+
93
+ [source,ruby]
94
+ ----
95
+ require 'asciidoctor'
96
+ require 'asciidoctor-ldl'
97
+
98
+ Asciidoctor.convert_file 'document.adoc', safe: :safe
99
+ ----
100
+
101
+ To register against a specific registry instead of globally:
102
+
103
+ [source,ruby]
104
+ ----
105
+ registry = Asciidoctor::Extensions.create
106
+ Asciidoctor::Ldl.register(registry)
107
+ ----
108
+
109
+ == Usage
110
+
111
+ === Inline block
112
+
113
+ Put LDL source in a block named `ldl`. Use a literal (`....`) delimiter so LDL's
114
+ own punctuation is never interpreted as AsciiDoc:
115
+
116
+ [source]
117
+ ----
118
+ [ldl]
119
+ ....
120
+ O1.Name = "Trip"
121
+ I1.Name = "Overcurrent"
122
+ O1 = I1 AND NOT I2
123
+ ....
124
+ ----
125
+
126
+ Optional positional attributes are `target` (a stable output file name) and
127
+ `format`:
128
+
129
+ [source]
130
+ ----
131
+ [ldl,trip-logic,svg]
132
+ ....
133
+ O1 = I1 AND NOT I2
134
+ ....
135
+ ----
136
+
137
+ === From a file
138
+
139
+ Use the `ldl::` block macro to render an external `.ldl` file (path relative to
140
+ the document):
141
+
142
+ [source]
143
+ ----
144
+ ldl::diagrams/trip-matrix.ldl[format=png,scale=2]
145
+ ----
146
+
147
+ == Attributes
148
+
149
+ Every option can be set on the individual block/macro, or document-wide using
150
+ the `ldl-` prefixed form (block/macro wins). Set document attributes in the
151
+ header or on the command line (`-a ldl-format=png`).
152
+
153
+ [cols="1m,1m,1,3",options="header"]
154
+ |===
155
+ | Block attribute | Document attribute | Default | Description
156
+
157
+ | format
158
+ | ldl-format
159
+ | `svg`
160
+ | Output format: `svg` or `png`.
161
+
162
+ | scale
163
+ | ldl-scale
164
+ | `1`
165
+ | Scale multiplier. For `svg`, sets explicit `width`/`height` from the diagram's
166
+ intrinsic size (the `viewBox` is preserved, so it still scales crisply). For
167
+ `png`, multiplies the rasterised device pixels.
168
+
169
+ | theme
170
+ | ldl-theme
171
+ | `light`
172
+ | Colour theme: `light` or `dark`.
173
+
174
+ | show-ids
175
+ | ldl-show-ids
176
+ | `false`
177
+ | Draw bare identifiers (e.g. `I1`, `AND#G1`) instead of / in addition to labels.
178
+
179
+ | show-labels
180
+ | ldl-show-labels
181
+ | `true`
182
+ | Draw `.Name`/`.Description` labels.
183
+
184
+ | target
185
+ | —
186
+ | _(hash)_
187
+ | Output file basename (without extension). When omitted, a content-addressed
188
+ name (`ldl-<hash>.<ext>`) is used so identical diagrams share a file.
189
+
190
+ | _(n/a)_
191
+ | ldl-node
192
+ | `node`
193
+ | Path to the Node executable.
194
+
195
+ | _(n/a)_
196
+ | ldl-package-dir
197
+ | _(auto)_
198
+ | Directory from which to resolve the LDL npm package (and `@resvg/resvg-js`).
199
+ Overrides auto-detection; the `LDL_PACKAGE_DIR` env var does the same.
200
+
201
+ | _(n/a)_
202
+ | ldl-cache
203
+ | `true`
204
+ | Set to `false` to always re-render, ignoring the cache.
205
+ |===
206
+
207
+ Standard image attributes — `alt`, `title`, `width`, `height`, `align`,
208
+ `float`, `id`, `link`, `role` — pass straight through to the generated image.
209
+
210
+ TIP: Most *diagram* styling (inversion bubbles vs NOT gates, input bars,
211
+ compactness, stroke width, margins, …) is authored in the LDL source itself with
212
+ `OPTION` lines — see the {ldl-url}[LDL documentation]. This extension only
213
+ surfaces the render-time view options above.
214
+
215
+ === Language `OPTION` lines vs. attributes
216
+
217
+ The LDL source can carry its own `OPTION` lines; the extension honours them and
218
+ then applies the attribute-driven overrides (`theme`, `show-ids`,
219
+ `show-labels`) on top. For example:
220
+
221
+ [source]
222
+ ----
223
+ [ldl,theme=dark]
224
+ ....
225
+ OPTION INVERSION = BUBBLES
226
+ OPTION MARGIN = 16
227
+ O1 = I1 AND NOT I2
228
+ ....
229
+ ----
230
+
231
+ == Output formats
232
+
233
+ Choosing a format:
234
+
235
+ [cols="1m,4",options="header"]
236
+ |===
237
+ | Format | When to use
238
+
239
+ | svg
240
+ | *Default and recommended for `asciidoctor-pdf`.* Vector, so it is sharp at any
241
+ zoom or print resolution, embeds via `prawn-svg` with no rasterisation, and
242
+ compresses to a fraction of an equivalent PNG. Also ideal for HTML.
243
+
244
+ | png
245
+ | When a raster image is required (e.g. a target that cannot embed SVG). Use
246
+ `scale` to control resolution — `scale=2` renders at 2× device pixels.
247
+ Requires `@resvg/resvg-js`.
248
+ |===
249
+
250
+ For `asciidoctor-pdf`, prefer `svg`: it keeps PDFs small and crisp. If you must
251
+ raster for PDF, `png` with `scale=2` or higher gives good print quality at the
252
+ cost of size.
253
+
254
+ == Styling with roles
255
+
256
+ Every generated image is tagged with roles so it can be targeted from CSS or an
257
+ `asciidoctor-pdf` theme without touching each block:
258
+
259
+ * `ldl` — on every LDL diagram.
260
+ * `ldl-svg` / `ldl-png` — the output format.
261
+ * `ldl-light` / `ldl-dark` — the theme.
262
+
263
+ Any `role` you set on the block is preserved and appended.
264
+
265
+ In HTML the roles appear as classes on the image block, so you can style them
266
+ with CSS:
267
+
268
+ [source,css]
269
+ ----
270
+ .imageblock.ldl { margin-block: 1.5rem; }
271
+ .imageblock.ldl img { max-width: 100%; }
272
+ .imageblock.ldl-dark { background: #1b1b1f; padding: 0.5rem; border-radius: 6px; }
273
+ ----
274
+
275
+ In `asciidoctor-pdf`, target the same roles from your theme (roles map to
276
+ `role_<name>` keys). For example, to centre and pad LDL diagrams:
277
+
278
+ [source,yaml]
279
+ ----
280
+ role:
281
+ ldl:
282
+ align: center
283
+ ldl-dark:
284
+ background-color: '1B1B1F'
285
+ ----
286
+
287
+ Because the roles are stable, a document-wide look needs no per-block markup.
288
+
289
+ == Caching
290
+
291
+ Rendered files are content-addressed and cached: the source plus the resolved
292
+ options (format, scale, theme, label toggles, and the gem version) are hashed
293
+ into a small sidecar (`<image>.ldlcache`). On the next build, an unchanged
294
+ diagram is reused and Node is not invoked. Editing the diagram or changing an
295
+ option transparently regenerates it. Disable with `-a ldl-cache=false`.
296
+
297
+ Generated images are written to the usual Asciidoctor image output location —
298
+ `imagesoutdir` if set, otherwise `imagesdir` under the output directory — so
299
+ they interoperate with the rest of your image pipeline.
300
+
301
+ == Error handling
302
+
303
+ If a diagram fails to parse or render, the extension emits a visible error block
304
+ (role `ldl-error`) with the message and logs a warning, rather than aborting the
305
+ whole conversion. This keeps a single broken diagram from failing a large book.
306
+
307
+ == Example document
308
+
309
+ [source]
310
+ ----
311
+ = Protection Scheme
312
+ :ldl-format: svg
313
+ :ldl-theme: light
314
+ :imagesdir: images
315
+
316
+ == Transformer trip
317
+
318
+ [ldl,xfmr-trip]
319
+ ....
320
+ TRIP.Name = "Transformer trip"
321
+ DIFF.Name = "Differential (87T)"
322
+ OC.Name = "Overcurrent (50/51)"
323
+ BUCH.Name = "Buchholz (63)"
324
+ TRIP = DIFF OR OC OR BUCH
325
+ ....
326
+
327
+ == SEL-style seal-in (from a file)
328
+
329
+ ldl::diagrams/seal-in.ldl[]
330
+ ----
331
+
332
+ Convert to HTML and PDF:
333
+
334
+ [source,console]
335
+ ----
336
+ $ asciidoctor -r asciidoctor-ldl protection-scheme.adoc
337
+ $ asciidoctor-pdf -r asciidoctor-ldl protection-scheme.adoc
338
+ ----
339
+
340
+ == Development
341
+
342
+ [source,console]
343
+ ----
344
+ $ bundle install
345
+ $ (cd test/js && npm install) # LDL package + resvg for the test suite
346
+ $ bundle exec rake test # Ruby unit + end-to-end tests
347
+ $ bundle exec rake jstest # Node helper tests
348
+ ----
349
+
350
+ The Ruby end-to-end tests convert real AsciiDoc through Asciidoctor and assert on
351
+ the generated files, roles and markup. They resolve the npm package from
352
+ `test/js` (or `LDL_PACKAGE_DIR`) and skip cleanly if Node or the package is
353
+ unavailable.
354
+
355
+ == Releasing
356
+
357
+ Releases publish to RubyGems via https://guides.rubygems.org/trusted-publishing/[Trusted
358
+ Publishing] (OIDC) — no API key is stored in the repository. Push a version tag
359
+ (`git tag v0.1.0 && git push origin v0.1.0`) and the `Release` workflow builds,
360
+ tests and publishes the gem. Configure the trusted publisher once on RubyGems to
361
+ match `.github/workflows/release.yml`.
362
+
363
+ == License
364
+
365
+ MIT © Daniel Mulholland. See link:LICENSE[].
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'asciidoctor'
4
+ require 'asciidoctor/extensions'
5
+ require_relative 'version'
6
+ require_relative 'renderer'
7
+
8
+ module Asciidoctor
9
+ module Ldl
10
+ # Shared behaviour for the block and block-macro processors: read options
11
+ # from attributes, drive the Renderer, and emit an image (or, on failure,
12
+ # a visible error block).
13
+ module Processor
14
+ # Turn LDL +source+ into an image block placed in +parent+.
15
+ def render_ldl(parent, source, attrs, target_hint)
16
+ doc = parent.document
17
+ renderer = build_renderer(doc, attrs)
18
+ out_dir = renderer.out_dir
19
+
20
+ begin
21
+ filename = renderer.render(source, resolve_basename(attrs, target_hint))
22
+ rescue Renderer::Error => e
23
+ return error_block(parent, attrs, e.message)
24
+ end
25
+
26
+ create_ldl_image(parent, attrs, filename, renderer, doc)
27
+ end
28
+
29
+ private
30
+
31
+ def build_renderer(doc, attrs)
32
+ Renderer.new(
33
+ format: attr(attrs, doc, 'format', 'ldl-format', 'svg'),
34
+ scale: attr(attrs, doc, 'scale', 'ldl-scale', nil),
35
+ theme: attr(attrs, doc, 'theme', 'ldl-theme', 'light'),
36
+ show_ids: attr(attrs, doc, 'show-ids', 'ldl-show-ids', nil),
37
+ show_labels: attr(attrs, doc, 'show-labels', 'ldl-show-labels', nil),
38
+ node: doc.attr('ldl-node', 'node'),
39
+ package_dir: doc.attr('ldl-package-dir') || doc.attr('ldl-node-modules'),
40
+ out_dir: image_output_dir(doc),
41
+ cache: cache_enabled?(doc)
42
+ )
43
+ end
44
+
45
+ # Block/macro attribute wins; otherwise the document attribute; otherwise
46
+ # the default. Empty strings are treated as "unset".
47
+ def attr(attrs, doc, block_key, doc_key, default)
48
+ value = attrs[block_key]
49
+ value = doc.attr(doc_key) if value.nil? || value.to_s.empty?
50
+ value.nil? || value.to_s.empty? ? default : value
51
+ end
52
+
53
+ def cache_enabled?(doc)
54
+ raw = doc.attr('ldl-cache')
55
+ return true if raw.nil?
56
+
57
+ !%w[false 0 no off].include?(raw.to_s.strip.downcase)
58
+ end
59
+
60
+ def resolve_basename(attrs, target_hint)
61
+ candidate = attrs['target'] || target_hint
62
+ return nil if candidate.nil? || candidate.to_s.strip.empty?
63
+
64
+ candidate
65
+ end
66
+
67
+ # Physical directory the image is written to, following the same
68
+ # conventions as asciidoctor-diagram (imagesoutdir overrides; otherwise
69
+ # imagesdir under the output/base directory).
70
+ def image_output_dir(doc)
71
+ images_outdir = doc.attr('imagesoutdir')
72
+ return images_outdir if images_outdir && !images_outdir.to_s.empty?
73
+
74
+ base = doc.attr('outdir')
75
+ base = doc.options[:to_dir] if (base.nil? || base.empty?) && doc.options[:to_dir]
76
+ base ||= doc.base_dir
77
+ images_dir = doc.attr('imagesdir')
78
+ (images_dir && !images_dir.to_s.empty?) ? File.join(base, images_dir) : base
79
+ end
80
+
81
+ def create_ldl_image(parent, attrs, filename, renderer, _doc)
82
+ image_attrs = { 'target' => filename }
83
+ %w[alt title width height align float id link].each do |key|
84
+ image_attrs[key] = attrs[key] if attrs.key?(key)
85
+ end
86
+ image_attrs['alt'] ||= (attrs['target'] || 'LDL logic diagram')
87
+ image_attrs['role'] = image_roles(attrs, renderer)
88
+ create_image_block(parent, image_attrs)
89
+ end
90
+
91
+ # Always tag the image with the +ldl+ role plus format/theme modifier
92
+ # roles, so it can be targeted from CSS or an asciidoctor-pdf theme.
93
+ # Any author-supplied role is preserved (and wins for ordering).
94
+ def image_roles(attrs, renderer)
95
+ roles = %w[ldl]
96
+ roles << "ldl-#{renderer.format}"
97
+ roles << "ldl-#{renderer.theme}"
98
+ author = attrs['role']
99
+ roles.concat(author.to_s.split) if author && !author.to_s.empty?
100
+ roles.uniq.join(' ')
101
+ end
102
+
103
+ def error_block(parent, attrs, message)
104
+ warn "asciidoctor-ldl: #{message}"
105
+ text = "LDL diagram error:\n#{message}"
106
+ Asciidoctor::Block.new(parent, :listing, source: text,
107
+ attributes: { 'role' => 'ldl-error' })
108
+ end
109
+ end
110
+
111
+ # Delimited block:
112
+ #
113
+ # [ldl]
114
+ # ----
115
+ # O1 = I1 AND NOT I2
116
+ # ----
117
+ #
118
+ # Optional positional attributes: [ldl, target-basename, format].
119
+ class BlockProcessor < Asciidoctor::Extensions::BlockProcessor
120
+ include Processor
121
+
122
+ use_dsl
123
+ named :ldl
124
+ on_contexts :listing, :literal, :paragraph, :open
125
+ name_positional_attributes 'target', 'format'
126
+ parse_content_as :raw
127
+
128
+ def process(parent, reader, attrs)
129
+ render_ldl(parent, reader.source, attrs, attrs['target'])
130
+ end
131
+ end
132
+
133
+ # Block macro reading from a file:
134
+ #
135
+ # ldl::path/to/diagram.ldl[format=png, scale=2]
136
+ class BlockMacroProcessor < Asciidoctor::Extensions::BlockMacroProcessor
137
+ include Processor
138
+
139
+ use_dsl
140
+ named :ldl
141
+ name_positional_attributes 'format'
142
+
143
+ def process(parent, target, attrs)
144
+ doc = parent.document
145
+ path = doc.normalize_system_path(target, doc.attr('docdir'))
146
+ unless File.readable?(path)
147
+ return error_block(parent, attrs, "cannot read LDL file: #{target}")
148
+ end
149
+
150
+ source = File.read(path)
151
+ hint = attrs['target'] || File.basename(target, File.extname(target))
152
+ render_ldl(parent, source, attrs, hint)
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env node
2
+ // ldl_render.mjs — render LDL source to SVG or PNG for the asciidoctor-ldl gem.
3
+ //
4
+ // Contract (kept deliberately small so the Ruby side is easy to drive):
5
+ // * LDL source is read from stdin.
6
+ // * Options come from CLI flags (see parseArgs below).
7
+ // * The rendered artifact is written to the path given by --out.
8
+ // * On success nothing is written to stdout; a one-line JSON summary
9
+ // ({"format","width","height","out"}) is written to stderr for logging.
10
+ // * On failure a human-readable message is written to stderr and the
11
+ // process exits non-zero.
12
+ //
13
+ // SVG rendering is isomorphic (only needs @openpowershift/logic-diagram-language).
14
+ // PNG rendering additionally needs @resvg/resvg-js, resolved lazily so an
15
+ // SVG-only install never has to have it present.
16
+
17
+ import { readFileSync, writeFileSync } from 'node:fs';
18
+ import { createRequire } from 'node:module';
19
+ import { fileURLToPath, pathToFileURL } from 'node:url';
20
+ import { dirname, join, resolve, isAbsolute } from 'node:path';
21
+
22
+ const PACKAGE = '@openpowershift/logic-diagram-language';
23
+
24
+ // Resolve an installed package's ESM entry point from a base directory,
25
+ // without relying on a `require` export condition (the LDL package is
26
+ // ESM-only, so `require.resolve(pkg)` throws ERR_PACKAGE_PATH_NOT_EXPORTED).
27
+ // The `./package.json` subpath *is* exported, so resolve that and read the
28
+ // declared entry ourselves. Returns a file:// URL string, or null.
29
+ function resolveEntryFrom(base, pkg) {
30
+ let pkgJsonPath;
31
+ try {
32
+ const req = createRequire(pathToFileURL(join(base, 'package.json')).href);
33
+ pkgJsonPath = req.resolve(`${pkg}/package.json`);
34
+ } catch (_) {
35
+ return null;
36
+ }
37
+ const meta = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
38
+ const dot = meta.exports && meta.exports['.'];
39
+ const entry =
40
+ (dot && typeof dot === 'object' && (dot.import || dot.default)) ||
41
+ (typeof dot === 'string' ? dot : null) ||
42
+ meta.module ||
43
+ meta.main ||
44
+ 'index.js';
45
+ return pathToFileURL(resolve(dirname(pkgJsonPath), entry)).href;
46
+ }
47
+
48
+ // Candidate base directories to search, in priority order.
49
+ function candidateBases(packageDir) {
50
+ const bases = [];
51
+ if (packageDir) bases.push(packageDir);
52
+ if (process.env.LDL_PACKAGE_DIR) bases.push(process.env.LDL_PACKAGE_DIR);
53
+ bases.push(process.cwd());
54
+ bases.push(dirname(fileURLToPath(import.meta.url)));
55
+ return bases;
56
+ }
57
+
58
+ function parseArgs(argv) {
59
+ const opts = {
60
+ format: 'svg',
61
+ scale: 1,
62
+ theme: 'light',
63
+ showIds: false,
64
+ showLabels: true,
65
+ out: null,
66
+ packageDir: null,
67
+ };
68
+ for (let i = 0; i < argv.length; i++) {
69
+ const arg = argv[i];
70
+ const next = () => argv[++i];
71
+ switch (arg) {
72
+ case '--format': opts.format = String(next()).toLowerCase(); break;
73
+ case '--scale': opts.scale = Number(next()); break;
74
+ case '--theme': opts.theme = String(next()).toLowerCase(); break;
75
+ case '--show-ids': opts.showIds = true; break;
76
+ case '--no-show-ids': opts.showIds = false; break;
77
+ case '--show-labels': opts.showLabels = true; break;
78
+ case '--no-show-labels': opts.showLabels = false; break;
79
+ case '--out': opts.out = next(); break;
80
+ case '--package-dir': opts.packageDir = next(); break;
81
+ default:
82
+ throw new Error(`unknown argument: ${arg}`);
83
+ }
84
+ }
85
+ if (!opts.out) throw new Error('missing required --out <path>');
86
+ if (!Number.isFinite(opts.scale) || opts.scale <= 0) {
87
+ throw new Error(`invalid --scale: ${opts.scale}`);
88
+ }
89
+ if (opts.format !== 'svg' && opts.format !== 'png') {
90
+ throw new Error(`unsupported --format: ${opts.format} (expected svg or png)`);
91
+ }
92
+ return opts;
93
+ }
94
+
95
+ // Resolve the LDL package from a set of candidate base directories, so it can
96
+ // live in the user's project, a globally installed location, or a checkout
97
+ // pointed at by --package-dir / LDL_PACKAGE_DIR.
98
+ async function loadLibrary(packageDir) {
99
+ const tried = [];
100
+ for (const base of candidateBases(packageDir)) {
101
+ // Allow a base that points straight at a package checkout (…/lib/index.js).
102
+ for (const direct of [join(base, 'lib', 'index.js'), join(base, 'index.js')]) {
103
+ try {
104
+ return await import(pathToFileURL(direct).href);
105
+ } catch (e) { tried.push(`${direct} (${e.code || 'error'})`); }
106
+ }
107
+ // Otherwise resolve it as an installed dependency from that base.
108
+ const entry = resolveEntryFrom(base, PACKAGE);
109
+ if (entry) {
110
+ try {
111
+ return await import(entry);
112
+ } catch (e) { tried.push(`${entry} (${e.code || 'error'})`); }
113
+ } else {
114
+ tried.push(`${base}:${PACKAGE} (not found)`);
115
+ }
116
+ }
117
+ throw new Error(
118
+ `could not resolve ${PACKAGE}.\n` +
119
+ `Install it where your document is built, e.g.\n` +
120
+ ` npm install ${PACKAGE}\n` +
121
+ `or point the ldl-package-dir attribute / LDL_PACKAGE_DIR env var at it.\n` +
122
+ `Searched:\n ${tried.join('\n ')}`);
123
+ }
124
+
125
+ async function loadResvg(packageDir) {
126
+ for (const base of candidateBases(packageDir)) {
127
+ try {
128
+ const req = createRequire(pathToFileURL(join(base, 'package.json')).href);
129
+ return await import(pathToFileURL(req.resolve('@resvg/resvg-js')).href);
130
+ } catch (_) { /* try next */ }
131
+ const entry = resolveEntryFrom(base, '@resvg/resvg-js');
132
+ if (entry) {
133
+ try { return await import(entry); } catch (_) { /* try next */ }
134
+ }
135
+ }
136
+ throw new Error(
137
+ 'PNG output needs @resvg/resvg-js, which was not found.\n' +
138
+ 'Install it alongside the LDL package:\n' +
139
+ ' npm install @resvg/resvg-js\n' +
140
+ 'or use format=svg (the default), which needs no extra dependency and is\n' +
141
+ 'ideal for asciidoctor-pdf (vector, scalable, compact).');
142
+ }
143
+
144
+ // The renderer emits <svg viewBox="0 0 W H" …> with no intrinsic width/height.
145
+ // Add them (scaled) so downstream consumers — prawn-svg in asciidoctor-pdf,
146
+ // browsers, resvg — get a concrete size and the scale attribute is honoured.
147
+ function sizeFromViewBox(svg) {
148
+ const m = svg.match(/viewBox="0 0 ([\d.]+) ([\d.]+)"/);
149
+ if (!m) return null;
150
+ return { width: parseFloat(m[1]), height: parseFloat(m[2]) };
151
+ }
152
+
153
+ function applySvgScale(svg, scale) {
154
+ const size = sizeFromViewBox(svg);
155
+ if (!size) return { svg, size };
156
+ const w = size.width * scale;
157
+ const h = size.height * scale;
158
+ // Insert width/height right after "<svg"; drop the max-width/height clamp so
159
+ // an explicit size actually takes effect when embedded as an image.
160
+ const scaled = svg
161
+ .replace(/<svg/, `<svg width="${w}" height="${h}"`)
162
+ .replace(/\s*max-width:100%;max-height:100%;/, '');
163
+ return { svg: scaled, size: { width: w, height: h } };
164
+ }
165
+
166
+ async function main() {
167
+ const opts = parseArgs(process.argv.slice(2));
168
+ const source = readFileSync(0, 'utf8'); // fd 0 = stdin
169
+
170
+ const lib = await loadLibrary(opts.packageDir);
171
+ const { parse, renderDiagram, resolveOptions, LIGHT_DIAGRAM, DARK_DIAGRAM } = lib;
172
+
173
+ const { diagram, errors } = parse(source);
174
+ if (errors && errors.length) {
175
+ const lines = errors.map((e) => {
176
+ const pos = e.position ? ` (line ${e.position.line}, col ${e.position.column})` : '';
177
+ return ` ${e.message}${pos}`;
178
+ });
179
+ throw new Error(`LDL parse error:\n${lines.join('\n')}`);
180
+ }
181
+
182
+ const renderOptions = resolveOptions(diagram.options);
183
+ renderOptions.showIds = opts.showIds;
184
+ renderOptions.showLabels = opts.showLabels;
185
+ const theme = opts.theme === 'dark' ? DARK_DIAGRAM : LIGHT_DIAGRAM;
186
+
187
+ let svg = renderDiagram(diagram, renderOptions, theme);
188
+ const scaled = applySvgScale(svg, opts.scale);
189
+ svg = scaled.svg;
190
+ const size = scaled.size || { width: 0, height: 0 };
191
+
192
+ const outPath = isAbsolute(opts.out) ? opts.out : join(process.cwd(), opts.out);
193
+
194
+ if (opts.format === 'svg') {
195
+ writeFileSync(outPath, svg, 'utf8');
196
+ process.stderr.write(JSON.stringify({ format: 'svg', width: size.width, height: size.height, out: outPath }) + '\n');
197
+ return;
198
+ }
199
+
200
+ // PNG
201
+ const { Resvg } = await loadResvg(opts.packageDir);
202
+ // Rasterise from the *unscaled* viewBox using resvg's own zoom, so the scale
203
+ // multiplies device pixels crisply rather than baking a size into the SVG.
204
+ const base = renderDiagram(diagram, renderOptions, theme);
205
+ const resvg = new Resvg(base, {
206
+ fitTo: { mode: 'zoom', value: opts.scale },
207
+ background: 'white',
208
+ });
209
+ const rendered = resvg.render();
210
+ const png = rendered.asPng();
211
+ writeFileSync(outPath, png);
212
+ process.stderr.write(JSON.stringify({ format: 'png', width: rendered.width, height: rendered.height, out: outPath }) + '\n');
213
+ }
214
+
215
+ main().catch((err) => {
216
+ process.stderr.write(`[asciidoctor-ldl] ${err && err.message ? err.message : err}\n`);
217
+ process.exit(1);
218
+ });
Binary file
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asciidoctor
4
+ module Ldl
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'ldl/version'
4
+ require_relative 'ldl/renderer'
5
+ require_relative 'ldl/extension'
6
+
7
+ module Asciidoctor
8
+ module Ldl
9
+ # Register the block and block-macro processors with an Asciidoctor
10
+ # extension registry (defaults to the global one).
11
+ def self.register(registry = Asciidoctor::Extensions)
12
+ registry.block Asciidoctor::Ldl::BlockProcessor, :ldl
13
+ registry.block_macro Asciidoctor::Ldl::BlockMacroProcessor, :ldl
14
+ end
15
+ end
16
+ end
17
+
18
+ Asciidoctor::Extensions.register do
19
+ block Asciidoctor::Ldl::BlockProcessor, :ldl
20
+ block_macro Asciidoctor::Ldl::BlockMacroProcessor, :ldl
21
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Conventional require name (matches the gem name) so that:
4
+ # asciidoctor -r asciidoctor-ldl document.adoc
5
+ # works. Delegates to the namespaced implementation.
6
+ require_relative 'asciidoctor/ldl'
metadata ADDED
@@ -0,0 +1,111 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: asciidoctor-ldl
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Daniel Mulholland
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-07-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: asciidoctor
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '3.0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '2.0'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.0'
33
+ - !ruby/object:Gem::Dependency
34
+ name: minitest
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '5.0'
40
+ type: :development
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '5.0'
47
+ - !ruby/object:Gem::Dependency
48
+ name: rake
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '13.0'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '13.0'
61
+ description: |
62
+ An Asciidoctor extension that renders protection-relay / logic diagrams
63
+ written in the Logic Diagram Language (LDL) — the
64
+ @openpowershift/logic-diagram-language npm package — into SVG or PNG at
65
+ conversion time. SVG output is vector and embeds cleanly in asciidoctor-pdf;
66
+ PNG is available via @resvg/resvg-js. Format, scale, theme and label
67
+ visibility are controlled with AsciiDoc attributes.
68
+ email:
69
+ - dan.mulholland@gmail.com
70
+ executables: []
71
+ extensions: []
72
+ extra_rdoc_files: []
73
+ files:
74
+ - CHANGELOG.md
75
+ - LICENSE
76
+ - README.adoc
77
+ - lib/asciidoctor-ldl.rb
78
+ - lib/asciidoctor/ldl.rb
79
+ - lib/asciidoctor/ldl/extension.rb
80
+ - lib/asciidoctor/ldl/js/ldl_render.mjs
81
+ - lib/asciidoctor/ldl/renderer.rb
82
+ - lib/asciidoctor/ldl/version.rb
83
+ homepage: https://github.com/OpenPowerShift/asciidoctor-ldl
84
+ licenses:
85
+ - MIT
86
+ metadata:
87
+ homepage_uri: https://github.com/OpenPowerShift/asciidoctor-ldl
88
+ source_code_uri: https://github.com/OpenPowerShift/asciidoctor-ldl
89
+ bug_tracker_uri: https://github.com/OpenPowerShift/asciidoctor-ldl/issues
90
+ changelog_uri: https://github.com/OpenPowerShift/asciidoctor-ldl/blob/main/CHANGELOG.md
91
+ rubygems_mfa_required: 'true'
92
+ post_install_message:
93
+ rdoc_options: []
94
+ require_paths:
95
+ - lib
96
+ required_ruby_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: 2.5.0
101
+ required_rubygems_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: '0'
106
+ requirements: []
107
+ rubygems_version: 3.5.22
108
+ signing_key:
109
+ specification_version: 4
110
+ summary: Asciidoctor extension for OpenPowerShift LDL logic diagrams
111
+ test_files: []