Project

claricle

0.0
The project is in a healthy, maintained state
Claricle detects an image's format from its bytes -- PNG, SVG, EMF, WMF, EPS, PS and PDF -- and gives one object and one set of models to work with. The name combines "clarity" and "particle". It inspects, checks conformance and converts between formats, wrapping png_conform, svg_conform, vectory and pdfrb behind a single interface.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

~> 0.1.0
~> 0.1.2
~> 0.8.32
~> 0.7.23
~> 0.1.4
~> 0.2.0
~> 0.3.0
~> 3.4.4
~> 0.2.2
~> 1.2
~> 0.12.0
 Project Readme

Claricle

RubyGems Version License Build Status

Purpose

Claricle is a Ruby library and CLI for working with image files. It detects a file’s format from its bytes and gives one object and one set of models to work with, wrapping specialist tools behind a single API.

Detection, inspection, conformance checking and conversion ship today across PNG, SVG, EMF, EPS, PS and PDF. Not every format supports every operation — claricle formats lists which, and "What does not work yet" below says why.

Note

Version 0.1.0 was a name reservation. Its validate, convert and compress commands printed "coming soon" and did nothing; they have been removed rather than left in place. Its metadata also advertised image compression, which was never implemented — compression is not in v1’s scope. Rendering, editing and WMF conversion are likewise outside v1.

Etymology & Symbolism

Claricle = Clari (clarity) + cle (particle)

Combined: "Clear particle" or "Bright essence"

The name reflects what the library is for:

  • Detection → know exactly what a file is

  • Validation → clarify every pixel

  • Conversion → preserve the clear essence

  • Ruby → gem-like, small but brilliant

What works today

  • Format detection for PNG, SVG, EMF, WMF, EPS, PS and PDF

  • inspect for EMF — dimensions, DPI and header metadata

  • inspect for PNG — dimensions, colour space, bit depth and DPI

  • inspect for SVG — dimensions from the root, absolute units converted to CSS px; a relative unit such as % reports no dimension. It stops at the root start tag, so its parse status means "the root was readable", not "the document is well-formed". A file damaged after that tag still reports ok. Judging the whole document is conformance, which conform now does for SVG; inspect still only reads the bounded root prefix

  • inspect for EPS and PS — dimensions from a concrete bounding box declared in the DSC header, preferring %%HiResBoundingBox and falling back to %%BoundingBox, plus the title, creator, creation date and language level. Inspection parses a bounded header. When a bounding box is declared as (atend), it also scans the outer document trailer while skipping nested documents and declared data sections. A box is reported only when the parser reads the same values the file’s first declaration spells out in DSC syntax, or when one unambiguous concrete trailer value resolves (atend). A box that fails that check is discarded rather than guessed at — the other box, if the file declares one, is still used, and dimensions are nil only when neither survives. The page count is deliberately not reported: the parser fills it in only for the two-argument %%Pages: n m form, so it would be present for some files and absent for others with nothing in meta to tell the cases apart

  • inspect for PDF — the document’s version and the declared page count from the page tree’s /Count. The version is the higher of the two the file states, compared as numbers: the %PDF- header, and the catalog’s /Version where one is present, well formed and no more than 1,024 bytes long. It comes from the file’s own bytes rather than the parser, which fabricates 1.4 for an empty file. The count is a declaration and nothing walks the page tree to check it, so a document declaring /Count 999 over one page reports 999. Dimensions are deliberately not reported: reaching the first page’s box safely needs cycle-guarded page-tree walking the parser does not offer. The count is omitted whenever it cannot be read as a non-negative whole number of at most 1,024 decimal digits — including when the reference holding it is refused. A deadline expiry after the version is settled leaves the file inspecting as ok with the count omitted. An earlier expiry with no failure cause already established reports parse_status: "failed" with code pdf.timeout; an established failure keeps its own code

  • conform for PNG, SVG, EMF and PDF — a yes/no verdict plus typed issues, exit 1 for a nonconformant file

  • convert between SVG, EMF, EPS and PS, in every direction, each conversion labelled lossless, lossy or unknown (see "Conversion lossiness" below)

  • formats, listing each format and what Claricle can do with it

  • Claricle::Image, built from a path or from bytes

  • A unified issue and report model, serialising to and from JSON

  • An error taxonomy mapped to documented CLI exit codes

