diff --git a/LICENSE b/LICENSE
index 7511f3e..689d036 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2017 - 2024, Přemysl Eric Janouch 
+Copyright (c) 2017 - 2025, Přemysl Eric Janouch 
 
 Permission to use, copy, modify, and/or distribute this software for any
 purpose with or without fee is hereby granted.
diff --git a/README.adoc b/README.adoc
index 10e581f..674594f 100644
--- a/README.adoc
+++ b/README.adoc
@@ -33,6 +33,8 @@ Runtime dependencies: libcrypto (OpenSSL 1.1 API)
  $ cd builddir
  $ ninja
 
+Go
+~~
 In addition to the C++ version, also included is a native Go port,
 which has enhanced PDF 1.5 support:
 
@@ -56,6 +58,15 @@ Type=^PDF
 Open=%cd %p/extfs-pdf://
 ----
 
+Lua PDF generator
+~~~~~~~~~~~~~~~~~
+Build dependencies: Meson, a C++17 compiler, pkg-config +
+Runtime dependencies: C++ Lua >= 5.3 (custom Meson wrap fallback),
+ cairo >= 1.15.4, pangocairo, libqrencode
+
+This is a parasitic subproject located in the _lpg_ subdirectory.
+It will generate its own documentation.
+
 Contributing and Support
 ------------------------
 Use https://git.janouch.name/p/pdf-simple-sign to report bugs, request features,
