From 27df875912a13014b6cc4b9f38d840e41c786013 Mon Sep 17 00:00:00 2001 From: Jun Pataleta Date: Fri, 21 Aug 2026 20:21:40 +0800 Subject: [PATCH] [docs] Document tagged PDF generation for MDL-83411 Add a PDF API guide covering the new \core\pdf\document class, which wraps tc-lib-pdf and emits tagged PDF/UA output by default, and add a Moodle 5.3 developer update entry pointing at it. The guide covers creating a document, writing content that tags correctly, the memory cost of the structure tree, font handling and the CLI conversion script that replaces addTTFfont(), the two constants TCPDF and tc-lib-pdf share, and a migration table for plugins currently extending \pdf. Co-Authored-By: Claude Opus 5 --- docs/apis/core/pdf/index.md | 252 ++++++++++++++++++++++++++++++++++++ docs/devupdate.md | 14 ++ project-words.txt | 1 + 3 files changed, 267 insertions(+) create mode 100644 docs/apis/core/pdf/index.md diff --git a/docs/apis/core/pdf/index.md b/docs/apis/core/pdf/index.md new file mode 100644 index 000000000..638cba5e9 --- /dev/null +++ b/docs/apis/core/pdf/index.md @@ -0,0 +1,252 @@ +--- +title: PDF API +tags: + - PDF + - Accessibility + - Files +description: Generating tagged, accessible PDF documents in Moodle. +--- + + + +Moodle generates PDF documents using [tc-lib-pdf](https://github.com/tecnickcom/tc-lib-pdf), wrapped by +the `\core\pdf\document` class. This is the preferred way to create a PDF from Moodle code. + +The older `\pdf` class in `lib/pdflib.php`, which wraps +[TCPDF](https://github.com/tecnickcom/TCPDF), is still present and still supported, but it is +discouraged for new code. TCPDF cannot produce _tagged_ output, which means the documents it +generates carry no structural information and cannot be navigated by a screen reader. + +:::info[Why the change] + +A PDF is only accessible if it is _tagged_: the file has to carry a structure tree describing which +runs of text are headings, which are paragraphs, which cells belong to which table header, and what +an image depicts. TCPDF has never emitted one. tc-lib-pdf gained PDF/UA support in April 2026, which +made this migration possible. + +::: + +## Creating a document {/* #creating-a-document */} + +```php title="A minimal document" +$doc = new \core\pdf\document(); + +// PDF/UA requires a document title, so always set a meaningful one. +$doc->setTitle(get_string('coursereport', 'mod_example')); + +$doc->addPage(); +$region = $doc->page->getRegion(); + +$doc->addHTMLCell( + html: '

Course report

Hello world.

', + posx: 20, + posy: 20, + width: $region['RW'] - 40, + height: 0, +); + +$contents = $doc->getOutPDFString(); +``` + +The document can then be sent to the browser, or stored: + +```php title="Sending the document" +$doc->setPDFFilename('coursereport.pdf'); +send_file($doc->getOutPDFString(), 'coursereport.pdf', 0, 0, true, true, 'application/pdf'); +``` + +### What `\core\pdf\document` adds {/* #what-document-adds */} + +`\core\pdf\document` extends the library's own `Com\Tecnick\Pdf\Tcpdf` class, so the full upstream +API is available. On top of it, the Moodle wrapper: + +- defaults the conformance profile to **PDF/UA-1**, so accessible output is what you get without + opting in; +- sets the document language and reading direction from the current language; +- sets the creator to the Moodle release, and the author to the site full name; +- resolves the default font, honouring `$CFG->pdfexportfont`; +- points the library's temporary file cache inside `$CFG->cachedir` rather than the system temporary + directory; +- builds the allowlist of local filesystem paths the library may read images and fonts from. + +:::warning[Use `addPage()`, not `$doc->page->add()`] + +Both add a page, but only `addPage()` tells the graphics layer the dimensions of the new page. Text +is positioned correctly either way, so the difference does not show up until something is drawn. At +that point, cell borders and background fills are placed against a page height of zero and land off +the page. + +::: + +### Conformance profiles {/* #conformance-profiles */} + +The constructor takes a `Com\Tecnick\Pdf\PdfConformance` enum. It defaults to +`PdfConformance::Pdfua1`, and the PDF/A and PDF/X profiles are also available. Pass +`PdfConformance::None` only when a caller genuinely needs a feature the accessible profile forbids: + +```php title="Opting out of tagged output" +$doc = new \core\pdf\document(mode: \Com\Tecnick\Pdf\PdfConformance::None); +``` + +## Writing accessible content {/* #writing-accessible-content */} + +Headings, tables, lists, links and images with alt text passed to `addHTMLCell()` are mapped to PDF +structure elements automatically. **Preferring HTML content over manually positioned text is the +simplest way to keep a document accessible**, and the reason `dataformat_pdf` was rewritten to build +an HTML table rather than drawing each cell. + +The rules are the familiar HTML accessibility rules: + +- always call `setTitle()`; +- do not skip heading levels; +- mark table header cells up as `` with a `scope` attribute, and put the header row inside a + `` so the library repeats it on each new page; +- give every `` an `alt` attribute. + +```php title="A tagged table" +$html = \html_writer::tag('thead', \html_writer::tag('tr', + \html_writer::tag('th', 'Name', ['scope' => 'col']) . + \html_writer::tag('th', 'Grade', ['scope' => 'col']), +)); +$html .= \html_writer::tag('tbody', $rows); + +$doc->addHTMLCell(html: \html_writer::tag('table', $html), /* ... */); +``` + +:::note[Images must be embedded] + +The library refuses local reads outside an explicit allowlist, and a Moodle file URL would be +fetched over HTTP and return the login page. Embed file content as a `data:` URI instead, as +`dataformat_pdf` does in `export_html_image_source()`. + +::: + +### Tagging costs memory {/* #tagging-costs-memory */} + +Rendering HTML in one pass means holding the document in memory, and the structure tree is itself +substantial, at roughly 0.08MB per table row, where TCPDF's cell-by-cell approach was flat +regardless of size. Moodle only requires a 96MB memory limit, so **code that generates a document +of unbounded size should raise the limit**: + +```php +raise_memory_limit(MEMORY_EXTRA); +``` + +The generated files are legitimately larger too: a thousand-row report export grows from around +200KB to around 770KB. + +## Fonts {/* #fonts */} + +The bundled font families are `core` (standard-14 metrics, not embedded), `freefont` (GNU FreeFont, +the default) and `pdfa` (embedding-safe, required for PDF/A and PDF/UA output). They live in +`lib/tecnickcom/tc-lib-pdf-font/fonts/`. + +`\core\pdf\document::DEFAULT_FONT` is `freeserif`. The `freefont` family names line up exactly with +TCPDF's, so `PDF_DEFAULT_FONT` and `$CFG->pdfexportfont` carry over unchanged. + +A handful of static helpers are available for working out what a site has: + +| Method | Purpose | +| --- | --- | +| `document::get_available_fonts()` | Every font family available, mapped to the directory it was found in. | +| `document::font_exists($family)` | Whether a family is bundled or present in the site font directory. | +| `document::find_font_file($family)` | The definition file for a family, or `null`. | +| `document::get_font_directories()` | The directories searched for font definitions. | +| `document::get_site_font_directory()` | Where a site adds its own fonts. Honours `PDF_CUSTOM_FONT_PATH`, otherwise `$CFG->dataroot/fonts`. | +| `document::get_unconverted_fonts()` | Families the site added for TCPDF that this library cannot read. | + +### Site-added fonts have to be converted again {/* #converting-fonts */} + +The two libraries share the `.z` and `.ctg.z` companion files but differ in the metrics file: TCPDF +reads a `.php`, tc-lib-pdf reads a `.json`. A font added for TCPDF is therefore **invisible** to the +new library, and a document configured to use it silently falls back to the default font. For a +script FreeSerif does not cover, that means a document full of blank space. + +TCPDF's `addTTFfont()` has no equivalent here. A new CLI script replaces it: + +```bash +php admin/cli/convert_pdf_font.php --font=/path/to/font.ttf +``` + +It writes to the site font directory by default, and prints the resulting family name for +`$CFG->pdfexportfont`. `--list` shows what the site already has. Sites are also notified on the admin +notifications page when unconverted fonts are detected. + +The existing TCPDF-format files should be left in place: PDF annotation and the Brickfield +accessibility report still use them. + +:::note[Non-fatal, but noisy] + +When `$CFG->pdfexportfont` names a font that cannot be found, `\core\pdf\document` emits a +`DEBUG_DEVELOPER` message rather than failing, and falls back to `freeserif`. + +::: + +## Coexistence with TCPDF {/* #coexistence-with-tcpdf */} + +Both libraries are in core, and they share some ground. Two constants matter if you are working in +this area: + +- **`K_PATH_CACHE`** is read _and_ defined by both libraries, so whichever loads first in a request + wins. Both now agree on `$CFG->cachedir . '/tcpdf/'`, and neither redefines it. Left unguarded, + TCPDF's cache could silently move out of moodledata and into the system temporary directory. +- **`K_PATH_FONTS`** deliberately still belongs to TCPDF and points at the TCPDF font set. It is + claimed first-come, first-served, so Moodle must not define it: TCPDF would then look for its own + fonts in the wrong place, breaking PDF annotation and Brickfield. tc-lib-pdf finds its bundled + fonts by walking up from its own source directory, and the site font directory through the + Moodle-specific `K_PATH_ADDITIONAL_FONTS` constant instead. + +Both behaviours are covered by unit tests in `lib/tests/pdf/document_test.php`, because they are +easy to break by tidying up the font path later. + +:::info[tc-lib-pdf-font is patched] + +`K_PATH_ADDITIONAL_FONTS` exists because of a small Moodle patch to two files in +`tc-lib-pdf-font`, marked with `MOODLE PATCH` comments and recorded in +`lib/tecnickcom/readme_moodle.txt`. The package is flagged `` in `thirdpartylibs.xml`. +The patch is temporary: once TCPDF is removed, `K_PATH_FONTS` is free and both hunks can go. + +That same file is the starting point for anyone updating the bundled library. + +::: + +## Migrating from `\pdf` {/* #migrating-from-pdf */} + +`class pdf extends TCPDF` is public API, so it is unchanged and plugins that extend it keep working. +It is not deprecated yet: the deprecation and the eventual removal of TCPDF are tracked separately. + +When you do migrate, the important lesson from converting `dataformat_pdf` is that **swapping the +engine is not enough; the calling code has to change**. The accessibility gain came from rewriting +the writer to emit a real `` with `` and scoped `
`. The old writer drew each cell +individually, which is precisely the untagged grid of unrelated cells the migration exists to get +away from, and it would have stayed that way under a new library. This is also why `\pdf` cannot +simply be reparented onto tc-lib-pdf. + +Rough equivalents: + +| TCPDF / `\pdf` | tc-lib-pdf / `\core\pdf\document` | +| --- | --- | +| `new \pdf()` | `new \core\pdf\document()` | +| `AddPage('L')` | `addPage(['format' => 'A4', 'orientation' => 'L'])` | +| `writeHTMLCell($w, $h, $x, $y, $html, ...)` | `addHTMLCell(html: $html, posx: $x, posy: $y, width: $w, height: $h)` | +| `Output($filename, 'D')` | `getOutPDFString()`, then `send_file()` | +| `Output($filepath, 'F')` | `getOutPDFString()`, then `file_put_contents()` | +| `SetFont($family, '', $size)` | `set_default_font($family, $size)` | +| `getPageWidth()`, `getMargins()` | `$doc->page->getRegion()` | +| `addTTFfont()` | `admin/cli/convert_pdf_font.php` | +| `TCPDF2DBarcode` | `Com\Tecnick\Barcode` (tc-lib-barcode, already bundled) | +| `Header()` / `Footer()` overrides | No direct equivalent. | + +TCPDF also remains in place for `mod_assign`'s PDF annotation, which depends on FPDI. tc-lib-pdf has +its own import subsystem, so FPDI is not needed long term, but that conversion has not been done. + +## Testing {/* #testing */} + +`lib/tests/other/pdfdocumenttestpage.php` is the counterpart of the existing +`lib/tests/other/pdflibtestpage.php`, and is available to site administrators. It generates the same +document with and without the PDF/UA profile, so the two can be compared in a structure inspector. +This matters because tc-lib-pdf has no interface of its own to test against. + +To confirm that output really is tagged, inspect the structure tree with a PDF/UA checker such as +[PAC](https://pdfua.foundation/en/pdf-accessibility-checker-pac/), or with the accessibility panel +of a PDF reader. diff --git a/docs/devupdate.md b/docs/devupdate.md index 4b3df850c..529800abd 100644 --- a/docs/devupdate.md +++ b/docs/devupdate.md @@ -28,3 +28,17 @@ The `moodle_page` class now includes `set_supplementary_content()` and `get_supp For instance, `mod_forum` uses this new mechanism to display a "Go to all discussions" link in the sticky footer when viewing an individual discussion. For more information, see the [Adding supplementary content to the sticky footer](./apis/plugintypes/format/linear_navigation.md#adding-supplementary-content-to-the-sticky-footer) section. + +## Tagged, accessible PDF generation + + + +Moodle 5.3 adds the [tc-lib-pdf](https://github.com/tecnickcom/tc-lib-pdf) library, and a new `\core\pdf\document` class that wraps it with the Moodle defaults for language, metadata, fonts and local file access. It emits tagged PDF/UA output by default, so documents generated through it are accessible to assistive technology. + +`\core\pdf\document` is now the preferred way to generate a PDF. The `\pdf` class in `lib/pdflib.php`, which wraps TCPDF, is unchanged and still supported, so plugins that extend it keep working. It is, however, discouraged for new code, because TCPDF cannot produce tagged output. Whether TCPDF is eventually removed is being tracked separately. + +`dataformat_pdf` is the first core output to be converted, so report exports are now tagged tables rather than grids of unrelated cells. The remaining core PDF output (PDF annotation, the Brickfield accessibility report, and QR code generation) is unchanged for now, and conversion of each is being tracked separately. + +Note that sites which added their own fonts for PDF export have to convert them again from the original `.ttf` or `.otf` file, using the new `admin/cli/convert_pdf_font.php` script. The two libraries read different font metrics formats, so a font prepared for TCPDF is invisible to the new one and exports silently fall back to the default font. + +For usage, the accessibility rules, font handling and a migration table, see the [PDF API guide](./apis/core/pdf/index.md). diff --git a/project-words.txt b/project-words.txt index 1aa8653f2..78bd673e4 100644 --- a/project-words.txt +++ b/project-words.txt @@ -25,6 +25,7 @@ Docsearch Docusaurus Equella Filepool +FPDI Gapfill Gifreu GSOC