Skip to content

PDF toolkit: watermark, page numbers and password for every PDF a preset writes - #436

Merged
KnifeLemon merged 11 commits into
KnifeLemon:mainfrom
cre8ive-marketing:feature/pdf-toolkit
Oct 23, 2026
Merged

KnifeLemon merged 11 commits into
KnifeLemon:mainfrom
cre8ive-marketing:feature/pdf-toolkit

Conversation

@cre8ive-marketing

Copy link
Copy Markdown
Collaborator

What does this change?

Three finishing options for every PDF a preset writes. They use only PDFsharp and the HarfBuzz/SkiaSharp Filee already ships, with no new package, download or engine:

  • Watermark: text such as "DRAFT" or "CONFIDENTIAL", large and light grey along each page's diagonal.
  • Page numbers: "3 / 10" at the bottom of each page.
  • Password to open: AES-256.

They appear in the preset editor's PDF card for PDF targets, in all 12 languages.

Where it applies

  • JobQueue calls a new IPdfFinisher (implemented by PdfSharpConverter → Pdf/PdfFinishing.cs) on each PDF a file became, whichever engine wrote it: images through PDFsharp, DOCX/XLSX/HWPX through rhwp, older formats through LibreOffice, PDF → PDF (split, page range, compress).
  • With Merge into one PDF it runs once, on the merged file, so pages are numbered through (1 / 12 … 12 / 12), not per part.
  • Other targets, and presets without these options, don't touch it (PdfOptions.FinishesPdf).

How the text is drawn

  • As glyph outlines, not text with an embedded font, so a Korean watermark doesn't add a 13 MB Malgun Gothic to the PDF; it adds a few KB. Any script works with a font the PC has (Skia's fallback, else Arial or the default face).
  • The shaping code is OutlinedTextPlayback's, moved unchanged into Vector/GlyphOutlines.cs so both use it. SVG → PDF text output is the same as before; its tests pass.
  • Watermark: bold, 22 % grey, 70 % of the diagonal, at most ¼ of the short side high. Page numbers: 9 pt dark grey, 10 mm above the bottom edge.

Password: the user and owner passwords are the same. Encryption is AES-256 (SetEncryptionToV5). The password is saved with the preset in the settings file; the hint under the box says so. A PDF already locked with only an owner password is opened the way the compressor already does it (OpenForChanges, now internal).

Light on low-end PCs: no new dependency, nothing new in the installer, one pass per PDF with PDFsharp.

Known limits (also in docs/ENGINES.md):

  • Watermark and page-number text can't be selected or searched.
  • A password makes a PDF/A no longer conform, because PDF/A forbids encryption.
  • With "One file per page", each file is numbered "1 / 1".

No settings schema change. It doesn't overlap my other open PRs: #429 also adds lines to the PDF card in PresetEditorView.axaml, in a different place.

How was it tested?

  • dotnet format --verify-no-changes
  • dotnet test with FFmpeg 9.0.2 and rhwp 0.8.7: Platform 48, Core 457, Cli 27, Setup 145, Engines 1463, App 809 pass, none fail.
  • New tests:
    • PdfFinishingQueueTests (Core):
      • a JPG (PDFsharp) and a DOCX (another engine) are both finished;
      • nothing is finished without the options or for a PNG target;
      • a merged PDF is finished once.
    • PdfFinishingTests (Engines, real PDFs rendered with PDFium):
      • on both pages of a 2-page PDF: the watermark crosses the middle, the page number sits at the bottom, and the corners stay white;
      • the source file is untouched;
      • a locked PDF won't open without the password, opens with it, and is AES-256 (/AESV3);
      • "기밀", "機密" and "Черновик" have outlines (skipped where the machine has no font for them).
    • RenderTests (App), in English and Korean: the editor shows the options for a PDF target and saves them (the watermark trimmed), and a PNG target drops them.

Screenshots (UI changes)

Editor (English) Editor (한국어) Result: page 2 of 2