diff --git a/lpg/.clang-format b/lpg/.clang-format
new file mode 100644
index 0000000..339b7e1
--- /dev/null
+++ b/lpg/.clang-format
@@ -0,0 +1,9 @@
+BasedOnStyle: LLVM
+ColumnLimit: 80
+IndentWidth: 4
+TabWidth: 4
+UseTab: ForContinuationAndIndentation
+SpaceAfterCStyleCast: true
+AlignAfterOpenBracket: DontAlign
+AlignOperands: DontAlign
+SpacesBeforeTrailingComments: 2
diff --git a/lpg/lpg.cpp b/lpg/lpg.cpp
new file mode 100644
index 0000000..0301945
--- /dev/null
+++ b/lpg/lpg.cpp
@@ -0,0 +1,1138 @@
+//
+// lpg: Lua PDF generator
+//
+// Copyright (c) 2017 - 2025, Přemysl Eric Janouch 
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
+// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
+// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+//
+
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+
+using namespace std;
+using attribute = variant;
+
+#define DefWidget(name) struct name : public Widget
+struct Widget {
+	virtual ~Widget() {}
+
+	unordered_map attributes;
+	using attribute_map = decltype(attributes);
+	Widget *setattr(string key, attribute_map::mapped_type value) {
+		attributes.insert({key, value});
+		return this;
+	}
+
+	optional getattr(const string &name) {
+		if (auto it = attributes.find("_" + name); it != attributes.end())
+			return {it->second};
+		if (auto it = attributes.find(name); it != attributes.end())
+			return {it->second};
+		return {};
+	}
+
+	/// Top-down attribute propagation.
+	virtual void apply_attributes(const attribute_map &attrs = {}) {
+		for (const auto &kv : attrs)
+			if (*kv.first.c_str() != '_')
+				attributes.insert(kv);
+	}
+
+	// We need CAIRO_ROUND_GLYPH_POS_OFF to be set in font options,
+	// which can only be done internally, se we have to pass a context that
+	// is based on an actual PDF surface.
+	//
+	// Font maps also need some kind of a backend, like Cairo.
+
+	/// Compute and return space required for the widget's contents.
+	virtual tuple prepare([[maybe_unused]] PangoContext *pc) {
+		return {0, 0};
+	}
+
+	/// Compute and return space required for the widget's contents,
+	/// given a fixed size (favouring any dimension).
+	virtual tuple prepare_for_size(PangoContext *pc,
+		[[maybe_unused]] double width, [[maybe_unused]] double height) {
+		return prepare(pc);
+	}
+
+	/// Render to the context within the designated space, no clipping.
+	virtual void render([[maybe_unused]] cairo_t *cr, [[maybe_unused]] double w,
+		[[maybe_unused]] double h) {}
+};
+
+/// Special container that basically just fucks with the system right now.
+DefWidget(Frame) {
+	unique_ptr child;
+	Frame(Widget *w) : child(w) {}
+
+	virtual void apply_attributes(const attribute_map &attrs) override {
+		Widget::apply_attributes(attrs);
+		child->apply_attributes(attributes);
+	}
+
+	virtual tuple prepare(PangoContext *pc) override {
+		auto d = child->prepare(pc);
+		if (auto v = getattr("w_override"))
+			get<0>(d) = get(*v);
+		if (auto v = getattr("h_override"))
+			get<1>(d) = get(*v);
+		return d;
+	}
+
+	virtual void render(cairo_t *cr, double w, double h) override {
+		cairo_save(cr);
+
+		if (auto v = getattr("color")) {
+			int rgb = get(*v);
+			cairo_set_source_rgb(cr, ((rgb >> 16) & 0xFF) / 255.,
+				((rgb >> 8) & 0xFF) / 255., (rgb & 0xFF) / 255.);
+		}
+
+		child->render(cr, w, h);
+		cairo_restore(cr);
+	}
+};
+
+#define DefContainer(name) struct name : public Container
+DefWidget(Container) {
+	vector> children;
+
+	inline void add() {}
+	template  void add(Widget *w, Args && ...args) {
+		children.push_back(unique_ptr(w));
+		add(args...);
+	}
+
+	Container(vector> &&children)
+		: children(std::move(children)) {}
+
+	virtual void apply_attributes(const attribute_map &attrs) override {
+		Widget::apply_attributes(attrs);
+		for (auto &i : children)
+			i->apply_attributes(attributes);
+	}
+};
+
+static void finalize_box(vector &sizes, double available) {
+	double fixed = 0, stretched = 0;
+	for (auto s : sizes) {
+		if (s >= 0)
+			fixed += s;
+		else
+			stretched += s;
+	}
+	if (stretched) {
+		auto factor = max(0., available - fixed) / stretched;
+		for (auto &s : sizes)
+			if (s < 0)
+				s *= factor;
+	} else {
+		// TODO(p): One should be able to *opt in* for this.
+		auto redistribute = max(0., available - fixed) / sizes.size();
+		for (auto &s : sizes)
+			s += redistribute;
+	}
+}
+
+DefContainer(HBox) {
+	HBox(vector> children = {})
+		: Container(std::move(children)) {}
+
+	vector widths;
+	virtual tuple prepare(PangoContext *pc) override {
+		double w = 0, h = 0;
+		widths.resize(children.size());
+		for (size_t i = 0; i < children.size(); i++) {
+			auto d = children[i]->prepare(pc);
+			if ((widths[i] = get<0>(d)) > 0)
+				w += widths[i];
+			h = max(h, get<1>(d));
+		}
+		return {w, h};
+	}
+
+	virtual tuple prepare_for_size(
+		PangoContext *pc, double width, double height) override {
+		double w = 0, h = 0;
+		widths.resize(children.size());
+		for (size_t i = 0; i < children.size(); i++) {
+			auto d = children[i]->prepare_for_size(pc, width, height);
+			if ((widths[i] = get<0>(d)) > 0)
+				w += widths[i];
+			h = max(h, get<1>(d));
+		}
+		return {w, h};
+	}
+
+	virtual void render(cairo_t *cr, double w, double h) override {
+		finalize_box(widths, w);
+		for (size_t i = 0; i < children.size(); i++) {
+			cairo_save(cr);
+			children[i]->render(cr, widths[i], h);
+			cairo_restore(cr);
+			cairo_translate(cr, widths[i], 0.);
+		}
+	}
+};
+
+DefContainer(VBox) {
+	VBox(vector> children = {})
+		: Container(std::move(children)) {}
+
+	vector heights;
+	virtual tuple prepare(PangoContext *pc) override {
+		double w = 0, h = 0;
+		heights.resize(children.size());
+		for (size_t i = 0; i < children.size(); i++) {
+			auto d = children[i]->prepare(pc);
+			if ((heights[i] = get<1>(d)) > 0)
+				h += heights[i];
+			w = max(w, get<0>(d));
+		}
+		return {w, h};
+	}
+
+	virtual tuple prepare_for_size(
+		PangoContext *pc, double width, double height) override {
+		double w = 0, h = 0;
+		heights.resize(children.size());
+		for (size_t i = 0; i < children.size(); i++) {
+			auto d = children[i]->prepare_for_size(pc, width, height);
+			if ((heights[i] = get<1>(d)) > 0)
+				h += heights[i];
+			w = max(w, get<0>(d));
+		}
+		return {w, h};
+	}
+
+	virtual void render(cairo_t *cr, double w, double h) override {
+		finalize_box(heights, h);
+		for (size_t i = 0; i < children.size(); i++) {
+			cairo_save(cr);
+			children[i]->render(cr, w, heights[i]);
+			cairo_restore(cr);
+			cairo_translate(cr, 0., heights[i]);
+		}
+	}
+};
+
+/// Fillers just take up space and don't render anything.
+DefWidget(Filler) {
+	double w, h;
+	Filler(double w = -1, double h = -1) : w(w), h(h) {}
+	virtual tuple prepare(
+		[[maybe_unused]] PangoContext *pc) override {
+		return {w, h};
+	}
+};
+
+DefWidget(HLine) {
+	double thickness;
+	HLine(double thickness = 1) : thickness(thickness) {}
+	virtual tuple prepare(
+		[[maybe_unused]] PangoContext *pc) override {
+		return {-1, thickness};
+	}
+	virtual void render(cairo_t *cr, double w, double h) override {
+		cairo_move_to(cr, 0, h / 2);
+		cairo_line_to(cr, w, h / 2);
+		cairo_set_line_width(cr, thickness);
+		cairo_stroke(cr);
+	}
+};
+
+DefWidget(VLine) {
+	double thickness;
+	VLine(double thickness = 1) : thickness(thickness) {}
+	virtual tuple prepare(
+		[[maybe_unused]] PangoContext *pc) override {
+		return {thickness, -1};
+	}
+	virtual void render(cairo_t *cr, double w, double h) override {
+		cairo_move_to(cr, w / 2, 0);
+		cairo_line_to(cr, w / 2, h);
+		cairo_set_line_width(cr, thickness);
+		cairo_stroke(cr);
+	}
+};
+
+DefWidget(Text) {
+	string text;
+	PangoLayout *layout = nullptr;
+	double y_offset = 0.;
+
+	Text(string text = "") : text(text) {}
+	virtual ~Text() override { g_clear_object(&layout); }
+
+	static string escape(const char *s, size_t len) {
+		auto escapechar = [](char c) -> const char * {
+			if (c == '<') return "<";
+			if (c == '>') return ">";
+			if (c == '&') return "&";
+			return nullptr;
+		};
+		string escaped;
+		for (size_t i = 0; i < len; i++)
+			if (auto entity = escapechar(s[i]))
+				escaped += entity;
+			else
+				escaped += s[i];
+		return escaped;
+	}
+
+	void prepare_layout(PangoContext *pc) {
+		g_clear_object(&layout);
+		layout = pango_layout_new(pc);
+		pango_layout_set_markup(layout, text.c_str(), -1);
+		pango_layout_set_alignment(layout, PANGO_ALIGN_LEFT);
+
+		auto fd = pango_font_description_new();
+		if (auto v = getattr("fontfamily"))
+			pango_font_description_set_family(fd, get(*v).c_str());
+		if (auto v = getattr("fontsize"))
+			pango_font_description_set_size(fd, get(*v) * PANGO_SCALE);
+		if (auto v = getattr("fontweight"))
+			pango_font_description_set_weight(fd, PangoWeight(get(*v)));
+
+		// We need this for the line-height calculation.
+		auto font_size =
+			double(pango_font_description_get_size(fd)) / PANGO_SCALE;
+		if (!font_size)
+			pango_font_description_set_size(fd, (font_size = 10));
+
+		// Supposedly this is how this shit works.
+		// XXX: This will never work if the markup changes the font size.
+		if (auto v = getattr("lineheight")) {
+			auto increment = get(*v) - 1;
+			y_offset = increment * font_size / 2;
+			pango_layout_set_spacing(
+				layout, increment * font_size * PANGO_SCALE);
+		}
+
+		// FIXME: We don't want to override what's in the markup.
+		pango_layout_set_font_description(layout, fd);
+		pango_font_description_free(fd);
+	}
+
+	virtual tuple prepare(PangoContext *pc) override {
+		prepare_layout(pc);
+
+		int w, h;
+		pango_layout_get_size(layout, &w, &h);
+		return {
+			double(w) / PANGO_SCALE, double(h) / PANGO_SCALE + 2 * y_offset};
+	}
+
+	virtual tuple prepare_for_size(PangoContext *pc,
+		double width, [[maybe_unused]] double height) override {
+		prepare_layout(pc);
+
+		// It's difficult to get vertical text, so wrap horizontally.
+		pango_layout_set_width(layout, PANGO_SCALE * width);
+
+		int w, h;
+		pango_layout_get_size(layout, &w, &h);
+		return {
+			double(w) / PANGO_SCALE, double(h) / PANGO_SCALE + 2 * y_offset};
+	}
+
+	virtual void render(cairo_t *cr, double w, [[maybe_unused]] double h)
+		override {
+		g_return_if_fail(layout);
+		// Assuming horizontal text, make it span the whole allocation.
+		pango_layout_set_width(layout, PANGO_SCALE * w);
+		pango_cairo_update_layout(cr, layout);
+		cairo_translate(cr, 0, y_offset);
+		pango_cairo_show_layout(cr, layout);
+	}
+};
+
+DefWidget(Link) {
+	string target_uri;
+	unique_ptr child;
+
+	Link(const string &target_uri, Widget *w)
+		: target_uri(target_uri), child(w) {}
+
+	virtual void apply_attributes(const attribute_map &attrs) override {
+		Widget::apply_attributes(attrs);
+		child->apply_attributes(attributes);
+	}
+
+	virtual tuple prepare(PangoContext *pc) override {
+		return child->prepare(pc);
+	}
+
+	virtual void render(cairo_t *cr, double w, double h) override {
+		cairo_save(cr);
+		cairo_tag_begin(
+			cr, CAIRO_TAG_LINK, ("uri='" + target_uri + "'").c_str());
+		child->render(cr, w, h);
+		cairo_tag_end(cr, CAIRO_TAG_LINK);
+		cairo_restore(cr);
+	}
+};
+
+// --- Pictures ----------------------------------------------------------------
+
+struct image_info {
+	double width = 0., height = 0., dpi_x = 72., dpi_y = 72.;
+};
+
+/// http://libpng.org/pub/png/spec/1.2/PNG-Contents.html
+static bool read_png_info(image_info &info, const char *data, size_t length) {
+	return length >= 24 && !memcmp(data, "\211PNG\r\n\032\n", 8) &&
+		!memcmp(data + 12, "IHDR", 4) &&
+		(info.width = ntohl(*(uint32_t *) (data + 16))) &&
+		(info.height = ntohl(*(uint32_t *) (data + 20)));
+}
+
+DefWidget(Picture) {
+	double w = 0, h = 0;
+	double scale_x = 1., scale_y = 1.;
+	cairo_surface_t *surface = nullptr;
+
+	virtual tuple prepare(PangoContext *) override {
+		return {w * scale_x, h * scale_y};
+	}
+
+	virtual void render(cairo_t *cr, double width, double height) override {
+		if (!surface || width <= 0 || height <= 0)
+			return;
+
+		double ww = this->w * scale_x;
+		double hh = this->h * scale_y;
+		double postscale = width / ww;
+		if (hh * postscale > height)
+			postscale = height / hh;
+
+		// For PDF-A, ISO 19005-3:2012 6.2.8: interpolation is not allowed
+		// (Cairo sets it on by default).
+		bool interpolate = true;
+
+		auto pattern = cairo_pattern_create_for_surface(surface);
+		cairo_pattern_set_filter(
+			pattern, interpolate ? CAIRO_FILTER_GOOD : CAIRO_FILTER_NEAREST);
+
+		// Maybe we should also center the picture or something...
+		cairo_scale(cr, scale_x * postscale, scale_y * postscale);
+		cairo_set_source(cr, pattern);
+		cairo_paint(cr);
+
+		cairo_pattern_destroy(pattern);
+	}
+
+	static cairo_surface_t *make_surface_png(const string &data) {
+		using CharRange = pair;
+		CharRange iterator{&*data.begin(), &*data.end()};
+		return cairo_image_surface_create_from_png_stream(
+			[](void *closure, unsigned char *data, uint len) {
+				auto i = (CharRange *) closure;
+				if (i->second - i->first < len)
+					return CAIRO_STATUS_READ_ERROR;
+
+				memcpy(data, i->first, len);
+				i->first += len;
+				return CAIRO_STATUS_SUCCESS;
+			},
+			&iterator);
+	}
+
+	// Cairo doesn't support PNGs in PDFs by MIME type,
+	// until then we'll have to parametrize.
+	static function identify(
+		const string &picture, image_info &info) {
+		if (read_png_info(info, picture.data(), picture.length()))
+			return bind(make_surface_png, picture);
+		return nullptr;
+	}
+
+	Picture(const string &filename) {
+		ifstream t{filename};
+		stringstream buffer;
+		buffer << t.rdbuf();
+		string picture = buffer.str();
+
+		image_info info;
+		if (auto make_surface = identify(picture, info)) {
+			surface = make_surface();
+			w = info.width;
+			h = info.height;
+			scale_x = info.dpi_x / 72.;
+			scale_y = info.dpi_y / 72.;
+		} else {
+			cerr << "warning: unreadable picture: " << filename << endl;
+		}
+	}
+};
+
+// --- QR ----------------------------------------------------------------------
+
+DefWidget(QR) {
+	QRcode *code = nullptr;
+	double T = 1.;
+
+	QR(string text, double T) : T(T) {
+		QRinput *data = QRinput_new2(
+			0 /* Version, i.e., size, here autoselect */,
+			QR_ECLEVEL_M /* 15% correction */);
+		if (!data)
+			return;
+
+		auto u8 = reinterpret_cast(text.data());
+		(void) QRinput_append(data, !QRinput_check(QR_MODE_AN, text.size(), u8)
+			? QR_MODE_AN : QR_MODE_8, text.size(), u8);
+
+		code = QRcode_encodeInput(data);
+		QRinput_free(data);
+	}
+
+	virtual ~QR() override {
+		if (code)
+			QRcode_free(code);
+	}
+
+	virtual tuple prepare([[maybe_unused]] PangoContext *pc)
+		override {
+		if (!code)
+			return {0, 0};
+
+		return {T * code->width, T * code->width};
+	}
+
+	virtual void render(cairo_t *cr,
+		[[maybe_unused]] double w, [[maybe_unused]] double h) override {
+		if (!code)
+			return;
+
+		auto line = code->data;
+		for (int y = 0; y < code->width; y++) {
+			for (int x = 0; x < code->width; x++) {
+				if (line[x] & 1)
+					cairo_rectangle(cr, T * x, T * y, T, T);
+			}
+			line += code->width;
+		}
+		cairo_fill(cr);
+	}
+};
+
+// --- Lua Widget --------------------------------------------------------------
+
+#define XLUA_WIDGET_METATABLE "widget"
+
+struct LuaWidget {
+	// shared_ptr would resolve the reference stealing API design issue.
+	unique_ptr widget;
+};
+
+static void xlua_widget_check(lua_State *L, LuaWidget *self) {
+	if (!self->widget)
+		luaL_error(L, "trying to use a consumed widget reference");
+}
+
+static attribute xlua_widget_tovalue(lua_State *L, LuaWidget *self, int idx) {
+	xlua_widget_check(L, self);
+	if (lua_isnumber(L, idx))
+		return lua_tonumber(L, idx);
+	if (lua_isstring(L, idx)) {
+		size_t len = 0;
+		const char *s = lua_tolstring(L, idx, &len);
+		return string(s, len);
+	}
+	luaL_error(L, "expected string or numeric attributes");
+	return {};
+}
+
+static void xlua_widget_set(
+	lua_State *L, LuaWidget *self, Widget *widget, int idx_attrs) {
+	self->widget.reset(widget);
+	if (!idx_attrs)
+		return;
+
+	lua_pushvalue(L, idx_attrs);
+	lua_pushnil(L);
+	while (lua_next(L, -2)) {
+		if (lua_type(L, -2) == LUA_TSTRING) {
+			size_t key_len = 0;
+			const char *key = lua_tolstring(L, -2, &key_len);
+			widget->setattr(
+				string(key, key_len), xlua_widget_tovalue(L, self, -1));
+		}
+		lua_pop(L, 1);
+	}
+	lua_pop(L, 1);
+}
+
+static int xlua_widget_gc(lua_State *L) {
+	auto self = (LuaWidget *) luaL_checkudata(L, 1, XLUA_WIDGET_METATABLE);
+	self->widget.reset(nullptr);
+	return 0;
+}
+
+static int xlua_widget_index(lua_State *L) {
+	auto self = (LuaWidget *) luaL_checkudata(L, 1, XLUA_WIDGET_METATABLE);
+	// In theory, this could also index container children,
+	// but it does not seem practically useful.
+	auto key = luaL_checkstring(L, 2);
+	xlua_widget_check(L, self);
+
+	if (auto it = self->widget->attributes.find(key);
+		it == self->widget->attributes.end())
+		lua_pushnil(L);
+	else if (auto s = get_if(&it->second))
+		lua_pushlstring(L, s->c_str(), s->length());
+	else if (auto n = get_if(&it->second))
+		lua_pushnumber(L, *n);
+	return 1;
+}
+
+static int xlua_widget_newindex(lua_State *L) {
+	auto self = (LuaWidget *) luaL_checkudata(L, 1, XLUA_WIDGET_METATABLE);
+	auto key = luaL_checkstring(L, 2);
+	xlua_widget_check(L, self);
+
+	self->widget->attributes[key] = xlua_widget_tovalue(L, self, 3);
+	return 0;
+}
+
+static luaL_Reg xlua_widget_table[] = {
+	{"__gc",       xlua_widget_gc},
+	{"__index",    xlua_widget_index},
+	{"__newindex", xlua_widget_newindex},
+	{}
+};
+
+// --- Lua Document ------------------------------------------------------------
+
+#define XLUA_DOCUMENT_METATABLE "document"
+
+struct LuaDocument {
+	cairo_t *cr = nullptr;              ///< Cairo
+	cairo_surface_t *pdf = nullptr;     ///< PDF surface
+	PangoContext *pc = nullptr;         ///< Pango context
+
+	double page_width = 0.;             ///< Page width in 72 DPI points
+	double page_height = 0.;            ///< Page height in 72 DPI points
+	double page_margin = 0.;            ///< Page margins in 72 DPI points
+};
+
+static int xlua_document_gc(lua_State *L) {
+	auto self = (LuaDocument *) luaL_checkudata(L, 1, XLUA_DOCUMENT_METATABLE);
+	cairo_destroy(self->cr);
+	g_object_unref(self->pc);
+	return 0;
+}
+
+static int xlua_document_index(lua_State *L) {
+	if (auto key = luaL_checkstring(L, 2); *key == '_')
+		lua_pushnil(L);
+	else
+		luaL_getmetafield(L, 1, key);
+	return 1;
+}
+
+// And probably for links as well.
+#if CAIRO_VERSION < CAIRO_VERSION_ENCODE(1, 15, 4)
+#error "At least Cairo 1.15.4 is required for setting PDF metadata."
+#endif
+
+static optional metadata_by_name(const char *name) {
+	if (!strcmp(name, "title"))
+		return CAIRO_PDF_METADATA_TITLE;
+	if (!strcmp(name, "author"))
+		return CAIRO_PDF_METADATA_AUTHOR;
+	if (!strcmp(name, "subject"))
+		return CAIRO_PDF_METADATA_SUBJECT;
+	if (!strcmp(name, "keywords"))
+		return CAIRO_PDF_METADATA_KEYWORDS;
+	if (!strcmp(name, "creator"))
+		return CAIRO_PDF_METADATA_CREATOR;
+	if (!strcmp(name, "create_date"))
+		return CAIRO_PDF_METADATA_CREATE_DATE;
+	if (!strcmp(name, "mod_date"))
+		return CAIRO_PDF_METADATA_MOD_DATE;
+	return {};
+}
+
+static int xlua_document_newindex(lua_State *L) {
+	auto self = (LuaDocument *) luaL_checkudata(L, 1, XLUA_DOCUMENT_METATABLE);
+	auto name = luaL_checkstring(L, 2);
+	auto value = luaL_checkstring(L, 3);
+
+	// These are all read-only in Cairo.
+	if (auto id = metadata_by_name(name))
+		cairo_pdf_surface_set_metadata(self->pdf, id.value(), value);
+	else
+		return luaL_error(L, "%s: unknown property");
+	return 0;
+}
+
+static int xlua_document_show(lua_State *L) {
+	auto self = (LuaDocument *) luaL_checkudata(L, 1, XLUA_DOCUMENT_METATABLE);
+	for (int i = 2; i <= lua_gettop(L); i++) {
+		auto w = (LuaWidget *) luaL_checkudata(L, i, XLUA_WIDGET_METATABLE);
+		xlua_widget_check(L, w);
+		auto widget = w->widget.get();
+		widget->apply_attributes();
+
+		auto inner_width = self->page_width - 2 * self->page_margin;
+		auto inner_height = self->page_height - 2 * self->page_margin;
+		widget->prepare_for_size(self->pc, inner_width, inner_height);
+
+		cairo_save(self->cr);
+		cairo_translate(self->cr, self->page_margin, self->page_margin);
+		widget->render(self->cr, inner_width, inner_height);
+		cairo_restore(self->cr);
+	}
+	cairo_show_page(self->cr);
+	return 0;
+}
+
+static luaL_Reg xlua_document_table[] = {
+	{"__gc",       xlua_document_gc},
+	{"__index",    xlua_document_index},
+	{"__newindex", xlua_document_newindex},
+	{"show",       xlua_document_show},
+	{}
+};
+
+// --- Library -----------------------------------------------------------------
+
+// 1 point is 1/72 inch, also applies to PDF surfaces.
+static int xlua_cm(lua_State *L) {
+	lua_pushnumber(L, luaL_checknumber(L, 1) / 2.54 * 72);
+	return 1;
+}
+
+struct xlua_numpunct : public numpunct {
+	optional thousands_sep_override;
+	optional decimal_point_override;
+	optional grouping_override;
+
+	using super = std::numpunct;
+
+	virtual char do_thousands_sep() const override {
+		return thousands_sep_override.value_or(super::do_thousands_sep());
+	}
+
+	virtual char do_decimal_point() const override {
+		return decimal_point_override.value_or(super::do_decimal_point());
+	}
+
+	virtual string_type do_grouping() const override {
+		return grouping_override.value_or(super::do_grouping());
+	}
+};
+
+static int xlua_ntoa(lua_State *L) {
+	luaL_checktype(L, 1, LUA_TTABLE);
+
+	auto np = new xlua_numpunct();
+	const char *field = nullptr;
+	if (lua_getfield(L, 1, (field = "thousands_sep")) != LUA_TNIL) {
+		size_t len = 0;
+		auto str = lua_tolstring(L, -1, &len);
+		if (!str || len != 1)
+			return luaL_error(L, "invalid %s", field);
+		np->thousands_sep_override.emplace(str[0]);
+	}
+	if (lua_getfield(L, 1, (field = "decimal_point")) != LUA_TNIL) {
+		size_t len = 0;
+		auto str = lua_tolstring(L, -1, &len);
+		if (!str || len != 1)
+			return luaL_error(L, "invalid %s", field);
+		np->decimal_point_override.emplace(str[0]);
+	}
+	if (lua_getfield(L, 1, (field = "grouping")) != LUA_TNIL) {
+		size_t len = 0;
+		auto str = lua_tolstring(L, -1, &len);
+		if (!str)
+			return luaL_error(L, "invalid %s", field);
+		np->grouping_override.emplace(string(str, len));
+	}
+
+	ostringstream formatted;
+	formatted.imbue(locale(locale(), np));
+	if (lua_getfield(L, 1, "precision") != LUA_TNIL) {
+		formatted.setf(formatted.fixed, formatted.floatfield);
+		formatted.precision(lua_tointeger(L, -1));
+	}
+
+	lua_geti(L, 1, 1);
+	if (lua_isinteger(L, -1))
+		formatted << lua_tointeger(L, -1);
+	else if (lua_isnumber(L, -1))
+		formatted << lua_tonumber(L, -1);
+	else
+		return luaL_error(L, "number expected as the first field");
+
+	lua_pushstring(L, formatted.str().c_str());
+	return 1;
+}
+
+static int xlua_escape(lua_State *L) {
+	string escaped;
+	for (int i = 1; i <= lua_gettop(L); i++) {
+		size_t len = 0;
+		const char *s = luaL_checklstring(L, i, &len);
+		escaped.append(Text::escape(s, len));
+	}
+	lua_pushlstring(L, escaped.data(), escaped.length());
+	return 1;
+}
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+static int xlua_document(lua_State *L) {
+	const char *filename = luaL_checkstring(L, 1);
+	lua_Number width = luaL_checknumber(L, 2);
+	lua_Number height = luaL_checknumber(L, 3);
+
+	LuaDocument *self =
+		static_cast(lua_newuserdata(L, sizeof *self));
+	luaL_setmetatable(L, XLUA_DOCUMENT_METATABLE);
+	new(self) LuaDocument;
+
+	self->pdf = cairo_pdf_surface_create(filename,
+		(self->page_width = width), (self->page_height = height));
+	self->cr = cairo_create(self->pdf);
+	cairo_surface_destroy(self->pdf);
+
+	self->page_margin = luaL_optnumber(L, 4, self->page_margin);
+
+	auto pc = self->pc = pango_cairo_create_context(self->cr);
+	// By default the resolution is set to 96 DPI but the PDF surface uses 72.
+	pango_cairo_context_set_resolution(pc, 72.);
+
+#if PANGO_VERSION_CHECK(1, 44, 0)
+	// Otherwise kerning was broken in Pango before 1.48.6.
+	// Seems like this issue: https://gitlab.gnome.org/GNOME/pango/-/issues/562
+	// and might be related to: https://blogs.gnome.org/mclasen/2019/08/
+	pango_context_set_round_glyph_positions(pc, FALSE);
+#endif
+	return 1;
+}
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+static LuaWidget *xlua_newwidget(lua_State *L) {
+	LuaWidget *self =
+		static_cast(lua_newuserdata(L, sizeof *self));
+	luaL_setmetatable(L, XLUA_WIDGET_METATABLE);
+	new(self) LuaWidget;
+	return self;
+}
+
+static int xlua_filler(lua_State *L) {
+	luaL_checktype(L, 1, LUA_TTABLE);
+
+	double width = -1, height = -1;
+	if (lua_geti(L, 1, 1); !lua_isnoneornil(L, -1))
+		width = lua_tonumber(L, -1);
+	if (lua_geti(L, 1, 2); !lua_isnoneornil(L, -1))
+		height = lua_tonumber(L, -1);
+
+	auto self = xlua_newwidget(L);
+	xlua_widget_set(L, self, new Filler{width, height}, 1);
+	return 1;
+}
+
+static int xlua_hline(lua_State *L) {
+	luaL_checktype(L, 1, LUA_TTABLE);
+
+	double thickness = 1;
+	if (lua_geti(L, 1, 1); !lua_isnoneornil(L, -1))
+		thickness = lua_tonumber(L, -1);
+
+	auto self = xlua_newwidget(L);
+	xlua_widget_set(L, self, new HLine{thickness}, 1);
+	return 1;
+}
+
+static int xlua_vline(lua_State *L) {
+	luaL_checktype(L, 1, LUA_TTABLE);
+
+	double thickness = 1;
+	if (lua_geti(L, 1, 1); !lua_isnoneornil(L, -1))
+		thickness = lua_tonumber(L, -1);
+
+	auto self = xlua_newwidget(L);
+	xlua_widget_set(L, self, new VLine{thickness}, 1);
+	return 1;
+}
+
+static string xlua_tostring(lua_State *L, int idx) {
+	// Automatic conversions are unlikely to be valid XML.
+	bool escape = !lua_isstring(L, idx);
+
+	size_t length = 0;
+	const char *s = luaL_tolstring(L, idx, &length);
+	string text = escape ? Text::escape(s, length) : string(s, length);
+	lua_pop(L, 1);
+	return text;
+}
+
+static int xlua_text(lua_State *L) {
+	luaL_checktype(L, 1, LUA_TTABLE);
+
+	string text;
+	for (lua_Integer i = 1, len = luaL_len(L, 1); i <= len; i++) {
+		lua_geti(L, 1, i);
+		text.append(xlua_tostring(L, -1));
+		lua_pop(L, 1);
+	}
+
+	auto self = xlua_newwidget(L);
+	xlua_widget_set(L, self, new Text{text}, 1);
+	return 1;
+}
+
+static LuaWidget *xlua_towidget(lua_State *L) {
+	if (luaL_testudata(L, -1, XLUA_WIDGET_METATABLE))
+		return (LuaWidget *) luaL_checkudata(L, -1, XLUA_WIDGET_METATABLE);
+
+	string text = xlua_tostring(L, -1);
+	lua_pop(L, 1);
+
+	auto self = xlua_newwidget(L);
+	xlua_widget_set(L, self, new Text{text}, 0);
+	return self;
+}
+
+static int xlua_frame(lua_State *L) {
+	luaL_checktype(L, 1, LUA_TTABLE);
+	if (luaL_len(L, 1) != 1)
+		return luaL_error(L, "expected one child widget");
+
+	lua_geti(L, 1, 1);
+	auto child = xlua_towidget(L);
+
+	auto self = xlua_newwidget(L);
+	xlua_widget_set(L, self, new Frame{child->widget.release()}, 1);
+	return 1;
+}
+
+static int xlua_link(lua_State *L) {
+	luaL_checktype(L, 1, LUA_TTABLE);
+	if (luaL_len(L, 1) != 2)
+		return luaL_error(L, "expected link target and one child widget");
+
+	lua_geti(L, 1, 1);
+	size_t length = 0;
+	const char *s = luaL_tolstring(L, -1, &length);
+	string target(s, length);
+	lua_pop(L, 1);
+
+	lua_geti(L, 1, 2);
+	auto child = xlua_towidget(L);
+
+	auto self = xlua_newwidget(L);
+	xlua_widget_set(L, self, new Link{target, child->widget.release()}, 1);
+	return 1;
+}
+
+static int xlua_hbox(lua_State *L) {
+	luaL_checktype(L, 1, LUA_TTABLE);
+
+	vector> children;
+	for (lua_Integer i = 1, len = luaL_len(L, 1); i <= len; i++) {
+		lua_geti(L, 1, i);
+		children.emplace_back(xlua_towidget(L)->widget.release());
+		lua_pop(L, 1);
+	}
+
+	auto self = xlua_newwidget(L);
+	xlua_widget_set(L, self, new HBox{std::move(children)}, 1);
+	return 1;
+}
+
+static int xlua_vbox(lua_State *L) {
+	luaL_checktype(L, 1, LUA_TTABLE);
+
+	vector> children;
+	for (lua_Integer i = 1, len = luaL_len(L, 1); i <= len; i++) {
+		lua_geti(L, 1, i);
+		children.emplace_back(xlua_towidget(L)->widget.release());
+		lua_pop(L, 1);
+	}
+
+	auto self = xlua_newwidget(L);
+	xlua_widget_set(L, self, new VBox{std::move(children)}, 1);
+	return 1;
+}
+
+static int xlua_picture(lua_State *L) {
+	luaL_checktype(L, 1, LUA_TTABLE);
+	if (luaL_len(L, 1) != 1)
+		return luaL_error(L, "expected picture path");
+
+	lua_geti(L, 1, 1);
+	size_t length = 0;
+	const char *s = luaL_tolstring(L, -1, &length);
+	string filename(s, length);
+	lua_pop(L, 1);
+
+	auto self = xlua_newwidget(L);
+	xlua_widget_set(L, self, new Picture{filename}, 1);
+	return 1;
+}
+
+static int xlua_qr(lua_State *L) {
+	luaL_checktype(L, 1, LUA_TTABLE);
+	if (luaL_len(L, 1) != 2)
+		return luaL_error(L, "expected contents and module size");
+
+	lua_geti(L, 1, 1);
+	size_t length = 0;
+	const char *s = luaL_tolstring(L, -1, &length);
+	string target(s, length);
+	lua_pop(L, 1);
+
+	lua_geti(L, 1, 2);
+	auto T = lua_tonumber(L, -1);
+	lua_pop(L, 1);
+
+	auto self = xlua_newwidget(L);
+	xlua_widget_set(L, self, new QR{target, T}, 1);
+	return 1;
+}
+
+static luaL_Reg xlua_library[] = {
+	{"cm",       xlua_cm},
+	{"ntoa",     xlua_ntoa},
+	{"escape",   xlua_escape},
+
+	{"Document", xlua_document},
+
+	{"Filler",   xlua_filler},
+	{"HLine",    xlua_hline},
+	{"VLine",    xlua_vline},
+	{"Text",     xlua_text},
+	{"Frame",    xlua_frame},
+	{"Link",     xlua_link},
+	{"HBox",     xlua_hbox},
+	{"VBox",     xlua_vbox},
+	{"Picture",  xlua_picture},
+	{"QR",       xlua_qr},
+	{}
+};
+
+// --- Initialisation, event handling ------------------------------------------
+
+static int xlua_error_handler(lua_State *L) {
+	// Don't add tracebacks when there's already one, and pass nil through.
+	const char *string = luaL_optstring(L, 1, NULL);
+	if (string && !strchr(string, '\n')) {
+		luaL_traceback(L, L, string, 1);
+		lua_remove(L, 1);
+	}
+	return 1;
+}
+
+static void *xlua_alloc([[maybe_unused]] void *ud, void *ptr,
+	[[maybe_unused]] size_t o_size, size_t n_size) {
+	if (n_size)
+		return realloc(ptr, n_size);
+
+	free(ptr);
+	return NULL;
+}
+
+static int xlua_panic(lua_State *L) {
+	cerr << "fatal: Lua panicked: " << lua_tostring(L, -1) << endl;
+	lua_close(L);
+	exit(EXIT_FAILURE);
+	return 0;
+}
+
+int main(int argc, char *argv[]) {
+	if (argc < 2) {
+		cerr << "Usage: " << argv[0] << " program.lua [args...]" << endl;
+		return 1;
+	}
+
+	lua_State *L = lua_newstate(xlua_alloc, NULL);
+	if (!L) {
+		cerr << "fatal: Lua initialization failed" << endl;
+		return 1;
+	}
+	lua_atpanic(L, xlua_panic);
+	luaL_openlibs(L);
+	luaL_checkversion(L);
+
+	luaL_newlib(L, xlua_library);
+	lua_setglobal(L, "lpg");
+
+	luaL_newmetatable(L, XLUA_DOCUMENT_METATABLE);
+	luaL_setfuncs(L, xlua_document_table, 0);
+	lua_pop(L, 1);
+
+	luaL_newmetatable(L, XLUA_WIDGET_METATABLE);
+	luaL_setfuncs(L, xlua_widget_table, 0);
+	lua_pop(L, 1);
+
+	luaL_checkstack(L, argc, NULL);
+
+	// Joining the first two might make a tiny bit more sense.
+	lua_createtable(L, argc - 1, 0);
+	lua_pushstring(L, (string(argv[0]) + " " + argv[1]).c_str());
+	lua_rawseti(L, 1, 1);
+	for (int i = 2; i < argc; i++) {
+		lua_pushstring(L, argv[i]);
+		lua_rawseti(L, 1, i - 1);
+	}
+	lua_setglobal(L, "arg");
+
+	int status = 0;
+	lua_pushcfunction(L, xlua_error_handler);
+	if ((status = luaL_loadfile(L, strcmp(argv[1], "-") ? argv[1] : NULL)))
+		goto error;
+	for (int i = 2; i < argc; i++)
+		lua_pushstring(L, argv[i]);
+	if ((status = lua_pcall(L, argc - 2, 0, 1)))
+		goto error;
+	lua_close(L);
+	return 0;
+
+error:
+	// Lua will unfortunately discard exceptions that it hasn't thrown itself.
+	if (const char *err = lua_tostring(L, -1))
+		cerr << "error: " << err << endl;
+	else
+		cerr << "error: " << status << endl;
+	lua_close(L);
+	return 1;
+}
diff --git a/lpg/lpg.lua b/lpg/lpg.lua
new file mode 100644
index 0000000..caf7b3e
--- /dev/null
+++ b/lpg/lpg.lua
@@ -0,0 +1,240 @@
+#!/usr/bin/env lpg
+local project_url = "https://git.janouch.name/p/pdf-simple-sign"
+
+function h1 (title)
+	return lpg.VBox {fontsize=18., fontweight=600,
+		title, lpg.HLine {2}, lpg.Filler {-1, 6}}
+end
+function h2 (title)
+	return lpg.VBox {fontsize=16., fontweight=600,
+		lpg.Filler {-1, 8}, title, lpg.HLine {1}, lpg.Filler {-1, 6}}
+end
+function h3 (title)
+	return lpg.VBox {fontsize=14., fontweight=600,
+		lpg.Filler {-1, 8}, title, lpg.HLine {.25}, lpg.Filler {-1, 6}}
+end
+function p (...)
+	return lpg.VBox {..., lpg.Filler {-1, 6}}
+end
+function code (...)
+	return lpg.VBox {
+		lpg.Filler {-1, 4},
+		lpg.HBox {
+			lpg.Filler {12},
+			lpg.VBox {"" .. table.concat {...} .. ""},
+			lpg.Filler {},
+		},
+		lpg.Filler {-1, 6},
+	}
+end
+function define (name, ...)
+	return lpg.VBox {
+		lpg.Filler {-1, 2},
+		lpg.Text {fontweight=600, name}, lpg.Filler {-1, 2},
+		lpg.HBox {lpg.Filler {12}, lpg.VBox {...}, lpg.Filler {}},
+		lpg.Filler {-1, 2},
+	}
+end
+function pad (widget)
+	return lpg.VBox {
+		lpg.Filler {-1, 2},
+		lpg.HBox {lpg.Filler {4}, widget, lpg.Filler {}, lpg.Filler {4}},
+		lpg.Filler {-1, 2},
+	}
+end
+
+local page1 = lpg.VBox {fontfamily="sans serif", fontsize=12.,
+	h1("lpg User Manual"),
+	p("lpg is a Lua-based PDF document generator, exposing a trivial " ..
+		"layouting engine on top of the Cairo graphics library, " ..
+		"with manual paging."),
+	p("The author has primarily been using this system to typeset invoices."),
+
+	h2("Synopsis"),
+	p("lpg program.lua [args...]"),
+
+	h2("API"),
+	p("The Lua program receives lpg's and its own path joined " ..
+		"as arg[0].  Any remaining sequential elements " ..
+		"of this table represent the passed args."),
+
+	h3("Utilities"),
+
+	define("lpg.cm (centimeters)",
+		p("Returns how many document points are needed " ..
+			"for the given physical length.")),
+
+	define("lpg.ntoa {number [, precision=…]\n" ..
+		"\t[, thousands_sep=…] [, decimal_point=…] [, grouping=…]}",
+		p("Formats a number using the C++ localization " ..
+			"and I/O libraries.  " ..
+			"For example, the following call results in “3 141,59”:"),
+		code("ntoa {3141.592, precision=2,\n" ..
+			"  thousands_sep=\" \", decimal_point=\",\", " ..
+			"grouping=\"\\003\"}")),
+
+	define("lpg.escape (values...)",
+		p("Interprets all values as strings, " ..
+			"and escapes them to be used as literal text—" ..
+			"all text within lpg is parsed as Pango markup, " ..
+			"which is a subset of XML.")),
+
+	h3("PDF documents"),
+
+	define("lpg.Document (filename, width, height [, margin])",
+		p("Returns a new Document object, whose pages are all " ..
+			"the same size in 72 DPI points, as specified by width " ..
+			"and height.  The margin is used by show " ..
+			"on all sides of pages."),
+		p("The file is finalized when the object is garbage collected.")),
+
+	define("Document.title, author, subject, keywords, " ..
+		"creator, create_date, mod_date",
+		p("Write-only PDF Info dictionary metadata strings.")),
+
+	define("Document:show ([widget...])",
+		p("Starts a new document page, and renders Widget trees over " ..
+		"the whole print area.")),
+
+	lpg.Filler {},
+}
+
+local page2 = lpg.VBox {fontfamily="sans serif", fontsize=12.,
+	h3("Widgets"),
+	p("The layouting system makes heavy use of composition, " ..
+		"and thus stays simple."),
+	p("For convenience, anywhere a Widget is expected but another " ..
+		"kind of value is received, lpg.Text widget will be invoked " ..
+		"on that value."),
+	p("Once a Widget is included in another Widget, " ..
+		"the original Lua object can no longer be used, " ..
+		"as its reference has been consumed."),
+	p("Widgets can be indexed by strings to get or set " ..
+		"their attributes.  All Widget constructor tables " ..
+		"also accept attributes, for convenience.  Attributes can be " ..
+		"either strings or numbers, mostly only act " ..
+		"on specific Widget kinds, and are hereditary.  " ..
+		"Prefix their names with an underscore to set them ‘privately’."),
+	p("Widget sizes can be set negative, which signals to their " ..
+		"container that they should take any remaining space, " ..
+		"after all their siblings’ requests have been satisfied.  " ..
+		"When multiple widgets make this request, that space is distributed " ..
+		"in proportion to these negative values."),
+
+	define("lpg.Filler {[width] [, height]}",
+		p("Returns a new blank widget with the given dimensions, " ..
+			"which default to -1, -1.")),
+	define("lpg.HLine {[thickness]}",
+		p("Returns a new widget that draws a simple horizontal line " ..
+			"of the given thickness.")),
+	define("lpg.VLine {[thickness]}",
+		p("Returns a new widget that draws a simple vertical line " ..
+			"of the given thickness.")),
+	define("lpg.Text {[value...]}",
+		p("Returns a new text widget that renders the concatenation of all " ..
+			"passed values filtered through Lua’s tostring " ..
+			"function.  Non-strings will additionally be escaped."),
+		define("Text.fontfamily, fontsize, fontweight, lineheight",
+			p("Various font properties, similar to their CSS counterparts."))),
+	define("lpg.Frame {widget}",
+		p("Returns a special container widget that can override " ..
+			"a few interesting properties."),
+		define("Frame.color",
+			p("Text and line colour, for example 0xff0000 for red.")),
+		define("Frame.w_override",
+			p("Forcefully changes the child Widget’s " ..
+				"requested width, such as to negative values.")),
+		define("Frame.h_override",
+			p("Forcefully changes the child Widget’s " ..
+				"requested height, such as to negative values."))),
+
+	lpg.Filler {},
+}
+
+local page3 = lpg.VBox {fontfamily="sans serif", fontsize=12.,
+	define("lpg.Link {target, widget}",
+		p("Returns a new hyperlink widget pointing to the target, " ..
+			"which is a URL.  The hyperlink applies " ..
+			"to the entire area of the child widget.  " ..
+			"It has no special appearance.")),
+	define("lpg.HBox {[widget...]}",
+		p("Returns a new container widget that places children " ..
+			"horizontally, from left to right."),
+		p("If any space remains after satisfying the children widgets’ " ..
+			"requisitions, it is distributed equally amongst all of them.  " ..
+			"Also see the note about negative sizes.")),
+	define("lpg.VBox {[widget...]}",
+		p("Returns a new container widget that places children " ..
+			"vertically, from top to bottom.")),
+	define("lpg.Picture {filename}",
+		p("Returns a new picture widget, showing the given filename, " ..
+			"which currently must be in the PNG format.  " ..
+			"Pictures are rescaled to fit, but keep their aspect ratio.")),
+	define("lpg.QR {contents, module}",
+		p("Returns a new QR code widget, encoding the contents " ..
+			"string using the given module size.  " ..
+			"The QR code version is chosen automatically.")),
+	
+	h2("Examples"),
+	p("See the source code of this user manual " ..
+		"for the general structure of scripts."),
+
+	h3("Size distribution and composition"),
+	lpg.VBox {
+		lpg.HLine {},
+		lpg.HBox {
+			lpg.VLine {}, lpg.Frame {_w_override=lpg.cm(3), pad "3cm"},
+			lpg.VLine {}, lpg.Frame {pad "Measured"},
+			lpg.VLine {}, lpg.Frame {_w_override=-1, pad "-1"},
+			lpg.VLine {}, lpg.Frame {_w_override=-2, pad "-2"},
+			lpg.VLine {},
+		},
+		lpg.HLine {},
+	},
+	lpg.Filler {-1, 6},
+	code([[
+function pad (widget)
+  local function f (...) return lpg.Filler {...} end
+  return lpg.VBox {f(-1, 2), lpg.HBox {f(4), w, f(), f(4)}, f(-1, 2)}
+end
+
+lpg.VBox {lpg.HLine {}, lpg.HBox {
+  lpg.VLine {}, lpg.Frame {_w_override=lpg.cm(3), pad "3cm"},
+  lpg.VLine {}, lpg.Frame {pad "Measured"},
+  lpg.VLine {}, lpg.Frame {_w_override=-1, pad "-1"},
+  lpg.VLine {}, lpg.Frame {_w_override=-2, pad "-2"},
+  lpg.VLine {},
+}, lpg.HLine {}}]]),
+
+	h3("Clickable QR code link"),
+	lpg.HBox {
+		lpg.VBox {
+			p("Go here to report bugs, request features, " ..
+				"or submit pull requests:"),
+			code(([[
+url = "%s"
+lpg.Link {url, lpg.QR {url, 2.5}}]]):format(project_url)),
+		},
+		lpg.Filler {},
+		lpg.Link {project_url, lpg.QR {project_url, 2.5}},
+	},
+
+	lpg.Filler {},
+}
+
+if #arg < 1 then
+	io.stderr:write("Usage: " .. arg[0] .. " OUTPUT-PDF..." .. "\n")
+	os.exit(false)
+end
+local width, height, margin = lpg.cm(21), lpg.cm(29.7), lpg.cm(2.0)
+for i = 1, #arg do
+	local pdf = lpg.Document(arg[i], width, height, margin)
+	pdf.title = "lpg User Manual"
+	pdf.subject = "lpg User Manual"
+	pdf.author = "Přemysl Eric Janouch"
+	pdf.creator = ("lpg (%s)"):format(project_url)
+
+	pdf:show(page1)
+	pdf:show(page2)
+	pdf:show(page3)
+end
diff --git a/lpg/meson.build b/lpg/meson.build
new file mode 100644
index 0000000..3ce57ea
--- /dev/null
+++ b/lpg/meson.build
@@ -0,0 +1,24 @@
+project('lpg', 'cpp', default_options : ['cpp_std=c++17'],
+	version : '1.1.1')
+
+conf = configuration_data()
+conf.set_quoted('PROJECT_NAME', meson.project_name())
+conf.set_quoted('PROJECT_VERSION', meson.project_version())
+configure_file(output : 'config.h', configuration : conf)
+
+luapp = dependency('lua++', allow_fallback : true)
+cairo = dependency('cairo')
+pangocairo = dependency('pangocairo')
+libqrencode = dependency('libqrencode')
+lpg_exe = executable('lpg', 'lpg.cpp',
+	install : true,
+	dependencies : [luapp, cairo, pangocairo, libqrencode])
+
+# XXX: https://github.com/mesonbuild/meson/issues/825
+docdir = get_option('datadir') / 'doc' / meson.project_name()
+lpg_pdf = custom_target('lpg.pdf',
+	output : 'lpg.pdf',
+	input : 'lpg.lua',
+	command : [lpg_exe, '@INPUT@', '@OUTPUT@'],
+	install_dir : docdir,
+	build_by_default : true)
diff --git a/lpg/subprojects/lua++.wrap b/lpg/subprojects/lua++.wrap
new file mode 100644
index 0000000..1ddf5d6
--- /dev/null
+++ b/lpg/subprojects/lua++.wrap
@@ -0,0 +1,10 @@
+[wrap-file]
+directory = lua-5.4.7
+source_url = https://github.com/lua/lua/archive/refs/tags/v5.4.7.tar.gz
+source_filename = lua-5.4.7.tar.gz
+source_hash = 5c39111b3fc4c1c9e56671008955a1730f54a15b95e1f1bd0752b868b929d8e3
+patch_directory = lua-5.4.7
+
+[provide]
+lua++-5.4 = lua_dep
+lua++ = lua_dep
diff --git a/lpg/subprojects/packagefiles/lua-5.4.7/LICENSE.build b/lpg/subprojects/packagefiles/lua-5.4.7/LICENSE.build
new file mode 100644
index 0000000..c62f655
--- /dev/null
+++ b/lpg/subprojects/packagefiles/lua-5.4.7/LICENSE.build
@@ -0,0 +1,20 @@
+Copyright (c) 2025 Přemysl Eric Janouch 
+Copyright (c) 2021 The Meson development team
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/lpg/subprojects/packagefiles/lua-5.4.7/meson.build b/lpg/subprojects/packagefiles/lua-5.4.7/meson.build
new file mode 100644
index 0000000..dbc8ff6
--- /dev/null
+++ b/lpg/subprojects/packagefiles/lua-5.4.7/meson.build
@@ -0,0 +1,50 @@
+project(
+  'lua-5.4',
+  'cpp',
+  license : 'MIT',
+  meson_version : '>=0.49.2',
+  version : '5.4.7',
+  default_options : ['c_std=c99', 'warning_level=2'],
+)
+
+cxx = meson.get_compiler('cpp')
+
+# Skip bogus warning.
+add_project_arguments(cxx.get_supported_arguments(
+  '-Wno-string-plus-int', '-Wno-stringop-overflow'), language : 'cpp')
+
+# Platform-specific defines.
+is_posix = host_machine.system() in ['cygwin', 'darwin', 'dragonfly', 'freebsd',
+  'gnu', 'haiku', 'linux', 'netbsd', 'openbsd', 'sunos']
+if is_posix
+  add_project_arguments('-DLUA_USE_POSIX', language : 'cpp')
+endif
+
+# Library dependencies.
+lua_lib_deps = [cxx.find_library('m', required : false)]
+
+if meson.version().version_compare('>= 0.62')
+  dl_dep = dependency('dl', required : get_option('loadlib'))
+else
+  dl_dep = cxx.find_library('dl', required : get_option('loadlib'))
+endif
+
+if dl_dep.found()
+  lua_lib_deps += dl_dep
+  add_project_arguments('-DLUA_USE_DLOPEN', language : 'cpp')
+endif
+
+# Targets.
+add_project_arguments('-DMAKE_LIB', language : 'cpp')
+lua_lib = static_library(
+  'lua',
+  'onelua.cpp',
+  dependencies : lua_lib_deps,
+  implicit_include_directories : false,
+)
+
+inc = include_directories('.')
+lua_dep = declare_dependency(
+  link_with : lua_lib,
+  include_directories : inc,
+)
diff --git a/lpg/subprojects/packagefiles/lua-5.4.7/meson_options.txt b/lpg/subprojects/packagefiles/lua-5.4.7/meson_options.txt
new file mode 100644
index 0000000..ea6f6c4
--- /dev/null
+++ b/lpg/subprojects/packagefiles/lua-5.4.7/meson_options.txt
@@ -0,0 +1,4 @@
+option(
+  'loadlib', type : 'feature',
+  description : 'Allow Lua to "require" C extension modules'
+)
diff --git a/lpg/subprojects/packagefiles/lua-5.4.7/onelua.cpp b/lpg/subprojects/packagefiles/lua-5.4.7/onelua.cpp
new file mode 100644
index 0000000..6517028
--- /dev/null
+++ b/lpg/subprojects/packagefiles/lua-5.4.7/onelua.cpp
@@ -0,0 +1 @@
+#include "onelua.c"
diff --git a/meson.build b/meson.build
index d68a99d..668b612 100644
--- a/meson.build
+++ b/meson.build
@@ -14,10 +14,10 @@ executable('pdf-simple-sign', 'pdf-simple-sign.cpp',
 asciidoctor = find_program('asciidoctor')
 foreach page : ['pdf-simple-sign']
 	custom_target('manpage for ' + page,
-		input: page + '.adoc', output: page + '.1',
-		command: [asciidoctor, '-b', 'manpage',
+		input : page + '.adoc', output: page + '.1',
+		command : [asciidoctor, '-b', 'manpage',
 			'-a', 'release-version=' + meson.project_version(),
 			'@INPUT@', '-o', '@OUTPUT@'],
-		install: true,
-		install_dir: join_paths(get_option('mandir'), 'man1'))
+		install : true,
+		install_dir : join_paths(get_option('mandir'), 'man1'))
 endforeach