Skip to content

feat(pdf): keep bookmarks, links and form fields when merging PDFs - #1227

Open
SeoJCalive wants to merge 1 commit into
mainfrom
feat/pdf-merge-keep-structure
Open

SeoJCalive wants to merge 1 commit into
mainfrom
feat/pdf-merge-keep-structure

Conversation

@SeoJCalive

Copy link
Copy Markdown
Collaborator

What does this change?

Part of #965 (steps 1–3 of the plan). Merge PDF copied pages only, so bookmarks, internal links, named destinations and the form field tree were lost. The merged file now keeps what the sources had:

  • Bookmarks: each source's outline, in source order, with titles, levels, open state, colour and style. Each one points at its page in the merged file with the same view (/XYZ with its nulls, /Fit, /FitH, ...).
  • Links and named destinations: /Dest and /GoTo links point at the merged page with the same view; URI links are left alone. Names are read from both the name tree and the old /Dests dictionary and written to one /Names /Dests.
  • Form fields: fields whose widgets are on a merged page go into one AcroForm, with their values, hierarchy and appearances. A field with widgets on two pages stays one field.

The pages themselves are copied exactly as before (AddPage), and a merge of PDFs with none of these is unchanged. The new code is in PdfMergeStructure; PdfSharpConverter.Merge hands it each source after its pages are added.

Choices I made (open to change)

Your plan set the direction (per-file prefix for duplicate field names). These are the details I picked; tell me if you'd rather have any of them differently.

  1. Field prefix: a source whose field names collide with an earlier one has its fields put under a parent named after its position, so the second file's name becomes 2.name. Fields that don't collide keep their names.
  2. Colliding destination names get -2, -3, ... (target → target-2), and the links and bookmarks that use them follow.
  3. Form defaults: each source's /DA and /Q stay with its own fields. The merged file has one /DR, so a resource name a later source also uses (/F1 for another font) is renamed (/F1_2) together with that source's /DA. Regenerating a field's appearance then uses its own source's font. /NeedAppearances is on if any source had it.
  4. Untrusted structure: the outlines, name trees, field trees and form resources come from the user's file, so they are walked without recursion and with limits (64 levels; 100,000 bookmarks, names, name-tree nodes and fields; 4 MB of titles and names; 10,000 resource objects). Cycles, broken entries and anything over a limit are left out; every page is still merged. Shared objects are handled once, so the work stays in proportion to the file.

