diff --git a/.github/workflows/rake.yml b/.github/workflows/rake.yml index 5da2e1e..a84c5f9 100644 --- a/.github/workflows/rake.yml +++ b/.github/workflows/rake.yml @@ -1,4 +1,4 @@ -# Auto-generated by Cimas: Do not edit it manually! +# Based on the Cimas-generated workflow; maintained here for native Lasem CI hooks. # See https://github.com/metanorma/cimas name: rake @@ -14,5 +14,51 @@ permissions: jobs: rake: uses: metanorma/ci/.github/workflows/generic-rake.yml@main + with: + submodules: recursive + before-setup-ruby: | + case "$RUNNER_OS" in + Linux) + sudo apt-get update + sudo apt-get install -y build-essential pkg-config meson \ + ninja-build bison flex gettext libglib2.0-dev \ + libgdk-pixbuf-2.0-dev libcairo2-dev libpango1.0-dev \ + libxml2-dev fonts-lyx + ;; + macOS) + brew install bison cairo flex gdk-pixbuf gettext glib libxml2 \ + meson ninja pango pkg-config + { + echo "$(brew --prefix bison)/bin" + echo "$(brew --prefix flex)/bin" + echo "$(brew --prefix gettext)/bin" + } >> "$GITHUB_PATH" + gettext_pkg_config="$(brew --prefix gettext)/lib/pkgconfig" + libxml_pkg_config="$(brew --prefix libxml2)/lib/pkgconfig" + pkg_config_path="$gettext_pkg_config:$libxml_pkg_config:${PKG_CONFIG_PATH:-}" + echo "PKG_CONFIG_PATH=$pkg_config_path" >> "$GITHUB_ENV" + ;; + Windows) + true + ;; + esac + after-setup-ruby: | + case "$RUNNER_OS" in + Windows) + export PATH="${MINGW_PREFIX:-/ucrt64}/bin:$PATH" + cygpath -w "${MINGW_PREFIX:-/ucrt64}/bin" >> "$GITHUB_PATH" + + package_prefix="${MINGW_PACKAGE_PREFIX:-mingw-w64-ucrt-x86_64}" + pacman --noconfirm -S --needed \ + "${package_prefix}-lasem" \ + "${package_prefix}-pkgconf" + bundle exec rake clean compile + ;; + *) + bundle exec rake lasem:build + bundle exec rake clean compile + ;; + esac + ruby -Ilib -rlasem -e 'abort "Lasem native extension unavailable" unless Lasem.native_available?' secrets: pat_token: ${{ secrets.METANORMA_CI_PAT_TOKEN }} diff --git a/.gitignore b/.gitignore index 8ed8fa0..2be4221 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /_yardoc/ /coverage/ /doc/ +/lasem-*.gem /lasem-ruby-*.gem /pkg/ /tmp/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..a0c9c86 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "vendor/lasem/source"] + path = vendor/lasem/source + url = https://github.com/LasemProject/lasem.git diff --git a/.rubocop.yml b/.rubocop.yml index 9187922..4efa3f5 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -4,4 +4,4 @@ inherit_from: - https://raw.githubusercontent.com/riboseinc/oss-guides/main/ci/rubocop.yml AllCops: - TargetRubyVersion: 3.2 + TargetRubyVersion: 3.3 diff --git a/Gemfile b/Gemfile index 6cb1935..ff354b4 100644 --- a/Gemfile +++ b/Gemfile @@ -1,10 +1,11 @@ source "https://rubygems.org" -# Specify the gem's dependencies in lasem-ruby.gemspec. +# Specify the gem's dependencies in lasem.gemspec. gemspec gem "irb" gem "rake", "~> 13.0" +gem "rake-compiler", "~> 1.2" gem "rspec", "~> 3.0" gem "rubocop-performance" gem "rubocop-rake" diff --git a/README.adoc b/README.adoc index 6ee093e..06c16fe 100644 --- a/README.adoc +++ b/README.adoc @@ -1,20 +1,380 @@ -= lasem-ruby += Lasem: Ruby bindings for the Lasem MathML and SVG renderer + +image:https://img.shields.io/gem/v/lasem.svg[Gem Version, link=https://rubygems.org/gems/lasem] +image:https://img.shields.io/github/license/plurimath/lasem-ruby.svg[License] + +image::docs/images/hero_quadratic.png[Quadratic formula rendered with Lasem] -Ruby bindings for the Lasem SVG and MathML rendering library. == Purpose -`lasem-ruby` is intended to provide a Ruby API for rendering MathML, SVG, and -Lasem-supported TeX input through the Lasem C library. +`lasem` is a Ruby native extension that wraps the +https://wiki.gnome.org/Projects/Lasem[Lasem] C library to render mathematical +notation and SVG. It parses MathML, SVG, or Lasem-supported itex/LaTeX input +and emits SVG, PNG, PDF, or PostScript through Lasem's existing layout +pipeline. + +The gem keeps the Ruby layer focused on input validation, ergonomic entry +points, and predictable errors. Parsing, layout, and rendering are delegated +to Lasem. + +`lasem` is intended for Ruby applications that need an embeddable interface to +Lasem-supported rendering without shelling out to the `lasem-render` +executable. + +The source repository is named `lasem-ruby` to make the language binding +clear; the gem itself is named `lasem` because the public Ruby API is already +namespaced as `Lasem`. + + +[#prerequisites] +== Prerequisites + +`lasem` is a native extension. Ruby 3.3 or newer is required, and the upstream +Lasem C library must be available for rendering support. + +The released gem does not package or build the upstream Lasem C library. If +Lasem is missing when the gem is installed, installation can still succeed with +a stub extension, but `Lasem.render` raises `Lasem::DependencyError` until +Lasem is installed and the extension is rebuilt. + +Install your operating system's Lasem development package when one is +available. The package lists below cover the Ruby native extension toolchain, +the vendored Lasem source-checkout build, and fonts commonly needed for math +rendering. + +=== Debian or Ubuntu + +[source,sh] +---- +sudo apt-get install build-essential ruby-dev pkg-config meson ninja-build \ + bison flex gettext libglib2.0-dev libgdk-pixbuf-2.0-dev libcairo2-dev \ + libpango1.0-dev libxml2-dev fonts-lyx +---- + +=== Fedora + +[source,sh] +---- +sudo dnf install gcc ruby-devel pkgconf-pkg-config meson ninja-build \ + bison flex gettext glib2-devel gdk-pixbuf2-devel cairo-devel pango-devel \ + libxml2-devel lyx-fonts +---- + +Run `lasem-doctor --all-warnings` after installation if rendering is +unavailable or the gem reports missing native dependencies. + + +== Installation + +Add to your application's Gemfile: + +[source,ruby] +---- +gem "lasem" +---- + +Then: + +[source,sh] +---- +bundle install +---- + +Or install directly: + +[source,sh] +---- +gem install lasem +---- + +Lasem itself is a native C library and is not bundled with the gem. See +<> before installing the gem. + + +== Quick start + +[source,ruby] +---- +require "lasem" + +png = Lasem.render( + "$x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$", + input: :latex, + output: :png, + ppi: 192.0, +) + +File.binwrite("quadratic.png", png) +---- + +Output: + +image::docs/images/hero_quadratic.png[Quadratic formula] + + +== Usage + +=== `Lasem.render` + +[source,ruby] +---- +Lasem.render(source, input: :xml, output: :svg, **options) # => String +---- + +Returns the rendered output as a binary string in the requested format. + +==== Options + +`input`:: + Source language. One of `:xml`, `:mathml`, `:svg`, `:latex`, `:itex`. + Default `:xml`. `:xml`, `:mathml`, and `:svg` are equivalent: all are parsed + by Lasem's XML document parser, which auto-detects MathML vs SVG from the + document. `:latex` and `:itex` are passed unchanged to Lasem's itex parser; + the gem does not infer or add math delimiters. + +`output`:: + Target format. One of `:svg`, `:png`, `:pdf`, `:ps`. Default `:svg`. + +`ppi`:: + Pixels per inch used for rasterized output. Positive float. Default `72.0`. + +`zoom`:: + Render scale factor. Positive float. Default `1.0`. + +`width`, `height`:: + Optional fixed output dimensions in user units. Must be supplied together. + Default `nil` (use Lasem's natural size). + +`offset_x`, `offset_y`:: + Optional translation in user units. Default `0.0`. At the default `zoom: 1.0` + the on-canvas displacement equals the offset; with a non-default `zoom` the + offset is applied in the zoom-scaled coordinate system, so the effective + displacement also scales with `zoom`. + +=== `Lasem.native_available?` + +Returns `true` when the native extension is linked against a usable Lasem +build. Returns `false` when only the stub fallback is loaded; in that case +`Lasem.render` raises `Lasem::DependencyError`. + + +== Examples + +.LaTeX → PNG (Basel problem) +[example] +==== +[source,ruby] +---- +Lasem.render( + "$\\sum_{n=1}^{\\infty} \\frac{1}{n^2} = \\frac{\\pi^2}{6}$", + input: :latex, + output: :png, + ppi: 192.0, +) +---- + +image::docs/images/latex_euler.png[Basel problem rendered to PNG] +==== + + +.LaTeX → PNG (Gaussian integral) +[example] +==== +[source,ruby] +---- +Lasem.render( + "$\\int_{0}^{\\infty} e^{-x^2}\\,dx = \\frac{\\sqrt{\\pi}}{2}$", + input: :latex, + output: :png, + ppi: 192.0, +) +---- + +image::docs/images/latex_integral.png[Gaussian integral rendered to PNG] +==== + + +.MathML → PNG (3×3 matrix) +[example] +==== +[source,ruby] +---- +mathml = <<~XML + + + A + = + + + 123 + 456 + 789 + + + + +XML + +Lasem.render(mathml, input: :mathml, output: :png, ppi: 192.0) +---- + +image::docs/images/mathml_matrix.png[3x3 matrix rendered from MathML] +==== + + +.MathML → SVG +[example] +==== +[source,ruby] +---- +svg = Lasem.render(mathml, input: :mathml, output: :svg) +File.write("equation.svg", svg) +---- +==== + +The script that produced the images above is at +link:docs/images/render_samples.rb[`docs/images/render_samples.rb`]. + + +[#native-dependency] +== Native dependency resolution + +The released gem does not package or build the upstream Lasem C library. +Install Lasem before installing this gem when possible, so the native +extension can link against it at install time. + +The native build resolves Lasem in this order: + +. A source-checkout vendored Lasem install under `vendor/lasem/install`, when present. +. A system Lasem package discovered with `pkg-config`. +. A compiled stub extension that raises `Lasem::DependencyError` when called. + +Released gems do not include the vendored Lasem source, so installed gems +normally resolve Lasem through the system `pkg-config` path. + +The stub keeps `require "lasem"` working on machines that do not have Lasem +yet, while making rendering failures explicit. + +If the gem was installed before Lasem was available, rebuild the native +extension once Lasem is in place: + +[source,sh] +---- +gem pristine lasem --extensions +---- + +With Bundler: + +[source,sh] +---- +bundle pristine lasem +---- + +For a source checkout: + +[source,sh] +---- +bundle exec rake clean compile +---- + +Verify the result: + +[source,sh] +---- +ruby -rlasem -e 'p Lasem.native_available?' +---- + + +== Troubleshooting + +Run the bundled doctor: + +[source,sh] +---- +bundle exec lasem-doctor --all-warnings +---- + +It reports missing executables and missing pkg-config packages, and with +`--all-warnings` (or `--lasem-conflict-warnings`) it also reports submodule and +setup state. The line `Required dependencies look available.` is printed when +the required toolchain is present; any warnings are listed after the status +section. A successful dependency check does not by itself guarantee the native +extension is built -- verify that separately with `Lasem.native_available?`. + +The same checks are available through Rake, where the `WARNINGS` variable maps +to the CLI flags: `WARNINGS=all` (all warnings), `WARNINGS=lasem` (Lasem setup +warnings), `WARNINGS=deps` (dependency warnings): + +[source,sh] +---- +bundle exec rake lasem:doctor WARNINGS=all +---- + +If `Lasem.render` raises `Lasem::DependencyError`, the gem is loading the stub +extension. Rebuild after installing Lasem (see <>). + + +[#vendored-lasem] +== Vendored Lasem (source checkout) + +The vendored build is a development helper for source checkouts. It is not +part of normal gem installation. + +[source,sh] +---- +git submodule update --init vendor/lasem/source +bundle exec rake lasem:doctor +bundle exec rake lasem:build +bundle exec rake clean compile +---- + +The build task installs Lasem into `vendor/lasem/install`. The native +extension adds that install's `pkgconfig` directory to `PKG_CONFIG_PATH` and +embeds the vendored library directory as a runtime library path. + +The vendored Lasem source is licensed under LGPL-2.1-or-later. Keep Lasem's +license files with the vendored source when updating it. -This initial project scaffold establishes the gem structure, development -tooling, and test layout. Native rendering support is added separately. == Development +Ruby 3.3 or newer is required. + [source,sh] ---- bundle install -bundle exec rake +bundle exec rake # runs RSpec bundle exec rubocop ---- + +Rendering specs are skipped unless the native extension is built against a +usable Lasem. In a source checkout, build the vendored Lasem first (see +<>) — that is the development +step that requires `git submodule update --init vendor/lasem/source`. + +Environment variables understood by the build: + +`LASEM_PKG_CONFIG`:: + Override the pkg-config package name (for example `lasem-0.6`). + +`LASEM_SOURCE_DIR`:: + Override the vendored source directory used by `rake lasem:build`. + +`LASEM_BUILD_DIR`:: + Override the Meson build directory used by `rake lasem:build`. + +`LASEM_INSTALL_DIR`:: + Override the install prefix used by `rake lasem:build` and the native + extension. + + +== Copyright and license + +Copyright https://www.ribose.com[Ribose]. + +`lasem` (the Ruby gem) is licensed under the BSD 2-Clause license. See +link:LICENSE.txt[LICENSE.txt] for details. + +The vendored Lasem C library is licensed under LGPL-2.1-or-later. Its license +files ship with the vendored source under `vendor/lasem/source/`. diff --git a/Rakefile b/Rakefile index b6ae734..7833f5b 100644 --- a/Rakefile +++ b/Rakefile @@ -1,8 +1,17 @@ # frozen_string_literal: true require "bundler/gem_tasks" +require "rake/extensiontask" require "rspec/core/rake_task" +spec = Gem::Specification.load("lasem.gemspec") + +Rake::ExtensionTask.new("lasem", spec) do |ext| + ext.lib_dir = "lib/lasem" +end + +Dir.glob("rakelib/*.rake").each { |task| import task } + RSpec::Core::RakeTask.new(:spec) -task default: :spec +task default: %i[compile spec] diff --git a/docs/images/hero_quadratic.png b/docs/images/hero_quadratic.png new file mode 100644 index 0000000..a8631aa Binary files /dev/null and b/docs/images/hero_quadratic.png differ diff --git a/docs/images/latex_euler.png b/docs/images/latex_euler.png new file mode 100644 index 0000000..8c620f3 Binary files /dev/null and b/docs/images/latex_euler.png differ diff --git a/docs/images/latex_integral.png b/docs/images/latex_integral.png new file mode 100644 index 0000000..613f06d Binary files /dev/null and b/docs/images/latex_integral.png differ diff --git a/docs/images/mathml_matrix.png b/docs/images/mathml_matrix.png new file mode 100644 index 0000000..5def348 Binary files /dev/null and b/docs/images/mathml_matrix.png differ diff --git a/docs/images/render_samples.rb b/docs/images/render_samples.rb new file mode 100755 index 0000000..0c56321 --- /dev/null +++ b/docs/images/render_samples.rb @@ -0,0 +1,90 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Regenerate the sample renders embedded in README.adoc. +# Run from the gem root: +# bundle exec ruby docs/images/render_samples.rb + +require "lasem" + +abort "Lasem native extension not available" unless Lasem.native_available? + +OUT_DIR = File.expand_path(__dir__) +# Padding around each equation, in user units (points). +PADDING_X = 24 +PADDING_Y = 18 + +SAMPLES = [ + { + name: "hero_quadratic", + input: :latex, + source: "$x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$", + ppi: 192.0, + }, + { + name: "latex_euler", + input: :latex, + source: "$\\sum_{n=1}^{\\infty} \\frac{1}{n^2} = \\frac{\\pi^2}{6}$", + ppi: 192.0, + }, + { + name: "latex_integral", + input: :latex, + source: "$\\int_{0}^{\\infty} e^{-x^2}\\,dx = \\frac{\\sqrt{\\pi}}{2}$", + ppi: 192.0, + }, + { + name: "mathml_matrix", + input: :mathml, + source: <<~MATHML, + + + A + = + + + 123 + 456 + 789 + + + + + MATHML + ppi: 192.0, + }, +].freeze + +def png_size(png) + png.byteslice(16, 8).unpack("NN") +end + +SAMPLES.each do |sample| + ppi = sample[:ppi] + natural_png = Lasem.render( + sample[:source], + input: sample[:input], + output: :png, + ppi: ppi, + ) + natural_width_px, natural_height_px = png_size(natural_png) + # png_size returns pixels; width/height/offset are user units (points). + px_to_pt = 72.0 / ppi + width_pt = (natural_width_px * px_to_pt) + (2 * PADDING_X) + height_pt = (natural_height_px * px_to_pt) + (2 * PADDING_Y) + + png = Lasem.render( + sample[:source], + input: sample[:input], + output: :png, + ppi: ppi, + width: width_pt, + height: height_pt, + offset_x: -PADDING_X, + offset_y: -PADDING_Y, + ) + + path = File.join(OUT_DIR, "#{sample[:name]}.png") + File.binwrite(path, png) + puts "wrote #{path} (#{png.bytesize} bytes)" +end diff --git a/exe/lasem-doctor b/exe/lasem-doctor new file mode 100755 index 0000000..05f802d --- /dev/null +++ b/exe/lasem-doctor @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +$LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) + +require "lasem" + +exit Lasem::DependencyDoctor::CLI.call(ARGV) diff --git a/ext/lasem/extconf.rb b/ext/lasem/extconf.rb new file mode 100644 index 0000000..6c8889a --- /dev/null +++ b/ext/lasem/extconf.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "mkmf" +require "shellwords" +require_relative "../../lib/lasem/pkg_config" + +ROOT = File.expand_path("../..", __dir__) +VENDORED_INSTALL_DIR = File.expand_path( + ENV.fetch("LASEM_INSTALL_DIR", "vendor/lasem/install"), + ROOT, +) + +def add_pkg_config_path(path) + return unless Dir.exist?(path) + + paths = ENV.fetch("PKG_CONFIG_PATH", "").split(File::PATH_SEPARATOR) + paths.unshift(path) + ENV["PKG_CONFIG_PATH"] = paths.uniq.join(File::PATH_SEPARATOR) +end + +def add_runtime_library_path(path) + return unless Dir.exist?(path) + + $DLDFLAGS << " -Wl,-rpath,#{Shellwords.escape(path)}" +end + +def find_lasem_package(candidates) + candidates.find { |candidate| pkg_config(candidate) } +end + +add_pkg_config_path(File.join(VENDORED_INSTALL_DIR, "lib", "pkgconfig")) +add_pkg_config_path(File.join(VENDORED_INSTALL_DIR, "lib64", "pkgconfig")) + +lasem_package = find_lasem_package(Lasem::PkgConfig.candidates) + +required_headers = %w[ + lsm.h + lsmdomparser.h + lsmmathmldocument.h + cairo-svg.h + cairo-pdf.h + cairo-ps.h +] +has_lasem_headers = lasem_package && required_headers.all? do |header| + have_header(header) +end + +if has_lasem_headers + add_runtime_library_path(File.join(VENDORED_INSTALL_DIR, "lib")) + add_runtime_library_path(File.join(VENDORED_INSTALL_DIR, "lib64")) + + # Embed the resolved package's own libdir as a runtime path too, so a system + # Lasem installed under a non-default prefix (Homebrew/mise/Nix) loads without + # the user having to set LD_LIBRARY_PATH. Use mkmf's pkg_config so the same + # pkg-config tool that resolved the package supplies the libdir. + # add_runtime_library_path skips missing/standard dirs, so this is a no-op for + # /usr-style installs. + lasem_libdir = pkg_config(lasem_package, "variable=libdir")&.strip + add_runtime_library_path(lasem_libdir) if lasem_libdir && !lasem_libdir.empty? + + $defs << "-DHAVE_LASEM" + $srcs = ["lasem_ext.c"] + warn "Building lasem against #{lasem_package}." +else + $srcs = ["lasem_stub.c"] + warn "Lasem was not found; building a stub extension." + warn "Install a system Lasem development package, then rebuild the gem." + warn "Run `lasem-doctor` for setup guidance after installation." +end + +create_makefile("lasem/lasem") diff --git a/ext/lasem/lasem_ext.c b/ext/lasem/lasem_ext.c new file mode 100644 index 0000000..01f5abf --- /dev/null +++ b/ext/lasem/lasem_ext.c @@ -0,0 +1,487 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* Lasem: core DOM and parser APIs used to parse SVG, MathML, and itex input. */ +#include +#include +#include + +/* Upper bound on total raster (ARGB32) pixels, to keep a single render from + * allocating unbounded memory when fed untrusted input. The default ~= 256 MB + * (4 bytes/pixel). Override at build time with -DLASEM_MAX_RASTER_PIXELS=N. */ +#ifndef LASEM_MAX_RASTER_PIXELS +#define LASEM_MAX_RASTER_PIXELS (64ULL * 1024ULL * 1024ULL) +#endif + +static VALUE m_lasem; +static VALUE m_native; +static VALUE e_render_error; + +/* Keep in sync with lib/lasem/error.rb: Lasem::Error is a marker MODULE mixed + * into every gem error so `rescue Lasem::Error` catches them all, while each + * error keeps its own superclass (e.g. OptionError stays an ArgumentError). */ +static VALUE +lasem_get_or_define_module(VALUE parent, const char *name) +{ + ID id = rb_intern(name); + + if (rb_const_defined_at(parent, id)) { + return rb_const_get(parent, id); + } + + return rb_define_module_under(parent, name); +} + +/* Define (or fetch) a StandardError subclass under `parent` and include the + * Lasem::Error marker module so the error is rescuable both as itself and as + * Lasem::Error. Tolerant of the class already existing (Ruby autoload order). */ +static VALUE +lasem_define_error_class(VALUE parent, const char *name, VALUE marker) +{ + ID id = rb_intern(name); + VALUE klass; + + if (rb_const_defined_at(parent, id)) { + klass = rb_const_get(parent, id); + } else { + klass = rb_define_class_under(parent, name, rb_eStandardError); + } + + rb_include_module(klass, marker); + return klass; +} + +static void +lasem_raise_gerror(VALUE error_class, GError *error, const char *context_message) +{ + if (error != NULL) { + /* Copy the detail onto the stack and free the GError before any Ruby + * allocation, so a raising allocation cannot leak the GError. Always + * prefix our own context so the message is stable across libxml2 + * versions, which otherwise vary (e.g. "Invalid document"). */ + char detail[512]; + + g_strlcpy(detail, error->message, sizeof(detail)); + g_error_free(error); + rb_raise(error_class, "%s: %s", context_message, detail); + } + + rb_raise(error_class, "%s", context_message); +} + +/* Growable C byte buffer used to collect Cairo output. The Ruby result string + * is built from it only after every native resource is released, so the Cairo + * write callback never calls into the Ruby runtime (where an allocation could + * raise and longjmp out, leaking the surface/context/view/document). */ +typedef struct { + unsigned char *data; + size_t length; + size_t capacity; + int failed; +} lasem_buffer; + +static cairo_status_t +lasem_buffer_write(void *closure, const unsigned char *data, unsigned int length) +{ + lasem_buffer *buffer = (lasem_buffer *) closure; + size_t needed; + + if (buffer->failed) { + return CAIRO_STATUS_WRITE_ERROR; + } + + needed = buffer->length + length; + if (needed < buffer->length) { + /* size_t overflow */ + buffer->failed = 1; + return CAIRO_STATUS_WRITE_ERROR; + } + + if (needed > buffer->capacity) { + size_t capacity = buffer->capacity ? buffer->capacity : 4096; + unsigned char *grown; + + while (capacity < needed) { + if (capacity > SIZE_MAX / 2) { + capacity = needed; + break; + } + capacity *= 2; + } + + grown = realloc(buffer->data, capacity); + if (grown == NULL) { + buffer->failed = 1; + return CAIRO_STATUS_WRITE_ERROR; + } + + buffer->data = grown; + buffer->capacity = capacity; + } + + memcpy(buffer->data + buffer->length, data, length); + buffer->length += length; + return CAIRO_STATUS_SUCCESS; +} + +struct lasem_string_build { + const char *data; + long length; +}; + +/* Runs under rb_protect so the caller can free the C buffer even if this + * (Ruby) allocation raises. */ +static VALUE +lasem_build_output_string(VALUE arg) +{ + struct lasem_string_build *build = (struct lasem_string_build *) arg; + VALUE string = rb_str_new(build->data, build->length); + + rb_enc_associate_index(string, rb_ascii8bit_encindex()); + return string; +} + +static int +lasem_positive_pixel_size(double value, unsigned int *size, const char **message) +{ + if (!isfinite(value) || value <= 0.0) { + *message = "must be greater than 0"; + return 0; + } + + if (value > INT_MAX) { + *message = "is too large"; + return 0; + } + + *size = (unsigned int) ceil(value); + return 1; +} + +static unsigned int +lasem_checked_positive_pixel_size(double value, const char *name) +{ + unsigned int size; + const char *message; + + if (!lasem_positive_pixel_size(value, &size, &message)) { + rb_raise(e_render_error, "%s %s", name, message); + } + + return size; +} + +/* Reject raster surfaces whose total pixel count would exceed the configured + * budget, guarding against OOM from hostile or pathological input. */ +static int +lasem_raster_budget_ok(unsigned int width_px, unsigned int height_px) +{ + return (uint64_t) width_px * (uint64_t) height_px <= LASEM_MAX_RASTER_PIXELS; +} + +static int +lasem_supported_output_format(const char *format) +{ + return strcmp(format, "svg") == 0 || + strcmp(format, "pdf") == 0 || + strcmp(format, "ps") == 0 || + strcmp(format, "png") == 0; +} + +/* Mirrors Lasem::RenderOptions::INPUT_TYPES. xml/mathml/svg use the XML parser, + * latex/itex use the itex parser; anything else is rejected. */ +static int +lasem_supported_input_type(const char *input_type) +{ + return strcmp(input_type, "xml") == 0 || + strcmp(input_type, "mathml") == 0 || + strcmp(input_type, "svg") == 0 || + strcmp(input_type, "latex") == 0 || + strcmp(input_type, "itex") == 0; +} + +static LsmDomDocument * +lasem_document_from_input(const char *input, gssize input_size, const char *input_type, GError **error) +{ + if (strcmp(input_type, "latex") == 0 || strcmp(input_type, "itex") == 0) { + /* Lasem: itex parser accepts TeX-like math input and returns a MathML document. */ + return LSM_DOM_DOCUMENT(lsm_mathml_document_new_from_itex(input, input_size, error)); + } + + /* Lasem: XML parser accepts SVG or MathML documents from memory. */ + return lsm_dom_document_new_from_memory(input, input_size, error); +} + +static cairo_surface_t * +lasem_create_surface(const char *format, lasem_buffer *buffer, double width_pt, double height_pt, + unsigned int width_px, unsigned int height_px) +{ + if (strcmp(format, "svg") == 0) { + /* Cairo: vector SVG output is streamed into the C byte buffer. */ + return cairo_svg_surface_create_for_stream(lasem_buffer_write, buffer, width_pt, height_pt); + } + + if (strcmp(format, "pdf") == 0) { + /* Cairo: vector PDF output is streamed into the C byte buffer. */ + return cairo_pdf_surface_create_for_stream(lasem_buffer_write, buffer, width_pt, height_pt); + } + + if (strcmp(format, "ps") == 0) { + /* Cairo: vector PostScript output is streamed into the C byte buffer. */ + return cairo_ps_surface_create_for_stream(lasem_buffer_write, buffer, width_pt, height_pt); + } + + if (strcmp(format, "png") == 0) { + /* Cairo: raster PNG output is rendered through an ARGB image surface. */ + return cairo_image_surface_create(CAIRO_FORMAT_ARGB32, (int) width_px, (int) height_px); + } + + return NULL; +} + +static VALUE +lasem_native_available(VALUE self) +{ + return Qtrue; +} + +static VALUE +lasem_native_render(VALUE self, VALUE input_value, VALUE input_type_value, VALUE format_value, + VALUE ppi_value, VALUE zoom_value, VALUE width_value, VALUE height_value, + VALUE offset_x_value, VALUE offset_y_value) +{ + GError *error = NULL; + /* Lasem: parsed input document, either XML-backed or generated from itex. */ + LsmDomDocument *document; + /* Lasem: layout/rendering view created from the parsed document. */ + LsmDomView *view; + /* Cairo: target surface and drawing context for the requested output format. */ + cairo_surface_t *surface; + cairo_t *cairo; + cairo_status_t status; + lasem_buffer buffer = { NULL, 0, 0, 0 }; + VALUE output; + const char *input; + const char *input_type; + const char *format; + gssize input_size; + double ppi; + double zoom; + double width_pt; + double height_pt; + double offset_x; + double offset_y; + double render_offset_x; + double render_offset_y; + unsigned int width_px = 0; + unsigned int height_px = 0; + int explicit_size; + int raster_output; + const char *pixel_size_error; + + StringValue(input_value); + StringValue(input_type_value); + StringValue(format_value); + + input = RSTRING_PTR(input_value); + input_size = (gssize) RSTRING_LEN(input_value); + input_type = StringValueCStr(input_type_value); + format = StringValueCStr(format_value); + ppi = NUM2DBL(ppi_value); + zoom = NUM2DBL(zoom_value); + offset_x = NUM2DBL(offset_x_value); + offset_y = NUM2DBL(offset_y_value); + render_offset_x = zoom * offset_x; + render_offset_y = zoom * offset_y; + explicit_size = !NIL_P(width_value) && !NIL_P(height_value); + raster_output = strcmp(format, "png") == 0; + /* Defense in depth: Lasem::RenderOptions already validates these, but + * Native.render is a public entry point, so re-check before doing any work. */ + if (!isfinite(ppi) || ppi <= 0.0) { + rb_raise(e_render_error, "ppi must be greater than 0"); + } + if (!isfinite(zoom) || zoom <= 0.0) { + rb_raise(e_render_error, "zoom must be greater than 0"); + } + if (!isfinite(offset_x) || !isfinite(offset_y)) { + rb_raise(e_render_error, "offset must be finite"); + } + if (!lasem_supported_output_format(format)) { + rb_raise(e_render_error, "unsupported output format: %s", format); + } + if (!lasem_supported_input_type(input_type)) { + rb_raise(e_render_error, "unsupported input type: %s", input_type); + } + if (explicit_size) { + width_pt = zoom * NUM2DBL(width_value); + height_pt = zoom * NUM2DBL(height_value); + if (raster_output) { + width_px = lasem_checked_positive_pixel_size(width_pt * ppi / 72.0, "width"); + height_px = lasem_checked_positive_pixel_size(height_pt * ppi / 72.0, "height"); + if (!lasem_raster_budget_ok(width_px, height_px)) { + rb_raise(e_render_error, + "requested raster size %ux%u exceeds the maximum of %llu pixels", + width_px, height_px, + (unsigned long long) LASEM_MAX_RASTER_PIXELS); + } + } + } + + /* The GVL is deliberately held for the whole parse/layout/render. Lasem's + * XML parser and Pango/fontconfig are not guaranteed thread-safe, so the + * GVL is what serializes concurrent renders in one process. Do NOT wrap + * this in rb_thread_call_without_gvl without first making upstream Lasem + * usage thread-safe (e.g. a process-wide mutex). */ + document = lasem_document_from_input(input, input_size, input_type, &error); + if (document == NULL) { + lasem_raise_gerror(e_render_error, error, "Lasem could not parse the input document"); + } + + view = lsm_dom_document_create_view(document); + if (view == NULL) { + g_object_unref(document); + rb_raise(e_render_error, "Lasem could not create a rendering view"); + } + + lsm_dom_view_set_resolution(view, ppi); + + if (!explicit_size) { + width_pt = 2.0; + height_pt = 2.0; + lsm_dom_view_get_size(view, &width_pt, &height_pt, NULL); + width_pt *= zoom; + height_pt *= zoom; + if (raster_output) { + lsm_dom_view_get_size_pixels(view, &width_px, &height_px, NULL); + if (!lasem_positive_pixel_size((double) width_px * zoom, &width_px, &pixel_size_error)) { + g_object_unref(view); + g_object_unref(document); + rb_raise(e_render_error, "width %s", pixel_size_error); + } + if (!lasem_positive_pixel_size((double) height_px * zoom, &height_px, &pixel_size_error)) { + g_object_unref(view); + g_object_unref(document); + rb_raise(e_render_error, "height %s", pixel_size_error); + } + if (!lasem_raster_budget_ok(width_px, height_px)) { + g_object_unref(view); + g_object_unref(document); + rb_raise(e_render_error, + "rendered raster size %ux%u exceeds the maximum of %llu pixels", + width_px, height_px, + (unsigned long long) LASEM_MAX_RASTER_PIXELS); + } + } + } + + surface = lasem_create_surface(format, &buffer, width_pt, height_pt, width_px, height_px); + if (surface == NULL) { + g_object_unref(view); + g_object_unref(document); + rb_raise(e_render_error, "Cairo could not allocate a rendering surface"); + } + status = cairo_surface_status(surface); + if (status != CAIRO_STATUS_SUCCESS) { + cairo_surface_destroy(surface); + g_object_unref(view); + g_object_unref(document); + rb_raise(e_render_error, "Cairo could not create a rendering surface: %s", + cairo_status_to_string(status)); + } + + cairo = cairo_create(surface); + cairo_scale(cairo, zoom, zoom); + lsm_dom_view_render(view, cairo, -render_offset_x, -render_offset_y); + + status = cairo_status(cairo); + if (status != CAIRO_STATUS_SUCCESS) { + cairo_destroy(cairo); + cairo_surface_destroy(surface); + g_object_unref(view); + g_object_unref(document); + free(buffer.data); + rb_raise(e_render_error, "Cairo rendering failed: %s", cairo_status_to_string(status)); + } + + if (raster_output) { + status = cairo_surface_write_to_png_stream(cairo_get_target(cairo), + lasem_buffer_write, + &buffer); + if (status != CAIRO_STATUS_SUCCESS) { + cairo_destroy(cairo); + cairo_surface_destroy(surface); + g_object_unref(view); + g_object_unref(document); + free(buffer.data); + rb_raise(e_render_error, "Cairo PNG output failed: %s", cairo_status_to_string(status)); + } + } + + cairo_destroy(cairo); + cairo_surface_finish(surface); + status = cairo_surface_status(surface); + cairo_surface_destroy(surface); + g_object_unref(view); + g_object_unref(document); + + /* All native resources are now released. Build the Ruby string last so a + * Ruby allocation that raises cannot longjmp past the cleanup above. */ + if (buffer.failed) { + free(buffer.data); + rb_raise(e_render_error, "out of memory while collecting rendered output"); + } + if (status != CAIRO_STATUS_SUCCESS) { + free(buffer.data); + rb_raise(e_render_error, "Cairo output failed: %s", cairo_status_to_string(status)); + } + + if (buffer.length > (size_t) LONG_MAX) { + free(buffer.data); + rb_raise(e_render_error, "rendered output is too large"); + } + + { + struct lasem_string_build build = { + (const char *) buffer.data, + (long) buffer.length, + }; + int state = 0; + + /* Build the Ruby string under rb_protect so buffer.data is freed even + * if the allocation raises (e.g. on memory pressure). */ + output = rb_protect(lasem_build_output_string, (VALUE) &build, &state); + free(buffer.data); + buffer.data = NULL; + if (state) { + rb_jump_tag(state); + } + } + + RB_GC_GUARD(output); + return output; +} + +void +Init_lasem(void) +{ + VALUE e_error; + + m_lasem = rb_define_module("Lasem"); + m_native = rb_define_module_under(m_lasem, "Native"); + e_error = lasem_get_or_define_module(m_lasem, "Error"); + lasem_define_error_class(m_lasem, "DependencyError", e_error); + e_render_error = lasem_define_error_class(m_lasem, "RenderError", e_error); + + rb_define_singleton_method(m_native, "native_available?", lasem_native_available, 0); + rb_define_singleton_method(m_native, "render", lasem_native_render, 9); +} diff --git a/ext/lasem/lasem_stub.c b/ext/lasem/lasem_stub.c new file mode 100644 index 0000000..0be73f3 --- /dev/null +++ b/ext/lasem/lasem_stub.c @@ -0,0 +1,63 @@ +#include + +/* Keep in sync with lib/lasem/error.rb and lasem_ext.c: Lasem::Error is a + * marker MODULE mixed into every gem error. */ +static VALUE +lasem_get_or_define_module(VALUE parent, const char *name) +{ + ID id = rb_intern(name); + + if (rb_const_defined_at(parent, id)) { + return rb_const_get(parent, id); + } + + return rb_define_module_under(parent, name); +} + +static VALUE +lasem_define_error_class(VALUE parent, const char *name, VALUE marker) +{ + ID id = rb_intern(name); + VALUE klass; + + if (rb_const_defined_at(parent, id)) { + klass = rb_const_get(parent, id); + } else { + klass = rb_define_class_under(parent, name, rb_eStandardError); + } + + rb_include_module(klass, marker); + return klass; +} + +static VALUE +lasem_native_available(VALUE self) +{ + return Qfalse; +} + +static VALUE +lasem_native_render(int argc, VALUE *argv, VALUE self) +{ + VALUE m_lasem = rb_define_module("Lasem"); + VALUE e_error = lasem_get_or_define_module(m_lasem, "Error"); + VALUE e_dependency_error = lasem_define_error_class(m_lasem, "DependencyError", e_error); + + /* Keep in sync with Lasem::DependencyError::MESSAGE. */ + rb_raise(e_dependency_error, + "Lasem native library is not available. Install a system " + "Lasem development package, then rebuild the gem. Run " + "`lasem-doctor --all-warnings` or `bundle exec rake " + "lasem:doctor WARNINGS=all` for setup diagnostics."); + return Qnil; +} + +void +Init_lasem(void) +{ + VALUE m_lasem = rb_define_module("Lasem"); + VALUE m_native = rb_define_module_under(m_lasem, "Native"); + + rb_define_singleton_method(m_native, "native_available?", lasem_native_available, 0); + rb_define_singleton_method(m_native, "render", lasem_native_render, -1); +} diff --git a/lasem-ruby.gemspec b/lasem-ruby.gemspec deleted file mode 100644 index b171760..0000000 --- a/lasem-ruby.gemspec +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -require_relative "lib/lasem/version" - -Gem::Specification.new do |spec| - spec.name = "lasem-ruby" - spec.version = Lasem::VERSION - spec.authors = ["Ribose Inc."] - spec.email = ["open.source@ribose.com"] - - spec.summary = "Ruby bindings for the Lasem SVG and MathML renderer." - spec.description = "Ruby bindings for the Lasem SVG and MathML renderer." - spec.homepage = "https://github.com/plurimath/lasem-ruby" - spec.license = "BSD-2-Clause" - - spec.required_ruby_version = Gem::Requirement.new(">= 3.2.0") - - spec.metadata["homepage_uri"] = spec.homepage - spec.metadata["source_code_uri"] = "https://github.com/plurimath/lasem-ruby" - spec.metadata["rubygems_mfa_required"] = "true" - - tracked_files = Dir.chdir(File.expand_path(__dir__)) do - `git ls-files -z`.split("\x0") - end - spec.files = if tracked_files.empty? - Dir[ - "LICENSE.txt", - "README.adoc", - "Rakefile", - "Gemfile", - "lasem-ruby.gemspec", - "lib/**/*.rb", - ] - else - tracked_files - end - - spec.bindir = "exe" - spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } - spec.require_paths = ["lib"] -end diff --git a/lasem.gemspec b/lasem.gemspec new file mode 100644 index 0000000..b3a7583 --- /dev/null +++ b/lasem.gemspec @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require_relative "lib/lasem/version" + +Gem::Specification.new do |spec| + spec.name = "lasem" + spec.version = Lasem::VERSION + spec.authors = ["Ribose Inc."] + spec.email = ["open.source@ribose.com"] + + spec.summary = "Ruby bindings for the Lasem SVG and MathML renderer." + spec.description = "Provides a native Ruby extension for rendering " \ + "MathML, SVG, and Lasem-supported TeX input through " \ + "the Lasem C library." + spec.homepage = "https://github.com/plurimath/lasem-ruby" + spec.license = "BSD-2-Clause" + + spec.required_ruby_version = Gem::Requirement.new(">= 3.3.0") + + spec.metadata = { + "rubygems_mfa_required" => "true", + "source_code_uri" => spec.homepage, + } + + # Ship only what the installed gem needs: lib/, ext/, exe/, rakelib/, README + # and LICENSE. Exclude tests, the vendored Lasem source (resolved via system + # pkg-config at install time), dev/CI scaffolding, docs assets, and dotfiles. + spec.files = Dir.chdir(File.expand_path(__dir__)) do + `git ls-files -z`.split("\x0").reject do |f| + f.match(%r{\A(?:test|spec|features|vendor|bin|docs|\.github)/}) || + f.match(/\A\.(?:git|rspec|rubocop)/) || + f == "Gemfile" + end + end + + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } + spec.extensions = ["ext/lasem/extconf.rb"] + spec.require_paths = ["lib"] +end diff --git a/lib/lasem.rb b/lib/lasem.rb index 0059faa..7ebb2c8 100644 --- a/lib/lasem.rb +++ b/lib/lasem.rb @@ -1,6 +1,26 @@ # frozen_string_literal: true -require "lasem/version" - module Lasem + autoload :Error, "lasem/error" + autoload :VERSION, "lasem/version" + autoload :DependencyError, "lasem/error/dependency_error" + autoload :OptionError, "lasem/error/option_error" + autoload :RenderError, "lasem/error/render_error" + autoload :Renderer, "lasem/renderer" + autoload :NativeLoader, "lasem/native_loader" + autoload :RenderOptions, "lasem/render_options" + autoload :DependencyDoctor, "lasem/dependency_doctor" + + def self.native_available? + NativeLoader.available? + end + + def self.render(source, input: :xml, output: :svg, **) + Renderer.render( + source, + input: input, + output: output, + **, + ) + end end diff --git a/lib/lasem/dependency_doctor.rb b/lib/lasem/dependency_doctor.rb new file mode 100644 index 0000000..9c71cf1 --- /dev/null +++ b/lib/lasem/dependency_doctor.rb @@ -0,0 +1,244 @@ +# frozen_string_literal: true + +require "rbconfig" +require "rubygems" +require_relative "pkg_config" + +module Lasem + class DependencyDoctor + autoload :CLI, "lasem/dependency_doctor/cli" + autoload :Probe, "lasem/dependency_doctor/probe" + autoload :Report, "lasem/dependency_doctor/report" + + ROOT = File.expand_path("../..", __dir__) + + ExecutableDependency = Struct.new(:name, :executables, keyword_init: true) + PkgConfigDependency = Struct.new( + :name, + :requirement, + :candidates, + keyword_init: true, + ) + OutdatedPackage = Struct.new(:dependency, :version, keyword_init: true) + + # Tools required to compile the gem's native extension against any Lasem + # (system or vendored). Missing any of these fails the doctor. + CORE_EXECUTABLE_DEPENDENCIES = [ + ExecutableDependency.new( + name: "C compiler (cc, gcc, or clang)", + executables: %w[cc gcc clang], + ), + ExecutableDependency.new(name: "make", executables: %w[make]), + ExecutableDependency.new(name: "pkg-config", executables: %w[pkg-config]), + ].freeze + + # Tools needed ONLY to build the vendored Lasem from source + # (`rake lasem:build`). These are not required for the released-gem path + # that links against a system Lasem, so they only fail the doctor when a + # vendored source checkout is present. + VENDORED_BUILD_EXECUTABLE_DEPENDENCIES = [ + ExecutableDependency.new(name: "meson", executables: %w[meson]), + ExecutableDependency.new( + name: "ninja or ninja-build", + executables: %w[ninja ninja-build], + ), + ExecutableDependency.new(name: "bison", executables: %w[bison]), + ExecutableDependency.new(name: "flex", executables: %w[flex]), + ExecutableDependency.new( + name: "msgfmt (gettext)", + executables: %w[msgfmt], + ), + ].freeze + + PKG_CONFIG_DEPENDENCIES = [ + PkgConfigDependency.new(name: "glib-2.0", requirement: ">= 2.36"), + PkgConfigDependency.new(name: "gobject-2.0"), + PkgConfigDependency.new(name: "gio-2.0"), + PkgConfigDependency.new(name: "gdk-pixbuf-2.0"), + PkgConfigDependency.new(name: "cairo", requirement: ">= 1.2"), + PkgConfigDependency.new(name: "pangocairo", requirement: ">= 1.16.0"), + PkgConfigDependency.new(name: "libxml-2.0"), + ].freeze + + def initialize(root: ROOT, probe: Probe.new) + @root = root + @probe = probe + end + + def report(lasem_conflict_warnings: false, dep_conflict_warnings: false) + Report.new( + missing_executables: missing_executables, + missing_build_executables: missing_build_executables, + building_from_source: building_from_source?, + missing_pkg_config: missing_pkg_config, + outdated_pkg_config: outdated_pkg_config, + unverifiable_pkg_config: unverifiable_pkg_config, + lasem_warnings: lasem_warnings(lasem_conflict_warnings), + dependency_warnings: dependency_warnings(dep_conflict_warnings), + ) + end + + private + + attr_reader :root, :probe + + def missing_executables + reject_present(CORE_EXECUTABLE_DEPENDENCIES) + end + + def missing_build_executables + reject_present(VENDORED_BUILD_EXECUTABLE_DEPENDENCIES) + end + + def reject_present(dependencies) + dependencies.reject do |dependency| + dependency.executables.any? do |executable| + probe.executable?(executable) + end + end + end + + # True only when a vendored Lasem source checkout is present, i.e. the user + # is set up to build Lasem from source and therefore needs the build tools. + def building_from_source? + probe.file?(File.join(root, "vendor/lasem/source/meson.build")) + end + + def pkg_config_versions + @pkg_config_versions ||= pkg_config_dependencies.to_h do |dependency| + version = pkg_config_candidates_for(dependency).filter_map do |package| + probe.pkg_config_version(package) + end.first + + [dependency, version] + end + end + + def pkg_config_dependencies + [lasem_pkg_config_dependency, *PKG_CONFIG_DEPENDENCIES] + end + + def lasem_pkg_config_dependency + PkgConfigDependency.new( + name: lasem_pkg_config_candidates.join(" or "), + candidates: lasem_pkg_config_candidates, + ) + end + + def pkg_config_candidates_for(dependency) + dependency.candidates || [dependency.name] + end + + def missing_pkg_config + pkg_config_versions.filter_map do |dependency, version| + dependency if version.nil? + end + end + + def outdated_pkg_config + pkg_config_versions.filter_map do |dependency, version| + next if version.nil? || dependency.requirement.nil? + # Skip versions Gem::Version cannot parse here (handled by + # unverifiable_pkg_config) so the comparison below never raises. + next unless Gem::Version.correct?(version) + next if Gem::Requirement.new(dependency.requirement).satisfied_by?( + Gem::Version.new(version), + ) + + OutdatedPackage.new(dependency: dependency, version: version) + end + end + + # Required packages whose reported version cannot be parsed, so we cannot + # confirm the requirement. Reported and failed-closed rather than crashing + # (old behavior) or silently passing. + def unverifiable_pkg_config + pkg_config_versions.filter_map do |dependency, version| + next if version.nil? || dependency.requirement.nil? + next if Gem::Version.correct?(version) + + OutdatedPackage.new(dependency: dependency, version: version) + end + end + + def lasem_warnings(enabled) + return [] unless enabled + + [ + missing_submodule_warning, + stale_extension_warning, + pkg_config_precedence_warning, + ].compact + end + + # Only relevant in a source checkout (where .gitmodules is present and the + # vendored build applies). The published gem ships no .gitmodules, so this + # never advises installed-gem users to run a meaningless submodule command. + def missing_submodule_warning + return unless probe.file?(File.join(root, ".gitmodules")) + + source_meson = File.join(root, "vendor/lasem/source/meson.build") + return if probe.file?(source_meson) + + "Lasem submodule source was not found; run " \ + "`git submodule update --init vendor/lasem/source`." + end + + def stale_extension_warning + extension = File.join( + root, + "lib/lasem/lasem.#{RbConfig::CONFIG.fetch('DLEXT')}", + ) + return unless probe.file?(vendored_pc) && !probe.file?(extension) + + "Vendored Lasem is installed, but the native extension is missing; run " \ + "`bundle exec rake clean compile`." + end + + def pkg_config_precedence_warning + return unless probe.file?(vendored_pc) + + resolved_package, resolved_pc_dir = resolved_lasem_pkg_config + return if resolved_pc_dir.nil? + return if File.expand_path(resolved_pc_dir) == vendored_pc_dir + + "`pkg-config #{resolved_package}` resolves to #{resolved_pc_dir}, " \ + "while vendored Lasem is installed at #{vendored_pc_dir}." + end + + def resolved_lasem_pkg_config + lasem_pkg_config_candidates.filter_map do |package| + pc_dir = probe.pkg_config_variable(package, "pcfiledir") + [package, pc_dir] unless pc_dir.nil? + end.first + end + + def lasem_pkg_config_candidates + Lasem::PkgConfig.candidates + end + + def dependency_warnings(enabled) + return [] unless enabled + + warnings = [] + warnings << ruby_headers_warning + warnings.compact + end + + def vendored_pc_dir + File.join(root, "vendor/lasem/install/lib/pkgconfig") + end + + def vendored_pc + File.join(vendored_pc_dir, "lasem-0.6.pc") + end + + def ruby_headers_warning + ruby_header = File.join(RbConfig::CONFIG.fetch("rubyhdrdir"), "ruby.h") + return if probe.file?(ruby_header) + + "Ruby headers were not found at #{ruby_header}; install the Ruby " \ + "development package for this Ruby version." + end + end +end diff --git a/lib/lasem/dependency_doctor/cli.rb b/lib/lasem/dependency_doctor/cli.rb new file mode 100644 index 0000000..1ae773e --- /dev/null +++ b/lib/lasem/dependency_doctor/cli.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "optparse" + +module Lasem + class DependencyDoctor + class CLI + def self.call( + argv, + output: $stdout, + error: $stderr, + root: ROOT, + probe: Probe.new + ) + new(argv, output: output, error: error, root: root, probe: probe).call + end + + def initialize(argv, output:, error:, root:, probe:) + @argv = argv.dup + @output = output + @error = error + @root = root + @probe = probe + @options = { + lasem_conflict_warnings: false, + dep_conflict_warnings: false, + } + end + + def call + parser.parse!(argv) + run_doctor + rescue OptionParser::ParseError => e + error.puts(e.message) + error.puts(parser) + 2 + end + + private + + attr_reader :argv, :output, :error, :root, :probe, :options + + def run_doctor + doctor = DependencyDoctor.new(root: root, probe: probe) + report = doctor.report(**options) + output.puts(report) + report.success? ? 0 : 1 + end + + def parser + @parser ||= OptionParser.new do |opts| + opts.banner = "Usage: lasem-doctor [options]" + add_warning_options(opts) + end + end + + def add_warning_options(opts) + opts.on("--lasem-conflict-warnings", "Show Lasem setup warnings") do + options[:lasem_conflict_warnings] = true + end + opts.on("--dep-conflict-warnings", "Show dependency warnings") do + options[:dep_conflict_warnings] = true + end + opts.on("--all-warnings", "Show all warnings") do + options[:lasem_conflict_warnings] = true + options[:dep_conflict_warnings] = true + end + end + end + end +end diff --git a/lib/lasem/dependency_doctor/probe.rb b/lib/lasem/dependency_doctor/probe.rb new file mode 100644 index 0000000..1cbc016 --- /dev/null +++ b/lib/lasem/dependency_doctor/probe.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "open3" + +module Lasem + class DependencyDoctor + class Probe + def executable?(name) + executable_candidates(name).any? do |candidate| + ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |path| + executable = File.join(path, candidate) + File.executable?(executable) && !File.directory?(executable) + end + end + end + + def file?(path) + File.file?(path) + end + + def pkg_config_version(package) + return unless executable?("pkg-config") + + output, status = capture("pkg-config", "--modversion", package) + status.success? ? output.strip : nil + end + + def pkg_config_variable(package, variable) + return unless executable?("pkg-config") + + output, status = capture( + "pkg-config", + "--variable=#{variable}", + package, + ) + status.success? && !output.strip.empty? ? output.strip : nil + end + + private + + # On Windows an executable is found as e.g. `ruby.exe`, not `ruby`, so try + # the bare name plus each PATHEXT extension. Elsewhere the bare name is + # used as-is. + def executable_candidates(name) + return [name] unless Gem.win_platform? + return [name] if name.match?(/\.\w+\z/) + + pathext = ENV.fetch("PATHEXT", ".COM;.EXE;.BAT;.CMD") + extensions = pathext.split(";").map(&:downcase) + [name, *extensions.map { |extension| "#{name}#{extension}" }] + end + + def capture(*command) + stdout, _stderr, status = Open3.capture3(*command) + [stdout, status] + end + end + end +end diff --git a/lib/lasem/dependency_doctor/report.rb b/lib/lasem/dependency_doctor/report.rb new file mode 100644 index 0000000..b89c1b4 --- /dev/null +++ b/lib/lasem/dependency_doctor/report.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +module Lasem + class DependencyDoctor + class Report + def initialize(attributes) + @missing_executables = attributes.fetch(:missing_executables) + @missing_build_executables = + attributes.fetch(:missing_build_executables) + @building_from_source = attributes.fetch(:building_from_source) + @missing_pkg_config = attributes.fetch(:missing_pkg_config) + @outdated_pkg_config = attributes.fetch(:outdated_pkg_config) + @unverifiable_pkg_config = attributes.fetch(:unverifiable_pkg_config) + @lasem_warnings = attributes.fetch(:lasem_warnings) + @dependency_warnings = attributes.fetch(:dependency_warnings) + end + + def success? + missing_executables.empty? && + missing_pkg_config.empty? && + outdated_pkg_config.empty? && + unverifiable_pkg_config.empty? && + (!building_from_source || missing_build_executables.empty?) + end + + def to_s + lines = ["Lasem dependency doctor"] + append_status(lines) + append_warnings(lines, "Lasem setup warnings", lasem_warnings) + append_warnings(lines, "Dependency warnings", dependency_warnings) + lines.join("\n") + end + + private + + attr_reader :missing_executables, :missing_build_executables, + :building_from_source, :missing_pkg_config, + :outdated_pkg_config, :unverifiable_pkg_config, + :lasem_warnings, :dependency_warnings + + def append_status(lines) + lines << "" + append_dependency_status(lines) + lines << "Required dependencies look available." if success? + end + + def append_dependency_status(lines) + append_list( + lines, + "Missing executables", + missing_executables.map(&:name), + ) + append_list(lines, "Missing pkg-config packages", pkg_config_names) + append_outdated_pkg_config(lines) + append_unverifiable_pkg_config(lines) + append_build_executables(lines) + end + + def append_unverifiable_pkg_config(lines) + values = unverifiable_pkg_config.map do |package| + "#{package.dependency.name} #{package.dependency.requirement} " \ + "(found #{package.version}; version not recognized, cannot verify)" + end + append_list(lines, "Unverifiable pkg-config versions", values) + end + + def append_build_executables(lines) + heading = + if building_from_source + "Missing vendored-build tools" + else + "Missing vendored-build tools " \ + "(only needed to build Lasem from source)" + end + append_list(lines, heading, missing_build_executables.map(&:name)) + end + + def append_outdated_pkg_config(lines) + append_list( + lines, + "Outdated pkg-config packages", + outdated_pkg_config_names, + ) + end + + def append_warnings(lines, heading, warnings) + return if warnings.empty? + + lines << "" + append_list(lines, heading, warnings) + end + + def append_list(lines, heading, values) + return if values.empty? + + lines << "#{heading}:" + values.each { |value| lines << " - #{value}" } + end + + def pkg_config_names + missing_pkg_config.map do |dependency| + next dependency.name unless dependency.requirement + + "#{dependency.name} #{dependency.requirement}" + end + end + + def outdated_pkg_config_names + outdated_pkg_config.map do |package| + "#{package.dependency.name} #{package.dependency.requirement} " \ + "(found #{package.version})" + end + end + end + end +end diff --git a/lib/lasem/error.rb b/lib/lasem/error.rb new file mode 100644 index 0000000..d5feb80 --- /dev/null +++ b/lib/lasem/error.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Lasem + # Mixed into every error this gem raises so callers can rescue all + # Lasem-originated failures with a single `rescue Lasem::Error`, regardless of + # each error's concrete superclass. In particular OptionError remains an + # ArgumentError (so `rescue ArgumentError` keeps working) while still being a + # Lasem::Error. + module Error + end +end diff --git a/lib/lasem/error/dependency_error.rb b/lib/lasem/error/dependency_error.rb new file mode 100644 index 0000000..785fb06 --- /dev/null +++ b/lib/lasem/error/dependency_error.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require_relative "../error" + +module Lasem + class DependencyError < StandardError + include Error + + MESSAGE = "Lasem native library is not available. Install a system " \ + "Lasem development package, then rebuild the gem. Run " \ + "`lasem-doctor --all-warnings` or `bundle exec rake " \ + "lasem:doctor WARNINGS=all` for setup diagnostics." + + def self.native_library_unavailable(original_error: nil) + message = MESSAGE + if original_error + message = "#{message} Original load error: #{original_error.message}" + end + + new(message) + end + + def self.unavailable(original_error: nil) + native_library_unavailable(original_error: original_error) + end + end +end diff --git a/lib/lasem/error/option_error.rb b/lib/lasem/error/option_error.rb new file mode 100644 index 0000000..a5981fb --- /dev/null +++ b/lib/lasem/error/option_error.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require_relative "../error" + +module Lasem + class OptionError < ArgumentError + include Error + + def self.unknown_options(names:) + new("unknown option(s): #{names.join(', ')}") + end + + def self.non_empty_source + new("source must be a non-empty string") + end + + def self.invalid_choice(name:, allowed_values:) + new("#{name} must be one of: #{allowed_values.join(', ')}") + end + + def self.not_numeric(name:) + new("#{name} must be numeric") + end + + def self.not_finite(name:) + new("#{name} must be finite") + end + + def self.not_positive(name:) + new("#{name} must be greater than 0") + end + + def self.incomplete_size_pair + new("width and height must be provided together") + end + end +end diff --git a/lib/lasem/error/render_error.rb b/lib/lasem/error/render_error.rb new file mode 100644 index 0000000..aceb943 --- /dev/null +++ b/lib/lasem/error/render_error.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require_relative "../error" + +module Lasem + class RenderError < StandardError + include Error + end +end diff --git a/lib/lasem/native_loader.rb b/lib/lasem/native_loader.rb new file mode 100644 index 0000000..1a7ddbf --- /dev/null +++ b/lib/lasem/native_loader.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "rbconfig" +require "rubygems" + +module Lasem + module NativeLoader + EXTENSION_REQUIRE_PATH = "lasem/lasem" + + module_function + + def available? + load + !!(defined?(Native) && Native.native_available?) + rescue DependencyError + false + end + + def render(*) + load! + unless Native.native_available? + raise DependencyError.native_library_unavailable + end + + Native.render(*) + end + + def load + load! + rescue DependencyError + false + end + + def load! + return true if defined?(Native) + + raise DependencyError.native_library_unavailable unless extension_path + + require EXTENSION_REQUIRE_PATH + true + rescue LoadError => e + raise DependencyError.native_library_unavailable(original_error: e) + end + + def extension_path + gem_extension_path || load_path_extension_path + end + + def gem_extension_path + Gem.find_files(File.join("lasem", extension_filename)).find do |path| + File.file?(path) + end + end + + def load_path_extension_path + $LOAD_PATH + .map { |path| File.join(path, "lasem", extension_filename) } + .find { |path| File.file?(path) } + end + + def extension_filename + @extension_filename ||= "lasem.#{RbConfig::CONFIG.fetch('DLEXT')}" + end + end +end diff --git a/lib/lasem/pkg_config.rb b/lib/lasem/pkg_config.rb new file mode 100644 index 0000000..57b2fe9 --- /dev/null +++ b/lib/lasem/pkg_config.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Lasem + # Single source of truth for how Lasem is located via pkg-config. Required by + # both the native build (ext/lasem/extconf.rb) and the dependency doctor so + # the two never drift on the candidate package names or the override env var. + module PkgConfig + # Candidate pkg-config package names, in preference order. + CANDIDATES = %w[lasem-0.6 lasem lasem-0.4].freeze + + # Pkg-config names to probe, honoring the LASEM_PKG_CONFIG override. + def self.candidates(env: ENV) + override = env["LASEM_PKG_CONFIG"] + return [override] if override && !override.empty? + + CANDIDATES + end + end +end diff --git a/lib/lasem/render_options.rb b/lib/lasem/render_options.rb new file mode 100644 index 0000000..73dd5fc --- /dev/null +++ b/lib/lasem/render_options.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +module Lasem + class RenderOptions + INPUT_TYPES = %w[xml mathml svg latex itex].freeze + OUTPUT_FORMATS = %w[svg png pdf ps].freeze + DEFAULT_OPTIONS = { + input: :xml, + output: :svg, + ppi: 72.0, + zoom: 1.0, + width: nil, + height: nil, + offset_x: 0.0, + offset_y: 0.0, + }.freeze + + attr_reader :input_type, :output_format, :ppi, :zoom, :width, :height, + :offset_x, :offset_y + + def initialize(options = {}) + validate_option_names(options) + options = DEFAULT_OPTIONS.merge(options) + + normalize_format_options(options) + normalize_dimension_options(options) + normalize_position_options(options) + validate_size_pair + end + + def native_arguments(input) + [ + input, + input_type, + output_format, + ppi, + zoom, + width, + height, + offset_x, + offset_y, + ] + end + + private + + def validate_option_names(options) + unknown_names = options.keys - DEFAULT_OPTIONS.keys + return if unknown_names.empty? + + raise OptionError.unknown_options(names: unknown_names) + end + + def normalize_format_options(options) + @input_type = choice( + options.fetch(:input), + INPUT_TYPES, + "input", + ) + @output_format = choice( + options.fetch(:output), + OUTPUT_FORMATS, + "output", + ) + end + + def normalize_dimension_options(options) + @ppi = positive_float(options.fetch(:ppi), "ppi") + @zoom = positive_float(options.fetch(:zoom), "zoom") + @width = optional_positive_float(options.fetch(:width), "width") + @height = optional_positive_float(options.fetch(:height), "height") + end + + def normalize_position_options(options) + @offset_x = finite_numeric(options.fetch(:offset_x), "offset_x") + @offset_y = finite_numeric(options.fetch(:offset_y), "offset_y") + end + + def choice(value, allowed_values, name) + normalized = value.to_s + return normalized if allowed_values.include?(normalized) + + raise OptionError.invalid_choice( + name: name, + allowed_values: allowed_values, + ) + end + + def numeric(value, name) + Float(value) + rescue ArgumentError, TypeError + raise OptionError.not_numeric(name: name) + end + + def finite_numeric(value, name) + number = numeric(value, name) + return number if number.finite? + + raise OptionError.not_finite(name: name) + end + + def positive_float(value, name) + number = finite_numeric(value, name) + return number if number.positive? + + raise OptionError.not_positive(name: name) + end + + def optional_positive_float(value, name) + return nil if value.nil? + + positive_float(value, name) + end + + def validate_size_pair + return if width.nil? && height.nil? + return unless width.nil? || height.nil? + + raise OptionError.incomplete_size_pair + end + end +end diff --git a/lib/lasem/renderer.rb b/lib/lasem/renderer.rb new file mode 100644 index 0000000..a1db848 --- /dev/null +++ b/lib/lasem/renderer.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Lasem + class Renderer + def self.render(source, **options) + new(source, options).render + end + + def initialize(source, options = {}) + @source = normalize_source(source) + @options = RenderOptions.new(options) + end + + def render + NativeLoader.render(*@options.native_arguments(@source)) + end + + private + + # String.try_convert returns nil for non-String-like input (nil/Symbol/ + # Integer), which we reject. A misbehaving #to_str (raising, or returning a + # non-String) propagates to the caller -- a programmer error on their + # object, not a Lasem input error, so we let it surface unmasked. + # + # Encoding is the caller's responsibility: XML/MathML/SVG declare their own + # encoding and Lasem (libxml2) reads it, so we pass bytes through untouched + # and let Lasem report bad input as a RenderError. The blank check is + # byte-level: `.b.strip` trims ASCII whitespace and NUL without decoding + # (so it never raises on non-UTF-8 bytes), so e.g. a BOM-only UTF-16 string + # is still non-empty afterwards, passes through, and surfaces as a + # RenderError. + def normalize_source(source) + normalized = String.try_convert(source) + if normalized.nil? || normalized.b.strip.empty? + raise OptionError.non_empty_source + end + + normalized + end + end +end diff --git a/rakelib/lasem.rake b/rakelib/lasem.rake new file mode 100644 index 0000000..674d48b --- /dev/null +++ b/rakelib/lasem.rake @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require "fileutils" +require "lasem" + +LASEM_RAKE_ROOT = File.expand_path("..", __dir__) +LASEM_MESON_OPTIONS = %w[ + --buildtype=release + -Ddocumentation=disabled + -Dintrospection=disabled + -Dviewer=disabled +].freeze +LASEM_COMPILER_EXECUTABLES = %w[ + cc + gcc + clang +].freeze +LASEM_BUILD_EXECUTABLES = %w[ + meson + pkg-config + bison + flex + msgfmt +].freeze + +def lasem_rake_path(env_name, default) + File.expand_path(ENV.fetch(env_name, default), LASEM_RAKE_ROOT) +end + +def lasem_executable?(name) + ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |path| + executable = File.join(path, name) + File.executable?(executable) && !File.directory?(executable) + end +end + +def lasem_ninja? + lasem_executable?("ninja") || lasem_executable?("ninja-build") +end + +def lasem_missing_executables + missing = LASEM_BUILD_EXECUTABLES.reject do |executable| + lasem_executable?(executable) + end + has_compiler = LASEM_COMPILER_EXECUTABLES.any? do |executable| + lasem_executable?(executable) + end + missing << "C compiler (cc, gcc, or clang)" unless has_compiler + missing << "ninja or ninja-build" unless lasem_ninja? + missing +end + +def lasem_require_build_tools! + missing = lasem_missing_executables + return if missing.empty? + + abort("Missing Lasem build tools: #{missing.join(', ')}") +end + +def lasem_doctor_args + case ENV.fetch("WARNINGS", nil) + when "all" + ["--all-warnings"] + when "lasem" + ["--lasem-conflict-warnings"] + when "deps", "dependencies" + ["--dep-conflict-warnings"] + else + [] + end +end + +def lasem_setup_command(build_dir) + command = ["meson", "setup"] + command << "--reconfigure" if File.exist?(File.join(build_dir, "build.ninja")) + command +end + +# rubocop:disable Metrics/BlockLength +namespace :lasem do + source_dir = lasem_rake_path("LASEM_SOURCE_DIR", "vendor/lasem/source") + build_dir = lasem_rake_path("LASEM_BUILD_DIR", "vendor/lasem/build") + install_dir = lasem_rake_path("LASEM_INSTALL_DIR", "vendor/lasem/install") + + desc "Configure vendored Lasem with Meson" + task :configure do + lasem_require_build_tools! + + meson_file = File.join(source_dir, "meson.build") + unless File.exist?(meson_file) + abort( + "Lasem source not found at #{source_dir}. Put upstream Lasem there.", + ) + end + + FileUtils.mkdir_p(build_dir) + sh( + *lasem_setup_command(build_dir), + build_dir, + source_dir, + "--prefix=#{install_dir}", + "--libdir=lib", + *LASEM_MESON_OPTIONS, + ) + end + + desc "Compile vendored Lasem" + task compile: :configure do + sh("meson", "compile", "-C", build_dir) + end + + desc "Install vendored Lasem into vendor/lasem/install" + task install: :compile do + sh("meson", "install", "-C", build_dir) + end + + desc "Build and install vendored Lasem" + task build: :install + + desc "Check vendored Lasem build tool availability" + task :doctor do + status = Lasem::DependencyDoctor::CLI.call(lasem_doctor_args) + next if status.zero? + + abort("Lasem dependency doctor found missing required dependencies.") + end +end +# rubocop:enable Metrics/BlockLength diff --git a/spec/lasem/dependency_doctor_spec.rb b/spec/lasem/dependency_doctor_spec.rb new file mode 100644 index 0000000..0905bab --- /dev/null +++ b/spec/lasem/dependency_doctor_spec.rb @@ -0,0 +1,346 @@ +# frozen_string_literal: true + +# rubocop:disable RSpec/ExampleLength, RSpec/MultipleExpectations + +require "stringio" +require "tmpdir" + +RSpec.describe Lasem::DependencyDoctor do + let(:fake_probe_class) do + Struct.new( + :executables, + :pkg_config_versions, + :pkg_config_variables, + :files, + :os_release, + :platform, + keyword_init: true, + ) do + def executable?(name) + executables.include?(name) + end + + def file?(path) + files.include?(path) + end + + def pkg_config_version(package) + pkg_config_versions[package] + end + + def pkg_config_variable(package, variable) + pkg_config_variables[[package, variable]] + end + end + end + + let(:root) { "/repo" } + let(:required_executables) do + %w[cc make pkg-config meson ninja bison flex msgfmt] + end + let(:apt_executables) do + [*required_executables, "apt-get"] + end + let(:all_pkg_config_versions) do + { + "glib-2.0" => "2.80.0", + "gobject-2.0" => "2.80.0", + "gio-2.0" => "2.80.0", + "gdk-pixbuf-2.0" => "2.42.0", + "cairo" => "1.18.0", + "pangocairo" => "1.54.0", + "libxml-2.0" => "2.12.0", + "lasem-0.6" => "0.6.0", + } + end + + def probe(**overrides) + fake_probe_class.new( + { + executables: [], + pkg_config_versions: {}, + pkg_config_variables: {}, + files: [], + }.merge(overrides), + ) + end + + def with_env(values) + original = values.keys.to_h { |key| [key, ENV.fetch(key, nil)] } + values.each { |key, value| ENV[key] = value } + yield + ensure + original.each do |key, value| + value.nil? ? ENV.delete(key) : ENV[key] = value + end + end + + def with_lasem_pkg_config(value) + original = ENV.fetch("LASEM_PKG_CONFIG", nil) + if value.nil? + ENV.delete("LASEM_PKG_CONFIG") + else + ENV["LASEM_PKG_CONFIG"] = value + end + yield + ensure + if original.nil? + ENV.delete("LASEM_PKG_CONFIG") + else + ENV["LASEM_PKG_CONFIG"] = original + end + end + + describe "#report" do + it "reports missing dependencies" do + report = described_class.new( + root: root, + probe: probe(executables: %w[pkg-config]), + ).report + + expect(report).not_to be_success + expect(report.to_s).to include("Missing executables:") + expect(report.to_s).to include("Missing pkg-config packages:") + end + + it "passes when required dependencies are available" do + report = described_class.new( + root: root, + probe: probe(executables: apt_executables, + pkg_config_versions: all_pkg_config_versions), + ).report + + expect(report).to be_success + expect(report.to_s).to include("Required dependencies look available.") + end + + it "passes for a system install without the vendored-build toolchain" do + report = described_class.new( + root: root, + probe: probe(executables: %w[cc make pkg-config], + pkg_config_versions: all_pkg_config_versions), + ).report + + expect(report).to be_success + expect(report.to_s).to include("only needed to build Lasem from source") + end + + it "requires the vendored-build toolchain when building from source" do + report = described_class.new( + root: root, + probe: probe( + executables: %w[cc make pkg-config], + pkg_config_versions: all_pkg_config_versions, + files: ["/repo/vendor/lasem/source/meson.build"], + ), + ).report + + expect(report).not_to be_success + expect(report.to_s).to include("Missing vendored-build tools") + end + + it "fails closed (without crashing) on an unverifiable required version" do + versions = all_pkg_config_versions.merge("cairo" => "1.18.0_p1") + report = described_class.new( + root: root, + probe: probe(executables: apt_executables, + pkg_config_versions: versions), + ).report + + expect { report.to_s }.not_to raise_error + expect(report).not_to be_success + expect(report.to_s).to include("Unverifiable pkg-config versions") + end + + it "requires a Lasem pkg-config package" do + versions = all_pkg_config_versions.reject do |package, _version| + package.start_with?("lasem") + end + report = described_class.new( + root: root, + probe: probe(executables: apt_executables, + pkg_config_versions: versions), + ).report + + expect(report).not_to be_success + expect(report.to_s).to include("lasem-0.6 or lasem or lasem-0.4") + end + + it "can include Lasem-specific setup warnings" do + vendored_pc_dir = "/repo/vendor/lasem/install/lib/pkgconfig" + report = described_class.new( + root: root, + probe: probe( + executables: apt_executables, + pkg_config_versions: all_pkg_config_versions, + pkg_config_variables: { + ["lasem-0.6", "pcfiledir"] => "/usr/lib/pkgconfig", + }, + files: ["/repo/vendor/lasem/install/lib/pkgconfig/lasem-0.6.pc"], + ), + ).report(lasem_conflict_warnings: true) + + expect(report.to_s).to include("Lasem setup warnings:") + expect(report.to_s).to include("run `bundle exec rake clean compile`") + expect(report.to_s).to include(vendored_pc_dir) + end + + it "advises a submodule init only in a source checkout (.gitmodules)" do + report = described_class.new( + root: root, + probe: probe(executables: apt_executables, + pkg_config_versions: all_pkg_config_versions, + files: ["/repo/.gitmodules"]), + ).report(lasem_conflict_warnings: true) + + expect(report.to_s).to include("git submodule update --init") + end + + it "omits submodule advice for an installed gem (no .gitmodules)" do + report = described_class.new( + root: root, + probe: probe(executables: apt_executables, + pkg_config_versions: all_pkg_config_versions), + ).report(lasem_conflict_warnings: true) + + expect(report.to_s).not_to include("git submodule update") + end + + it "uses the first resolved Lasem pkg-config candidate in setup warnings" do + vendored_pc_dir = "/repo/vendor/lasem/install/lib/pkgconfig" + report = with_lasem_pkg_config(nil) do + described_class.new( + root: root, + probe: probe( + executables: apt_executables, + pkg_config_versions: all_pkg_config_versions, + pkg_config_variables: { + ["lasem", "pcfiledir"] => "/usr/lib/pkgconfig", + }, + files: ["/repo/vendor/lasem/install/lib/pkgconfig/lasem-0.6.pc"], + ), + ).report(lasem_conflict_warnings: true) + end + + expect(report.to_s).to include("`pkg-config lasem` resolves") + expect(report.to_s).to include(vendored_pc_dir) + end + + it "uses LASEM_PKG_CONFIG in setup warnings" do + report = with_lasem_pkg_config("lasem") do + described_class.new( + root: root, + probe: probe( + executables: apt_executables, + pkg_config_versions: all_pkg_config_versions, + pkg_config_variables: { + ["lasem", "pcfiledir"] => "/usr/lib/pkgconfig", + ["lasem-0.6", "pcfiledir"] => "/other/pkgconfig", + }, + files: ["/repo/vendor/lasem/install/lib/pkgconfig/lasem-0.6.pc"], + ), + ).report(lasem_conflict_warnings: true) + end + + expect(report.to_s).to include("`pkg-config lasem` resolves") + expect(report.to_s).not_to include("`pkg-config lasem-0.6` resolves") + end + + it "can include dependency warnings" do + report = described_class.new( + root: root, + probe: probe(executables: required_executables, + pkg_config_versions: all_pkg_config_versions), + ).report(dep_conflict_warnings: true) + + expect(report.to_s).to include("Dependency warnings:") + expect(report.to_s).to include("Ruby headers were not found") + end + end + + describe described_class::CLI do + it "returns a non-zero status when dependencies are missing" do + output = StringIO.new + status = described_class.call( + [], + output: output, + error: StringIO.new, + root: root, + probe: probe, + ) + + expect(status).to eq(1) + expect(output.string).to include("Missing executables:") + end + + it "supports all warning flags" do + output = StringIO.new + status = described_class.call( + ["--all-warnings"], + output: output, + error: StringIO.new, + root: root, + probe: probe(files: ["/repo/.gitmodules"]), + ) + + expect(status).to eq(1) + expect(output.string).to include("Lasem setup warnings:") + expect(output.string).to include("Dependency warnings:") + end + + it "reports an invalid option without raising" do + errio = StringIO.new + status = described_class.call( + ["--nope"], + output: StringIO.new, + error: errio, + root: root, + probe: probe, + ) + + expect(status).to eq(2) + expect(errio.string).to include("invalid option") + end + end + + describe described_class::Probe do + subject(:real_probe) { described_class.new } + + it "detects an executable present on PATH" do + expect(real_probe.executable?("ruby")).to be(true) + end + + it "reports an absent executable as missing" do + expect(real_probe.executable?("lasem-not-a-real-binary-xyz")) + .to be(false) + end + + it "finds a Windows executable by its PATHEXT extension" do + Dir.mktmpdir do |dir| + tool = File.join(dir, "tool.exe") + File.write(tool, "") + File.chmod(0o755, tool) + allow(Gem).to receive(:win_platform?).and_return(true) + + with_env("PATH" => dir, "PATHEXT" => ".EXE") do + expect(real_probe.executable?("tool")).to be(true) + end + end + end + + it "does not append executable extensions off Windows" do + Dir.mktmpdir do |dir| + tool = File.join(dir, "tool.exe") + File.write(tool, "") + File.chmod(0o755, tool) + allow(Gem).to receive(:win_platform?).and_return(false) + + with_env("PATH" => dir) do + expect(real_probe.executable?("tool")).to be(false) + end + end + end + end +end + +# rubocop:enable RSpec/ExampleLength, RSpec/MultipleExpectations diff --git a/spec/lasem/error_spec.rb b/spec/lasem/error_spec.rb new file mode 100644 index 0000000..1c98166 --- /dev/null +++ b/spec/lasem/error_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +RSpec.describe Lasem::Error do + it "is a marker module, not a class" do + expect(described_class).to be_a(Module) + expect(described_class).not_to be_a(Class) + end + + it "is rescuable as Lasem::Error for every gem error type" do + [ + Lasem::OptionError.non_empty_source, + Lasem::RenderError.new("boom"), + Lasem::DependencyError.native_library_unavailable, + ].each do |error| + expect(error).to be_a(Lasem::Error) + end + end + + it "keeps OptionError rescuable as an ArgumentError" do + expect(Lasem::OptionError.non_empty_source).to be_a(ArgumentError) + end + + it "catches every gem error with a single rescue Lasem::Error" do + [ + Lasem::OptionError.non_empty_source, + Lasem::RenderError.new("boom"), + Lasem::DependencyError.native_library_unavailable, + ].each do |error| + caught = + begin + raise error + rescue Lasem::Error => e + e + end + + expect(caught).to equal(error) + end + end +end diff --git a/spec/lasem/native_loader_spec.rb b/spec/lasem/native_loader_spec.rb new file mode 100644 index 0000000..0c8ca18 --- /dev/null +++ b/spec/lasem/native_loader_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "rbconfig" + +RSpec.describe Lasem::NativeLoader do + it "derives the extension filename from the platform DLEXT" do + expect(described_class.extension_filename) + .to eq("lasem.#{RbConfig::CONFIG.fetch('DLEXT')}") + end + + describe ".available?" do + it "returns a boolean" do + expect([true, false]).to include(described_class.available?) + end + end + + describe ".render" do + it "raises DependencyError when the native library reports unavailable" do + allow(described_class).to receive(:load!).and_return(true) + stub_const("Lasem::Native", Class.new do + def self.native_available? = false + end) + + expect { described_class.render("x") } + .to raise_error(Lasem::DependencyError) + end + end +end diff --git a/spec/lasem/option_error_spec.rb b/spec/lasem/option_error_spec.rb new file mode 100644 index 0000000..b412162 --- /dev/null +++ b/spec/lasem/option_error_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +RSpec.describe Lasem::OptionError do + describe ".invalid_choice" do + subject(:error) do + described_class.invalid_choice( + name: "output", + allowed_values: %w[svg png], + ) + end + + it "builds an invalid choice error" do + expect(error).to have_attributes( + class: described_class, + message: "output must be one of: svg, png", + ) + end + end + + describe ".non_empty_source" do + it "builds a source validation error" do + error = described_class.non_empty_source + + expect(error).to have_attributes( + class: described_class, + message: "source must be a non-empty string", + ) + end + end + + describe ".not_numeric" do + it "builds a numeric type error" do + error = described_class.not_numeric(name: "ppi") + + expect(error).to have_attributes( + class: described_class, + message: "ppi must be numeric", + ) + end + end + + describe ".not_finite" do + it "builds a finite number error" do + error = described_class.not_finite(name: "offset_x") + + expect(error).to have_attributes( + class: described_class, + message: "offset_x must be finite", + ) + end + end + + describe ".not_positive" do + it "builds a positive number error" do + error = described_class.not_positive(name: "zoom") + + expect(error).to have_attributes( + class: described_class, + message: "zoom must be greater than 0", + ) + end + end + + describe ".incomplete_size_pair" do + it "builds a width and height pairing error" do + error = described_class.incomplete_size_pair + + expect(error).to have_attributes( + class: described_class, + message: "width and height must be provided together", + ) + end + end + + describe ".unknown_options" do + it "builds an unknown options error" do + error = described_class.unknown_options(names: %i[format scale]) + + expect(error).to have_attributes( + class: described_class, + message: "unknown option(s): format, scale", + ) + end + end +end diff --git a/spec/lasem/renderer_spec.rb b/spec/lasem/renderer_spec.rb new file mode 100644 index 0000000..3c1fc52 --- /dev/null +++ b/spec/lasem/renderer_spec.rb @@ -0,0 +1,284 @@ +# frozen_string_literal: true + +RSpec.describe Lasem::Renderer do + let(:mathml) do + <<~MATHML + + + x + + + MATHML + end + + def render_sample_mathml + described_class.render(mathml, input: :mathml, output: :svg) + end + + def render_svg(**options) + described_class.render( + mathml, + input: :mathml, + output: :svg, + **options, + ) + end + + def render_output(format, **options) + described_class.render( + mathml, + input: :mathml, + output: format, + **options, + ) + end + + def png_dimensions(png) + [ + png.byteslice(16, 4).unpack1("N"), + png.byteslice(20, 4).unpack1("N"), + ] + end + + def first_use_coordinates(svg) + match = svg.match(/]*\sx="([^"]+)"[^>]*\sy="([^"]+)"/) + raise "No SVG use element found" unless match + + [Float(match[1]), Float(match[2])] + end + + def native_offset_deltas(**options) + base_x, base_y = first_use_coordinates(render_svg(zoom: 2.0)) + offset_x, offset_y = first_use_coordinates(render_svg(zoom: 2.0, **options)) + + [base_x - offset_x, base_y - offset_y] + end + + def skip_without_native_lasem + skip "Lasem native library is not available" unless Lasem.native_available? + end + + def stub_native_render + allow(Lasem::NativeLoader).to receive(:render).and_return("") + end + + def expect_native_rendered(input, input_type) + expect(Lasem::NativeLoader).to have_received(:render).with( + input, input_type, "svg", 72.0, 1.0, nil, nil, 0.0, 0.0 + ) + end + + describe ".render" do + it "validates the input type" do + expect do + described_class.render(mathml, input: :unknown) + end.to raise_error(Lasem::OptionError, /input/) + end + + it "validates the output format" do + expect do + described_class.render(mathml, output: :jpeg) + end.to raise_error(Lasem::OptionError, /output/) + end + + it "rejects unknown options" do + expect do + described_class.render(mathml, zooom: 2.0) + end.to raise_error(Lasem::OptionError, /unknown option.*zooom/) + end + + it "requires source to be a string" do + expect do + described_class.render(nil) + end.to raise_error(Lasem::OptionError, /source/) + end + + it "requires source to be non-empty" do + expect do + described_class.render(" ") + end.to raise_error(Lasem::OptionError, /source/) + end + + it "rejects empty and ASCII-whitespace source" do + ["", " ", "\t\n "].each do |blank| + expect { described_class.render(blank) } + .to raise_error(Lasem::OptionError, /source/) + end + end + + it "does not mask a malformed #to_str as an empty-source error" do + bad = Object.new + def bad.to_str = 123 + + expect { described_class.render(bad) }.to raise_error(TypeError) + end + + it "passes source through without transcoding (encoding is the caller's)" do + stub_native_render + source = mathml.encode(Encoding::UTF_16) + + described_class.render(source, input: :mathml) + + expect(Lasem::NativeLoader).to have_received(:render) do |passed, *| + expect(passed.encoding).to eq(Encoding::UTF_16) + end + end + + it "requires a positive ppi value" do + expect do + described_class.render(mathml, ppi: 0) + end.to raise_error(Lasem::OptionError, /ppi/) + end + + it "requires a positive zoom value" do + expect do + described_class.render(mathml, zoom: -1) + end.to raise_error(Lasem::OptionError, /zoom/) + end + + it "requires width and height to be provided together" do + expect do + described_class.render(mathml, width: 100) + end.to raise_error(Lasem::OptionError, /width and height/) + end + + it "requires finite offset values" do + expect do + described_class.render(mathml, offset_x: Float::INFINITY) + end.to raise_error(Lasem::OptionError, /offset_x/) + end + + it "passes LaTeX input unchanged" do + stub_native_render + + expect(described_class.render("\\sum_d^d", input: :latex)) + .to eq("") + expect_native_rendered("\\sum_d^d", "latex") + end + + it "passes itex input unchanged" do + stub_native_render + + expect(described_class.render("\\(\\sum_d^d\\)", input: :itex)) + .to eq("") + expect_native_rendered("\\(\\sum_d^d\\)", "itex") + end + + it "renders SVG output when the native layer is available" do + skip_without_native_lasem + + expect(render_sample_mathml).to include(" 0 + expect(height).to be > 0 + end + + it "renders PDF output when the native layer is available" do + skip_without_native_lasem + + expect(render_output(:pdf)).to start_with("%PDF") + end + + it "renders PostScript output when the native layer is available" do + skip_without_native_lasem + + expect(render_output(:ps)).to start_with("%!PS-Adobe") + end + + it "scales explicit export dimensions by zoom" do + skip_without_native_lasem + + expect(render_svg(width: 10, height: 20, zoom: 2.0)).to match( + /width="20(?:pt)?" height="40(?:pt)?" viewBox="0 0 20 40"/, + ) + end + + it "applies offsets with upstream zoom scaling" do + skip_without_native_lasem + + expect(native_offset_deltas(offset_x: 1.0, offset_y: 1.0)) + .to all(be_within(0.001).of(4.0)) + end + + it "renders correctly from multiple threads" do + skip_without_native_lasem + + threads = Array.new(4) { Thread.new { render_sample_mathml } } + + expect(threads.map(&:value)).to all(include(">><<<", input: :mathml) + end.to raise_error(Lasem::RenderError, /could not parse/) + end + + it "raises a render error for an oversized raster request" do + skip_without_native_lasem + + expect do + render_output(:png, width: 200_000, height: 200_000, ppi: 144.0) + end.to raise_error(Lasem::RenderError, /exceeds the maximum/) + end + + it "re-validates ppi at the native boundary" do + skip_without_native_lasem + + expect do + Lasem::Native.render( + mathml, "mathml", "svg", 0.0, 1.0, nil, nil, 0.0, 0.0 + ) + end.to raise_error(Lasem::RenderError, /ppi/) + end + + it "rejects an unsupported input type at the native boundary" do + skip_without_native_lasem + + expect do + Lasem::Native.render( + mathml, "bogus", "svg", 72.0, 1.0, nil, nil, 0.0, 0.0 + ) + end.to raise_error(Lasem::RenderError, /input type/) + end + + it "surfaces a dependency error from the native loader" do + # Deterministic (does not depend on native availability): verifies the + # contract a consumer like Plurimath rescues when only the stub is loaded. + allow(Lasem::NativeLoader).to receive(:render) + .and_raise(Lasem::DependencyError.native_library_unavailable) + + expect { render_sample_mathml } + .to raise_error(Lasem::DependencyError, /not available/) + end + + it "raises a dependency error when the native layer is unavailable" do + skip "Lasem native library is available" if Lasem.native_available? + + expect { render_sample_mathml }.to raise_error(Lasem::DependencyError) + end + end +end diff --git a/spec/lasem_spec.rb b/spec/lasem_spec.rb index 58975b7..af17c06 100644 --- a/spec/lasem_spec.rb +++ b/spec/lasem_spec.rb @@ -4,4 +4,25 @@ it "has a version number" do expect(described_class::VERSION).not_to be_nil end + + it "delegates native availability to NativeLoader" do + allow(Lasem::NativeLoader).to receive(:available?).and_return(:sentinel) + + expect(described_class.native_available?).to eq(:sentinel) + end + + it "delegates render to Renderer with the given input/output" do + allow(Lasem::Renderer).to receive(:render).and_return("") + + expect(described_class.render("x", input: :mathml, output: :png)) + .to eq("") + expect(Lasem::Renderer).to have_received(:render) + .with("x", input: :mathml, output: :png) + end + + it "does not expose per-input-type render shortcuts" do + shortcuts = %i[render_mathml render_svg render_latex render_itex] + + expect(described_class.public_methods & shortcuts).to be_empty + end end diff --git a/vendor/lasem/source b/vendor/lasem/source new file mode 160000 index 0000000..9fa47d9 --- /dev/null +++ b/vendor/lasem/source @@ -0,0 +1 @@ +Subproject commit 9fa47d952c0f68a0d89c9f743d99cf044c51fe92