What does not work yet

  • inspect for WMF — every other format has a handler, so WMF is the one that still raises Claricle::UnsupportedFormat

  • A memory bound for PDF. The five second deadline bounds TIME only. A small crafted file can still make the parser build a very large string before the clock fires — measured, a 190 KB file whose object stream decodes to 200 MB spent that memory inside the window and then timed out. So inspect for PDF is not safe to run on untrusted input in a long-lived server process. Run it on files you trust, or in a separate process you can cap and kill

  • conform for EPS and PS. The PostScript parser rejects only unterminated strings — it accepts raw binary — so passing it says almost nothing about the file, and calling that conformance would mislead. Both raise Claricle::UnsupportedFormat and the CLI exits 3

  • convert to or from PNG and PDF. Both raise Claricle::UnsupportedFormat and the CLI exits 3

  • WMF beyond detection. detect returns :wmf, but no handler registers it, so every operation on a WMF raises UnsupportedFormat. That is deliberate: the upstream parser this library would use reports "WMF parser not yet implemented"

Command Line Interface

Available commands

Command What it does

claricle inspect FILE

Report a file’s format and metadata

claricle conform FILE…​

Check files against their format’s conformance rules

claricle convert SOURCE…​

Convert files to another format

claricle formats

List the formats Claricle handles, and what it can do with each

claricle version

Print the version

claricle help lists these, plus the commands Thor contributes itself.

The operations are added as they are implemented, so this table never lists a command that does nothing.

claricle inspect prints a file’s own metadata under meta. keys, and shows control characters escaped. Both are there so a crafted file cannot print a line that looks like Claricle’s own. --json is unaffected: it carries the real values.

Inspecting a file

Output below is a real transcript, run against a fixture in this repo (spec/fixtures/inspect/basic.eps, an EPS with a DSC header):

$ claricle inspect spec/fixtures/inspect/basic.eps
format: eps
dimensions: 100.0x50.0
meta.bounding_box: [0.0, 0.0, 100.0, 50.0]
meta.creation_date: 2026-08-20
meta.creator: claricle specs
meta.language_level: 2
meta.title: Baseline figure
parse status: ok

$ claricle inspect spec/fixtures/inspect/basic.eps --json
{"format":"eps","width":100.0,"height":50.0,"meta":{"bounding_box":[0.0,0.0,100.0,50.0],"title":"Baseline figure","creator":"claricle specs","creation_date":"2026-08-20","language_level":2},"parse_status":"ok","issues":[]}

A file Claricle cannot recognise, or cannot find, exits with the code named below rather than a stack trace:

$ claricle inspect not-an-image.txt; echo "exit: $?"
claricle: no known image signature
exit: 3

$ claricle inspect missing.png; echo "exit: $?"
claricle: No such file or directory @ rb_sysopen - missing.png
exit: 2

Listing formats

$ claricle formats
emf	inspect, conform, convert
eps	inspect, convert
pdf	inspect, conform
png	inspect, conform
ps	inspect, convert
svg	inspect, conform, convert

$ claricle formats --json
[{"format":"emf","inspect":true,"conform":true,"convert":true,"convert_to":["svg","eps","ps"]},{"format":"eps","inspect":true,"conform":false,"convert":true,"convert_to":["svg","emf","ps"]},{"format":"pdf","inspect":true,"conform":true,"convert":false,"convert_to":[]},{"format":"png","inspect":true,"conform":true,"convert":false,"convert_to":[]},{"format":"ps","inspect":true,"conform":false,"convert":true,"convert_to":["svg","emf","eps"]},{"format":"svg","inspect":true,"conform":true,"convert":true,"convert_to":["eps","ps","emf"]}]

This is a derived fact, not a hand-maintained value — formats reads it off the handler classes actually loaded (see "Architecture" below), so it cannot advertise an operation that does not run.

Checking conformance

The generic check is format-specific: PNG validates its chunk stream, SVG checks well-formed structure and the base requirement set, EMF checks record framing and its terminating EOF record, and PDF combines pdfrb’s structural validator with Claricle’s Arlington document walk. These are deliberately not interchangeable claims; each report records the profile when a named requirement set ran.

SVG profiles are base (the generic default), lucid_fix, metanorma, no_external_css, svg_1_2_rfc, and svg_1_2_rfc_with_rdf. PDF offers pdf_a, pdf_ua, pdf_x, pdf_vt, pades, ltv, pdf_2_af, and tagged_pdf. Four PDF families accept a level:

Profile Accepted --level values

pdf_a

a1b, a1a, a2b, a2a, a3b, a3a, a4

pdf_x

x1a, x3, x4, x6

pdf_vt

vt1, vt2

pades

b-b, b-t, b-lt, b-lta

An unknown profile, an invalid profile/format pairing, an unknown level, or --level with a profile that takes none is a bad invocation and exits 2. Named PDF validation first requires the generic structural and Arlington checks to pass.

$ claricle conform valid.svg no_viewbox.svg; echo "exit: $?"
no_viewbox.svg: no
  error [viewbox_required] SVG root element must have a viewBox attribute
  error [viewbox_required] Trying to put in the attribute with value '0 0 64.0 64.0'
valid.svg: yes
exit: 1