Not in this PR

  • The option to add one bookmark per source file (step 1's "optionally") and the single paper size option (step 4, Turn a folder into one printable PDF — and let users add their own actions #966): separate PRs.
  • Default colour spaces (/DefaultRGB, ...) of a form are kept for what a field's /DA can select: device colour operators, colour spaces that name a device space (also as the base of /Pattern, /Indexed, /Separation, /DeviceN), and shading patterns. Device spaces inside tiling pattern content, an /ICCBased /Alternate or /DeviceN /Attributes are not rewritten, so a regenerated appearance could differ in colour there.
  • As before: fields with no widget on any page, attachments, page labels, structure tags, document JavaScript and /OpenAction are not carried. Signed or encrypted input, XFA and optional content are left as they are.
  • Save in the page editor loses the same structure; it isn't in the plan, so I left it for a follow-up.
  • fix(pdf): stop merged PDF from repeating its pages #1134 changes the same merge method; I'll rebase onto whichever lands first.

Type of change

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

How was it tested?

On this branch on top of main at 7fa8cd72.

  • dotnet format --verify-no-changes
  • Build warnings: 19, the same list as on main, none of them in a file this PR touches.
  • dotnet test: Core 1145 passed (2 skipped), Cli 85/85, PdfMergeStructureTests 85/85. Engines on Linux: 8 failed; seven fail the same way on main on this machine (LibreOffice: five PackageRepackerTests, the SVG and linked-picture cases), and the HwpxReaderTests timing case passes on its own. App on Linux: 7 failed; six fail the same way on main (Hindi label, two Japanese font cases, the Engines page, two GNOME socket cases) and the music-symbol case passes on its own.
  • Tried it with the CLI (filee convert -p merge-pdf) on Linux, merging pairs of real PDFs from LibreOffice (bookmarks, form fields), reportlab (fields with the same names and resource names, /F1 as Helvetica and Courier), Chrome (bookmarks, named destinations) and Ghostscript (bookmarks, named destinations):
    • Bookmarks, links, named destinations and fields matched the sources in all four, with colliding names renamed.
    • Every page rendered the same as main's merge (pdftoppm, annotations hidden).
    • Changing both name fields in the merged reportlab file with pypdf's appearance regeneration drew the first in Helvetica and the second in Courier (screenshot below).
  • I haven't tried PDFs from Word, WPS or Acrobat, or editing fields in Acrobat or Edge, yet.

New tests in PdfMergeStructureTests (85 tests): bookmarks with every destination kind, links, both name spaces and colliding names, fields across pages and with colliding names, edited independently after save; form defaults and resource renaming; the limits, cycles and broken entries; inputs over each limit merge in under 10 s with allocation under 3× a pages-only merge + 64 MB; cancellation. The class runs on its own (DisableParallelization), so other tests don't stretch the time limit.

Screenshots (UI changes)

The same two LibreOffice PDFs merged on main and on this branch, opened in Chrome's PDF viewer (PDFium).

main This PR
bookmarks on main bookmarks with this PR
No outline: only page thumbnails. Both sources' bookmarks, in order.
field on main field with this PR
The field shows no value. The field shows its value, as in the source.
Form resources with the same name

Both name fields of the merged reportlab file changed and their appearances regenerated: the first source's field in Helvetica, the second's (2.name) in Courier.

regenerated fields in Helvetica and Courier

Checklist

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

Merge PDF copied pages only, so outlines, internal links, named
destinations and the AcroForm field tree were lost. PdfMergeStructure now
carries them from each source after its pages are added: bookmarks point
at the merged pages with the same view, /Dest and /GoTo links are
re-pointed, names from the name tree and the old /Dests dictionary go to
one /Names /Dests, and page-backed fields go into one AcroForm. Colliding
field names get a file-position prefix, colliding destination names a
-2 suffix, and colliding form resource names are renamed with the /DA
that uses them.

The new structure is untrusted input: it is walked iteratively with
depth, count and byte limits, and broken or oversized parts are left out
while every page is still merged.

Part of #965

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
@github-actions github-actions Bot added the feature New features label Nov 6, 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.

고마워요! 책갈피, 링크, 양식 필드를 이렇게 꼼꼼하게 살려 주셔서 정말 좋아요. 일부러 망가뜨린 PDF로 꽤 많이 시험해 봤는데 대부분 잘 버텼어요. 책갈피 순환과 30만 단계 깊이, 이름 트리 순환, 파일 사이의 이름 충돌, 같은 양식을 채운 사본 세 개, 같은 파일 중복 병합, 암호 걸린 파일 모두 문제없었어요.

꼭 고칠 점

아래 두 경우는 main에서는 병합되는데 이 PR에서는 병합 전체가 실패해요. 망가진 파일이 나오지는 않지만, 부가 정보 때문에 병합 자체를 잃게 돼요.

1. 병합된 페이지에 위젯이 없는 필드가 있을 때 (PdfMergeStructure.cs:476, :507, :534)

MapExternalObject는 가져오지 않은 객체를 만나면 null을 주지 않고 KeyNotFoundException을 던져요. 그래서 PR 설명대로 "옮기지 않고 넘어가는" 대신 병합이 멈춰요. 아래 경우에 모두 재현됐어요.

  • /Fields에 위젯 없는 필드가 있을 때
  • 위젯이 페이지 트리에 없는 페이지에 있을 때
  • 자식 위젯이 어느 페이지에도 없을 때
  • /Fields나 /Kids에 직접 객체(direct object)가 있을 때
  • 페이지가 없는 파일에 필드가 있을 때

2. /AcroForm /DR 안에 없는 객체를 가리키는 참조가 있을 때 (PdfMergeStructure.cs:710)

예를 들어 글꼴의 /ToUnicode 777777 0 R이 가리키는 객체가 파일에 없으면, Value()가 null을 돌려주고 Elements[key] = null에서 ArgumentNullException이 나요.

고치는 방법

제 쪽에서 아래처럼 고쳐 보니 위 경우가 모두 병합됐고, 멀쩡한 필드도 그대로 남았어요. PdfMergeStructureTests 85개도 모두 통과했어요.

private PdfObject? Imported(PdfObject external)
{
    if (external.Reference is null)
        return null;
    try { return output.Internals.MapExternalObject(external); }
    catch (KeyNotFoundException) { return null; }
}

// :476, :507, :534의 root와 node에 사용
// Copy 안에서:
var direct = Value(item) ?? PdfNull.Value;

이렇게 고치면 /Kids에 직접 객체가 있는 경우에 결과 파일을 PdfPig의 양식 읽기가 거부해요. 이 경우는 :465에서 잘못된 필드로 처리하는 편이 나을 수도 있어요. 이건 아직 시험해 보지 않았어요.

이 두 경우를 테스트에 추가하고 고쳐 주세요. 제가 쓴 코드를 그대로 쓰셔도 되고, 더 좋은 방법이 있으면 그걸로 해 주세요.

급하지 않은 점

모두 main보다 나빠진 건 아니에요. 이번 PR에 넣어도 되고 다음으로 미뤄도 돼요.

  • 페이지 트리 밖을 가리키는 동작: 푸시 버튼의 /A /GoTo와 링크 액션의 /Next는 아직 페이지 트리 밖의 복사본을 가리켜요.
  • 책갈피 액션: /URI나 /Named 액션이 걸린 책갈피는 제목만 남아요. 제외 목록에 적어 두면 좋겠어요.
  • 값이 모자란 대상: [p /XYZ 10 20], [p /FitH]처럼 값이 모자란 대상은 버려져요.
  • 문자열 한도: 4 MB 한도가 사용할 때마다 줄어서, 큰 파일에서는 링크 대상이 조용히 사라져요. 42바이트 이름 6만 개를 한 번씩 링크했더니 39,864개만 남았어요.
  • /CO(계산 순서): 옮겨지지 않아요. 이것도 제외 목록에 적어 두면 좋겠어요.
  • PdfMergeStructureTests.cs:1126: 10초 제한은 컴퓨터가 바쁘면 실패해요. 코어 8개에 바쁜 프로세스 8개를 돌렸더니 shared-field-resources, name-nodes, outline-items가 실패했어요. CI에서 가끔 실패할 수 있어서, 시간 대신 메모리 할당량으로 보거나 제한을 넉넉히 늘리면 좋겠어요.

#1134와의 충돌

PdfSharpConverter.cs에서 실제로 충돌이 나요. 먼저 들어가는 쪽에 맞춰 다시 올려 주세요.

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