Checklist

  • New UI strings are in all src/Filee.App/Assets/i18n/*.json files (5 keys)
  • New third-party code or binaries are listed in THIRD-PARTY-NOTICES.md (none)

…set writes

PdfFinishing (PDFsharp only) draws the watermark and page numbers as glyph outlines (GlyphOutlines, HarfBuzz, moved out of OutlinedTextPlayback) and locks the PDF with AES-256. JobQueue runs it through IPdfFinisher on each PDF a file became, from any engine, and once on a merged PDF. The preset editor shows the three options for PDF targets, in all 12 languages.
# Conflicts:
#	docs/ENGINES.md
#	src/Filee.Core/Conversion/JobQueue.cs
#	src/Filee.Engines/Pdf/PdfSharpConverter.cs
@cre8ive-marketing

Copy link
Copy Markdown
Collaborator Author

Merged main into this branch (9b41e81):

  • JobQueue.cs: kept both. "Only if smaller" runs first, then the watermark, page numbers and password, so a stamp is never lost when the original is copied back.
  • PdfSharpConverter.cs: the header comment now names both metadata removal and the finishing.
  • docs/ENGINES.md: the PDF toolkit section sits after "Media tools".
  • The new Thai and Nepali files have the 5 PDF toolkit strings.

dotnet format --verify-no-changes is clean. Full dotnet test: Platform 48, Core 504, Cli 27, Setup 149, Engines 1596, App 849 pass, none fail.

@cre8ive-marketing

Copy link
Copy Markdown
Collaborator Author

The build check failed in CatalogBackgroundCheckTests.A_status_asked_during_the_background_check_waits_for_it_instead_of_checking_again (ElevationAndCatalogTests.cs:82). That line waits 5 s for the background engine check to start, and on the busy Windows runner it didn't start in time. This PR doesn't touch ConverterCatalog or that test, and it passed in my full local run (Core 504/504). Could you re-run the failed job? If it keeps flaking, the 5 s waits in that test could be raised. Happy to do that in a separate PR if you'd like.

The Windows CI runner didn't start the background check within 5 s, so the test failed although the catalog behaved right. The waits are now 30 s; the checks are unchanged.
@cre8ive-marketing

Copy link
Copy Markdown
Collaborator Author

Rather than wait for a re-run, the latest commit raises the 5 s waits in CatalogBackgroundCheckTests to 30 s, so a slow start on a busy runner no longer fails the test. What it checks is unchanged, and it passes locally.

@KnifeLemon KnifeLemon added the feature New features label Oct 23, 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 @cre8ive-marketing, this is a nice addition and it's well built: one pass with PDFsharp after whichever engine made the PDF, once on a merged PDF so the numbers run through, outlines instead of an embedded 13 MB font, AES-256, and all 14 languages. I built the branch and ran your tests plus a few of my own. Some things to fix before it goes in:

Where the stamps land

  1. Rotated pages. On a page with /Rotate 90 the page number doesn't end up at the bottom of the page as it's shown (I tried a blank A4 page with Rotate = 90: nothing in the bottom strip, the number is elsewhere on the page). Scans and landscape exports use /Rotate a lot. Please draw in the page's displayed orientation and add a test.
  2. Crop box. The position is worked out from the media box. On a page whose CropBox is smaller (MediaBox A4, CropBox inset by 100 pt in my test) the number falls outside the visible area and nothing shows. Please centre the watermark and place the number inside the CropBox.

Password
3. Long non-ASCII passwords can't be opened. AES-256 (revision 6) passwords are UTF-8, and readers use at most the first 127 bytes. A short Korean password and pässwörd open fine in PDFium, but a 54-character Korean one (162 bytes, under the box's limit of 64 characters) gives a PDF that PDFium won't open with that password. Please limit the password to 127 UTF-8 bytes in the editor (or truncate it exactly the way the spec says) and add a test that opens a non-ASCII password with PDFium, not only with PDFsharp.
4. Where else the password goes. Besides the preset in the library file:

  • Settings → General → Export… writes the presets to a JSON file meant for sharing, password included. Please leave Pdf.Password out of the export.
  • When files fail, history keeps a copy of the preset for "convert again" (HistoryEntry.Preset), so the password stays in history.json after it's been removed from the preset or the preset is deleted. Please keep it out of that copy. A retry should then take the password from the preset if it still exists, and fail with a clear message if it doesn't, rather than write a PDF without the password.
    Nothing logs it as far as I can see, good.

Smaller
5. If saving fails, <output>.pdf.finished stays next to the user's file. Please delete it on failure.
6. In JobQueue.cs the new <summary> landed under RunMergedAsync's, so FinishPdfsAsync has two summaries and RunMergedAsync none.
7. docs/ARCHITECTURE.md, step 5 (Run): one line that a PDF target's finishing (IPdfFinisher) runs after the last step, and once on a merged PDF.
8. PDF/A plus a password can't conform. The PDF/A identification does survive a watermark (I checked SVG → PDF/A with a watermark), so only the password is the problem: maybe say it in the password hint, or disable the box while PDF/A is ticked.

The 30 s waits in CatalogBackgroundCheckTests are fine to keep here. #429 touches the same lines in PresetEditorViewModel, Preset.cs and RenderTests, so whichever of the two goes in second will need a small rebase. Thanks!

Review of KnifeLemon#436:
1. Watermark and page numbers go on the page as it is shown: the page is
   drawn on unturned and mapped through its /Rotate (90, 180, 270), then
   its /Rotate put back. Tests render turned pages with PDFium.
2. Both are placed in the crop box (the media box when there is none).
3. The editor keeps a password to 127 UTF-8 bytes, all that AES-256
   readers use; a longer one from elsewhere fails the file instead of
   writing a PDF nobody can open. Tests open non-ASCII passwords in PDFium.
4. The password is left out of a preset export and of the copy history
   keeps for "convert again". A retry takes it from the preset again and
   fails with error.pdf_password_gone when the preset has none.
5. <output>.pdf.finished is deleted when saving fails.
6. FinishPdfsAsync and RunMergedAsync each have their own summary.
7. ARCHITECTURE.md: the finishing step in "Run".
8. The password box is off while PDF/A is ticked, and its hint says why.
@cre8ive-marketing

Copy link
Copy Markdown
Collaborator Author

Thanks @KnifeLemon for the careful review. All eight points are fixed in dcebddc:

Where the stamps land

  1. Rotated pages: the stamps now go on the page as it is shown. The page is drawn on unturned, the stamps are mapped through its /Rotate (90, 180 or 270), and the /Rotate is put back. The tests render turned A4 pages with PDFium: the number is in the bottom strip of the shown page, nothing is along its top edge, the watermark is in the middle, and /Rotate is unchanged.
  2. Crop box: the watermark is centred in the CropBox (or the MediaBox when there's none), and the number sits inside it. The test uses your case: A4 with the CropBox 100 pt in.

Password

  1. Long non-ASCII passwords: the editor keeps a password to 127 UTF-8 bytes. Your 54-letter Korean one becomes the 42 letters that fit. A longer password coming from somewhere else, such as an older library or an import, fails the file with a clear message rather than writing a PDF no reader can open. New tests open pässwörd and a Korean password in PDFium and check that a wrong password is refused.
  2. Where else it goes:
    • Export: Settings → General → Export… leaves Pdf.Password out. The library keeps it.
    • History: HistoryEntry.Preset is saved without the password, and PasswordLeftOut records that one was there. A retry (from Home or from watch-folder results, both through ConversionService.Retry) takes the password from the library preset with the same id. When that preset is gone or no longer has a password, each file fails with error.pdf_password_gone ("…Convert the files again with a preset that has the password."), in all 14 languages, and no PDF is written. There's a test for both.

Smaller

  1. <output>.pdf.finished is deleted in a finally when saving fails. A test checks nothing is left behind.
  2. FinishPdfsAsync and RunMergedAsync each have their own <summary> again.
  3. docs/ARCHITECTURE.md, step 5: one line on the finishing step. docs/ENGINES.md also covers the shown page, the crop box, the 127-byte limit, and history and export.
  4. PDF/A: the password box is off while PDF/A is ticked, nothing is saved from it then, and the hint says why (14 languages).

I checked that the new rotation, crop-box and password-length tests fail on the previous code. dotnet format --verify-no-changes is clean. Full dotnet test: Core 507, Platform 48, CLI 27, Setup 149, Engines 1603 and App 850 pass, none fail.

cre8ive-marketing and others added 6 commits October 23, 2026 17:23
main took OCR (KnifeLemon#429), which touches the same PDF options:
- Preset.cs: both PdfOptions groups kept (watermark, page numbers,
  password next to Ocr, OcrLanguage, OcrTargets).
- PresetEditorViewModel: both kept in the constructor, the properties
  and Apply (finishing, then OCR clearing what it doesn't do).
- RenderTests: main's file with the two PDF finishing editor tests
  added back before the OCR one.
…ko.json

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Fium

PDFsharp reads a page whose media box is wider than tall as landscape and
swaps its width and height when it is set to portrait, so forcing portrait
drew the watermark and page number of a landscape page as if it were
portrait: the number ended up in the middle of the page. The orientation
now follows the media box while the page is drawn on unturned.

Tests: a landscape page, unturned and turned 90, gets the number at the
bottom of the shown page; a password of exactly 127 UTF-8 bytes opens in
PDFium.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/ARCHITECTURE.md: kept the PDF finishing line and KnifeLemon#481's Replace the
original paragraph, in the order JobQueue runs them.
@cre8ive-marketing

Copy link
Copy Markdown
Collaborator Author

Merged main again (c1f6782), now with #481. The only conflict was in docs/ARCHITECTURE.md; I kept both paragraphs in the order JobQueue runs them (finishing, then replace). Core (539) and the PDF finishing tests pass.

One combination to be aware of, which I didn't change: a preset with "Only if smaller", a watermark or password, and "Replace the original" all on.

  • When the result isn't smaller, the source is copied as the result, and the finishing then stamps that copy.
  • The replace then sees "copied source", deletes the stamped copy and leaves the original as it was.

So the stamp is lost, and the notice says it was left as it is. If you'd rather keep the stamped copy in that case, the fix is to skip copiedSource when FinishesPdf is on. Happy to do that here or in a follow-up, whichever you prefer.

@KnifeLemon
KnifeLemon merged commit a2bcd42 into KnifeLemon:main Oct 23, 2026
8 checks passed
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.

2 participants