$ claricle conform document.pdf --profile pdf_a --level a1b
document.pdf: no
  error [6.1-2] PDF/A requires /Catalog/Metadata XMP stream (node_path: Catalog)

Converting a file

The summary line names the source, the target format, where the bytes were written and the lossiness verdict:

$ claricle convert rect_and_line.emf --to svg
rect_and_line.emf -> svg: ./rect_and_line.svg (unknown)

$ claricle convert gradient_linear.svg --to emf
gradient_linear.svg -> emf: ./gradient_linear.emf (lossy)

Version

$ claricle version
Claricle version 0.1.0

Exit codes

Code Meaning

0

Success

1

The file is not conformant

2

Bad invocation — unknown command, wrong arguments, missing file

3

The format is unknown, or is recognised but unsupported for that operation

4

The operation failed, or something unexpected went wrong

A command may also exit with a status of its own choosing. A status in 0-255 is passed through unchanged; one outside that range becomes 4, because a process status has to be a byte. The codes above are the ones this library assigns.

Exit code 1 means a nonconformant file. 0 and 1 are reachable end to end for every format with a conformance handler — PNG, SVG, EMF and PDF; EPS and PS answer 3.

claricle conform takes any number of files. A positional is a literal path when it names an existing file and a glob otherwise, and --pattern reads its argument as a glob whatever the filename looks like — which is how a file genuinely called star[1].png stays reachable both ways. A pattern adds to the positionals rather than replacing them, the combined set is deduplicated and processed in sorted order, a file that fails does not stop the ones after it, and the command exits with the highest code any of them reached. Matching no files at all is a bad invocation, not a silent pass. --json always emits an array, a single file included, so a consumer never gets one shape for one file and another for many.

