From fcdae7207b48bbc6f6f3b7ad43d697ca99755143 Mon Sep 17 00:00:00 2001 From: suleman-uzair Date: Wed, 6 May 2026 17:33:01 +0500 Subject: [PATCH 1/8] Add vendored Lasem native wrapper --- .gitmodules | 3 + Gemfile | 1 + README.adoc | 105 +++++- Rakefile | 11 +- exe/lasem-doctor | 8 + ext/lasem/extconf.rb | 123 +++++++ ext/lasem/lasem_ext.c | 242 ++++++++++++++ ext/lasem/lasem_stub.c | 41 +++ lasem-ruby.gemspec | 22 +- lib/lasem.rb | 45 +++ lib/lasem/dependency_doctor.rb | 466 +++++++++++++++++++++++++++ lib/lasem/error.rb | 9 + lib/lasem/renderer.rb | 126 ++++++++ rakelib/lasem.rake | 119 +++++++ spec/lasem/dependency_doctor_spec.rb | 163 ++++++++++ spec/lasem/renderer_spec.rb | 89 +++++ spec/lasem_spec.rb | 12 + vendor/lasem/source | 1 + 18 files changed, 1579 insertions(+), 7 deletions(-) create mode 100644 .gitmodules create mode 100755 exe/lasem-doctor create mode 100644 ext/lasem/extconf.rb create mode 100644 ext/lasem/lasem_ext.c create mode 100644 ext/lasem/lasem_stub.c create mode 100644 lib/lasem/dependency_doctor.rb create mode 100644 lib/lasem/error.rb create mode 100644 lib/lasem/renderer.rb create mode 100644 rakelib/lasem.rake create mode 100644 spec/lasem/dependency_doctor_spec.rb create mode 100644 spec/lasem/renderer_spec.rb create mode 160000 vendor/lasem/source 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..885c0b9 100644 --- a/Gemfile +++ b/Gemfile @@ -5,6 +5,7 @@ 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..cad00a3 100644 --- a/README.adoc +++ b/README.adoc @@ -4,11 +4,93 @@ 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-ruby` is a thin native wrapper around Lasem. It delegates parsing, +layout, and rendering behavior to the Lasem C API, and keeps the Ruby layer +focused on input validation, ergonomic entry points, and predictable errors. -This initial project scaffold establishes the gem structure, development -tooling, and test layout. Native rendering support is added separately. +The initial API renders MathML, SVG, and Lasem's itex/LaTeX input to SVG, PNG, +PDF, or PS output: + +[source,ruby] +---- +require "lasem" + +svg = Lasem.render_mathml(mathml_string, format: :svg) +png = Lasem.render_latex("\\sqrt{x}", format: :png, ppi: 72.0) +---- + +== Native dependency strategy + +The gem looks for Lasem in this order: + +1. A vendored Lasem install under `vendor/lasem/install`. +2. A system Lasem package discovered with `pkg-config`. +3. A compiled stub extension that raises `Lasem::DependencyError`. + +The stub keeps the Ruby API loadable on machines that do not have Lasem yet, +while making rendering failures explicit. + +== Vendored Lasem + +Initialize the Lasem submodule under `vendor/lasem/source`, then run: + +[source,sh] +---- +git submodule update --init vendor/lasem/source +bundle exec rake lasem:doctor +bundle exec rake lasem:build +bundle exec rake clean compile +---- + +For extra setup hints, run: + +[source,sh] +---- +exe/lasem-doctor --all-warnings +---- + +`ext/lasem/extconf.rb` also attempts this vendored Meson build automatically +when `vendor/lasem/source/meson.build` exists and no Lasem pkg-config package +has been found yet. + +The build task installs Lasem into `vendor/lasem/install`. The extension adds +that install's `pkgconfig` directories to `PKG_CONFIG_PATH` and embeds the +vendored library directory as a runtime library path. + +If `Lasem.native_available?` is still `false` after `lasem:build`, rebuild the +extension with `bundle exec rake clean compile` so Ruby stops loading any older +stub extension. + +The vendored Lasem source is LGPL-2.1-or-later. Keep Lasem's license files with +the vendored source and update this gem when the vendored library is updated. + +== System dependencies + +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 +---- + +Lasem's upstream documentation recommends Computer Modern fonts from the LyX +font packages: `fonts-lyx` on Debian/Ubuntu and `lyx-fonts` on Fedora. + +`exe/lasem-doctor` checks the local toolchain and pkg-config +dependencies, then prints the best-known install command for the detected +platform. The package suggestions are guidance; package names can vary by +distribution release. == Development @@ -18,3 +100,18 @@ bundle install bundle exec rake bundle exec rubocop ---- + +Useful environment variables: + +`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. diff --git a/Rakefile b/Rakefile index b6ae734..bbeb55b 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-ruby.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/exe/lasem-doctor b/exe/lasem-doctor new file mode 100755 index 0000000..4317304 --- /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/dependency_doctor" + +exit Lasem::DependencyDoctor::CLI.call(ARGV) diff --git a/ext/lasem/extconf.rb b/ext/lasem/extconf.rb new file mode 100644 index 0000000..3aa32d1 --- /dev/null +++ b/ext/lasem/extconf.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require "mkmf" +require "fileutils" +require "shellwords" + +ROOT = File.expand_path("../..", __dir__) +VENDORED_INSTALL_DIR = File.expand_path( + ENV.fetch("LASEM_INSTALL_DIR", "vendor/lasem/install"), + ROOT, +) +VENDORED_SOURCE_DIR = File.expand_path( + ENV.fetch("LASEM_SOURCE_DIR", "vendor/lasem/source"), + ROOT, +) +VENDORED_BUILD_DIR = File.expand_path( + ENV.fetch("LASEM_BUILD_DIR", "vendor/lasem/build"), + ROOT, +) +VENDORED_MESON_OPTIONS = %w[ + --buildtype=release + -Ddocumentation=disabled + -Dintrospection=disabled + -Dviewer=disabled +].freeze + +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 + +def meson_setup_command + command = ["meson", "setup"] + build_file = File.join(VENDORED_BUILD_DIR, "build.ninja") + command << "--reconfigure" if File.exist?(build_file) + command +end + +def run_vendored_lasem_build + system( + *meson_setup_command, + VENDORED_BUILD_DIR, + VENDORED_SOURCE_DIR, + "--prefix=#{VENDORED_INSTALL_DIR}", + "--libdir=lib", + *VENDORED_MESON_OPTIONS, + ) && + system("meson", "compile", "-C", VENDORED_BUILD_DIR) && + system("meson", "install", "-C", VENDORED_BUILD_DIR) +end + +def build_vendored_lasem + return false unless File.exist?(File.join(VENDORED_SOURCE_DIR, "meson.build")) + + unless find_executable("meson") + warn "Vendored Lasem source exists, but Meson was not found." + return false + end + + FileUtils.mkdir_p(VENDORED_BUILD_DIR) + run_vendored_lasem_build +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) + +if lasem_package.nil? && build_vendored_lasem + 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(pkg_config_candidates) +end + +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"] + $objs = ["lasem_ext.o"] + warn "Building lasem-ruby against #{lasem_package}." +else + $srcs = ["lasem_stub.c"] + $objs = ["lasem_stub.o"] + warn "Lasem was not found; building a stub extension." + warn "Run `bundle exec rake lasem:build` or install a system " \ + "Lasem development package, then rebuild." +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..500cb35 --- /dev/null +++ b/ext/lasem/lasem_ext.c @@ -0,0 +1,242 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +static VALUE m_lasem; +static VALUE m_native; +static VALUE e_error; +static VALUE e_dependency_error; +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 unsigned int +lasem_positive_pixel_size(double value, const char *name) +{ + if (!isfinite(value) || value <= 0.0) { + rb_raise(e_render_error, "%s must be greater than 0", name); + } + + if (value > UINT_MAX) { + rb_raise(e_render_error, "%s is too large", name); + } + + return (unsigned int) ceil(value); +} + +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) { + return LSM_DOM_DOCUMENT(lsm_mathml_document_new_from_itex(input, input_size, error)); + } + + 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) { + return cairo_svg_surface_create_for_stream(lasem_write_to_ruby_string, output, width_pt, height_pt); + } + + if (strcmp(format, "pdf") == 0) { + return cairo_pdf_surface_create_for_stream(lasem_write_to_ruby_string, output, width_pt, height_pt); + } + + if (strcmp(format, "ps") == 0) { + return cairo_ps_surface_create_for_stream(lasem_write_to_ruby_string, output, width_pt, height_pt); + } + + if (strcmp(format, "png") == 0) { + return cairo_image_surface_create(CAIRO_FORMAT_ARGB32, width_px, height_px); + } + + rb_raise(e_render_error, "unsupported output format: %s", format); +} + +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; + LsmDomDocument *document; + LsmDomView *view; + 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; + unsigned int width_px; + unsigned int height_px; + int explicit_size; + + 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); + explicit_size = !NIL_P(width_value) && !NIL_P(height_value); + + 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); + + width_pt = 2.0; + height_pt = 2.0; + lsm_dom_view_get_size(view, &width_pt, &height_pt, NULL); + lsm_dom_view_get_size_pixels(view, &width_px, &height_px, NULL); + + if (explicit_size) { + width_pt = NUM2DBL(width_value); + height_pt = NUM2DBL(height_value); + width_px = lasem_positive_pixel_size(width_pt, "width"); + height_px = lasem_positive_pixel_size(height_pt, "height"); + } else { + width_pt *= zoom; + height_pt *= zoom; + width_px = lasem_positive_pixel_size((double) width_px * zoom, "width"); + height_px = lasem_positive_pixel_size((double) height_px * zoom, "height"); + } + + 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); + status = cairo_surface_status(surface); + if (status != CAIRO_STATUS_SUCCESS) { + 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, -offset_x, -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) +{ + 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); + e_dependency_error = 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..e736e97 --- /dev/null +++ b/ext/lasem/lasem_stub.c @@ -0,0 +1,41 @@ +#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); + + rb_raise(e_dependency_error, + "Lasem native library is not available. Build vendored Lasem with " + "`bundle exec rake lasem:build` or install a system Lasem package."); +} + +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 index b171760..a1c6674 100644 --- a/lasem-ruby.gemspec +++ b/lasem-ruby.gemspec @@ -19,8 +19,13 @@ Gem::Specification.new do |spec| spec.metadata["source_code_uri"] = "https://github.com/plurimath/lasem-ruby" spec.metadata["rubygems_mfa_required"] = "true" + excluded_files = %r{ + \A(?:spec|features)/ + | + \Avendor/lasem/source/(?:\.github|docs|tests/data|tools)/ + }x tracked_files = Dir.chdir(File.expand_path(__dir__)) do - `git ls-files -z`.split("\x0") + `git ls-files -z --recurse-submodules`.split("\x0") end spec.files = if tracked_files.empty? Dir[ @@ -29,13 +34,26 @@ Gem::Specification.new do |spec| "Rakefile", "Gemfile", "lasem-ruby.gemspec", + ".gitmodules", + "exe/*", "lib/**/*.rb", + "ext/**/*.{c,rb}", + "rakelib/**/*.rake", + "vendor/lasem/source/{COPYING,NEWS.md,README.md,TODO}", + "vendor/lasem/source/meson.build", + "vendor/lasem/source/meson_options.txt", + "vendor/lasem/source/itex2mml/**/*", + "vendor/lasem/source/po/**/*", + "vendor/lasem/source/src/**/*", + "vendor/lasem/source/tests/*", + "vendor/lasem/source/viewer/**/*", ] else - tracked_files + tracked_files.grep_v(excluded_files) 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..26b88ee 100644 --- a/lib/lasem.rb +++ b/lib/lasem.rb @@ -1,6 +1,51 @@ # frozen_string_literal: true require "lasem/version" +require "lasem/error" module Lasem + DEPENDENCY_ERROR_MESSAGE = "Lasem native library is not available. " \ + "Build vendored Lasem with " \ + "`bundle exec rake lasem:build` or " \ + "install a system Lasem package." + + begin + require "lasem/lasem" + rescue LoadError + module Native + def self.native_available? + false + end + + def self.render(*) + raise DependencyError, DEPENDENCY_ERROR_MESSAGE + end + end + end + + require "lasem/renderer" + + def self.native_available? + Native.native_available? + end + + def self.render(input, input_type: :xml, format: :svg, **) + Renderer.render(input, input_type: input_type, format: format, **) + end + + def self.render_mathml(input, **) + render(input, input_type: :mathml, **) + end + + def self.render_svg(input, **) + render(input, input_type: :svg, **) + end + + def self.render_latex(input, **) + render(input, input_type: :latex, **) + end + + def self.render_itex(input, **) + render(input, input_type: :itex, **) + end end diff --git a/lib/lasem/dependency_doctor.rb b/lib/lasem/dependency_doctor.rb new file mode 100644 index 0000000..3ca29d6 --- /dev/null +++ b/lib/lasem/dependency_doctor.rb @@ -0,0 +1,466 @@ +# frozen_string_literal: true + +require "open3" +require "optparse" +require "rbconfig" +require "rubygems" + +module Lasem + class DependencyDoctor + ROOT = File.expand_path("../..", __dir__) + + ExecutableDependency = Struct.new(:name, :executables, keyword_init: true) + PkgConfigDependency = Struct.new(:name, :requirement, keyword_init: true) + Installer = Struct.new(:name, :label, :command, 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 + + INSTALLERS = [ + Installer.new( + name: "apt-get", + label: "Debian/Ubuntu", + command: "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", + ), + Installer.new( + name: "dnf", + label: "Fedora", + command: "sudo dnf install gcc make ruby-devel pkgconf-pkg-config " \ + "meson ninja-build bison flex gettext glib2-devel " \ + "gdk-pixbuf2-devel cairo-devel pango-devel libxml2-devel " \ + "lyx-fonts", + ), + Installer.new( + name: "yum", + label: "RHEL/CentOS", + command: "sudo yum install gcc make ruby-devel pkgconf-pkg-config " \ + "meson ninja-build bison flex gettext glib2-devel " \ + "gdk-pixbuf2-devel cairo-devel pango-devel libxml2-devel", + ), + Installer.new( + name: "pacman", + label: "Arch Linux", + command: "sudo pacman -S base-devel ruby pkgconf meson ninja bison " \ + "flex gettext glib2 gdk-pixbuf2 cairo pango libxml2", + ), + Installer.new( + name: "apk", + label: "Alpine Linux", + command: "sudo apk add build-base ruby-dev pkgconf meson ninja bison " \ + "flex gettext-dev glib-dev gdk-pixbuf-dev cairo-dev " \ + "pango-dev libxml2-dev", + ), + Installer.new( + name: "zypper", + label: "openSUSE", + command: "sudo zypper install gcc make ruby-devel pkg-config meson " \ + "ninja bison flex gettext-tools glib2-devel " \ + "gdk-pixbuf-devel cairo-devel pango-devel libxml2-devel " \ + "lyx-fonts", + ), + Installer.new( + name: "brew", + label: "macOS/Homebrew", + command: "brew install pkg-config meson ninja bison flex gettext " \ + "glib gdk-pixbuf cairo pango libxml2", + ), + ].freeze + OS_INSTALLER_IDS = { + "apt-get" => %w[debian ubuntu], + "dnf" => %w[fedora], + "yum" => %w[rhel centos], + "pacman" => %w[arch], + "apk" => %w[alpine], + "zypper" => %w[suse opensuse], + }.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( + installer: detect_installer, + 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| + [dependency, probe.pkg_config_version(dependency.name)] + end + 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 detect_installer + os_release = probe.os_release + preferred_installer(os_release) || available_installer + end + + def preferred_installer(os_release) + return installer_named("brew") if probe.platform.include?("darwin") + + ids = [os_release["ID"], *os_release.fetch("ID_LIKE", "").split].compact + installer_name = OS_INSTALLER_IDS.find do |_name, aliases| + ids.intersect?(aliases) + end&.first + installer_named(installer_name) if installer_name + end + + def available_installer + INSTALLERS.find { |installer| probe.executable?(installer.name) } + end + + def installer_named(name) + installer = INSTALLERS.find { |candidate| candidate.name == name } + return unless installer && probe.executable?(name) + + installer + 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.so") + 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 + resolved_pc_dir = probe.pkg_config_variable("lasem-0.6", "pcfiledir") + return unless probe.file?(vendored_pc) + return if resolved_pc_dir.nil? + return if File.expand_path(resolved_pc_dir) == vendored_pc_dir + + "`pkg-config lasem-0.6` resolves to #{resolved_pc_dir}, while vendored " \ + "Lasem is installed at #{vendored_pc_dir}." + end + + def dependency_warnings(enabled) + return [] unless enabled + + warnings = [] + if detect_installer.nil? + warnings << "No supported package installer was detected." + end + 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 + + class Report + def initialize(attributes) + @installer = attributes.fetch(:installer) + @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_install_suggestion(lines) + append_warnings(lines, "Lasem setup warnings", lasem_warnings) + append_warnings(lines, "Dependency warnings", dependency_warnings) + lines.join("\n") + end + + private + + attr_reader :installer, :missing_executables, :missing_pkg_config, + :outdated_pkg_config, :lasem_warnings, :dependency_warnings + + def append_status(lines) + lines << "" + lines << installer_line + 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_install_suggestion(lines) + return if success? + + lines << "" + if installer + lines << "Best-known install command for #{installer.label}:" + lines << " #{installer.command}" + else + lines << "No supported installer was detected. Install equivalent " \ + "development packages for the missing items above." + end + 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 installer_line + if installer + return "Detected installer: #{installer.name} (#{installer.label})" + end + + "Detected installer: unknown" + 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 + + 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 os_release + return {} unless file?("/etc/os-release") + + File.readlines("/etc/os-release").to_h do |line| + key, value = line.strip.split("=", 2) + [key, value&.delete_prefix("\"")&.delete_suffix("\"")] + end + end + + def platform + RUBY_PLATFORM + 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 + + 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/error.rb b/lib/lasem/error.rb new file mode 100644 index 0000000..0a6a90d --- /dev/null +++ b/lib/lasem/error.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Lasem + class Error < StandardError; end + + class DependencyError < Error; end + + class RenderError < Error; end +end diff --git a/lib/lasem/renderer.rb b/lib/lasem/renderer.rb new file mode 100644 index 0000000..af6449e --- /dev/null +++ b/lib/lasem/renderer.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +module Lasem + class Renderer + INPUT_TYPES = %w[xml mathml svg latex itex].freeze + ITEX_INPUT_TYPES = %w[latex itex].freeze + ITEX_DELIMITER_PAIRS = [ + ["$$", "$$"], + ["$", "$"], + ["\\(", "\\)"], + ["\\[", "\\]"], + ].freeze + OUTPUT_FORMATS = %w[svg png pdf ps].freeze + DEFAULT_PPI = 72.0 + DEFAULT_ZOOM = 1.0 + DEFAULT_OPTIONS = { + input_type: :xml, + format: :svg, + ppi: DEFAULT_PPI, + zoom: DEFAULT_ZOOM, + width: nil, + height: nil, + offset_x: 0.0, + offset_y: 0.0, + }.freeze + + def self.render(input, **options) + new(input, options).render + end + + def initialize(input, options = {}) + options = DEFAULT_OPTIONS.merge(options) + + normalize_options(options) + @input = normalize_input(String(input)) + validate_size_pair + end + + def render + Native.render( + @input, @input_type, @format, @ppi, @zoom, + @width, @height, @offset_x, @offset_y + ) + end + + private + + def normalize_options(options) + normalize_format_options(options) + normalize_dimension_options(options) + normalize_position_options(options) + end + + def normalize_input(input) + return input unless ITEX_INPUT_TYPES.include?(@input_type) + + stripped_input = input.strip + return input if itex_delimited?(stripped_input) + + "$#{stripped_input}$" + end + + def itex_delimited?(input) + ITEX_DELIMITER_PAIRS.any? do |opening, closing| + input.start_with?(opening) && + input.end_with?(closing) && + input.length > opening.length + closing.length + end + end + + def normalize_format_options(options) + @input_type = choice( + options.fetch(:input_type), + INPUT_TYPES, + "input_type", + ) + @format = choice(options.fetch(:format), OUTPUT_FORMATS, "format") + 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 = numeric(options.fetch(:offset_x), "offset_x") + @offset_y = 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 ArgumentError, + "#{name} must be one of: #{allowed_values.join(', ')}" + end + + def numeric(value, name) + Float(value) + rescue ArgumentError, TypeError + raise ArgumentError, "#{name} must be numeric" + end + + def positive_float(value, name) + number = numeric(value, name) + return number if number.positive? && number.finite? + + raise ArgumentError, "#{name} must be greater than 0" + 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 ArgumentError, "width and height must be provided together" + end + end +end diff --git a/rakelib/lasem.rake b/rakelib/lasem.rake new file mode 100644 index 0000000..d7b4014 --- /dev/null +++ b/rakelib/lasem.rake @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require "fileutils" +require_relative "../lib/lasem/dependency_doctor" + +LASEM_RAKE_ROOT = File.expand_path("..", __dir__) +LASEM_MESON_OPTIONS = %w[ + --buildtype=release + -Ddocumentation=disabled + -Dintrospection=disabled + -Dviewer=disabled +].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 + 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..40a4758 --- /dev/null +++ b/spec/lasem/dependency_doctor_spec.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +# rubocop:disable RSpec/ExampleLength, RSpec/MultipleExpectations + +require "stringio" +require "lasem/dependency_doctor" + +DependencyDoctorFakeProbe = 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 + +RSpec.describe Lasem::DependencyDoctor do + 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", + } + end + + def probe(**overrides) + DependencyDoctorFakeProbe.new( + { + executables: [], + pkg_config_versions: {}, + pkg_config_variables: {}, + files: [], + os_release: {}, + platform: "x86_64-linux", + }.merge(overrides), + ) + end + + describe "#report" do + it "reports missing dependencies with the best-known installer command" do + report = described_class.new( + root: root, + probe: probe(executables: %w[apt-get pkg-config], + os_release: { "ID" => "ubuntu" }), + ).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:") + expect(report.to_s).to include( + "Best-known install command for Debian/Ubuntu:", + ) + expect(report.to_s).to include("sudo apt-get install") + 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, + os_release: { "ID" => "ubuntu" }), + ).report + + expect(report).to be_success + expect(report.to_s).to include("Required dependencies look available.") + 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"], + os_release: { "ID" => "ubuntu" }, + ), + ).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 "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( + "No supported package installer was detected.", + ) + 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/renderer_spec.rb b/spec/lasem/renderer_spec.rb new file mode 100644 index 0000000..796b04c --- /dev/null +++ b/spec/lasem/renderer_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +RSpec.describe Lasem::Renderer do + let(:mathml) do + <<~MATHML + + + x + + + MATHML + end + + def render_mathml + described_class.render(mathml, input_type: :mathml, format: :svg) + end + + def stub_native_render + allow(Lasem::Native).to receive(:render).and_return("") + end + + def expect_native_rendered(input, input_type) + expect(Lasem::Native).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_type: :unknown) + end.to raise_error(ArgumentError, /input_type/) + end + + it "validates the output format" do + expect do + described_class.render(mathml, format: :jpeg) + end.to raise_error(ArgumentError, /format/) + end + + it "requires a positive ppi value" do + expect do + described_class.render(mathml, ppi: 0) + end.to raise_error(ArgumentError, /ppi/) + end + + it "requires a positive zoom value" do + expect do + described_class.render(mathml, zoom: -1) + end.to raise_error(ArgumentError, /zoom/) + end + + it "requires width and height to be provided together" do + expect do + described_class.render(mathml, width: 100) + end.to raise_error(ArgumentError, /width and height/) + end + + it "wraps bare LaTeX input in itex math delimiters" do + stub_native_render + + expect(described_class.render("\\sum_d^d", input_type: :latex)) + .to eq("") + expect_native_rendered("$\\sum_d^d$", "latex") + end + + it "preserves existing itex math delimiters" do + stub_native_render + + expect(described_class.render("\\(\\sum_d^d\\)", input_type: :itex)) + .to eq("") + expect_native_rendered("\\(\\sum_d^d\\)", "itex") + end + + it "renders SVG output when the native layer is available" do + unless Lasem.native_available? + skip "Lasem native library is not available" + end + + expect(render_mathml).to include(" Date: Wed, 6 May 2026 17:33:01 +0500 Subject: [PATCH 2/8] Rename gem to lasem --- .gitignore | 1 + Gemfile | 2 +- README.adoc | 26 ++++++++++++++++++++++---- Rakefile | 2 +- lasem-ruby.gemspec => lasem.gemspec | 9 +++++---- 5 files changed, 30 insertions(+), 10 deletions(-) rename lasem-ruby.gemspec => lasem.gemspec (88%) 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/Gemfile b/Gemfile index 885c0b9..ff354b4 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,6 @@ source "https://rubygems.org" -# Specify the gem's dependencies in lasem-ruby.gemspec. +# Specify the gem's dependencies in lasem.gemspec. gemspec gem "irb" diff --git a/README.adoc b/README.adoc index cad00a3..3b86061 100644 --- a/README.adoc +++ b/README.adoc @@ -1,12 +1,12 @@ -= lasem-ruby += lasem Ruby bindings for the Lasem SVG and MathML rendering library. == Purpose -`lasem-ruby` is a thin native wrapper around Lasem. It delegates parsing, -layout, and rendering behavior to the Lasem C API, and keeps the Ruby layer -focused on input validation, ergonomic entry points, and predictable errors. +`lasem` is a thin native wrapper around Lasem. It delegates parsing, layout, +and rendering behavior to the Lasem C API, and keeps the Ruby layer focused on +input validation, ergonomic entry points, and predictable errors. The initial API renders MathML, SVG, and Lasem's itex/LaTeX input to SVG, PNG, PDF, or PS output: @@ -19,6 +19,24 @@ svg = Lasem.render_mathml(mathml_string, format: :svg) png = Lasem.render_latex("\\sqrt{x}", format: :png, ppi: 72.0) ---- +The source repository is named `lasem-ruby` to make the language binding clear, +but the gem is named `lasem` because the public Ruby API is already namespaced +as `Lasem`. + +== Background + +Plurimath image-rendering work originally depended on a `lasem-render` +executable being available on the user's machine. This gem keeps that dependency +inside Ruby instead: applications can call `Lasem.render_*` methods and let the +native extension use Lasem directly. + +The `mathematical` gem is the closest prior Ruby example. It wraps Lasem for +TeX math image output, but its rendering code is coupled to that gem's TeX +parser, API shape, and packaging. This project uses the same underlying Lasem +and Cairo idea while keeping the wrapper focused on Lasem itself, so Plurimath +can depend on it without mixing native rendering code into the main Plurimath +repository. + == Native dependency strategy The gem looks for Lasem in this order: diff --git a/Rakefile b/Rakefile index bbeb55b..7833f5b 100644 --- a/Rakefile +++ b/Rakefile @@ -4,7 +4,7 @@ require "bundler/gem_tasks" require "rake/extensiontask" require "rspec/core/rake_task" -spec = Gem::Specification.load("lasem-ruby.gemspec") +spec = Gem::Specification.load("lasem.gemspec") Rake::ExtensionTask.new("lasem", spec) do |ext| ext.lib_dir = "lib/lasem" diff --git a/lasem-ruby.gemspec b/lasem.gemspec similarity index 88% rename from lasem-ruby.gemspec rename to lasem.gemspec index a1c6674..11eda7e 100644 --- a/lasem-ruby.gemspec +++ b/lasem.gemspec @@ -3,19 +3,20 @@ require_relative "lib/lasem/version" Gem::Specification.new do |spec| - spec.name = "lasem-ruby" + 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 = "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["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = "https://github.com/plurimath/lasem-ruby" spec.metadata["rubygems_mfa_required"] = "true" @@ -33,7 +34,7 @@ Gem::Specification.new do |spec| "README.adoc", "Rakefile", "Gemfile", - "lasem-ruby.gemspec", + "lasem.gemspec", ".gitmodules", "exe/*", "lib/**/*.rb", From 54802eff7db5da31cfa4924b7a05522658622da5 Mon Sep 17 00:00:00 2001 From: suleman-uzair Date: Fri, 8 May 2026 22:18:38 +0500 Subject: [PATCH 3/8] Refactor Lasem bootstrap API and packaging --- README.adoc | 22 +- exe/lasem-doctor | 2 +- ext/lasem/lasem_ext.c | 10 + ext/lasem/lasem_stub.c | 4 +- lasem.gemspec | 88 ++++--- lib/lasem.rb | 59 ++--- lib/lasem/dependency_doctor.rb | 319 +------------------------- lib/lasem/dependency_doctor/cli.rb | 71 ++++++ lib/lasem/dependency_doctor/probe.rb | 45 ++++ lib/lasem/dependency_doctor/report.rb | 87 +++++++ lib/lasem/error.rb | 4 - lib/lasem/error/dependency_error.rb | 20 ++ lib/lasem/error/option_error.rb | 25 ++ lib/lasem/error/render_error.rb | 5 + lib/lasem/native_loader.rb | 65 ++++++ lib/lasem/render_options.rb | 115 ++++++++++ lib/lasem/renderer.rb | 114 +-------- rakelib/lasem.rake | 2 +- spec/lasem/dependency_doctor_spec.rb | 21 +- spec/lasem/option_error_spec.rb | 63 +++++ spec/lasem/renderer_spec.rb | 30 ++- spec/lasem_spec.rb | 12 +- 22 files changed, 617 insertions(+), 566 deletions(-) create mode 100644 lib/lasem/dependency_doctor/cli.rb create mode 100644 lib/lasem/dependency_doctor/probe.rb create mode 100644 lib/lasem/dependency_doctor/report.rb create mode 100644 lib/lasem/error/dependency_error.rb create mode 100644 lib/lasem/error/option_error.rb create mode 100644 lib/lasem/error/render_error.rb create mode 100644 lib/lasem/native_loader.rb create mode 100644 lib/lasem/render_options.rb create mode 100644 spec/lasem/option_error_spec.rb diff --git a/README.adoc b/README.adoc index 3b86061..3a5324e 100644 --- a/README.adoc +++ b/README.adoc @@ -15,10 +15,13 @@ PDF, or PS output: ---- require "lasem" -svg = Lasem.render_mathml(mathml_string, format: :svg) -png = Lasem.render_latex("\\sqrt{x}", format: :png, ppi: 72.0) +svg = Lasem.render(mathml_string, input_type: :mathml, output_format: :svg) +png = Lasem.render("$\\sqrt{x}$", input_type: :latex, output_format: :png, ppi: 72.0) ---- +`latex` and `itex` input is passed unchanged to Lasem's itex parser. The gem +does not infer or add math delimiters. + The source repository is named `lasem-ruby` to make the language binding clear, but the gem is named `lasem` because the public Ruby API is already namespaced as `Lasem`. @@ -27,8 +30,8 @@ as `Lasem`. Plurimath image-rendering work originally depended on a `lasem-render` executable being available on the user's machine. This gem keeps that dependency -inside Ruby instead: applications can call `Lasem.render_*` methods and let the -native extension use Lasem directly. +inside Ruby instead: applications can call `Lasem.render` and let the native +extension use Lasem directly. The `mathematical` gem is the closest prior Ruby example. It wraps Lasem for TeX math image output, but its rendering code is coupled to that gem's TeX @@ -46,7 +49,9 @@ The gem looks for Lasem in this order: 3. A compiled stub extension that raises `Lasem::DependencyError`. The stub keeps the Ruby API loadable on machines that do not have Lasem yet, -while making rendering failures explicit. +while making rendering failures explicit. If rendering raises +`Lasem::DependencyError`, run `lasem-doctor --all-warnings` for setup +diagnostics. == Vendored Lasem @@ -105,10 +110,9 @@ sudo dnf install gcc ruby-devel pkgconf-pkg-config meson ninja-build \ Lasem's upstream documentation recommends Computer Modern fonts from the LyX font packages: `fonts-lyx` on Debian/Ubuntu and `lyx-fonts` on Fedora. -`exe/lasem-doctor` checks the local toolchain and pkg-config -dependencies, then prints the best-known install command for the detected -platform. The package suggestions are guidance; package names can vary by -distribution release. +`exe/lasem-doctor` checks the local toolchain and pkg-config dependencies. +Package names vary by operating system and distribution release, so install +the matching development packages with the package manager for your platform. == Development diff --git a/exe/lasem-doctor b/exe/lasem-doctor index 4317304..05f802d 100755 --- a/exe/lasem-doctor +++ b/exe/lasem-doctor @@ -3,6 +3,6 @@ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) -require "lasem/dependency_doctor" +require "lasem" exit Lasem::DependencyDoctor::CLI.call(ARGV) diff --git a/ext/lasem/lasem_ext.c b/ext/lasem/lasem_ext.c index 500cb35..a917b1c 100644 --- a/ext/lasem/lasem_ext.c +++ b/ext/lasem/lasem_ext.c @@ -9,6 +9,7 @@ #include #include +/* Lasem: core DOM and parser APIs used to parse SVG, MathML, and itex input. */ #include #include #include @@ -70,9 +71,11 @@ 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); } @@ -81,18 +84,22 @@ lasem_create_surface(const char *format, VALUE *output, double width_pt, double 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, width_px, height_px); } @@ -111,8 +118,11 @@ lasem_native_render(VALUE self, VALUE input_value, VALUE input_type_value, 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; diff --git a/ext/lasem/lasem_stub.c b/ext/lasem/lasem_stub.c index e736e97..25a1771 100644 --- a/ext/lasem/lasem_stub.c +++ b/ext/lasem/lasem_stub.c @@ -26,8 +26,8 @@ lasem_native_render(int argc, VALUE *argv, VALUE self) VALUE e_dependency_error = lasem_get_or_define_class(m_lasem, "DependencyError", e_error); rb_raise(e_dependency_error, - "Lasem native library is not available. Build vendored Lasem with " - "`bundle exec rake lasem:build` or install a system Lasem package."); + "Lasem native library is not available. Run `lasem-doctor --all-warnings` " + "or `bundle exec rake lasem:doctor WARNINGS=all` for setup diagnostics."); } void diff --git a/lasem.gemspec b/lasem.gemspec index 11eda7e..e8406cd 100644 --- a/lasem.gemspec +++ b/lasem.gemspec @@ -3,58 +3,54 @@ 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.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.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["source_code_uri"] = "https://github.com/plurimath/lasem-ruby" - spec.metadata["rubygems_mfa_required"] = "true" + spec.metadata = { + "rubygems_mfa_required" => "true", + "source_code_uri" => spec.homepage, + } - excluded_files = %r{ - \A(?:spec|features)/ - | - \Avendor/lasem/source/(?:\.github|docs|tests/data|tools)/ - }x - tracked_files = Dir.chdir(File.expand_path(__dir__)) do + spec.files = Dir.chdir(__dir__) do `git ls-files -z --recurse-submodules`.split("\x0") + .grep(%r{ + \A(?: + LICENSE\.txt + | README\.adoc + | lasem\.gemspec + | exe/[^/]+ + | ext/lasem/[^/]+\.(?:c|rb) + | lib/.+\.rb + | vendor/lasem/source/ + (?: + COPYING + | NEWS\.md + | README\.md + | TODO + | lasem\.doap + | lasem\.svg + | meson\.build + | meson_options\.txt + | org\.lasem\.Viewer\.json + | (?:itex2mml|po|src|subprojects|tests|viewer)/[^./][^/]* + ) + )\z + }x) + .sort end - spec.files = if tracked_files.empty? - Dir[ - "LICENSE.txt", - "README.adoc", - "Rakefile", - "Gemfile", - "lasem.gemspec", - ".gitmodules", - "exe/*", - "lib/**/*.rb", - "ext/**/*.{c,rb}", - "rakelib/**/*.rake", - "vendor/lasem/source/{COPYING,NEWS.md,README.md,TODO}", - "vendor/lasem/source/meson.build", - "vendor/lasem/source/meson_options.txt", - "vendor/lasem/source/itex2mml/**/*", - "vendor/lasem/source/po/**/*", - "vendor/lasem/source/src/**/*", - "vendor/lasem/source/tests/*", - "vendor/lasem/source/viewer/**/*", - ] - else - tracked_files.grep_v(excluded_files) - end - - spec.bindir = "exe" - spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } - spec.extensions = ["ext/lasem/extconf.rb"] + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{\Aexe/}) { |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 26b88ee..703fba0 100644 --- a/lib/lasem.rb +++ b/lib/lasem.rb @@ -1,51 +1,26 @@ # frozen_string_literal: true -require "lasem/version" -require "lasem/error" - module Lasem - DEPENDENCY_ERROR_MESSAGE = "Lasem native library is not available. " \ - "Build vendored Lasem with " \ - "`bundle exec rake lasem:build` or " \ - "install a system Lasem package." - - begin - require "lasem/lasem" - rescue LoadError - module Native - def self.native_available? - false - end - - def self.render(*) - raise DependencyError, DEPENDENCY_ERROR_MESSAGE - end - end - end - - require "lasem/renderer" + 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? - Native.native_available? - end - - def self.render(input, input_type: :xml, format: :svg, **) - Renderer.render(input, input_type: input_type, format: format, **) - end - - def self.render_mathml(input, **) - render(input, input_type: :mathml, **) - end - - def self.render_svg(input, **) - render(input, input_type: :svg, **) - end - - def self.render_latex(input, **) - render(input, input_type: :latex, **) + NativeLoader.available? end - def self.render_itex(input, **) - render(input, input_type: :itex, **) + def self.render(input, input_type: :xml, output_format: :svg, **) + Renderer.render( + input, + input_type: input_type, + output_format: output_format, + **, + ) end end diff --git a/lib/lasem/dependency_doctor.rb b/lib/lasem/dependency_doctor.rb index 3ca29d6..e194291 100644 --- a/lib/lasem/dependency_doctor.rb +++ b/lib/lasem/dependency_doctor.rb @@ -1,17 +1,18 @@ # frozen_string_literal: true -require "open3" -require "optparse" 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, keyword_init: true) - Installer = Struct.new(:name, :label, :command, keyword_init: true) OutdatedPackage = Struct.new(:dependency, :version, keyword_init: true) EXECUTABLE_DEPENDENCIES = [ @@ -41,67 +42,6 @@ class DependencyDoctor PkgConfigDependency.new(name: "libxml-2.0"), ].freeze - INSTALLERS = [ - Installer.new( - name: "apt-get", - label: "Debian/Ubuntu", - command: "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", - ), - Installer.new( - name: "dnf", - label: "Fedora", - command: "sudo dnf install gcc make ruby-devel pkgconf-pkg-config " \ - "meson ninja-build bison flex gettext glib2-devel " \ - "gdk-pixbuf2-devel cairo-devel pango-devel libxml2-devel " \ - "lyx-fonts", - ), - Installer.new( - name: "yum", - label: "RHEL/CentOS", - command: "sudo yum install gcc make ruby-devel pkgconf-pkg-config " \ - "meson ninja-build bison flex gettext glib2-devel " \ - "gdk-pixbuf2-devel cairo-devel pango-devel libxml2-devel", - ), - Installer.new( - name: "pacman", - label: "Arch Linux", - command: "sudo pacman -S base-devel ruby pkgconf meson ninja bison " \ - "flex gettext glib2 gdk-pixbuf2 cairo pango libxml2", - ), - Installer.new( - name: "apk", - label: "Alpine Linux", - command: "sudo apk add build-base ruby-dev pkgconf meson ninja bison " \ - "flex gettext-dev glib-dev gdk-pixbuf-dev cairo-dev " \ - "pango-dev libxml2-dev", - ), - Installer.new( - name: "zypper", - label: "openSUSE", - command: "sudo zypper install gcc make ruby-devel pkg-config meson " \ - "ninja bison flex gettext-tools glib2-devel " \ - "gdk-pixbuf-devel cairo-devel pango-devel libxml2-devel " \ - "lyx-fonts", - ), - Installer.new( - name: "brew", - label: "macOS/Homebrew", - command: "brew install pkg-config meson ninja bison flex gettext " \ - "glib gdk-pixbuf cairo pango libxml2", - ), - ].freeze - OS_INSTALLER_IDS = { - "apt-get" => %w[debian ubuntu], - "dnf" => %w[fedora], - "yum" => %w[rhel centos], - "pacman" => %w[arch], - "apk" => %w[alpine], - "zypper" => %w[suse opensuse], - }.freeze - def initialize(root: ROOT, probe: Probe.new) @root = root @probe = probe @@ -109,7 +49,6 @@ def initialize(root: ROOT, probe: Probe.new) def report(lasem_conflict_warnings: false, dep_conflict_warnings: false) Report.new( - installer: detect_installer, missing_executables: missing_executables, missing_pkg_config: missing_pkg_config, outdated_pkg_config: outdated_pkg_config, @@ -153,32 +92,6 @@ def outdated_pkg_config end end - def detect_installer - os_release = probe.os_release - preferred_installer(os_release) || available_installer - end - - def preferred_installer(os_release) - return installer_named("brew") if probe.platform.include?("darwin") - - ids = [os_release["ID"], *os_release.fetch("ID_LIKE", "").split].compact - installer_name = OS_INSTALLER_IDS.find do |_name, aliases| - ids.intersect?(aliases) - end&.first - installer_named(installer_name) if installer_name - end - - def available_installer - INSTALLERS.find { |installer| probe.executable?(installer.name) } - end - - def installer_named(name) - installer = INSTALLERS.find { |candidate| candidate.name == name } - return unless installer && probe.executable?(name) - - installer - end - def lasem_warnings(enabled) return [] unless enabled @@ -219,9 +132,6 @@ def dependency_warnings(enabled) return [] unless enabled warnings = [] - if detect_installer.nil? - warnings << "No supported package installer was detected." - end warnings << ruby_headers_warning warnings.compact end @@ -241,226 +151,5 @@ def ruby_headers_warning "Ruby headers were not found at #{ruby_header}; install the Ruby " \ "development package for this Ruby version." end - - class Report - def initialize(attributes) - @installer = attributes.fetch(:installer) - @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_install_suggestion(lines) - append_warnings(lines, "Lasem setup warnings", lasem_warnings) - append_warnings(lines, "Dependency warnings", dependency_warnings) - lines.join("\n") - end - - private - - attr_reader :installer, :missing_executables, :missing_pkg_config, - :outdated_pkg_config, :lasem_warnings, :dependency_warnings - - def append_status(lines) - lines << "" - lines << installer_line - 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_install_suggestion(lines) - return if success? - - lines << "" - if installer - lines << "Best-known install command for #{installer.label}:" - lines << " #{installer.command}" - else - lines << "No supported installer was detected. Install equivalent " \ - "development packages for the missing items above." - end - 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 installer_line - if installer - return "Detected installer: #{installer.name} (#{installer.label})" - end - - "Detected installer: unknown" - 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 - - 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 os_release - return {} unless file?("/etc/os-release") - - File.readlines("/etc/os-release").to_h do |line| - key, value = line.strip.split("=", 2) - [key, value&.delete_prefix("\"")&.delete_suffix("\"")] - end - end - - def platform - RUBY_PLATFORM - 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 - - 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/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 index 0a6a90d..ea0b2c0 100644 --- a/lib/lasem/error.rb +++ b/lib/lasem/error.rb @@ -2,8 +2,4 @@ module Lasem class Error < StandardError; end - - class DependencyError < Error; end - - class RenderError < 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..6110ba2 --- /dev/null +++ b/lib/lasem/error/dependency_error.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Lasem + class DependencyError < Error + NATIVE_LIBRARY_UNAVAILABLE_MESSAGE = "Lasem native library is not " \ + "available. 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 = NATIVE_LIBRARY_UNAVAILABLE_MESSAGE + if original_error + message = "#{message} Original load error: #{original_error.message}" + end + + new(message) + 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..ca19e00 --- /dev/null +++ b/lib/lasem/error/option_error.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Lasem + class OptionError < Error + 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_positive(name:) + new("#{name} must be greater than 0") + end + + def self.incomplete_size_pair + new("width and height must be provided together") + end + + def self.unknown_options(names:) + new("unknown option(s): #{names.join(', ')}") + 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..0a6e966 --- /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..63f8fda --- /dev/null +++ b/lib/lasem/render_options.rb @@ -0,0 +1,115 @@ +# 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_type: :xml, + output_format: :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_type), + INPUT_TYPES, + "input_type", + ) + @output_format = choice( + options.fetch(:output_format), + OUTPUT_FORMATS, + "output_format", + ) + 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 = numeric(options.fetch(:offset_x), "offset_x") + @offset_y = 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 positive_float(value, name) + number = numeric(value, name) + return number if number.positive? && number.finite? + + 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 index af6449e..0e95519 100644 --- a/lib/lasem/renderer.rb +++ b/lib/lasem/renderer.rb @@ -2,125 +2,17 @@ module Lasem class Renderer - INPUT_TYPES = %w[xml mathml svg latex itex].freeze - ITEX_INPUT_TYPES = %w[latex itex].freeze - ITEX_DELIMITER_PAIRS = [ - ["$$", "$$"], - ["$", "$"], - ["\\(", "\\)"], - ["\\[", "\\]"], - ].freeze - OUTPUT_FORMATS = %w[svg png pdf ps].freeze - DEFAULT_PPI = 72.0 - DEFAULT_ZOOM = 1.0 - DEFAULT_OPTIONS = { - input_type: :xml, - format: :svg, - ppi: DEFAULT_PPI, - zoom: DEFAULT_ZOOM, - width: nil, - height: nil, - offset_x: 0.0, - offset_y: 0.0, - }.freeze - def self.render(input, **options) new(input, options).render end def initialize(input, options = {}) - options = DEFAULT_OPTIONS.merge(options) - - normalize_options(options) - @input = normalize_input(String(input)) - validate_size_pair + @input = String(input) + @options = RenderOptions.new(options) end def render - Native.render( - @input, @input_type, @format, @ppi, @zoom, - @width, @height, @offset_x, @offset_y - ) - end - - private - - def normalize_options(options) - normalize_format_options(options) - normalize_dimension_options(options) - normalize_position_options(options) - end - - def normalize_input(input) - return input unless ITEX_INPUT_TYPES.include?(@input_type) - - stripped_input = input.strip - return input if itex_delimited?(stripped_input) - - "$#{stripped_input}$" - end - - def itex_delimited?(input) - ITEX_DELIMITER_PAIRS.any? do |opening, closing| - input.start_with?(opening) && - input.end_with?(closing) && - input.length > opening.length + closing.length - end - end - - def normalize_format_options(options) - @input_type = choice( - options.fetch(:input_type), - INPUT_TYPES, - "input_type", - ) - @format = choice(options.fetch(:format), OUTPUT_FORMATS, "format") - 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 = numeric(options.fetch(:offset_x), "offset_x") - @offset_y = 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 ArgumentError, - "#{name} must be one of: #{allowed_values.join(', ')}" - end - - def numeric(value, name) - Float(value) - rescue ArgumentError, TypeError - raise ArgumentError, "#{name} must be numeric" - end - - def positive_float(value, name) - number = numeric(value, name) - return number if number.positive? && number.finite? - - raise ArgumentError, "#{name} must be greater than 0" - 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 ArgumentError, "width and height must be provided together" + NativeLoader.render(*@options.native_arguments(@input)) end end end diff --git a/rakelib/lasem.rake b/rakelib/lasem.rake index d7b4014..9973336 100644 --- a/rakelib/lasem.rake +++ b/rakelib/lasem.rake @@ -1,7 +1,7 @@ # frozen_string_literal: true require "fileutils" -require_relative "../lib/lasem/dependency_doctor" +require "lasem" LASEM_RAKE_ROOT = File.expand_path("..", __dir__) LASEM_MESON_OPTIONS = %w[ diff --git a/spec/lasem/dependency_doctor_spec.rb b/spec/lasem/dependency_doctor_spec.rb index 40a4758..595d13f 100644 --- a/spec/lasem/dependency_doctor_spec.rb +++ b/spec/lasem/dependency_doctor_spec.rb @@ -3,15 +3,12 @@ # rubocop:disable RSpec/ExampleLength, RSpec/MultipleExpectations require "stringio" -require "lasem/dependency_doctor" DependencyDoctorFakeProbe = Struct.new( :executables, :pkg_config_versions, :pkg_config_variables, :files, - :os_release, - :platform, keyword_init: true, ) do def executable?(name) @@ -58,35 +55,27 @@ def probe(**overrides) pkg_config_versions: {}, pkg_config_variables: {}, files: [], - os_release: {}, - platform: "x86_64-linux", }.merge(overrides), ) end describe "#report" do - it "reports missing dependencies with the best-known installer command" do + it "reports missing dependencies" do report = described_class.new( root: root, - probe: probe(executables: %w[apt-get pkg-config], - os_release: { "ID" => "ubuntu" }), + 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:") - expect(report.to_s).to include( - "Best-known install command for Debian/Ubuntu:", - ) - expect(report.to_s).to include("sudo apt-get install") 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, - os_release: { "ID" => "ubuntu" }), + pkg_config_versions: all_pkg_config_versions), ).report expect(report).to be_success @@ -104,7 +93,6 @@ def probe(**overrides) ["lasem-0.6", "pcfiledir"] => "/usr/lib/pkgconfig", }, files: ["/repo/vendor/lasem/install/lib/pkgconfig/lasem-0.6.pc"], - os_release: { "ID" => "ubuntu" }, ), ).report(lasem_conflict_warnings: true) @@ -121,9 +109,6 @@ def probe(**overrides) ).report(dep_conflict_warnings: true) expect(report.to_s).to include("Dependency warnings:") - expect(report.to_s).to include( - "No supported package installer was detected.", - ) expect(report.to_s).to include("Ruby headers were not found") end end diff --git a/spec/lasem/option_error_spec.rb b/spec/lasem/option_error_spec.rb new file mode 100644 index 0000000..f102d38 --- /dev/null +++ b/spec/lasem/option_error_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +RSpec.describe Lasem::OptionError do + describe ".invalid_choice" do + subject(:error) do + described_class.invalid_choice( + name: "output_format", + allowed_values: %w[svg png], + ) + end + + it "builds an invalid choice error" do + expect(error).to have_attributes( + class: described_class, + message: "output_format must be one of: svg, png", + ) + 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_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 index 796b04c..cfc55ef 100644 --- a/spec/lasem/renderer_spec.rb +++ b/spec/lasem/renderer_spec.rb @@ -12,15 +12,15 @@ end def render_mathml - described_class.render(mathml, input_type: :mathml, format: :svg) + described_class.render(mathml, input_type: :mathml, output_format: :svg) end def stub_native_render - allow(Lasem::Native).to receive(:render).and_return("") + allow(Lasem::NativeLoader).to receive(:render).and_return("") end def expect_native_rendered(input, input_type) - expect(Lasem::Native).to have_received(:render).with( + expect(Lasem::NativeLoader).to have_received(:render).with( input, input_type, "svg", 72.0, 1.0, nil, nil, 0.0, 0.0 ) end @@ -29,42 +29,48 @@ def expect_native_rendered(input, input_type) it "validates the input type" do expect do described_class.render(mathml, input_type: :unknown) - end.to raise_error(ArgumentError, /input_type/) + end.to raise_error(Lasem::OptionError, /input_type/) end it "validates the output format" do expect do - described_class.render(mathml, format: :jpeg) - end.to raise_error(ArgumentError, /format/) + described_class.render(mathml, output_format: :jpeg) + end.to raise_error(Lasem::OptionError, /output_format/) + end + + it "rejects unknown options" do + expect do + described_class.render(mathml, format: :png) + end.to raise_error(Lasem::OptionError, /unknown option.*format/) end it "requires a positive ppi value" do expect do described_class.render(mathml, ppi: 0) - end.to raise_error(ArgumentError, /ppi/) + 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(ArgumentError, /zoom/) + 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(ArgumentError, /width and height/) + end.to raise_error(Lasem::OptionError, /width and height/) end - it "wraps bare LaTeX input in itex math delimiters" do + it "passes LaTeX input unchanged" do stub_native_render expect(described_class.render("\\sum_d^d", input_type: :latex)) .to eq("") - expect_native_rendered("$\\sum_d^d$", "latex") + expect_native_rendered("\\sum_d^d", "latex") end - it "preserves existing itex math delimiters" do + it "passes itex input unchanged" do stub_native_render expect(described_class.render("\\(\\sum_d^d\\)", input_type: :itex)) diff --git a/spec/lasem_spec.rb b/spec/lasem_spec.rb index 38d8c0d..f819380 100644 --- a/spec/lasem_spec.rb +++ b/spec/lasem_spec.rb @@ -9,11 +9,13 @@ expect(described_class.native_available?).to be(true).or be(false) end - it "provides convenience render entry points" do - expected_methods = %i[ - render render_mathml render_svg render_latex render_itex - ] + it "provides the render entry point" do + expect(described_class.public_methods).to include(:render) + 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).to include(*expected_methods) + expect(described_class.public_methods & shortcuts).to be_empty end end From 653144a1b77af9244b3951f7b26f5dfe21c9b7f9 Mon Sep 17 00:00:00 2001 From: suleman-uzair Date: Mon, 11 May 2026 22:30:42 +0500 Subject: [PATCH 4/8] Align native render zoom and offset behavior --- ext/lasem/lasem_ext.c | 10 +++++--- spec/lasem/renderer_spec.rb | 46 ++++++++++++++++++++++++++++++++++--- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/ext/lasem/lasem_ext.c b/ext/lasem/lasem_ext.c index a917b1c..43d3105 100644 --- a/ext/lasem/lasem_ext.c +++ b/ext/lasem/lasem_ext.c @@ -137,6 +137,8 @@ lasem_native_render(VALUE self, VALUE input_value, VALUE input_type_value, VALUE double height_pt; double offset_x; double offset_y; + double render_offset_x; + double render_offset_y; unsigned int width_px; unsigned int height_px; int explicit_size; @@ -153,6 +155,8 @@ lasem_native_render(VALUE self, VALUE input_value, VALUE input_type_value, 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); document = lasem_document_from_input(input, input_size, input_type, &error); @@ -174,8 +178,8 @@ lasem_native_render(VALUE self, VALUE input_value, VALUE input_type_value, VALUE lsm_dom_view_get_size_pixels(view, &width_px, &height_px, NULL); if (explicit_size) { - width_pt = NUM2DBL(width_value); - height_pt = NUM2DBL(height_value); + width_pt = zoom * NUM2DBL(width_value); + height_pt = zoom * NUM2DBL(height_value); width_px = lasem_positive_pixel_size(width_pt, "width"); height_px = lasem_positive_pixel_size(height_pt, "height"); } else { @@ -199,7 +203,7 @@ lasem_native_render(VALUE self, VALUE input_value, VALUE input_type_value, VALUE cairo = cairo_create(surface); cairo_scale(cairo, zoom, zoom); - lsm_dom_view_render(view, cairo, -offset_x, -offset_y); + lsm_dom_view_render(view, cairo, -render_offset_x, -render_offset_y); status = cairo_status(cairo); if (status != CAIRO_STATUS_SUCCESS) { diff --git a/spec/lasem/renderer_spec.rb b/spec/lasem/renderer_spec.rb index cfc55ef..5cd773d 100644 --- a/spec/lasem/renderer_spec.rb +++ b/spec/lasem/renderer_spec.rb @@ -15,6 +15,33 @@ def render_mathml described_class.render(mathml, input_type: :mathml, output_format: :svg) end + def render_svg(**options) + described_class.render( + mathml, + input_type: :mathml, + output_format: :svg, + **options, + ) + 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 @@ -79,13 +106,26 @@ def expect_native_rendered(input, input_type) end it "renders SVG output when the native layer is available" do - unless Lasem.native_available? - skip "Lasem native library is not available" - end + skip_without_native_lasem expect(render_mathml).to include(" Date: Tue, 12 May 2026 21:34:38 +0500 Subject: [PATCH 5/8] Refine Lasem render API and native fallback behavior --- README.adoc | 59 ++++++++++++++++--- ext/lasem/extconf.rb | 61 +------------------ ext/lasem/lasem_ext.c | 87 +++++++++++++++++++++------- ext/lasem/lasem_stub.c | 8 ++- lasem.gemspec | 39 ++++--------- lib/lasem.rb | 10 ++-- lib/lasem/dependency_doctor.rb | 5 +- lib/lasem/error/dependency_error.rb | 15 +++-- lib/lasem/error/option_error.rb | 18 ++++-- lib/lasem/native_loader.rb | 2 +- lib/lasem/render_options.rb | 27 +++++---- lib/lasem/renderer.rb | 21 +++++-- rakelib/lasem.rake | 8 +++ spec/lasem/dependency_doctor_spec.rb | 52 +++++++++-------- spec/lasem/option_error_spec.rb | 26 ++++++++- spec/lasem/renderer_spec.rb | 44 +++++++++----- 16 files changed, 289 insertions(+), 193 deletions(-) diff --git a/README.adoc b/README.adoc index 3a5324e..d904c3e 100644 --- a/README.adoc +++ b/README.adoc @@ -15,13 +15,16 @@ PDF, or PS output: ---- require "lasem" -svg = Lasem.render(mathml_string, input_type: :mathml, output_format: :svg) -png = Lasem.render("$\\sqrt{x}$", input_type: :latex, output_format: :png, ppi: 72.0) +svg = Lasem.render(mathml_string, input: :mathml, output: :svg) +png = Lasem.render("$\\sqrt{x}$", input: :latex, output: :png, ppi: 72.0) ---- `latex` and `itex` input is passed unchanged to Lasem's itex parser. The gem does not infer or add math delimiters. +Supported input values are `:xml`, `:mathml`, `:svg`, `:latex`, and `:itex`. +Supported output values are `:svg`, `:png`, `:pdf`, and `:ps`. + The source repository is named `lasem-ruby` to make the language binding clear, but the gem is named `lasem` because the public Ruby API is already namespaced as `Lasem`. @@ -42,10 +45,14 @@ repository. == Native dependency strategy +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 +to it during installation. + The gem looks for Lasem in this order: -1. A vendored Lasem install under `vendor/lasem/install`. -2. A system Lasem package discovered with `pkg-config`. +1. A system Lasem package discovered with `pkg-config`. +2. A source-checkout vendored Lasem install under `vendor/lasem/install`. 3. A compiled stub extension that raises `Lasem::DependencyError`. The stub keeps the Ruby API loadable on machines that do not have Lasem yet, @@ -53,9 +60,45 @@ while making rendering failures explicit. If rendering raises `Lasem::DependencyError`, run `lasem-doctor --all-warnings` for setup diagnostics. +== Rebuilding after installing Lasem + +If the gem was installed before Lasem was available, Ruby may have compiled the +fallback stub extension. After installing Lasem, rebuild the native extension so +the gem links against the real Lasem library. + +For an installed gem: + +[source,sh] +---- +gem pristine lasem --extensions +---- + +When using Bundler: + +[source,sh] +---- +bundle pristine lasem +---- + +For a source checkout: + +[source,sh] +---- +bundle exec rake clean compile +---- + +Then verify the native extension is available: + +[source,sh] +---- +ruby -rlasem -e 'p Lasem.native_available?' +---- + == Vendored Lasem -Initialize the Lasem submodule under `vendor/lasem/source`, then run: +The vendored build tasks are development helpers for source checkouts. They are +not part of normal gem installation. Initialize the Lasem submodule under +`vendor/lasem/source`, then run: [source,sh] ---- @@ -72,10 +115,6 @@ For extra setup hints, run: exe/lasem-doctor --all-warnings ---- -`ext/lasem/extconf.rb` also attempts this vendored Meson build automatically -when `vendor/lasem/source/meson.build` exists and no Lasem pkg-config package -has been found yet. - The build task installs Lasem into `vendor/lasem/install`. The extension adds that install's `pkgconfig` directories to `PKG_CONFIG_PATH` and embeds the vendored library directory as a runtime library path. @@ -116,6 +155,8 @@ the matching development packages with the package manager for your platform. == Development +Ruby 3.2 or newer is required. + [source,sh] ---- bundle install diff --git a/ext/lasem/extconf.rb b/ext/lasem/extconf.rb index 3aa32d1..9ad3c96 100644 --- a/ext/lasem/extconf.rb +++ b/ext/lasem/extconf.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "mkmf" -require "fileutils" require "shellwords" ROOT = File.expand_path("../..", __dir__) @@ -9,20 +8,6 @@ ENV.fetch("LASEM_INSTALL_DIR", "vendor/lasem/install"), ROOT, ) -VENDORED_SOURCE_DIR = File.expand_path( - ENV.fetch("LASEM_SOURCE_DIR", "vendor/lasem/source"), - ROOT, -) -VENDORED_BUILD_DIR = File.expand_path( - ENV.fetch("LASEM_BUILD_DIR", "vendor/lasem/build"), - ROOT, -) -VENDORED_MESON_OPTIONS = %w[ - --buildtype=release - -Ddocumentation=disabled - -Dintrospection=disabled - -Dviewer=disabled -].freeze def add_pkg_config_path(path) return unless Dir.exist?(path) @@ -42,38 +27,6 @@ def find_lasem_package(candidates) candidates.find { |candidate| pkg_config(candidate) } end -def meson_setup_command - command = ["meson", "setup"] - build_file = File.join(VENDORED_BUILD_DIR, "build.ninja") - command << "--reconfigure" if File.exist?(build_file) - command -end - -def run_vendored_lasem_build - system( - *meson_setup_command, - VENDORED_BUILD_DIR, - VENDORED_SOURCE_DIR, - "--prefix=#{VENDORED_INSTALL_DIR}", - "--libdir=lib", - *VENDORED_MESON_OPTIONS, - ) && - system("meson", "compile", "-C", VENDORED_BUILD_DIR) && - system("meson", "install", "-C", VENDORED_BUILD_DIR) -end - -def build_vendored_lasem - return false unless File.exist?(File.join(VENDORED_SOURCE_DIR, "meson.build")) - - unless find_executable("meson") - warn "Vendored Lasem source exists, but Meson was not found." - return false - end - - FileUtils.mkdir_p(VENDORED_BUILD_DIR) - run_vendored_lasem_build -end - add_pkg_config_path(File.join(VENDORED_INSTALL_DIR, "lib", "pkgconfig")) add_pkg_config_path(File.join(VENDORED_INSTALL_DIR, "lib64", "pkgconfig")) @@ -86,12 +39,6 @@ def build_vendored_lasem lasem_package = find_lasem_package(pkg_config_candidates) -if lasem_package.nil? && build_vendored_lasem - 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(pkg_config_candidates) -end - required_headers = %w[ lsm.h lsmdomparser.h @@ -110,14 +57,12 @@ def build_vendored_lasem $defs << "-DHAVE_LASEM" $srcs = ["lasem_ext.c"] - $objs = ["lasem_ext.o"] - warn "Building lasem-ruby against #{lasem_package}." + warn "Building lasem against #{lasem_package}." else $srcs = ["lasem_stub.c"] - $objs = ["lasem_stub.o"] warn "Lasem was not found; building a stub extension." - warn "Run `bundle exec rake lasem:build` or install a system " \ - "Lasem development package, then rebuild." + 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 index 43d3105..36bb7d7 100644 --- a/ext/lasem/lasem_ext.c +++ b/ext/lasem/lasem_ext.c @@ -16,8 +16,6 @@ static VALUE m_lasem; static VALUE m_native; -static VALUE e_error; -static VALUE e_dependency_error; static VALUE e_render_error; static VALUE @@ -53,18 +51,43 @@ lasem_write_to_ruby_string(void *closure, const unsigned char *data, unsigned in return CAIRO_STATUS_SUCCESS; } -static unsigned int -lasem_positive_pixel_size(double value, const char *name) +static int +lasem_positive_pixel_size(double value, unsigned int *size, const char **message) { if (!isfinite(value) || value <= 0.0) { - rb_raise(e_render_error, "%s must be greater than 0", name); + *message = "must be greater than 0"; + return 0; } if (value > UINT_MAX) { - rb_raise(e_render_error, "%s is too large", name); + *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 (unsigned int) ceil(value); + 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 * @@ -103,7 +126,7 @@ lasem_create_surface(const char *format, VALUE *output, double width_pt, double return cairo_image_surface_create(CAIRO_FORMAT_ARGB32, width_px, height_px); } - rb_raise(e_render_error, "unsupported output format: %s", format); + return NULL; } static VALUE @@ -142,6 +165,7 @@ lasem_native_render(VALUE self, VALUE input_value, VALUE input_type_value, VALUE unsigned int width_px; unsigned int height_px; int explicit_size; + const char *pixel_size_error; StringValue(input_value); StringValue(input_type_value); @@ -158,6 +182,15 @@ lasem_native_render(VALUE self, VALUE input_value, VALUE input_type_value, VALUE render_offset_x = zoom * offset_x; render_offset_y = zoom * offset_y; explicit_size = !NIL_P(width_value) && !NIL_P(height_value); + 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); + width_px = lasem_checked_positive_pixel_size(width_pt, "width"); + height_px = lasem_checked_positive_pixel_size(height_pt, "height"); + } document = lasem_document_from_input(input, input_size, input_type, &error); if (document == NULL) { @@ -172,29 +205,37 @@ lasem_native_render(VALUE self, VALUE input_value, VALUE input_type_value, VALUE lsm_dom_view_set_resolution(view, ppi); - width_pt = 2.0; - height_pt = 2.0; - lsm_dom_view_get_size(view, &width_pt, &height_pt, NULL); - lsm_dom_view_get_size_pixels(view, &width_px, &height_px, NULL); - - if (explicit_size) { - width_pt = zoom * NUM2DBL(width_value); - height_pt = zoom * NUM2DBL(height_value); - width_px = lasem_positive_pixel_size(width_pt, "width"); - height_px = lasem_positive_pixel_size(height_pt, "height"); - } else { + if (!explicit_size) { + width_pt = 2.0; + height_pt = 2.0; + lsm_dom_view_get_size(view, &width_pt, &height_pt, NULL); + lsm_dom_view_get_size_pixels(view, &width_px, &height_px, NULL); width_pt *= zoom; height_pt *= zoom; - width_px = lasem_positive_pixel_size((double) width_px * zoom, "width"); - height_px = lasem_positive_pixel_size((double) height_px * zoom, "height"); + 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", @@ -245,10 +286,12 @@ lasem_native_render(VALUE self, VALUE input_value, VALUE input_type_value, VALUE 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); - e_dependency_error = lasem_get_or_define_class(m_lasem, "DependencyError", e_error); + 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); diff --git a/ext/lasem/lasem_stub.c b/ext/lasem/lasem_stub.c index 25a1771..e58fa87 100644 --- a/ext/lasem/lasem_stub.c +++ b/ext/lasem/lasem_stub.c @@ -25,9 +25,13 @@ lasem_native_render(int argc, VALUE *argv, VALUE self) 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. Run `lasem-doctor --all-warnings` " - "or `bundle exec rake lasem:doctor WARNINGS=all` for setup diagnostics."); + "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 diff --git a/lasem.gemspec b/lasem.gemspec index e8406cd..9e2dba8 100644 --- a/lasem.gemspec +++ b/lasem.gemspec @@ -22,35 +22,16 @@ Gem::Specification.new do |spec| "source_code_uri" => spec.homepage, } - spec.files = Dir.chdir(__dir__) do - `git ls-files -z --recurse-submodules`.split("\x0") - .grep(%r{ - \A(?: - LICENSE\.txt - | README\.adoc - | lasem\.gemspec - | exe/[^/]+ - | ext/lasem/[^/]+\.(?:c|rb) - | lib/.+\.rb - | vendor/lasem/source/ - (?: - COPYING - | NEWS\.md - | README\.md - | TODO - | lasem\.doap - | lasem\.svg - | meson\.build - | meson_options\.txt - | org\.lasem\.Viewer\.json - | (?:itex2mml|po|src|subprojects|tests|viewer)/[^./][^/]* - ) - )\z - }x) - .sort + # 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{\Aexe/}) { |f| File.basename(f) } - spec.extensions = ["ext/lasem/extconf.rb"] + + 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 703fba0..6b7183f 100644 --- a/lib/lasem.rb +++ b/lib/lasem.rb @@ -15,12 +15,12 @@ def self.native_available? NativeLoader.available? end - def self.render(input, input_type: :xml, output_format: :svg, **) + def self.render(source, input: :xml, output: :svg, **options) Renderer.render( - input, - input_type: input_type, - output_format: output_format, - **, + source, + input: input, + output: output, + **options, ) end end diff --git a/lib/lasem/dependency_doctor.rb b/lib/lasem/dependency_doctor.rb index e194291..9dcebfe 100644 --- a/lib/lasem/dependency_doctor.rb +++ b/lib/lasem/dependency_doctor.rb @@ -111,7 +111,10 @@ def missing_submodule_warning end def stale_extension_warning - extension = File.join(root, "lib/lasem/lasem.so") + 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 " \ diff --git a/lib/lasem/error/dependency_error.rb b/lib/lasem/error/dependency_error.rb index 6110ba2..f500b5f 100644 --- a/lib/lasem/error/dependency_error.rb +++ b/lib/lasem/error/dependency_error.rb @@ -2,19 +2,22 @@ module Lasem class DependencyError < Error - NATIVE_LIBRARY_UNAVAILABLE_MESSAGE = "Lasem native library is not " \ - "available. Run " \ - "`lasem-doctor --all-warnings` or " \ - "`bundle exec rake lasem:doctor " \ - "WARNINGS=all` for setup diagnostics." + 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 = NATIVE_LIBRARY_UNAVAILABLE_MESSAGE + 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 index ca19e00..38985a4 100644 --- a/lib/lasem/error/option_error.rb +++ b/lib/lasem/error/option_error.rb @@ -1,7 +1,15 @@ # frozen_string_literal: true module Lasem - class OptionError < Error + 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 @@ -10,6 +18,10 @@ 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 @@ -17,9 +29,5 @@ def self.not_positive(name:) def self.incomplete_size_pair new("width and height must be provided together") end - - def self.unknown_options(names:) - new("unknown option(s): #{names.join(', ')}") - end end end diff --git a/lib/lasem/native_loader.rb b/lib/lasem/native_loader.rb index 0a6e966..1a7ddbf 100644 --- a/lib/lasem/native_loader.rb +++ b/lib/lasem/native_loader.rb @@ -11,7 +11,7 @@ module NativeLoader def available? load - defined?(Native) && Native.native_available? + !!(defined?(Native) && Native.native_available?) rescue DependencyError false end diff --git a/lib/lasem/render_options.rb b/lib/lasem/render_options.rb index 63f8fda..73dd5fc 100644 --- a/lib/lasem/render_options.rb +++ b/lib/lasem/render_options.rb @@ -5,8 +5,8 @@ class RenderOptions INPUT_TYPES = %w[xml mathml svg latex itex].freeze OUTPUT_FORMATS = %w[svg png pdf ps].freeze DEFAULT_OPTIONS = { - input_type: :xml, - output_format: :svg, + input: :xml, + output: :svg, ppi: 72.0, zoom: 1.0, width: nil, @@ -53,14 +53,14 @@ def validate_option_names(options) def normalize_format_options(options) @input_type = choice( - options.fetch(:input_type), + options.fetch(:input), INPUT_TYPES, - "input_type", + "input", ) @output_format = choice( - options.fetch(:output_format), + options.fetch(:output), OUTPUT_FORMATS, - "output_format", + "output", ) end @@ -72,8 +72,8 @@ def normalize_dimension_options(options) end def normalize_position_options(options) - @offset_x = numeric(options.fetch(:offset_x), "offset_x") - @offset_y = numeric(options.fetch(:offset_y), "offset_y") + @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) @@ -92,9 +92,16 @@ def numeric(value, name) raise OptionError.not_numeric(name: name) end - def positive_float(value, name) + def finite_numeric(value, name) number = numeric(value, name) - return number if number.positive? && number.finite? + 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 diff --git a/lib/lasem/renderer.rb b/lib/lasem/renderer.rb index 0e95519..213bda7 100644 --- a/lib/lasem/renderer.rb +++ b/lib/lasem/renderer.rb @@ -2,17 +2,28 @@ module Lasem class Renderer - def self.render(input, **options) - new(input, options).render + def self.render(source, **options) + new(source, options).render end - def initialize(input, options = {}) - @input = String(input) + def initialize(source, options = {}) + @source = normalize_source(source) @options = RenderOptions.new(options) end def render - NativeLoader.render(*@options.native_arguments(@input)) + 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 index 9973336..fbaa6bb 100644 --- a/rakelib/lasem.rake +++ b/rakelib/lasem.rake @@ -10,6 +10,11 @@ LASEM_MESON_OPTIONS = %w[ -Dintrospection=disabled -Dviewer=disabled ].freeze +LASEM_COMPILER_EXECUTABLES = %w[ + cc + gcc + clang +].freeze LASEM_BUILD_EXECUTABLES = %w[ meson pkg-config @@ -37,6 +42,9 @@ def lasem_missing_executables missing = LASEM_BUILD_EXECUTABLES.reject do |executable| lasem_executable?(executable) end + unless LASEM_COMPILER_EXECUTABLES.any? { |executable| lasem_executable?(executable) } + missing << "C compiler (cc, gcc, or clang)" + end missing << "ninja or ninja-build" unless lasem_ninja? missing end diff --git a/spec/lasem/dependency_doctor_spec.rb b/spec/lasem/dependency_doctor_spec.rb index 595d13f..4b0e60b 100644 --- a/spec/lasem/dependency_doctor_spec.rb +++ b/spec/lasem/dependency_doctor_spec.rb @@ -4,31 +4,35 @@ require "stringio" -DependencyDoctorFakeProbe = Struct.new( - :executables, - :pkg_config_versions, - :pkg_config_variables, - :files, - 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]] +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 -end -RSpec.describe Lasem::DependencyDoctor do let(:root) { "/repo" } let(:required_executables) do %w[cc make pkg-config meson ninja bison flex msgfmt] @@ -49,7 +53,7 @@ def pkg_config_variable(package, variable) end def probe(**overrides) - DependencyDoctorFakeProbe.new( + fake_probe_class.new( { executables: [], pkg_config_versions: {}, diff --git a/spec/lasem/option_error_spec.rb b/spec/lasem/option_error_spec.rb index f102d38..b412162 100644 --- a/spec/lasem/option_error_spec.rb +++ b/spec/lasem/option_error_spec.rb @@ -4,7 +4,7 @@ describe ".invalid_choice" do subject(:error) do described_class.invalid_choice( - name: "output_format", + name: "output", allowed_values: %w[svg png], ) end @@ -12,7 +12,18 @@ it "builds an invalid choice error" do expect(error).to have_attributes( class: described_class, - message: "output_format must be one of: svg, png", + 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 @@ -28,6 +39,17 @@ 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") diff --git a/spec/lasem/renderer_spec.rb b/spec/lasem/renderer_spec.rb index 5cd773d..7b5b1b4 100644 --- a/spec/lasem/renderer_spec.rb +++ b/spec/lasem/renderer_spec.rb @@ -11,15 +11,15 @@ MATHML end - def render_mathml - described_class.render(mathml, input_type: :mathml, output_format: :svg) + def render_sample_mathml + described_class.render(mathml, input: :mathml, output: :svg) end def render_svg(**options) described_class.render( mathml, - input_type: :mathml, - output_format: :svg, + input: :mathml, + output: :svg, **options, ) end @@ -55,20 +55,30 @@ def expect_native_rendered(input, input_type) describe ".render" do it "validates the input type" do expect do - described_class.render(mathml, input_type: :unknown) - end.to raise_error(Lasem::OptionError, /input_type/) + 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_format: :jpeg) - end.to raise_error(Lasem::OptionError, /output_format/) + 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, format: :png) - end.to raise_error(Lasem::OptionError, /unknown option.*format/) + described_class.render(mathml, zooom: 2.0) + end.to raise_error(Lasem::OptionError, /unknown option.*zooom/) + end + + it "requires a non-empty source string" do + expect do + described_class.render(nil) + end.to raise_error(Lasem::OptionError, /source/) + + expect do + described_class.render(" ") + end.to raise_error(Lasem::OptionError, /source/) end it "requires a positive ppi value" do @@ -89,10 +99,16 @@ def expect_native_rendered(input, input_type) 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_type: :latex)) + expect(described_class.render("\\sum_d^d", input: :latex)) .to eq("") expect_native_rendered("\\sum_d^d", "latex") end @@ -100,7 +116,7 @@ def expect_native_rendered(input, input_type) it "passes itex input unchanged" do stub_native_render - expect(described_class.render("\\(\\sum_d^d\\)", input_type: :itex)) + expect(described_class.render("\\(\\sum_d^d\\)", input: :itex)) .to eq("") expect_native_rendered("\\(\\sum_d^d\\)", "itex") end @@ -108,7 +124,7 @@ def expect_native_rendered(input, input_type) it "renders SVG output when the native layer is available" do skip_without_native_lasem - expect(render_mathml).to include(" Date: Wed, 13 May 2026 16:55:53 +0500 Subject: [PATCH 6/8] Refine Lasem README and dependency diagnostics --- README.adoc | 351 ++++++++++++++++++++------- docs/images/hero_quadratic.png | Bin 0 -> 2737 bytes docs/images/latex_euler.png | Bin 0 -> 2526 bytes docs/images/latex_integral.png | Bin 0 -> 3143 bytes docs/images/mathml_matrix.png | Bin 0 -> 4396 bytes docs/images/render_samples.rb | 84 +++++++ lib/lasem.rb | 4 +- lib/lasem/dependency_doctor.rb | 22 +- rakelib/lasem.rake | 5 +- spec/lasem/dependency_doctor_spec.rb | 56 +++++ spec/lasem/renderer_spec.rb | 8 +- 11 files changed, 431 insertions(+), 99 deletions(-) create mode 100644 docs/images/hero_quadratic.png create mode 100644 docs/images/latex_euler.png create mode 100644 docs/images/latex_integral.png create mode 100644 docs/images/mathml_matrix.png create mode 100755 docs/images/render_samples.rb diff --git a/README.adoc b/README.adoc index d904c3e..237bace 100644 --- a/README.adoc +++ b/README.adoc @@ -1,79 +1,261 @@ -= lasem += 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` is a thin native wrapper around Lasem. It delegates parsing, layout, -and rendering behavior to the Lasem C API, and keeps the Ruby layer focused on -input validation, ergonomic entry points, and predictable errors. +`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 -The initial API renders MathML, SVG, and Lasem's itex/LaTeX input to SVG, PNG, -PDF, or PS output: +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" -svg = Lasem.render(mathml_string, input: :mathml, output: :svg) -png = Lasem.render("$\\sqrt{x}$", input: :latex, output: :png, ppi: 72.0) +png = Lasem.render( + "$x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$", + input: :latex, + output: :png, + ppi: 192.0, +) + +File.binwrite("quadratic.png", png) ---- -`latex` and `itex` input is passed unchanged to Lasem's itex parser. The gem -does not infer or add math delimiters. +Output: + +image::docs/images/hero_quadratic.png[Quadratic formula] + -Supported input values are `:xml`, `:mathml`, `:svg`, `:latex`, and `:itex`. -Supported output values are `:svg`, `:png`, `:pdf`, and `:ps`. +== Usage -The source repository is named `lasem-ruby` to make the language binding clear, -but the gem is named `lasem` because the public Ruby API is already namespaced -as `Lasem`. +=== `Lasem.render` -== Background +[source,ruby] +---- +Lasem.render(source, input: :xml, output: :svg, **options) # => String +---- -Plurimath image-rendering work originally depended on a `lasem-render` -executable being available on the user's machine. This gem keeps that dependency -inside Ruby instead: applications can call `Lasem.render` and let the native -extension use Lasem directly. +Returns the rendered output as a binary string in the requested format. -The `mathematical` gem is the closest prior Ruby example. It wraps Lasem for -TeX math image output, but its rendering code is coupled to that gem's TeX -parser, API shape, and packaging. This project uses the same underlying Lasem -and Cairo idea while keeping the wrapper focused on Lasem itself, so Plurimath -can depend on it without mixing native rendering code into the main Plurimath -repository. +==== Options -== Native dependency strategy +`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. -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 -to it during installation. +`output`:: + Target format. One of `:svg`, `:png`, `:pdf`, `:ps`. Default `:svg`. -The gem looks for Lasem in this order: +`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`. -1. A system Lasem package discovered with `pkg-config`. -2. A source-checkout vendored Lasem install under `vendor/lasem/install`. -3. A compiled stub extension that raises `Lasem::DependencyError`. +=== `Lasem.native_available?` -The stub keeps the Ruby API loadable on machines that do not have Lasem yet, -while making rendering failures explicit. If rendering raises -`Lasem::DependencyError`, run `lasem-doctor --all-warnings` for setup -diagnostics. +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`. -== Rebuilding after installing Lasem -If the gem was installed before Lasem was available, Ruby may have compiled the -fallback stub extension. After installing Lasem, rebuild the native extension so -the gem links against the real Lasem library. +== Examples -For an installed gem: +.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 gem looks for Lasem in this order: + +. A system Lasem package discovered with `pkg-config`. +. A source-checkout vendored Lasem install under `vendor/lasem/install`. +. A compiled stub extension that raises `Lasem::DependencyError` when called. + +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 ---- -When using Bundler: +With Bundler: [source,sh] ---- @@ -87,71 +269,51 @@ For a source checkout: bundle exec rake clean compile ---- -Then verify the native extension is available: +Verify the result: [source,sh] ---- ruby -rlasem -e 'p Lasem.native_available?' ---- -== Vendored Lasem - -The vendored build tasks are development helpers for source checkouts. They are -not part of normal gem installation. Initialize the Lasem submodule under -`vendor/lasem/source`, then run: -[source,sh] ----- -git submodule update --init vendor/lasem/source -bundle exec rake lasem:doctor -bundle exec rake lasem:build -bundle exec rake clean compile ----- +== Troubleshooting -For extra setup hints, run: +Run the bundled doctor: [source,sh] ---- -exe/lasem-doctor --all-warnings +bundle exec lasem-doctor --all-warnings ---- -The build task installs Lasem into `vendor/lasem/install`. The extension adds -that install's `pkgconfig` directories to `PKG_CONFIG_PATH` and embeds the -vendored library directory as a runtime library path. +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.native_available?` is still `false` after `lasem:build`, rebuild the -extension with `bundle exec rake clean compile` so Ruby stops loading any older -stub extension. +If `Lasem.render` raises `Lasem::DependencyError`, the gem is loading the stub +extension. Rebuild after installing Lasem (see <>). -The vendored Lasem source is LGPL-2.1-or-later. Keep Lasem's license files with -the vendored source and update this gem when the vendored library is updated. -== System dependencies +== Vendored Lasem (source checkout) -Debian or Ubuntu: +The vendored build is a development helper for source checkouts. It is not +part of normal gem installation. [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 +git submodule update --init vendor/lasem/source +bundle exec rake lasem:doctor +bundle exec rake lasem:build +bundle exec rake clean compile ---- -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 ----- +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. -Lasem's upstream documentation recommends Computer Modern fonts from the LyX -font packages: `fonts-lyx` on Debian/Ubuntu and `lyx-fonts` on Fedora. +The vendored Lasem source is licensed under LGPL-2.1-or-later. Keep Lasem's +license files with the vendored source when updating it. -`exe/lasem-doctor` checks the local toolchain and pkg-config dependencies. -Package names vary by operating system and distribution release, so install -the matching development packages with the package manager for your platform. == Development @@ -160,14 +322,14 @@ Ruby 3.2 or newer is required. [source,sh] ---- bundle install -bundle exec rake +bundle exec rake # runs RSpec bundle exec rubocop ---- -Useful environment variables: +Environment variables understood by the build: `LASEM_PKG_CONFIG`:: - Override the pkg-config package name, for example `lasem-0.6`. + Override the pkg-config package name (for example `lasem-0.6`). `LASEM_SOURCE_DIR`:: Override the vendored source directory used by `rake lasem:build`. @@ -178,3 +340,14 @@ Useful environment variables: `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/docs/images/hero_quadratic.png b/docs/images/hero_quadratic.png new file mode 100644 index 0000000000000000000000000000000000000000..a8631aad41493260056d0935ab44dad8194a7a31 GIT binary patch literal 2737 zcma)8`#%$k1DzS>F${Sxc_)vd3?b3PQmT1o%p~u5e6Qz>%k;n~=|P2Y`%?EJj~>K^ zc~0rJ?8fGmrC4P#O`B)F_5BO(IiJrt=W~8Jzn-EqKAsRcZ8-n{06}>n(Exx1{AZ2? zOaD~exX7rVCVk%96A9S;#oF$NHvs^-C=}AoFSF|FZ0>dcNEl<)UI&URwk*!myxv|? zXzZ&JktZ1tbF2P!q5fJ#8anPogql|hxWR1ua{i$|E}Zo#)3z(J(^L+=GgW7H;Jy+0 zF4U-4^D)k%!?lOGie09ph&#m;BGZNF+H%)vl)|gx5k&k+I8#`~bCyB=Ys7_?)|u}^ z-`^skP=W66SkekWMgK-RW&D7b(QyXg7)g5H&E!xceF!S>XdTFxQG^Blds@M)AqW@H}Jro!AH?PC=8#vNg{OtK$oO)^NJt&`GefHu|6 z(;z)KP`+Vhk-Wp)!Nip;BOc62Kc1~ruy&mM;x}l7=f|EV<;)*Z(#r_Tm8AtlVQ;5H zCu@io`qQAR2q5*C$RvFgVT!3HKZjLT1$$NT{*YLgSATltcG+n=-D0UUDLAVBW}Cct zNUq#QGhSkd(82VCb0g(SPf5)mR!Yempl?Jj`a;Bj>9c|xw>F*A-#0hlv>r8=!2MfG zta$))7xC2(&Q-;aP-*A12!5jqwUSHo6QKguoj|ej$+~Qdv0%U9%XGN2*i!6#au~Sa zPiLtJ|12kQd;!HIPnG6kuNR0LusRB`X(*~-g<-`vv$-{PBHOa%zb;~;=|m)dp)=Q! z7yfuU>00zT6y~yv$#?<3daOAZt4hM;G1`PIMSR+{w1z{Xbt|~iXy%&B@n>4=`sqI7 z;2Sp%0d|M1W-3WCd2m1tC~>fB4~*_PYVq)PWhNJ~r5 zjb=ty*|Ymv9NF!C(UnPpDNu>h@<(x|r=*MT)YX!$;m5TKC(?j*d82UTCGknt~ z!VyVoUOHgMoy9bCn*5fqHw{x5+PYDLs9;hG*DAY4I%Lwk}+3@CKt>2eIlgqNevUJo1!*xz^QT2*7$OMz|ppv znoA#_jnrFS+rKnZ^&y9Gum33;+p)UC;h+rFEvEKw1Ml1o5;FtMc<+yIzosS$$U?F9 zqHaINt*5V1>$y6nNOZNZ>m$}s+?inRycFo4MLc7RrJJ~^oAdSOeP`K3!)sb!dX^bo z*{Bzx)$f()rmf}7&BC3Bl23|9dT$A>sUx;sD!diDFx6HCd82sm{Sjb+H6khvo(xGp z__5VH7p$|LOMGq92bv8vzGaF&ql8z_yz+^+^7}`Yp2d@_DY{;6s3i-0t zut@NFIT^I$)JllisHt7JMg97ZBLi;ew5TEKnZOBhj=pZUOq@=$`Jvl(cGP?}xHakr zw!fw~e&1HfJD2f>TLt6vgrLA>+rz#6cT-iG^?LH#mBuOI5Z7mZN9P%M{lstMQ0Qt0 zL?=d%JbRCwKveM z9l|v#fJ*Z$jA&Brz)DSf$aCtY-ySF@#V1$_Iy8l<2bGDD=N|Ryodvbl$6TCXA8GQw z8~=wll~!V#$VkesU*R=IzV$$M73~c&Hvubjj)G;(At9J3Ejg?K8rrArp@pZXYqkTA z$_RX4u1ttPS&a3uw<=J=sNn@k&T)M5?~vK}M{QDr)4;c$HNo2!wdA<>Iq{v1(31el z;P8N=u`djYx)>ZY+cXeB(&hEck-K&GDeee}uC*=fHoG?-NX^l*(f_34mY_p!k|Kjy zKi2FplvC^xCXINW0HJrNHkK9Exub9T^{G1$!JjUtX>CRLn&uzA+BabfXCS$Cw!)ET zP>g&C@{6CLz(EvqCQ)NG@p0%=JK4A1NFy{HzcH20U86l!b^HcP{m_W9gO(+`wAwvY z3SX&^7e+kpTOxh&^G45Saw~(x-4IN{jz1qVqj7UvvpF@wKHM!fij;FDLe>8wK%~R< z;Y#jlseb$TN$c*KB5-a9+hO-AAy<8 zbf$D7)*A*%J`oIZGkK{j{{8~E6+-TQ^eKf7<2cg~<{jbalpv7}RzcMl;*gt`Ryj7F zRtbk*B!ppKyeulbxOL0_;zcp$~pY4P?-mW6|5k=GpgRQgvK@hcMPu0Y>AGj}qT3zWxe;qm5S zs)#eaX3PJdQOuOiRFgk{StM`bD3(RlEVBXzL*1Zd*`@$Ej%mNx8&1_r*0W~ixJt|W zY#DspbP2wIMmFHo1aj)N{U%L{^!%~dsxN=^ptvRc;w$dKPUj@;e%{;$L0SI`yB;z literal 0 HcmV?d00001 diff --git a/docs/images/latex_euler.png b/docs/images/latex_euler.png new file mode 100644 index 0000000000000000000000000000000000000000..8c620f33863fca677f9bfff07482d7227dc7db1d GIT binary patch literal 2526 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 NX2w=VBm?(H{{qg^#yS81 literal 0 HcmV?d00001 diff --git a/docs/images/latex_integral.png b/docs/images/latex_integral.png new file mode 100644 index 0000000000000000000000000000000000000000..613f06dee8d55e849e03b94af87d9128c1c56c7f GIT binary patch literal 3143 zcmb7H`9Bkm{~t3ZSGn&(Zo+aOa~~l_BI@mIxzAB zH|Az4BfLw>*Y{8OJYJ8-^Y#4c`P1|9cs`#wFeh7{Gva3e007SwJ1bWJfJOS0dxF_d zadX9?^YpR%JJ?zQPX7NYdkN0~0IuUJR_1Q;rQgaD(301shD_VkWH`}g*^)~NvL5>U zT1XCSH?XfU4DE2X+RgaF4GShcTk<~pGEW`nb#pa4wmereFxX1)qR<7Tq~>a_Go z_cuz>rM0*H_`7-oqSw?SfQJ$Hy%oiFV^OVTL;wcmwCI16x}?zgUKHSDQML`_gkR1e{*DR9h^h4k4fdow=+VOk9#gZ3V<0$J zF|D1#)C>*UQ;G^5*mcNQrteF@Gyb&M`lT9Ed*R#1Z_jWxnn?_j?&EV_j(`V$)E(Q>dpb?CIJ8S8v0 z@}A`xdro~!(CRKq{NP~~v=2`Ck*27>^D>JVo=GG?${Sk3GGA(5qXLbJ{?-m!Vg|xj zrdxZdnutq1BZd51t%>O+R1aMCKH%O3^Iq$cIafCY*_sn0^y9bJf>fSUXh^PY{gd-B zvZ*od>!kUNPF!g-`c`(9UGevIN&0lw$|RNNFCQ>Y>LEZxyCMA_it=#U3jMC$ARtPM zK^UsZ&toe9t}ygB$e9QQPx2?O(NFa&$?8?t8ok~k2XgRE6s^| zYAs59B@2Efq>OWY{9E@!+Oy^pEghlyM9Oj1_!ZbQe)*XHW9#F=zWx&!N2E|73z@ZP zFoPi7Bs;Tl=O22s?_2JT>D*OV_KXlMJFOK8&)1m{KZ5T#t`mYS{r&5y>A%^OF|T z!xwr@n8H08zbpI4HjvKpQ}Z00WzhXS#qeg|HSXsYs2%ujp;WHe3I@7{?HLdh4I=VcJ!ZNV7DZC&yt=xN0dwd4$K=!2)_uuY177Q_v~vl|3pw9|`2#THXEN^+s(k7Cq4=WOdGs4U zWfTH1Qb4xx%&vWX2Mn_~GYMg~mNxfF5zjuyh>?qxf1JTLAMAh8Lv@ThK>6mYc0e?5 z8|VTd2}ku2dneL+xn|)>zddm~{!mfe{P5fwh7&84FW(KEa_CU@mYee^8CRH;`D(H6 zJG34@uahl1^S)m(bSm%OvO8C~wD$e(2TD@`+%ZWSRT$fbUsBYQo2tHAsgXpb!ZiK{ za;{Qmgz3fn2%JF=luP=VeH|=Wb7NcDatars?rkc*r8{tRWy`u`#F^^aA*cGJ!m0ZA zH1>#O%@8n_8;{kE_&f)mU%6vL{SPlF@NrgLD)>`%H*1UM+z0lP z3K?_JiIc~r%@Q85sRa98DVSGI%Xhp=T*;f@%^JyPuufiTd%NSdW6KU9jid3oSqSOI z{LA(Ioc#*C3IDM))hx-V4BPHAe1kc-X%6LvKSkuUKWf}M0W7~CIJ_Wm7#*}m>9XF| zeF_?Yt-$anzrsT#ZXU|pa62-6h%2rOQ}`wVk&=lwl(!ya!)v1xiYEweUwx4I z-BqwJuSPcyN$Bzb>BMsn4WlcbYI-1aCR~fAmXif>fBJmMU*Z^WK_H6vMjTR(%uk0Fr=;*ELHBTpq(?uc@mR8s$2P z?PittCm8Wko{=>w&3JE#B|r}E<=E|(*i>oRAjaFeL~9IKy1+jrTq{dtYzW|T&6s0K zX){S>V>?db9zF`AllkYLx=ZmyHGPkfXeGWw>!4L5R!?!;s=@GUqQ~urkYWj=g7!Pu zEyx#JsZe?1mnr`P=cjL` z3k6*~Vr2%d+A-W}<6;yTzKWmg^u&4rC$}p*ly7mveG_y<*8`d(bwlrwm)gg#Vw zl#<6-H;i2g@k9+IDqhS-nFXtk4r}}H$sakN^JY&);afF8f6qy#af4KaF(K5gF^=DS z`1ppFWYE#s4~q7)rdrcJNbl-b8RQLtl-8t-KMk8ZMsZ0|uY*R8WW2JqwpNTKQ*z(y zJ0tGc4@XCNq7o&}^7Kv$7SY(&0`;Z~fXGFlbCus$iOua)9b)^>P<*m67rG-#hqCHcZut=XkV!_g58<)aSPW(9~D0s2%bh%m$+B(rVjCSx=><8 zm)f;ansz@IBxffCXB#;eMbt@&wy4CajKz=$h96b{UV=Y&348GEE<*6C@u6-EH%l%1B!<~7-8j<@ z;x|mu7QPtb_^L&2Pn7)RcHqExX!2MVWRk76@&i&8cH=EjyIlBfQG~AuzNjmSs@)KC x^ARXdt2U?9@v!}+u<`#^r%wy`*4e*&{M(%GFavQZr^^6v#oEcL$-+0|{{Wuj^-ur+ literal 0 HcmV?d00001 diff --git a/docs/images/mathml_matrix.png b/docs/images/mathml_matrix.png new file mode 100644 index 0000000000000000000000000000000000000000..5def348468294621d5cab85494c683a02cf702e7 GIT binary patch literal 4396 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 H{qBDNs|6~2 literal 0 HcmV?d00001 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/lib/lasem.rb b/lib/lasem.rb index 6b7183f..7ebb2c8 100644 --- a/lib/lasem.rb +++ b/lib/lasem.rb @@ -15,12 +15,12 @@ def self.native_available? NativeLoader.available? end - def self.render(source, input: :xml, output: :svg, **options) + def self.render(source, input: :xml, output: :svg, **) Renderer.render( source, input: input, output: output, - **options, + **, ) end end diff --git a/lib/lasem/dependency_doctor.rb b/lib/lasem/dependency_doctor.rb index 9dcebfe..9086ff3 100644 --- a/lib/lasem/dependency_doctor.rb +++ b/lib/lasem/dependency_doctor.rb @@ -41,6 +41,7 @@ class DependencyDoctor 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 @@ -122,13 +123,28 @@ def stale_extension_warning end def pkg_config_precedence_warning - resolved_pc_dir = probe.pkg_config_variable("lasem-0.6", "pcfiledir") 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 lasem-0.6` resolves to #{resolved_pc_dir}, while vendored " \ - "Lasem is installed at #{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) diff --git a/rakelib/lasem.rake b/rakelib/lasem.rake index fbaa6bb..674d48b 100644 --- a/rakelib/lasem.rake +++ b/rakelib/lasem.rake @@ -42,9 +42,10 @@ def lasem_missing_executables missing = LASEM_BUILD_EXECUTABLES.reject do |executable| lasem_executable?(executable) end - unless LASEM_COMPILER_EXECUTABLES.any? { |executable| lasem_executable?(executable) } - missing << "C compiler (cc, gcc, or clang)" + 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 diff --git a/spec/lasem/dependency_doctor_spec.rb b/spec/lasem/dependency_doctor_spec.rb index 4b0e60b..a6b7ffa 100644 --- a/spec/lasem/dependency_doctor_spec.rb +++ b/spec/lasem/dependency_doctor_spec.rb @@ -63,6 +63,22 @@ def probe(**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( @@ -105,6 +121,46 @@ def probe(**overrides) 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, diff --git a/spec/lasem/renderer_spec.rb b/spec/lasem/renderer_spec.rb index 7b5b1b4..4ddc93d 100644 --- a/spec/lasem/renderer_spec.rb +++ b/spec/lasem/renderer_spec.rb @@ -71,11 +71,13 @@ def expect_native_rendered(input, input_type) end.to raise_error(Lasem::OptionError, /unknown option.*zooom/) end - it "requires a non-empty source string" do + 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/) @@ -130,8 +132,8 @@ def expect_native_rendered(input, input_type) it "scales explicit export dimensions by zoom" do skip_without_native_lasem - expect(render_svg(width: 10, height: 20, zoom: 2.0)).to include( - 'width="20" height="40" viewBox="0 0 20 40"', + expect(render_svg(width: 10, height: 20, zoom: 2.0)).to match( + /width="20(?:pt)?" height="40(?:pt)?" viewBox="0 0 20 40"/, ) end From 2a56761893a2d32ca84f24a30e5659c1201a2ed1 Mon Sep 17 00:00:00 2001 From: suleman-uzair Date: Fri, 15 May 2026 21:35:13 +0500 Subject: [PATCH 7/8] Refine native Lasem CI hooks --- .github/workflows/rake.yml | 48 +++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) 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 }} From 185ca31ec52e5abcecdb629d609f6b33b3d51fa4 Mon Sep 17 00:00:00 2001 From: suleman-uzair Date: Fri, 15 May 2026 22:20:17 +0500 Subject: [PATCH 8/8] Address Copilot native rendering follow-ups --- README.adoc | 7 +++-- ext/lasem/lasem_ext.c | 38 ++++++++++++++++------------ lib/lasem/dependency_doctor.rb | 30 +++++++++++++++++++--- spec/lasem/dependency_doctor_spec.rb | 15 +++++++++++ spec/lasem/renderer_spec.rb | 37 +++++++++++++++++++++++++++ 5 files changed, 106 insertions(+), 21 deletions(-) diff --git a/README.adoc b/README.adoc index 237bace..837a707 100644 --- a/README.adoc +++ b/README.adoc @@ -238,12 +238,15 @@ 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 gem looks for Lasem in this order: +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 source-checkout vendored Lasem install under `vendor/lasem/install`. . 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. diff --git a/ext/lasem/lasem_ext.c b/ext/lasem/lasem_ext.c index 36bb7d7..ee08b3a 100644 --- a/ext/lasem/lasem_ext.c +++ b/ext/lasem/lasem_ext.c @@ -59,7 +59,7 @@ lasem_positive_pixel_size(double value, unsigned int *size, const char **message return 0; } - if (value > UINT_MAX) { + if (value > INT_MAX) { *message = "is too large"; return 0; } @@ -123,7 +123,7 @@ lasem_create_surface(const char *format, VALUE *output, double width_pt, double if (strcmp(format, "png") == 0) { /* Cairo: raster PNG output is rendered through an ARGB image surface. */ - return cairo_image_surface_create(CAIRO_FORMAT_ARGB32, width_px, height_px); + return cairo_image_surface_create(CAIRO_FORMAT_ARGB32, (int) width_px, (int) height_px); } return NULL; @@ -162,9 +162,10 @@ lasem_native_render(VALUE self, VALUE input_value, VALUE input_type_value, VALUE double offset_y; double render_offset_x; double render_offset_y; - unsigned int width_px; - unsigned int height_px; + unsigned int width_px = 0; + unsigned int height_px = 0; int explicit_size; + int raster_output; const char *pixel_size_error; StringValue(input_value); @@ -182,14 +183,17 @@ lasem_native_render(VALUE self, VALUE input_value, VALUE input_type_value, 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); - width_px = lasem_checked_positive_pixel_size(width_pt, "width"); - height_px = lasem_checked_positive_pixel_size(height_pt, "height"); + 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); @@ -209,18 +213,20 @@ lasem_native_render(VALUE self, VALUE input_value, VALUE input_type_value, VALUE width_pt = 2.0; height_pt = 2.0; lsm_dom_view_get_size(view, &width_pt, &height_pt, NULL); - lsm_dom_view_get_size_pixels(view, &width_px, &height_px, NULL); width_pt *= zoom; height_pt *= zoom; - 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 (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); + } } } diff --git a/lib/lasem/dependency_doctor.rb b/lib/lasem/dependency_doctor.rb index 9086ff3..1a01081 100644 --- a/lib/lasem/dependency_doctor.rb +++ b/lib/lasem/dependency_doctor.rb @@ -12,7 +12,12 @@ class DependencyDoctor ROOT = File.expand_path("../..", __dir__) ExecutableDependency = Struct.new(:name, :executables, keyword_init: true) - PkgConfigDependency = Struct.new(:name, :requirement, keyword_init: true) + PkgConfigDependency = Struct.new( + :name, + :requirement, + :candidates, + keyword_init: true, + ) OutdatedPackage = Struct.new(:dependency, :version, keyword_init: true) EXECUTABLE_DEPENDENCIES = [ @@ -71,11 +76,30 @@ def missing_executables end def pkg_config_versions - @pkg_config_versions ||= PKG_CONFIG_DEPENDENCIES.to_h do |dependency| - [dependency, probe.pkg_config_version(dependency.name)] + @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? diff --git a/spec/lasem/dependency_doctor_spec.rb b/spec/lasem/dependency_doctor_spec.rb index a6b7ffa..2f1a493 100644 --- a/spec/lasem/dependency_doctor_spec.rb +++ b/spec/lasem/dependency_doctor_spec.rb @@ -49,6 +49,7 @@ def pkg_config_variable(package, variable) "cairo" => "1.18.0", "pangocairo" => "1.54.0", "libxml-2.0" => "2.12.0", + "lasem-0.6" => "0.6.0", } end @@ -102,6 +103,20 @@ def with_lasem_pkg_config(value) 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( diff --git a/spec/lasem/renderer_spec.rb b/spec/lasem/renderer_spec.rb index 4ddc93d..e70b51f 100644 --- a/spec/lasem/renderer_spec.rb +++ b/spec/lasem/renderer_spec.rb @@ -24,6 +24,22 @@ def render_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 @@ -129,6 +145,27 @@ def expect_native_rendered(input, input_type) expect(render_sample_mathml).to include("