Skip to content

feat(engines): WeasyPrint as an optional engine for HTML, Markdown and e-mail to PDF - #1412

Open
Ugoyo wants to merge 3 commits into
KnifeLemon:mainfrom
Ugoyo:claude/weasyprint-1403
Open

Ugoyo wants to merge 3 commits into
KnifeLemon:mainfrom
Ugoyo:claude/weasyprint-1403

Conversation

@Ugoyo

@Ugoyo Ugoyo commented Nov 12, 2026

Copy link
Copy Markdown

What does this change?

Part of #1403 (Windows; Linux and macOS follow in a separate PR).

Adds WeasyPrint as an optional engine for HTML, Markdown and e-mail → PDF, following the maintainer's comment on #1403:

  1. Optional download, like calibre. It's pinned in engines.json and offered in Settings → Engines as an EnginePackage (Windows only for now). It is never taken from the PATH: WeasyPrintConverter.Locate() only looks at Filee's download (engines/weasyprint) or a copy the user picked. It is not ticked by default in the first-run / installer choice (IsSuggested), even though it is under the 60 MB limit, because it changes how pages become PDFs. Tell me if you'd rather have it suggested.

    • Pinned asset: WeasyPrint 70.0 Windows "onedir" build, weasyprint-windows-onedir.zip
      (https://github.com/Kozea/WeasyPrint/releases/download/v70.0/weasyprint-windows-onedir.zip),
      32,271,073 bytes (~32 MB), SHA-256 AB1151F210B4E6BB7AA7A79E91A67E8DDB760094C107BFDA55241B6AAEFE7D53, about 65 MB unpacked.
      The zip holds LICENSE, README.rst and onedir/weasyprint/weasyprint.exe with its _internal/ folder (Python 3.14, Pango, HarfBuzz, FreeType, Fontconfig, FriBidi, GLib, Pillow, fontTools, Pyphen, OpenSSL, MinGW/UCRT runtime). The installer keeps the folder that contains weasyprint.exe and moves the licence into it (EngineInstaller.WeasyPrintFolder; build/fetch-engines.ps1 -Only weasyprint does the same thing for development). 70.0 is also the release with the fix for CVE-2026-55073 / GHSA-r543-q48m-4c9j.
  2. The ranking changes only when WeasyPrint is installed. WeasyPrint has one edge, HTML → PDF, with cost 1. A new ConversionEdge.Sources field (a one-line check in RoutePlanner, next to ImageSourcesOnly) limits that edge to routes that start from HTML, Markdown, EML, MSG or MHTML. Routes:

    • html → pdf = WeasyPrint;
    • md → pdf = Markdig → WeasyPrint;
    • eml/msg/mhtml → pdf = e-mail engine → WeasyPrint.

    This wins over the built-in route (31) and LibreOffice (37) wherever WeasyPrint sits in the engine order. A DOCX, an e-book or a PDF never goes through a page to reach it. Without WeasyPrint the edge isn't planned, so nothing changes. A test compares every format → pdf/png/jpg/docx/hwpx/txt/html route with and without WeasyPrint. I didn't add it to the default EnginePriority, because its cost already decides, so no settings migration is needed.

  3. Locked down. WeasyPrint gets a copy of the page made by LinkedContent's existing HTML rule (the one LibreOffice already uses, built on DocumentFiles):

    • pictures inside the document's folder (or the Markdown file's / e-mail's folder) become data: URIs, SVG included;
    • every other picture, frame, object, <base>, <link> stylesheet, @import and CSS url() address is removed.

    WeasyPrint then runs with --allowed-protocols data. Removing first and refusing second means a reference the regex rewrite misses (an <a rel=attachment>, a CSS image-set() string, …) still loads nothing.

    • Tests (WeasyPrintTests) build a page with a file:// stylesheet, an @import, a local picture by absolute path, one by relative path outside the folder, and an https:// picture pointing at a local TCP listener that records any connection.
    • Without WeasyPrint: the prepared page (HTML, and a Markdown step page) contains only the allowed picture, as a data: URI, and no loadable address.
    • With WeasyPrint: HTML, Markdown and EML PDFs contain only the allowed picture, none of the stylesheet text, and the listener is never contacted.
    • Lock on its own: one test runs weasyprint.exe on the raw page with Filee's arguments, and only the data: picture loads.
    • I checked that this test fails (the two outside pictures load) when --allowed-protocols data is removed.
  4. Size and time limits like the other engines.

    • Size: embedded pictures are capped at 256 MB per page, the same cap as the LibreOffice copy.
    • Time: WeasyPrint runs with --verbose, and its "Step 5 - Creating layout - Page N" lines drive the progress bar and the existing stall watch. It is stopped after 5 minutes without output (error.engine_stalled) and after 30 minutes in all. Cancelling stops it at once. Two runs at a time.
  5. Linux and macOS: later, in a separate PR, once a self-contained build exists. Until then the package isn't offered there, there's no download in their engines.json sections, and their routes are unchanged.

Other changes:

  • engine.weasyprint.description and engines.package.weasyprint.name/description are added in all 22 i18n/*.json files.
  • THIRD-PARTY-NOTICES.md has a line for WeasyPrint and what the executable bundles.
  • docs/ENGINES.md has a new WeasyPrint section.
  • A user's own copy has its version read ("WeasyPrint version 70.0").
  • LinkedContent now writes image/jpeg rather than image/jpg in its data URIs.

Type of change

  • Bug fix
  • New feature or improvement
  • New format or engine
  • Translation
  • Documentation
  • Build, CI or dependencies

How was it tested?

  • dotnet format --verify-no-changes (dotnet format Filee.slnx --verify-no-changes --severity warn, plus dotnet build Filee.slnx -c Release)
  • dotnet test, on Linux only:
    • Engines. WeasyPrint 70.0 was installed with pip and run through a development-only engines/weasyprint wrapper (git-ignored). With it, all WeasyPrintTests pass for real, including the end-to-end PDFs. Without it, the end-to-end tests are skipped as usual.
    • Remaining failures. The suite has the same failures with and without WeasyPrint, and they also fail on main in this container: system Pandoc and FFmpeg are incomplete, LibreOffice is missing, ISO tests fail, and tests that need a non-root user fail. Core and Cli have the same pre-existing failures as main. The App tests for engines and localization pass.
  • Tried it in the app: Windows was not tested. I had no Windows machine, so the pinned onedir build, its download and unpacking through Settings → Engines, and weasyprint.exe itself were not run. I downloaded the asset and checked its size, hash and contents on Linux. A check on Windows before merging would be welcome:
    1. Install it from Settings → Engines.
    2. Convert an HTML page, a Markdown file and an EML to PDF.

Checklist

  • New UI strings are in all src/Filee.App/Assets/i18n/*.json files
  • New third-party code or binaries are listed in THIRD-PARTY-NOTICES.md
  • The PR does one thing (unrelated changes go in a separate PR)

…d e-mail to PDF

Part of KnifeLemon#1403 (Windows; Linux and macOS follow in a separate change).

- Optional download pinned in engines.json: WeasyPrint 70.0's Windows
  "onedir" build (weasyprint-windows-onedir.zip, 32,271,073 bytes),
  offered in Settings > Engines, never taken from the PATH, not ticked
  by default.
- One HTML -> PDF edge, used only in routes that start from HTML,
  Markdown, EML, MSG or MHTML (new ConversionEdge.Sources). It wins
  wherever WeasyPrint is installed; without it no route changes.
- Locked down: pictures beside the document become data: URIs, every
  other reference is removed (LinkedContent), and WeasyPrint runs with
  --allowed-protocols data.
- Stall watch on --verbose output (5 min of silence), 30 min overall.
- Tests for routes, the prepared page and, with WeasyPrint, the PDFs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPxh5sY3QRo9xaLZemsLr8
The HTML, Markdown, MHTML and LibreOffice route tests check what the
built-in engines do; with WeasyPrint installed its routes are tested in
WeasyPrintTests instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPxh5sY3QRo9xaLZemsLr8
@Ugoyo
Ugoyo requested a review from KnifeLemon as a code owner November 12, 2026 06:15
@github-actions github-actions Bot added the feature New features label Nov 12, 2026

@KnifeLemon KnifeLemon left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, @Ugoyo, this is a careful PR, and it follows the plan from #1403 closely. The pin is optional and checked, it's never taken from PATH, routes change only when WeasyPrint is installed, there are two layers of lockdown, and it uses the existing stall watch. I went through the lockdown in the WeasyPrint 70.0 source as well:

  • every resource load (stylesheets, @import, images, SVG, @font-face, <link rel=attachment> / <a rel=attachment>) goes through the one URLFetcher the CLI builds, which refuses any scheme not in --allowed-protocols before opening anything;
  • only the main page is opened directly.

So file:, relative paths and network addresses stay blocked even where the LinkedContent copy misses something. The engines.json pin also matches the release asset exactly: size 32,271,073 and the GitHub-reported SHA-256.

Some smaller things:

  1. Inline SVG loses its same-document references. LinkedContent.StripHtml drops href / xlink:href on use and image unless the value is data:, and CssUrls empties url(#…). So <use href="#icon">, gradient xlink:href="#base", and fill:url(#g) in a style attribute or a <style> block come out blank. WeasyPrint resolves #fragment references locally (svg/defs.py), so keeping values that start with # is safe, for the LibreOffice copy too. Could you keep those, and add a small test with <use href="#s"/>?
  2. Linux and macOS status. The converter is registered everywhere, but off Windows there's nothing to install, so "Not installed" doesn't help the user. WhisperConverter returns engine.reason.unsupported_platform there. Could GetStatus do the same when nothing is found off Windows? Keep the BundledProgram lookup first, so your dev wrapper still works.
  3. PYTHONIOENCODING / PYTHONUNBUFFERED. PyInstaller starts Python with an isolated config, so the onedir weasyprint.exe ignores both. Progress still works because WeasyPrint logs to stderr, which stays line-buffered. Could you correct the comment "the stall watch counts on them"? The variables can stay for pip-installed copies.
  4. Docs.
    • The runtime table in docs/ENGINES.md ("calibre, Java (downloads) | ship their own…") could list WeasyPrint, since its MinGW/UCRT runtime is in _internal.
    • The "Optional:" list in docs/ARCHITECTURE.md could name it too.
  5. Korean wording. The three new ko.json strings use 합니다체, while the rest of the file uses 해요체: 만들어요, 나와요, 불러오지 않아요, PDF가 돼요.
  6. CI on Windows. Since you couldn't run it on Windows, could you add weasyprint to the fetch-engines.ps1 -Only list in .github/workflows/ci.yml (next to rhwp,pandoc,7zip,exiftool)? Then the Windows job runs your end-to-end and lock-on-its-own tests against the pinned build; right now they're skipped there.

Before merging, we'll also install it from Settings → Engines on a Windows machine and convert an HTML page, a Markdown file and an EML.

On your question in the PR body: keeping it unticked in the first-run choice is right, since it changes how pages become PDFs.

- Keep same-document references (#id) in the HTML copy, for WeasyPrint
  and LibreOffice: an inline SVG's <use href="#s">, a gradient's
  xlink:href="#base", fill: url(#g). Tests for both, and one that
  WeasyPrint draws the used symbol.
- Off Windows, a WeasyPrint that isn't found reports
  engine.reason.unsupported_platform, like Whisper (Filee's own copy is
  still looked up first).
- Correct the comment on PYTHONIOENCODING / PYTHONUNBUFFERED: the onedir
  build ignores them; progress comes from stderr, which stays
  line-buffered.
- Docs: WeasyPrint in the Windows runtime table (ENGINES.md) and the
  optional engines (ARCHITECTURE.md).
- Korean strings in 해요체 like the rest of ko.json.
- CI: fetch WeasyPrint for the Windows engine tests, so the end-to-end
  and lock tests run against the pinned build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPxh5sY3QRo9xaLZemsLr8
@Ugoyo

Ugoyo commented Nov 12, 2026

Copy link
Copy Markdown
Author

Thanks for the thorough review! All six points are addressed in a30bbe4:

  1. Same-document references (#id) are kept in the HTML copy, for WeasyPrint and LibreOffice: <use href="#s"/>, xlink:href="#base", fill: url(#g) in style attributes and <style> blocks. Tests for the prepared page, the LibreOffice copy, and a real PDF where WeasyPrint draws the used symbol.
  2. Off Windows, a WeasyPrint that isn't found reports engine.reason.unsupported_platform, like Whisper. BundledProgram is still looked up first.
  3. The comment now says the onedir build ignores PYTHONIOENCODING / PYTHONUNBUFFERED, and that progress arrives through stderr, which stays line-buffered. The variables stay for pip-installed copies.
  4. WeasyPrint is in the runtime table of docs/ENGINES.md and the "Optional" list of docs/ARCHITECTURE.md.
  5. The Korean strings now use 해요체.
  6. weasyprint is added to the fetch-engines.ps1 -Only list in ci.yml, so the Windows job runs the end-to-end and lock tests against the pinned build. The weasyprint block in fetch-engines.ps1 hasn't run anywhere yet (no PowerShell where I worked), so this CI run will be its first test.

And thanks for checking the lockdown against the WeasyPrint source.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New features

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants