Repository navigation
feat(pdf): keep bookmarks, links and form fields when merging PDFs - #1227
SeoJCalive wants to merge 1 commit into
Conversation
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>
KnifeLemon
left a comment
There was a problem hiding this comment.
고마워요! 책갈피, 링크, 양식 필드를 이렇게 꼼꼼하게 살려 주셔서 정말 좋아요. 일부러 망가뜨린 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에서 실제로 충돌이 나요. 먼저 들어가는 쪽에 맞춰 다시 올려 주세요.
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:
/XYZwith itsnulls,/Fit,/FitH, ...)./Destand/GoTolinks 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/Destsdictionary and written to one/Names /Dests.The pages themselves are copied exactly as before (
AddPage), and a merge of PDFs with none of these is unchanged. The new code is inPdfMergeStructure;PdfSharpConverter.Mergehands 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.
namebecomes2.name. Fields that don't collide keep their names.-2,-3, ... (target→target-2), and the links and bookmarks that use them follow./DAand/Qstay with its own fields. The merged file has one/DR, so a resource name a later source also uses (/F1for another font) is renamed (/F1_2) together with that source's/DA. Regenerating a field's appearance then uses its own source's font./NeedAppearancesis on if any source had it.Not in this PR
/DefaultRGB, ...) of a form are kept for what a field's/DAcan 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/Alternateor/DeviceN/Attributesare not rewritten, so a regenerated appearance could differ in colour there./OpenActionare not carried. Signed or encrypted input, XFA and optional content are left as they are.Type of change
How was it tested?
On this branch on top of
mainat7fa8cd72.dotnet format --verify-no-changesmain, none of them in a file this PR touches.dotnet test: Core 1145 passed (2 skipped), Cli 85/85,PdfMergeStructureTests85/85. Engines on Linux: 8 failed; seven fail the same way onmainon this machine (LibreOffice: fivePackageRepackerTests, the SVG and linked-picture cases), and theHwpxReaderTeststiming case passes on its own. App on Linux: 7 failed; six fail the same way onmain(Hindi label, two Japanese font cases, the Engines page, two GNOME socket cases) and the music-symbol case passes on its own.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,/F1as Helvetica and Courier), Chrome (bookmarks, named destinations) and Ghostscript (bookmarks, named destinations):main's merge (pdftoppm, annotations hidden).namefields in the merged reportlab file with pypdf's appearance regeneration drew the first in Helvetica and the second in Courier (screenshot below).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
mainand on this branch, opened in Chrome's PDF viewer (PDFium).mainForm resources with the same name
Both
namefields of the merged reportlab file changed and their appearances regenerated: the first source's field in Helvetica, the second's (2.name) in Courier.Checklist
src/Filee.App/Assets/i18n/*.jsonfiles (none added)THIRD-PARTY-NOTICES.md(none added)