laxit / sigil
Turn an integer into one recognizable Cistercian-style glyph: SVG, ASCII or DXF.
Requires
- php: >=8.1
Requires (Dev)
- phpunit/phpunit: ^10.5 || ^11.0
README
๐ Umbral Sigilstone โ PHP
laxit/sigil โ turns an integer into one recognizable Cistercian-style glyph, rendered as SVG, ASCII or DXF. Four digits by default, more on request.
The Umbral Sigilstone presses a number into a single struck mark: one stem and a handful of strokes, identical everywhere it is struck โ in pixels, in ink, or in cut metal.
7323 โ ๐ โ svg ยท ascii ยท dxf
This is the PHP implementation, one language directory of the
Sigil repo; the spec it implements is
SPEC.md.
composer require laxit/sigil
Requires PHP 8.1+. No runtime dependencies. The package ships model.json, the
declarative definition it loads, so it works with nothing else installed.
Use it
One Encoder resolves a number into a list of line segments. Each renderer
draws that same list in one format โ so the three sections below differ only
in which renderer you construct.
use Laxit\Sigil\Encoder; $encoder = new Encoder();
ASCII โ terminals, logs, commit messages
Draws on its own small integer grid (4 cells per quadrant by default), with Bresenham stepping for the diagonals.
use Laxit\Sigil\Renderer\AsciiRenderer; echo (new AsciiRenderer($encoder))->render(7323);
|
|\
| \
| \
|---| \
| |\
| | \
| | \
| | \
Read it by quadrant: top-right is ones, top-left tens, bottom-right hundreds,
bottom-left thousands. Widen it with new AsciiRenderer($encoder, cells: 6).
SVG โ the web
One <line> per segment plus the stem, drawn slightly heavier. The viewBox
is derived from the coordinates, so it stays correct at any geometry.
use Laxit\Sigil\Renderer\SvgRenderer; echo (new SvgRenderer($encoder))->render(7323);
<svg xmlns="http://www.w3.org/2000/svg" viewBox="16 6 168 228" role="img" aria-label="Sigil glyph for 7323"> <g stroke="#111111" stroke-width="6" stroke-linecap="round" fill="none"> <line x1="100" y1="20" x2="100" y2="220" stroke-width="8"/> <line x1="100" y1="20" x2="170" y2="120"/><!-- ones.diagDown --> <line x1="100" y1="120" x2="30" y2="120"/><!-- tens.bottom --> <line x1="100" y1="120" x2="170" y2="220"/><!-- hundreds.diagDown --> <line x1="100" y1="120" x2="30" y2="120"/><!-- thousands.top --> <line x1="30" y1="120" x2="30" y2="220"/><!-- thousands.outer --> </g> </svg>
Pass stroke: 'currentColor' to inherit the surrounding text colour, so the
glyph themes itself:
new SvgRenderer($encoder, stroke: 'currentColor', strokeWidth: 8);
DXF โ laser cutters and CNC
Minimal AutoCAD R12, LINE entities only. Y is negated: CAD Y grows
upward and the encoder's grows downward, so a straight copy would come off the
bed upside down.
use Laxit\Sigil\Renderer\DxfRenderer; file_put_contents('7323.dxf', (new DxfRenderer($encoder))->render(7323));
0
SECTION
2
ENTITIES
0
LINE
8
0
10
100.0
20
-20.0
...
One caveat that only matters on a real machine: quadrants meeting at the
vertical midpoint share an edge, so tens.bottom and thousands.top are
the same line and both are emitted. Invisible on screen; a doubled pass on a
laser. examples/04-laser-cutting.php detects
and strips them.
Numbers past 9999
Four places is the historical Cistercian system, not a limit of the geometry. A place is a side of the stem and a row down it, so a taller stem holds more digits โ as one mark, not several glyphs side by side.
$wide = new Encoder(rows: 3); // 6 places, 0-999999 $wide->digitsOf(123456); // ones 6, tens 5, ... hundredThousands 1 Encoder::fitting(12345678); // picks the 4 rows it needs Encoder::maxFor(4); // 99999999
The default stays rows: 2 on purpose: it is the historical system, it is
what the golden vectors pin, and a fixed height keeps a set of glyphs
visually uniform โ an avatar grid where each glyph is a different height is
worse than one that throws.
The range is derived from the number of places, never declared, so it
cannot disagree with them. That matters: on a four-place glyph 12345 would
render identically to 2345, silently losing its leading digit. Hence:
(new Encoder())->render(12345); // InvalidArgumentException, not a wrong glyph
The ceiling is 18 places (9 rows). Past that 10^n - 1 exceeds an exact
integer, so the encoder refuses rather than rounding.
CLI
php bin/demo.php 7323 ascii # also: svg, dxf, all
Examples
examples/ has four runnable, self-contained programs โ input,
output and implementation for each:
01-basics.php |
The whole API โ number in, segment list out, three renderers over the same list |
02-avatar-fingerprint.php |
Identicon-style avatars: a stable mark per user, no image files, no uploads |
03-wire-format.php |
Resolve on the server, draw in the browser with no Cistercian logic client-side |
04-laser-cutting.php |
DXF for a laser/CNC, and the doubled-cut gotcha you must handle |
composer install php examples/01-basics.php 7323
Start with examples/README.md, which shows each one's
output inline.
Renderers
| Renderer | Output |
|---|---|
SvgRenderer |
One <line> per segment plus the stem, drawn slightly heavier. viewBox is derived from the coordinates, so it stays correct for any geometry. |
AsciiRenderer |
Its own small integer grid (4 cells per quadrant by default), Bresenham stepping for the diagonals. |
DxfRenderer |
Minimal R12 LINE entities. Y is negated โ CAD Y grows upward, the encoder's Y grows downward, so a straight copy comes off a laser upside down. |
Geometry
Defaults come from geometryDefaults in model.json โ stemHeight=200,
quadrantWidth=70, stemX=100, stemTopY=20, the values the golden fixtures
were generated with. Override them per instance:
$encoder = new Encoder(stemHeight: 100, quadrantWidth: 35, stemX: 50, stemTopY: 10);
Encoder::stem() returns a positional [x1, y1, x2, y2] array, matching the
fixture format.
Architecture
model.json the definition -- segments, digit map, quadrants, geometry (at the repo root)
SegmentModel typed wrapper over model.json's segments + digitMap
Quadrant typed wrapper over model.json's places + quadrants
Encoder the generic resolver: model.json -> (x1,y1,x2,y2) segment list
Renderer/* segment list -> one specific output format
None of these classes contain the digit map. They read
model.json โ a vendored copy of the canonical definition in the
spec repo, which diffs it against every
implementation's copy on every build so it cannot drift.
Encoder::locateModel() resolves it: SIGIL_MODEL, then beside the package
root, then one level up.
Renderers call Encoder::segmentsFor() and Encoder::stem() and nothing
else โ they never touch SegmentModel or Quadrant. That constraint is what
makes new output formats and new language ports cheap, so it is enforced by a
test (RendererTest::testRenderersDoNotReachIntoTheNumberLogic), not by review.
Tests
composer install
composer test
CI runs the suite on PHP 8.1 through 8.4, checks the fixtures are not stale, and executes every example.
The suite that matters is tests/VectorsTest.php: it replays
../fixtures/vectors.json and asserts this
implementation reproduces every vector exactly. That is the whole definition
of "spec-compliant", and every language implementation in this repo has the
equivalent file running against the same JSON.
If the fixtures live somewhere else, point at them:
SIGIL_FIXTURES=/path/to/vectors.json composer test
Changing the glyph
model.json is the one file that defines what a glyph looks
like โ for this package and every other language implementation. Change it in
the spec repo first, then copy it here.
The fixtures must be regenerated in the same change:
php bin/vectors.php # rewrite ../fixtures/vectors.json php bin/vectors.php --check # CI: exit 1 if the committed fixtures are stale
Commit the regenerated fixtures alongside the change. This is a breaking change for every language implementation at once, not just for PHP.
License
MIT โ see LICENSE.
