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/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..837a707 100644 --- a/README.adoc +++ b/README.adoc @@ -1,20 +1,356 @@ -= 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 + +`lasem` is a native extension. Ruby 3.2 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`. `: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`. + +=== `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, missing pkg-config packages, and submodule +state. Output ends with `Required dependencies look available.` once the +toolchain is complete. + +If `Lasem.render` raises `Lasem::DependencyError`, the gem is loading the stub +extension. Rebuild after installing Lasem (see <>). + + +== 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.2 or newer is required. + [source,sh] ---- bundle install -bundle exec rake +bundle exec rake # runs RSpec bundle exec rubocop ---- + +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..ae80f0c --- /dev/null +++ b/docs/images/render_samples.rb @@ -0,0 +1,84 @@ +#!/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_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| + natural_png = Lasem.render( + sample[:source], + input: sample[:input], + output: :png, + ppi: sample[:ppi], + ) + natural_width, natural_height = png_size(natural_png) + + png = Lasem.render( + sample[:source], + input: sample[:input], + output: :png, + ppi: sample[:ppi], + width: natural_width + (2 * PADDING_X), + height: natural_height + (2 * PADDING_Y), + 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..9ad3c96 --- /dev/null +++ b/ext/lasem/extconf.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "mkmf" +require "shellwords" + +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")) + +pkg_config_candidates = + if ENV["LASEM_PKG_CONFIG"] && !ENV["LASEM_PKG_CONFIG"].empty? + [ENV["LASEM_PKG_CONFIG"]] + else + %w[lasem-0.6 lasem lasem-0.4] + end + +lasem_package = find_lasem_package(pkg_config_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")) + + $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..ee08b3a --- /dev/null +++ b/ext/lasem/lasem_ext.c @@ -0,0 +1,305 @@ +#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 + +static VALUE m_lasem; +static VALUE m_native; +static VALUE e_render_error; + +static VALUE +lasem_get_or_define_class(VALUE parent, const char *name, VALUE superclass) +{ + ID id = rb_intern(name); + + if (rb_const_defined_at(parent, id)) { + return rb_const_get(parent, id); + } + + return rb_define_class_under(parent, name, superclass); +} + +static void +lasem_raise_gerror(VALUE error_class, GError *error, const char *fallback_message) +{ + if (error != NULL) { + VALUE message = rb_str_new_cstr(error->message); + g_error_free(error); + rb_exc_raise(rb_exc_new_str(error_class, message)); + } + + rb_raise(error_class, "%s", fallback_message); +} + +static cairo_status_t +lasem_write_to_ruby_string(void *closure, const unsigned char *data, unsigned int length) +{ + VALUE *output = (VALUE *) closure; + + rb_str_cat(*output, (const char *) data, length); + return CAIRO_STATUS_SUCCESS; +} + +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; +} + +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; +} + +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, VALUE *output, 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 a Ruby string callback. */ + return cairo_svg_surface_create_for_stream(lasem_write_to_ruby_string, output, width_pt, height_pt); + } + + if (strcmp(format, "pdf") == 0) { + /* Cairo: vector PDF output is streamed into a Ruby string callback. */ + return cairo_pdf_surface_create_for_stream(lasem_write_to_ruby_string, output, width_pt, height_pt); + } + + if (strcmp(format, "ps") == 0) { + /* Cairo: vector PostScript output is streamed into a Ruby string callback. */ + return cairo_ps_surface_create_for_stream(lasem_write_to_ruby_string, output, 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; + 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; + if (!lasem_supported_output_format(format)) { + rb_raise(e_render_error, "unsupported output format: %s", format); + } + 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"); + } + } + + 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); + } + } + } + + output = rb_str_new(NULL, 0); + rb_enc_associate_index(output, rb_ascii8bit_encindex()); + + surface = lasem_create_surface(format, &output, width_pt, height_pt, width_px, height_px); + if (surface == NULL) { + g_object_unref(view); + g_object_unref(document); + rb_raise(e_render_error, "unsupported output format: %s", format); + } + 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); + rb_raise(e_render_error, "Cairo rendering failed: %s", cairo_status_to_string(status)); + } + + if (strcmp(format, "png") == 0) { + status = cairo_surface_write_to_png_stream(cairo_get_target(cairo), + lasem_write_to_ruby_string, + &output); + if (status != CAIRO_STATUS_SUCCESS) { + cairo_destroy(cairo); + cairo_surface_destroy(surface); + g_object_unref(view); + g_object_unref(document); + 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); + + if (status != CAIRO_STATUS_SUCCESS) { + rb_raise(e_render_error, "Cairo output failed: %s", cairo_status_to_string(status)); + } + + 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_class(m_lasem, "Error", rb_eStandardError); + lasem_get_or_define_class(m_lasem, "DependencyError", e_error); + e_render_error = lasem_get_or_define_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..e58fa87 --- /dev/null +++ b/ext/lasem/lasem_stub.c @@ -0,0 +1,45 @@ +#include + +static VALUE +lasem_get_or_define_class(VALUE parent, const char *name, VALUE superclass) +{ + ID id = rb_intern(name); + + if (rb_const_defined_at(parent, id)) { + return rb_const_get(parent, id); + } + + return rb_define_class_under(parent, name, superclass); +} + +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_class(m_lasem, "Error", rb_eStandardError); + VALUE e_dependency_error = lasem_get_or_define_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..9e2dba8 --- /dev/null +++ b/lasem.gemspec @@ -0,0 +1,37 @@ +# 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.2.0") + + spec.metadata = { + "rubygems_mfa_required" => "true", + "source_code_uri" => spec.homepage, + } + + # Specify which files should be added to the gem when it is released. + # The `git ls-files -z` loads files that have been added to git. + spec.files = Dir.chdir(File.expand_path(__dir__)) do + `git ls-files -z`.split("\x0").reject do |f| + f.match(%r{^(test|spec|features|vendor)/}) + 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..1a01081 --- /dev/null +++ b/lib/lasem/dependency_doctor.rb @@ -0,0 +1,198 @@ +# frozen_string_literal: true + +require "rbconfig" +require "rubygems" + +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) + + 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]), + 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", 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 + LASEM_PKG_CONFIG_CANDIDATES = %w[lasem-0.6 lasem lasem-0.4].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_pkg_config: missing_pkg_config, + outdated_pkg_config: outdated_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 + EXECUTABLE_DEPENDENCIES.reject do |dependency| + dependency.executables.any? do |executable| + probe.executable?(executable) + end + end + 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? + next if Gem::Requirement.new(dependency.requirement).satisfied_by?( + Gem::Version.new(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 + + def missing_submodule_warning + 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 + override = ENV.fetch("LASEM_PKG_CONFIG", nil) + return [override] if override && !override.empty? + + LASEM_PKG_CONFIG_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..c8934a4 --- /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::InvalidOption => 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..ed9d95f --- /dev/null +++ b/lib/lasem/dependency_doctor/probe.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "open3" + +module Lasem + class DependencyDoctor + class Probe + def 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 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 + + 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..31a5ab3 --- /dev/null +++ b/lib/lasem/dependency_doctor/report.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module Lasem + class DependencyDoctor + class Report + def initialize(attributes) + @missing_executables = attributes.fetch(:missing_executables) + @missing_pkg_config = attributes.fetch(:missing_pkg_config) + @outdated_pkg_config = attributes.fetch(:outdated_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? + 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_pkg_config, + :outdated_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) + 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..ea0b2c0 --- /dev/null +++ b/lib/lasem/error.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module Lasem + class Error < StandardError; end +end diff --git a/lib/lasem/error/dependency_error.rb b/lib/lasem/error/dependency_error.rb new file mode 100644 index 0000000..f500b5f --- /dev/null +++ b/lib/lasem/error/dependency_error.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Lasem + class DependencyError < 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..38985a4 --- /dev/null +++ b/lib/lasem/error/option_error.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Lasem + class OptionError < ArgumentError + 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..46a9657 --- /dev/null +++ b/lib/lasem/error/render_error.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module Lasem + class RenderError < 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/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..213bda7 --- /dev/null +++ b/lib/lasem/renderer.rb @@ -0,0 +1,29 @@ +# 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 + + def normalize_source(source) + normalized = source.to_str + raise OptionError.non_empty_source if normalized.strip.empty? + + normalized + rescue NoMethodError + raise OptionError.non_empty_source + 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..2f1a493 --- /dev/null +++ b/spec/lasem/dependency_doctor_spec.rb @@ -0,0 +1,223 @@ +# frozen_string_literal: true + +# rubocop:disable RSpec/ExampleLength, RSpec/MultipleExpectations + +require "stringio" + +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_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 "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 "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, + ) + + expect(status).to eq(1) + expect(output.string).to include("Lasem setup warnings:") + expect(output.string).to include("Dependency warnings:") + end + end +end + +# rubocop:enable RSpec/ExampleLength, RSpec/MultipleExpectations 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..e70b51f --- /dev/null +++ b/spec/lasem/renderer_spec.rb @@ -0,0 +1,190 @@ +# 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 "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("