claricle convert takes the same positional/--pattern shape as conform. --to FORMAT names the target; when it is omitted, --output FILE must name a recognised format extension to infer it from — --output - (stdout) cannot, since stdout has no extension, so that combination requires --to. --to and --output’s extension disagreeing is a bad invocation, not a silent preference for one over the other. `--output names a single destination, so it is refused whenever more than one source resolves; a multi-source run derives each destination from its own source name and the target’s extension instead. The whole destination set — derived or given — is preflighted for collisions and overwrites before anything is converted, and an existing destination needs --force to be replaced. --output - writes only the converted bytes to stdout, with no trailing newline; everything else — the summary, warnings, error text — goes to stderr.

Usage

Detect a format from a path or from bytes:

require "claricle"

Claricle.detect(File.binread("logo.png"))   # => :png
File.open("logo.png", "rb") { |io| Claricle.detect(io) } # => :png

image = Claricle::Image.from_path("logo.png")
image.format    # => :png
image.content   # read lazily, once

Image.from_path streams the file while detecting. Claricle.detect accepts a String of bytes or an IO; an IO is read from wherever it currently sits and is never rewound.

Detection reads the IO in chunks and reclassifies after each one, so it stops on the first chunk that settles the format and never reads past that chunk. Each read asks for the whole remaining probe allowance — the most any detector probe consults — so how far the IO advances is set by how much the source hands over per read. A file or a StringIO hands over the whole allowance at once, so a source longer than the allowance is read that far even though a PNG signature settled it after eight bytes. A pipe or a socket hands over what has arrived, so a short complete image settles without waiting for bytes nothing needs.

A source beginning %!PS is the exception. :ps and :eps are told apart by scanning the first line, and a byte that has not arrived yet can still flip one into the other, so that verdict always reads to the allowance or to the end of the stream.

Detection never reads beyond the allowance, and it leaves what it did not read on the IO: after a source longer than that, the IO sits mid-stream and eof? is false. A caller who needs the rest of the stream, or cares whether it reached EOF, should check rather than assume.

An SVG is recognised only when its root carries the SVG namespace. A bare <svg> with no xmlns is not detected, because SVG requires that declaration and the probe will not guess at one.

Bytes matching no known signature raise Claricle::UnknownFormat. A format that is recognised but unhandled raises Claricle::UnsupportedFormat — two different answers to two different questions.

Hand a delegate a real path, whichever way the image was built:

Claricle::Image.from_content(bytes, format: :png).with_path do |path|
  # a temporary file exists here, and only here
end

Hand it a source instead when the reader only needs a prefix:

Claricle::Image.from_path("logo.svg").with_source do |source|
  # the open file here; the bytes, for a content-born image
end

A path-born image is never slurped to satisfy with_source. That is how the SVG handler reads the root, so a 64 MiB file costs the 8 KiB it actually reads.

Convert one file. The result is the Models::Conversion: where it was written, how lossy it was, and the bytes.

conversion = Claricle.convert("diagram.emf", to: :svg)  # writes diagram.svg
conversion.output_path
conversion.lossiness

Claricle.convert("diagram.emf", output: "out/copy.svg", force: true)
Claricle.convert("diagram.emf", to: :svg, output: "-")  # bytes to stdout

output: defaults to the source’s name with the target’s extension, beside the source. An existing file is refused unless force: true. This is the same code as Claricle.convert_batch and claricle convert, so the collision and overwrite rules are identical. A file that cannot be converted raises its own error (UnsupportedFormat, InvocationError), where convert_batch collects it per file; a name matching more than one file raises InvocationError.

Architecture

lib/claricle/
  version.rb           # VERSION
  detector.rb          # magic-byte and XML-root format detection
  image.rb             # the object every operation takes
  lossiness.rb         # what a conversion loses, read from the source document
  registry.rb          # frozen format -> handler map
  writer.rb            # preflights and writes conversion output
  handlers/base.rb     # what a handler subclasses
  handlers/metafile.rb # EMF header metadata
  handlers/pdf.rb      # PDF version and declared page count
  handlers/png.rb      # PNG metadata
  handlers/postscript.rb # EPS and PS DSC header metadata
  handlers/svg.rb      # SVG root metadata
  models/              # Location, Issue, Report, Inspection, Conversion, BatchItem
  batch.rb             # one file set, one envelope per file
  fault.rb             # an exception's exit code and renderable message
  errors.rb            # Error, UnknownFormat, UnsupportedFormat, ...
  cli.rb               # Thor CLI and the exit-code Runner

Detector, Registry, Batch, Fault, Handlers::Base, Models::Base, Models::FreeFormHash, Models::UncoercedInteger, Models::Validation, Models::Location::POSITIONS, Models::BatchItem::CODES, Cli::Runner::Status::RANGE and UnsupportedFormat::ABSENT are private constants. The public surface is Claricle.detect, Claricle.conform?, Claricle.conformance_report, Claricle.conformance_batch, Claricle.convert, Claricle.convert_batch, Claricle::Image, Claricle::BatchResult, Claricle::Cli (including Cli::Runner and Runner::Status), Claricle::VERSION, the error classes, the model classes Models::BatchError, Models::BatchItem, Models::FormatCapability, Models::Inspection, Models::Issue, Models::Location and Models::Report, and the two vocabularies those models validate against, Inspection::PARSE_STATUSES and Issue::SEVERITIES.

Adding a format

One file: a handler class in lib/claricle/handlers/. Every file in that directory is loaded, and the registry is derived from the classes that subclass Handlers::Base — there is no list to extend.

module Claricle
  module Handlers
    class Qoi < Base
      formats :qoi                                   # what it owns
      detect { |header| :qoi if header.start_with?("qoif") }
      convert_from(:svg) { |content, to| encode(content) }
      loss_rules lost: %i[gradient], kept: %i[basic_shape]

      def inspection(image) = ...                    # each operation it supports
    end
  end
end
  • formats makes the format routable and listed by claricle formats.

  • detect is handed the first bytes (at most Detector::HEADER_BYTES) and answers one of the handler’s own formats, or nil. It runs after the built-in probes, so it cannot change what an existing format detects.

  • convert_from lets other formats convert INTO this one without editing their handlers; it also adds this format to their convert_to column. A source handler that already lists the target keeps its own path.

  • loss_rules is optional; without it a conversion into this format reports unknown lossiness, never lossless.

  • Operations (inspection, conformance_report, convert) are the methods the class overrides; capabilities is derived from them.

The five built-in formats still detect through Detector’s own ordered probes, which are heterogeneous (fixed offsets, an XML root, a PostScript line scan) and whose order is load-bearing; a handler’s `detect is the extension point for everything added after them.

Installation

Add this line to your application’s Gemfile:

gem 'claricle'

And then execute:

$ bundle install

Or install it yourself as:

$ gem install claricle

Conversion lossiness

Whether a conversion loses anything is decided per conversion, from what the source document actually contains — not from a table of format pairs. The same edge is clean for a rectangle and destructive for a gradient, so a per-edge label would call the second one lossless on the first one’s evidence.

A verdict is one of lossless, lossy or unknown, published as Models::Conversion::LOSSINESS_LEVELS. unknown is the default: a lossless verdict is a positive claim and is made only when every feature the document contains has been shown to survive that target. lossy and unknown both tell you to look; only lossless tells you not to, so only lossless can hide a loss.

The model class Models::Conversion and its vocabulary Conversion::LOSSINESS_LEVELS are public. Lossiness and Models::BinaryContent are private constants.

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run:

$ bundle exec rake install

To release a new version, update the version number in version.rb, and then run:

$ bundle exec rake release

This will create a git tag for the version, push git commits and the created tag, and push the .gem file to RubyGems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/claricle/claricle.

This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.

Copyright Ribose Inc.

License

The gem is available as open source under the terms of the BSD 2-Clause License.

Code of Conduct

Everyone interacting in the Claricle project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.