Porting poppler to meson

This article is part of the Don't shell out! series.

It took a hot minute.

Try several weeks.

Well, yeah. I got to contribute to a bunch of open-source projects in the meantime though, so I'm fairly pleased with it!

In the current state of things, I have: a fork of glib, a (tiny) fork of cairo, and a whole set of meson build files for poppler... and it's really easy to build for both Windows & Linux!

All of the following happens in poppler-meson, but you can assume we're pretty much starting from scratch: a lot of my fixes have already landed, and the others will soon, so for example I don't have to pass -DFFI_STATIC_BUILD=1 myself.

Let's get through it real quick:

In poppler-meson there's a top-level meson.build that reads like this:

meson.build file
project('poppler-meson', 'c',
          version: '1.0.0',
          meson_version: '>= 0.60.2',
          default_options: [

poppler_dep = dependency(
  static: true,

Because not every fix has landed yet, we still want to override some dependencies that would normally be pulled by poppler.

subprojects/glib.wrap points to my fork of glib:

meson .wrap file
directory = glib

dependency_names = gthread-2.0, gobject-2.0, gmodule-no-export-2.0, gmodule-export-2.0, gmodule-2.0, glib-2.0, gio-2.0, gio-win32-2.0, gio-unix-2.0
program_names = glib-genmarshal, glib-mkenums, glib-compile-schemas, glib-compile-resources, gio-querymodules, gdbus-codegen

And so does subprojects/cairo.wrap:

meson .wrap file
url = https://gitlab.freedesktop.org/fasterthanlime/cairo.git
revision = amos/win32-static-build

Can't make your mind about branch names huh?

Less judging more shipping.

Both these subprojects have their own dependencies (which also get checked out as subprojects, but which I don't track in git), but also, they do something like this:

meson.build file
if meson.version().version_compare('>=0.54.0')
  meson.override_dependency('glib-2.0', libglib_dep)

Which means that when poppler or cairo will request dependency glib-2.0, it'll use my fork, and not another version of glib.

subprojects/poppler.wrap is interesting: it fetches poppler sources from the official 21.12.0 release, and then overlays a folder on top of it, which contains only meson.build files:

meson .wrap file
directory = poppler
source_url = https://poppler.freedesktop.org/poppler-21.12.0.tar.xz
source_filename = poppler-21.12.0.tar.xz
source_hash = acb840c2c1ec07d07e53c57c4b3a1ff3e3ee2d888d44e1e9f2f01aaf16814de7

patch_directory = poppler-21.12.0

dependency_names = poppler, poppler-glib

That folder is in subprojects/packagefiles/poppler-21.12.0.

The first thing we should probably look at is meson_options.txt: it's just a list of features poppler can be built with & without:

meson.build file
  type: 'feature',
  value: 'enabled',
  description: 'Compile poppler with glib wrapper',

  type: 'feature',
  value: 'auto',
  description: 'Enable the cairo rendering backend',

# (etc.)

I didn't make an effort to ensure all combinations build, I just went for the minimal build I needed, so it's not ready to contribute upstream yet.

For comparison, in CMake, these look like this:

option(ENABLE_GLIB "Compile poppler glib wrapper." ON)

The main meson.build file starts by defining a project and some variables we'll need later;

meson.build file
project('poppler', 'cpp',
      version: '21.12.0',
      meson_version: '>= 0.60.2',
      default_options: ['default_library=static'],

gnome = import('gnome')

glib_required = '1.10.0'

poppler_version = meson.project_version()
poppler_version_array = poppler_version.split('.')
poppler_major_version = poppler_version_array[0].to_int()
poppler_minor_version = poppler_version_array[1].to_int()
poppler_micro_version = poppler_version_array[2].to_int()

os_unix   = false
os_linux  = false
os_win32  = false
os_darwin = false

# Some windowing system backends depend on the platform we're
# building for, so we need to ensure they are disabled; in other
# cases, they are the only windowing system available, so we need
# to ensure they are enabled
if host_machine.system() == 'darwin'
  os_darwin = true
elif host_machine.system() == 'windows'
  os_win32 = true
elif host_machine.system() == 'linux'
  os_linux = true
os_unix = not os_win32

poppler_prefix = get_option('prefix')
poppler_includedir = join_paths(poppler_prefix, get_option('includedir'))
poppler_libdir = join_paths(poppler_prefix, get_option('libdir'))
poppler_pkgconfigdir = join_paths(poppler_libdir, 'pkgconfig')

cc = meson.get_compiler('cpp')

Then retrieving all the options we've defined, and finding dependencies:

meson.build file
jpeg_opt = get_option('jpeg')
openjpeg_opt = get_option('openjpeg')
libcurl_opt = get_option('libcurl')

zlib_opt = get_option('zlib')
zlib_uncompress_opt = get_option('zlib-uncompress')
zlib_dep = dependency('zlib', required: zlib_opt)

glib_opt = get_option('glib')
glib_dep = dependency('glib-2.0',
  version: '>= ' + glib_required,
  static: true,
  required: glib_opt,
  default_options: ['tests=false'],
  fallback: 'glib',
gobject_dep = dependency('gobject-2.0',
  version: '>= ' + glib_required,
  required: glib_opt,
gio_dep = dependency('gio-2.0',
  version: '>= ' + glib_required,
  required: glib_opt,

cairo_opt = get_option('cairo')
cairo_dep = dependency('cairo',
  static: true,
  required: cairo_opt,
  default_options: [
  fallback: ['cairo'],
cairogobj_dep = dependency('cairo-gobject',
  required: cairo_opt,
  fallback: ['cairo'],

nss3_opt = get_option('nss3')

freetype_dep = dependency('freetype2',
  required: true,
  fallback: ['freetype2', 'freetype_dep'],

You can see it's.. opinionated already, disabling most of cairo's features.

Then, we have a bunch of checks! To make sure we have a certain header, or that a type is defined in a certain header, etc. Just portability stuff:

meson.build file
cdata = configuration_data()

# Header checks

cdata.set('HAVE_DLFCN_H', cc.has_header('dlfcn.h'))
cdata.set('HAVE_FCNTL_H', cc.has_header('fcntl.h'))
cdata.set('HAVE_STDLIB_H', cc.has_header('stdlib.h'))
cdata.set('HAVE_SYS_MMAN_H', cc.has_header('sys/mman.h'))
cdata.set('HAVE_SYS_STAT_H', cc.has_header('sys/stat.h'))
cdata.set('HAVE_UNISTD_H', cc.has_header('unistd.h'))

cdata.set('HAVE_FSEEK64', cc.has_function('fseek64'))
cdata.set('HAVE_FSEEKO', cc.has_header_symbol('stdio.h', 'fseeko'))
cdata.set('HAVE_FTELL64', cc.has_function('ftell64'))
cdata.set('HAVE_PREAD64', cc.has_function('pread64'))
cdata.set('HAVE_LSEEK64', cc.has_function('lseek64'))
cdata.set('HAVE_GMTIME_R', cc.has_function('gmtime_r'))
cdata.set('HAVE_GETTIMEOFDAY', cc.has_function('gettimeofday'))
cdata.set('HAVE_LOCALTIME_R', cc.has_function('localtime_r'))
cdata.set('HAVE_POPEN', cc.has_function('popen'))
cdata.set('HAVE_MKSTEMP', cc.has_function('mkstemp'))
cdata.set('HAVE_STRTOK_R', cc.has_function('strtok_r'))

dir_code = '''
int main(int argc, char *argv[])
  DIR* d = 0;
  return 0;
cdata.set('HAVE_DIRENT_H', cc.compiles('#include <dirent.h>\n' + dir_code, name : 'Header <dirent.h> defines DIR'))
cdata.set('HAVE_NDIR_H', cc.compiles('#include <ndir.h>\n' + dir_code, name : 'Header <ndir.h> defines DIR'))
cdata.set('HAVE_SYS_DIR_H', cc.compiles('#include <sys/dir.h>\n' + dir_code, name : 'Header <sys/dir.h> defines DIR'))
cdata.set('HAVE_SYS_NDIR_H', cc.compiles('#include <sys/ndir.h>\n' + dir_code, name : 'Header <sys/ndir.h> defines DIR'))

cdata.set('HAVE_NANOSLEEP', cc.has_function('nanosleep'))

# Enable these unconditionally.
cdata.set('OPI_SUPPORT', '1')
cdata.set('TEXTOUT_WORD_LIST', '1')

# Stubs
cdata.set('ICONV_CONST', '')
cdata.set('POPPLER_DATADIR', '')
cdata.set('POPPLER_VERSION', poppler_version)

if libcurl_opt.enabled()
  cdata.set('POPPLER_HAS_CURL_SUPPORT', '1')

That configuration data passed later to "configure_file" to generate a poppler-config.h, in other meson.build files (we'll get to that).

pkg-config files (.pc) have other variables, which we also need to set: it's mostly paths.

meson.build file
pkgconfig_cdata = configuration_data()

pkgconfig_cdata.set('CMAKE_INSTALL_PREFIX', poppler_prefix)
pkgconfig_cdata.set('CMAKE_INSTALL_FULL_LIBDIR', poppler_libdir)
pkgconfig_cdata.set('CMAKE_INSTALL_FULL_INCLUDEDIR', poppler_includedir)
pkgconfig_cdata.set('POPPLER_VERSION', poppler_version)
pkgconfig_cdata.set('GLIB_REQUIRED', glib_required)
pkgconfig_cdata.set('CAIRO_VERSION', cairo_dep.version())

# Configure "Requires" field & install .pc files for packagers
pkgconfig_cdata.set('PC_REQUIRES', '')
pkgconfig_cdata.set('PC_REQUIRES_PRIVATE', 'Requires.private: poppler = @0@'.format(poppler_version))

Then, there's an internal/ subdirectory, which is not in the original poppler sources:

meson.build file
# put config.h in an internal directory so
# users don't include it by accident

conf_inc = include_directories('internal')

We'll get to what's inside that internal/ subdirectory later.

There's two main poppler source directories we're interested in: poppler/, and glib/. There's also some base utilities, like goo/ and fofi/, but those are actually handled by poppler/meson.build.

meson.build file

if glib_opt.enabled()

Finally, we print a nice summary of everything:

meson.build file
#### Summary ####

summary('zlib support', zlib_dep.found(), section: 'Features')
summary('glib support', glib_dep.found(), section: 'Features')
summary('cairo support', cairo_dep.found(), section: 'Features')

summary('Compiler', cc.get_id(), section: 'Toolchain')
summary('Linker', cc.get_linker_id(), section: 'Toolchain')

# Build
summary('Debugging', get_option('debug'), section: 'Build')
summary('Optimization', get_option('optimization'), section: 'Build')

# Directories
summary('prefix', poppler_prefix, section: 'Directories')
summary('includedir', poppler_includedir, section: 'Directories')
summary('libdir', poppler_libdir, section: 'Directories')

Here's what the summary looks like on Fedora 35, for example:

Shell session
poppler 21.12.0

    zlib support : True
    glib support : True
    cairo support: True

    Compiler     : gcc
    Linker       : ld.bfd

    Debugging    : False
    Optimization : 3

    prefix       : /tmp/poppler-prefix
    includedir   : /tmp/poppler-prefix/include
    libdir       : /tmp/poppler-prefix/lib64

And here's what it looks like on Windows:

PowerShell session
poppler 21.12.0

    zlib support : True
    glib support : True
    cairo support: True

    Compiler     : msvc
    Linker       : link

    Debugging    : True
    Optimization : 0

    prefix       : C:\poppler-prefix
    includedir   : C:/poppler-prefix/include
    libdir       : C:/poppler-prefix/lib

Wowee, everybody gets a different slash!

Well, as it turns out, Windows has accepted forward-slash as an alternate path separator for a long time.

Oh word?

Yeah! I've tried finding a definitive source on this, but I mostly found endless arguments

So for now, just trust me that they both work:

PowerShell session
(Get-Item C:\Users\amos).Name && (Get-Item C:/Users/amos).Name

That's it for the top-level meson.build file!

Now let's peek inside internal/meson.build:

meson.build file
  input: '../config.h.cmake',
  output: 'config.h',
  configuration: cdata,
  format: 'cmake',

That's it! config.h.cmake ships with poppler, and it looks like this:

C code
/* config.h.  Generated from config.h.cmake by cmake.  */

/* Build against libcurl. */
#cmakedefine ENABLE_LIBCURL 1

/* (cut.) */

/* Define to 1 if you have the <dirent.h> header file, and it defines `DIR'.
#cmakedefine HAVE_DIRENT_H 1

/* Define to 1 if you have the <dlfcn.h> header file. */
#cmakedefine HAVE_DLFCN_H 1

/* Define to 1 if you have the <fcntl.h> header file. */
#cmakedefine HAVE_FCNTL_H 1

/* (cut.) */

Once configured, it ends up in build/subprojects/poppler-21.12.0/internal/config.h, looking like so:

C code
/* config.h.  Generated from config.h.cmake by cmake.  */

/* Build against libcurl. */
/* #undef ENABLE_LIBCURL */

/* (cut.) */

/* Define to 1 if you have the <dirent.h> header file, and it defines `DIR'.

/* Define to 1 if you have the <dlfcn.h> header file. */
#define HAVE_DLFCN_H

/* Define to 1 if you have the <fcntl.h> header file. */
#define HAVE_FCNTL_H

/* (cut.) */

As you can see, the Meson folks have made it rather easy to "port from CMake", by supporting the various formats use for configuring files. We'll see one more example of this.

Time to look at poppler/meson.build

yawn are we almost done?

Patience bear (and others) - the result is worth all that work.

Mh. If you say so.

First, we configure poppler-config.h.cmake, another magic header file:

meson.build file
  input: 'poppler-config.h.cmake',
  output: 'poppler-config.h',
  configuration: cdata,
  format: 'cmake',

That one contains stuff like version numbers, and some duplicates from config.h. For example, this:

C code
/* Defines the poppler version. */

Turns into this:

C code
/* Defines the poppler version. */
#define POPPLER_VERSION "21.12.0"

Next, we list all source files we're gonna need. This one gets pretty long...

Ready? Set? Scroll!

meson.build file
poppler_sources = files([





if libcurl_opt.enabled()
  poppler_sources += files([

if cairo_opt.enabled()
  poppler_sources += files([

if jpeg_opt.enabled()
  poppler_sources += files([

if openjpeg_opt.enabled()
  poppler_sources += files([
  poppler_sources += files([

if zlib_opt.enabled()
  poppler_sources += files([

if zlib_uncompress_opt.enabled()
  poppler_sources += files([

if nss3_opt.enabled()
  poppler_source += files([

Note that we use the files function, which returns a list[file] - that's right, not everything is stringly-typed in Meson!

The rest is pretty straightforward.

We declare a build target, from all those source files, with all the right include directories, specifying that that one should be installed to the prefix:

meson.build file
poppler = build_target('poppler',
  target_type : 'static_library',
  dependencies: [freetype_dep],
  include_directories: include_directories('../internal', '.', '../goo', '..'),
  install: true,

Then we declare a dependency, so other meson projects can depend on it:

meson.build file
poppler_dep = declare_dependency(
  link_with: poppler,
  include_directories : include_directories('.'),

And then, just like glib and cairo before us, we override the poppler dependency, so that if any other meson subprojects are looking for a poppler, they use us and no one else:

meson.build file
if meson.version().version_compare('>=0.54.0')
  meson.override_dependency('poppler', poppler_dep)

And finally, we configure the .pc file:

meson.build file
  input: '../poppler.pc.cmake',
  output: 'poppler.pc',
  configuration: pkgconfig_cdata,
  format: 'cmake@',
  install_dir: poppler_pkgconfigdir,

Which turns this:


Name: poppler
Description: PDF rendering library

Libs: -L${libdir} -lpoppler
Cflags: -I${includedir}/poppler

Into this:


Name: poppler
Description: PDF rendering library
Version: 21.12.0

Libs: -L${libdir} -lpoppler
Cflags: -I${includedir}/poppler


All we have left is glib/meson.build, which is the glib interface to poppler (just like poppler has a QT5/QT6 interface):

It looks very similar to what we've seen so far. First we configure a third header:

meson.build file
cdata_features = configuration_data()

cdata_features.set('CAIRO_FEATURE', '#define POPPLER_HAS_CAIRO 1')
cdata_features.set('POPPLER_MAJOR_VERSION', poppler_major_version)
cdata_features.set('POPPLER_MINOR_VERSION', poppler_minor_version)
cdata_features.set('POPPLER_MICRO_VERSION', poppler_micro_version)

  input: 'poppler-features.h.cmake',
  output: 'poppler-features.h',
  configuration: cdata_features,
  format: 'cmake@',

Then we list our "public" headers, that will be installed into the prefix:

meson.build file
poppler_glib_public_headers = files([
  subdir: poppler_includedir / 'poppler/glib')

Here's the tricky bit, and the reason we imported the gnome meson module:

meson.build file
poppler_enums = gnome.mkenums('poppler-enums',
  sources: poppler_glib_public_headers,
  c_template: 'poppler-enums.c.template',
  h_template: 'poppler-enums.h.template',
  install_dir: poppler_includedir / 'poppler/glib',
  install_header: true,

This configures the poppler-enums.h.template file, that looks like this:

C code
/*** BEGIN file-header ***/


#include <glib-object.h>

#include "poppler.h"

/*** END file-header ***/

/*** BEGIN file-production ***/

/* enumerations from "@filename@" */
/*** END file-production ***/

/*** BEGIN value-header ***/
GType @enum_name@_get_type (void) G_GNUC_CONST;
#define POPPLER_TYPE_@ENUMSHORT@ (@enum_name@_get_type ())
/*** END value-header ***/

/*** BEGIN file-tail ***/

#endif /* !POPPLER_ENUMS_H */
/*** END file-tail ***/

Into build/subprojects/poppler-21.12.0/glib/poppler-enums.h, which looks like that:

C code
/* This file is generated by glib-mkenums, do not modify it. This code is licensed under the same license as the containing project. Note that it links to GLib, so must comply with the LGPL linking clauses. */


#include <glib-object.h>

#include "poppler.h"


/* enumerations from "/home/amos/bearcove/poppler-meson/build/../subprojects/poppler-21.12.0/glib/poppler-action.h" */
GType poppler_action_type_get_type (void) G_GNUC_CONST;
#define POPPLER_TYPE_ACTION_TYPE (poppler_action_type_get_type ())
GType poppler_dest_type_get_type (void) G_GNUC_CONST;
#define POPPLER_TYPE_DEST_TYPE (poppler_dest_type_get_type ())
GType poppler_action_movie_operation_get_type (void) G_GNUC_CONST;
#define POPPLER_TYPE_ACTION_MOVIE_OPERATION (poppler_action_movie_operation_get_type ())
GType poppler_action_layer_action_get_type (void) G_GNUC_CONST;
#define POPPLER_TYPE_ACTION_LAYER_ACTION (poppler_action_layer_action_get_type ())

/* (cut.) */

#endif /* !POPPLER_ENUMS_H */

/* Generated data ends here */

Using a python3 script that parses headers like poppler's glib/poppler-action.h:

C code
#ifndef __POPPLER_ACTION_H__
#define __POPPLER_ACTION_H__

#include <glib-object.h>
#include "poppler.h"


/* (cut.) */

typedef enum
} PopplerDestType;

/* (cut.) */

That's... terrifying. They're parsing C headers from Python?

OOhhh yes.

        # read lines until we have no open comments
        while re.search(r'/\*([^*]|\*(?!/))*$', line):
            line += curfile.readline()

        # strip comments w/o options
        line = re.sub(r'''/\*(?!<)
           \*/''', '', line)

        # ignore forward declarations
        if re.match(r'\s*typedef\s+enum.*;', line):

        m = re.match(r'''\s*typedef\s+enum\s*[_A-Za-z]*[_A-Za-z0-9]*\s*
               \s*({)?''', line, flags=re.X)

Why does that Python feel like Perl...

...because it used to be:

#!/usr/bin/env python3

# If the code below looks horrible and unpythonic, do not panic.
# It is.
# This is a manual conversion from the original Perl script to
# Python. Improvements are welcome.


Are you having fun?

I mean, yes. This almost makes up for not really talking about vector graphics for like ten pages.

Splendid. I knew you'd like it.

Then, we list source files, including the ones we generated with glib-mkenums:

meson.build file
poppler_glib_sources = files([

poppler_glib_generated_sources = files([
poppler_glib_generated_sources += poppler_enums
poppler_glib_sources += poppler_glib_generated_sources

And then, the usual dance: declare a build target (with a lot of dependencies there, chaos glib is a ladder), declare a dependency, override it...

meson.build file
poppler_glib_cpp_args = []
if cc.get_id() == 'msvc'
  poppler_glib_cpp_args += ['-DG_OS_WIN32=1']

poppler_glib = build_target('poppler-glib',
  target_type: 'static_library',
  cpp_args: poppler_glib_cpp_args,
  dependencies: [glib_dep, gobject_dep, gio_dep, cairo_dep, cairogobj_dep],
  include_directories: include_directories('../internal', '.', '../goo', '../poppler', '..'),
  install: true,

poppler_glib_dep = declare_dependency(
  link_with: poppler_glib,
  include_directories : include_directories('.'),

if meson.version().version_compare('>=0.54.0')
  meson.override_dependency('poppler-glib', poppler_glib_dep)

And configure the pkg-config file:

meson.build file
  input: '../poppler-glib.pc.cmake',
  output: 'poppler-glib.pc',
  configuration: pkgconfig_cdata,
  format: 'cmake@',
  install_dir: poppler_pkgconfigdir,

And we're home free!

The next step, of course, is to build that in CI.

This article is part 5 of the Don't shell out! series.

Read the next part

If you liked what you saw, please support my work!

Github logo Donate on GitHub Patreon logo Donate on Patreon

Latest video View all

video cover image
C++ vs Rust: which is faster?

I ported some Advent of Code solutions from C/C++ to Rust, and used the opportunity to compare performance. When I couldn't explain why they performed differently, I had no choice but to disassemble both and look at what the codegen was like!

Watch now
Looking for the homepage?
Another article: Profiling linkers