Технічна стаття

PDFlibPas MovePage: коли успадковані бокси ділять екземпляр

У PDFlibPas, Delphi PDF-бібліотеці, сторінка, пересунута через MovePage, колись отримувала ті самі об'єкти MediaBox, CropBox і Resources, що й тримав її старий вузол Pages, тож пізніший SetPageBox чи DrawText на пересунутій сторінці тихо переписував той вузол і кожного сусіда, який досі успадковував від нього. З v3.539.36 пересунута сторінка отримує власні копії, а непрямий дотепер — це і досі посилання. Той самий реліз закрив два суміжні шляхи: SetPageBox на непрямому боксі, який ділять кілька сторінок, і CopyPageRanges, що лишав сторінки вихідного документа прив'язаними до їхнього вузла Pages, з CropBox, прив'язаним до MediaBox

Звіти, що ведуть сюди, ніколи не згадують тотожність об'єктів. Вони кажуть речі на кшталт «обрізав сторінку 7, і сторінки 8–12 теж обрізалися», чи «звузив CropBox, і MediaBox рушив за ним», або, найзаплутаніше, «скопіював сторінку в новий документ, і початковий файл змінився». Нічого не падає, нічого не тікає, а збережений файл — цілком валідний PDF. Він просто містить геометрію, якої ніхто не просив

Чому SetPageBox на одній сторінці змінює розмір сусідів?

SetPageBox змінював розмір сусідів тому, що два входи дерева сторінок вказували на один масив у пам'яті, а SetPageBox редагує свій цільовий масив на місці. Будь-яка сторінка чи вузол Pages, що тримали той самий екземпляр, бачили редагування. Три шляхи коду в PDFlibPas породжували таке спільне володіння до v3.539.36:

  • MovePage матеріалізує успадковувані атрибути на сторінку перед відкріпленням від батька, і він прикріплював власні об'єкти предка, а не копії, тож пересунута сторінка та її колишні сусіди ділили масив боксу й словник Resources
  • SetPageBox ішов за непрямими посиланнями і редагував масив, на який вони вказують, тож файл, де кілька сторінок вказують на один об'єкт /MediaBox 11 0 R, мав усі ті сторінки зміненими одним викликом — залучений MovePage чи ні
  • CopyPageRanges матеріалізує успадковані значення на вихідній сторінці перед клонуванням її в цільовий документ, і він прикріплював екземпляри вузла Pages до вихідної сторінки, плюс сам екземпляр MediaBox як усталений CropBox
Аліасинг MovePage у PDFlibPas, де пересунута сторінка та її колишній сусід обидва тримали власний екземпляр масиву MediaBox предка, тож SetPageBox редагував одну сторінку і змінював розмір іншої; з v3.539.36 матеріалізація прикріплює декодовані копії, і редагування лишаються локальними для сторінки, якої торкаєтеся
Два входи дерева сторінок, що вказують на один масив у пам'яті, робили кожне редагування приземленням у кожного тримача, а збережений PDF увесь цей час лишався валідним

Випадок MovePage має коротку історію. До v3.539.27 MovePage переносив лише /Resources, тож сторінка, пересунута під іншого батька, тихо приймала його розмір і ротацію. v3.539.27 полагодив відсутні MediaBox, CropBox і Rotate — на тому ж спирається CollateDocumentsEx, коли перевпорядковує сторінки, — але прикріплював значення предка як спільні екземпляри. Саме те вікно закриває v3.539.36. Шляхи SetPageBox і CopyPageRanges старіші; вони є в будь-якій збірці до v3.539.36

Прямі значення, непрямі посилання та успадкування атрибутів сторінки

Коректна копія успадкованого атрибута сторінки дублює прямі значення і лишає непрямі посилання посиланнями, бо саме ту відмінність проводить сам ISO 32000-1. Прямий об'єкт, як-от [0 0 400 300], записаний усередині словника, належить лише тому словнику. Непрямий об'єкт, визначений один раз як 11 0 obj і цитований як 11 0 R, спільний за задумом: ISO 32000-1 §7.3.10 робить його адресованим звідусіль із файлу, і кожен 11 0 R означає той самий об'єкт

Успадкування атрибутів сторінки, ISO 32000-1 §7.7.3.4, додає третій випадок. Resources, MediaBox, CropBox і Rotate можуть сидіти на вузлі Pages і застосовуватися до кожної сторінки-нащадка, яка не визначає власних. Сторінка не тримає значення; вона його виглядує через /Parent. Той ланцюг пошуку ламається щойно сторінка змінює батька, саме тому MovePage і BalancePageTree мусять спершу записати ефективні значення на саму сторінку. Питання лише в тому, як їх записати

Чому пул об'єктів ховає помилку

У PDFlibPas кожен розібраний чи створений PDF-об'єкт належить пулу TPDFStructure документа, а словники й масиви зберігають голі вказівники на свої записи. TPDFDictionary.Add запам'ятовує вказівник і нічого більше. Додавання одного екземпляра до двох батьківських контейнерів отже легальне на кожному рівні, який виконуване середовище може перевірити: без подвійного звільнення при розборі, без лічильника посилань, що може збитися, без виключення. Серіалізація так само поблажлива, бо кожен контейнер пише поточне значення спільного екземпляра інлайном, а до будь-якого редагування вихід збігається байт за байтом із тим, що дала б коректна копія

Аліасинг виринає лише тоді, коли хтось мутує спільний екземпляр на місці. SetPageBox робить саме це через обгортку-прямокутник над наявним масивом, а малювання на сторінці робить те саме зі словником Resources, коли реєструється шрифт чи зображення. Редагування приземлюється, тихо, у кожен інший контейнер, що тримає вказівник

Як PDFlibPas v3.539.36 копіює замість спільного володіння

PDFlibPas v3.539.36 полагоджує проблему з обох кінців: матеріалізація тепер прикріплює копії, а запис боксів редагує лише масив, яким володіє сторінка. Кожне виправлення покриває випадок, якого не покриває інше

Хелпер матеріалізації, PLInheritPageAttributes, тепер прикріплює Page.Owner.Decode(Value.Output) замість Value. Кругообіг крізь серіалізатор — грубий, але точний спосіб отримати семантику PDF безкоштовно. Прямий масив чи словник серіалізується в свій буквальний текст і декодується в свіжий, незалежний екземпляр. Непряме посилання серіалізується в 11 0 R і декодується в новий об'єкт-посилання, що вказує на той самий об'єкт 11, тож сторінка досі згадує спільний об'єкт замість отримання вбудованої копії, що зберігає поведінку посилань із v3.539.27. Копія рівно настільки глибока, наскільки глибока пряма структура: все, що досягається через посилання всередині скопійованого словника, лишається спільним, як і задумано форматом файлу. BalancePageTree викликає той самий хелпер для кожної сторінки, яку переприв'язує, тож сторінки, матеріалізовані там, також отримують окремі екземпляри

Кругообіг матеріалізації PDFlibPas, де PLInheritPageAttributes прикріплює Page.Owner.Decode(Value.Output): прямий масив серіалізується в буквальний текст і декодується в свіжий екземпляр, тоді як непряме 11 0 R серіалізується і декодується в нове посилання, що досі вказує на спільний об'єкт 11
Серіалізація з повторним розбором дає семантику PDF-об'єктів безкоштовно: прямі значення копіюються, посилання лишаються посиланнями — точно як задумав ISO 32000-1

Самого копіювання замало, бо випадок із посиланням досі вказує на спільний об'єкт. Якби SetPageBox ішов за тим посиланням і редагував об'єкт 11, пересунута сторінка знову змінювала б розмір старого батька та його інших дітей. Тому записувач боксів тепер застосовує copy-on-write: він редагує на місці, лише коли власний вхід сторінки — прямий масив, а непрямий чи відсутній бокс заміняє новим прямим масивом. Об'єкт 11 лишається недоторканим для кожної іншої сторінки, що його цитує

Рішення copy-on-write у SetPageBox PDFlibPas: коли власний вхід сторінки — прямий масив, він редагується на місці, а коли це непряме посилання чи відсутній запис, записувач замінює його новим прямим масивом, тож спільний об'єкт 11 зберігає своє значення для кожної іншої сторінки, що його цитує
Копіювання при матеріалізації замало, доки посилання досі вказують на спільні об'єкти, тож записувач боксів редагує лише те, чим володіє сторінка
Шлях кодуДо v3.539.36З v3.539.36
Матеріалізація MovePageСторінка тримає власні прямі екземпляри предкаСторінка тримає декодовані копії; посилання лишаються посиланнями
SetPageBoxІде за посиланням і редагує спільний масивРедагує лише прямий масив на сторінці, інакше пише новий
Вихідна сторінка CopyPageRangesДілить бокси вузла Pages; CropBox — екземпляр MediaBoxКожне матеріалізоване значення на вихідній сторінці — копія
Усталені бокси при клонуванні ресурсів сторінкиCropBox, BleedBox, TrimBox і ArtBox ділять один масивКожен усталений бокс отримує власний масив

Останній рядок — потенційний. Коли бібліотека клонує ресурси сторінки для захоплення чи злиття сторінок, вона доповнює відсутні записи CropBox, BleedBox, TrimBox і ArtBox, і ті колись були одним екземпляром масиву. Жоден нинішній викликач не давав тому аліасу дожити до редагування, але наступний викликач дав би. Як обираються ті усталені значення боксів — окрема тема, описана в посібнику PDFlibPas щодо усталених TrimBox, BleedBox і CropBox

Відтворюємо аліасинг MovePage рукописним PDF

Найшвидший спосіб перевірити будь-яку збірку PDFlibPas — маленький рукописний PDF, завантажений через LoadFromString, де кожен номер об'єкта відомий заздалегідь. Хелпер нижче пише класичну таблицю крос-посилань з правильно обчисленими зміщеннями байтів, тож тест не спирається на поведінку відновлення парсера для пошкоджених файлів

uses
  System.SysUtils, PDFlibrary;

function BuildPdf(const Objects: array of AnsiString): AnsiString;
var
  Offsets: array of Integer;
  I, XRefPos: Integer;
begin
  Result := '%PDF-1.4'#10;
  SetLength(Offsets, Length(Objects));
  for I := 0 to High(Objects) do
  begin
    Offsets[I] := Length(Result);   // зміщення байта "N 0 obj" від нуля
    Result := Result + AnsiString(IntToStr(I + 1)) + ' 0 obj'#10 +
      Objects[I] + #10'endobj'#10;
  end;
  XRefPos := Length(Result);
  Result := Result + 'xref'#10'0 ' + AnsiString(IntToStr(Length(Objects) + 1)) +
    #10'0000000000 65535 f '#10;
  for I := 0 to High(Offsets) do      // кожен запис — рівно 20 байтів
    Result := Result + AnsiString(Format('%.10d 00000 n ', [Offsets[I]])) + #10;
  Result := Result + 'trailer'#10'<< /Size ' +
    AnsiString(IntToStr(Length(Objects) + 1)) + ' /Root 1 0 R >>'#10 +
    'startxref'#10 + AnsiString(IntToStr(XRefPos)) + #10'%%EOF'#10;
end;

function StreamObj(const Content: AnsiString): AnsiString;
begin
  Result := '<< /Length ' + AnsiString(IntToStr(Length(Content))) +
    ' >>'#10'stream'#10 + Content + #10'endstream';
end;

Тестовий документ має два проміжні вузли Pages. Вузол 3 несе непрямий MediaBox (об'єкт 11, 400 на 300 пунктів), прямий CropBox і прямий словник Resources, і володіє двома сторінками. Вузол 4 має MediaBox розміру Letter і володіє третьою сторінкою. Пересування сторінки 1 на позицію 3 переприв'язує її під вузол 4 — і це точно той хід, що потребує матеріалізації: без неї сторінка стала б сторінкою формату Letter

procedure Check(Condition: Boolean; const Msg: string);
begin
  if not Condition then
    raise Exception.Create(Msg);
end;

procedure CheckMovedPageIsIsolated;
var
  Lib: TPDFlib;
  FontID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Check(Lib.LoadFromString(BuildPdf([
      '<< /Type /Catalog /Pages 2 0 R >>',
      '<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 3 >>',
      '<< /Type /Pages /Parent 2 0 R /Kids [5 0 R 6 0 R] /Count 2 ' +
        '/MediaBox 11 0 R /CropBox [10 20 390 280] /Resources << >> >>',
      '<< /Type /Pages /Parent 2 0 R /Kids [7 0 R] /Count 1 ' +
        '/MediaBox [0 0 612 792] >>',
      '<< /Type /Page /Parent 3 0 R /Contents 8 0 R >>',
      '<< /Type /Page /Parent 3 0 R /Contents 9 0 R >>',
      '<< /Type /Page /Parent 4 0 R /Contents 10 0 R >>',
      StreamObj('1 w'), StreamObj('2 w'), StreamObj('3 w'),
      '[0 0 400 300]']), '') = 1, 'load failed');

    Lib.SelectPage(1);
    Check(Lib.MovePage(3) = 1, 'MovePage failed');
    Lib.SelectPage(3);                       // сторінка, яку щойно пересунули
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'inherited MediaBox lost');

    Lib.SetPageBox(1, 0, 200, 200, 200);     // MediaBox 200 x 200
    Lib.SetPageBox(2, 0, 100, 100, 100);     // CropBox 100 x 100
    FontID := Lib.AddStandardFont(4);        // Helvetica
    Lib.SelectFont(FontID);
    Lib.SetTextSize(12);
    Lib.DrawText(20, 20, 'MOVED');

    // Інспектуйте старого батька ДО вибору іншої сторінки (див. нижче)
    Check(Pos(AnsiString('/Font'), Lib.GetObjectToString(3)) = 0,
      'font registered in the old Pages node');

    Lib.SelectPage(1);                       // колишня сторінка 2, досі під вузлом 3
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'sibling MediaBox changed');
    Check(Abs(Lib.GetPageBox(2, 2) - 380) < 0.001, 'sibling CropBox changed');
    Check(Pos(AnsiString('400'), Lib.GetObjectToString(11)) > 0,
      'shared object 11 was rewritten');
  finally
    Lib.Free;
  end;
end;

GetPageBox(BoxType, Dimension) бере тип боксу 1 для MediaBox і 2 для CropBox, а вимір 2 — для ширини. З усталеним початком у лівому нижньому куті SetPageBox(1, 0, 200, 200, 200) означає ліво 0, верх 200, 200 завширшки і 200 заввишки. На збірках між v3.539.27 і v3.539.35 перевірки сусідів падають: редагування CropBox приземлюється в прямий масив вузла 3, а редагування MediaBox переписує об'єкт 11 крізь посилання

Чи змінює CopyPageRanges вихідний документ?

З v3.539.36 CopyPageRanges досі пише на вихідні сторінки, але кожне значення, яке він пише, — окрема копія, тож пізніші редагування джерела лишаються локальними для сторінки, яку редагуєте. Сам запис — навмисний: вихідній сторінці потрібні явні MediaBox, CropBox, Rotate і Resources, перш ніж її словник буде клоновано в ціль, інакше копія втратила б усе успадковане. Перенумерація та копіювання сторінки в ціль описані в міждокументному глибокому копіюванні об'єктів у PDFlibPas; цей баг сидів на вихідному боці, який більшість вважає стороною, яку копія лише читає

Вихід цього не показував. Спільні чи скопійовані, матеріалізовані значення серіалізуються однаково, тож обидва документи зберігалися байт за байтом так само до і після виправлення. Аліас викривало лише редагування вихідного документа після копіювання:

procedure CheckSourceSurvivesCopy;
var
  Lib: TPDFlib;
  SourceID, TargetID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Check(Lib.LoadFromString(BuildPdf([
      '<< /Type /Catalog /Pages 2 0 R >>',
      '<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 ' +
        '/MediaBox [0 0 400 300] /Resources << >> >>',
      '<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>',
      '<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>',
      StreamObj('1 w'), StreamObj('2 w')]), '') = 1, 'load failed');
    SourceID := Lib.SelectedDocument;

    TargetID := Lib.NewDocument;             // стає вибраним документом
    Check(Lib.CopyPageRanges(SourceID, '1') = 1, 'copy failed');

    Lib.SelectDocument(SourceID);
    Lib.SelectPage(1);
    Lib.SetPageBox(2, 50, 250, 100, 100);    // звужуємо лише CropBox
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'MediaBox followed CropBox');
    Lib.SetPageBox(1, 0, 200, 200, 200);

    Lib.SelectPage(2);
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'sibling page resized');

    Lib.SelectDocument(TargetID);            // копія зберігає початковий розмір
    Lib.SelectPage(Lib.PageCount);
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'copied page resized');
  finally
    Lib.Free;
  end;
end;

До v3.539.36 обидві сторінки тут успадковували прямий MediaBox кореневого вузла, копія прикріплювала той екземпляр до вихідної сторінки 1 і ще раз — як CropBox сторінки 1. Звуження CropBox отже звужувало MediaBox, а зміна розміру MediaBox змінювала сторінку 2 крізь кореневий вузол. Робочі процеси, що копіюють сторінки геть, а потім далі редагують джерело, як-от зшивання дуплексних сканів в один PDF перед обрізанням оригіналів, — саме там це виринало

Чому аліасинг екземплярів так важко тестувати?

Аліасинг екземплярів важко тестувати тому, що спостережуваний ефект потребує трьох кроків у певному порядку: створити аліас, мутувати один бік, потім інспектувати інший бік, доки його не торкнулося будь-що інше. Більшість тестів робить лише перший крок і порівнює збережений вихід, який ідентичний, існує аліас чи ні

Пастка порядку в PDFlibPas — SelectPage. Вибір сторінки повторно застосовує поточний шрифт через SelectFont, який реєструє той шрифт у ресурсах сторінки. Сторінка без власного /Resources розрішує словник батька, тож сам вибір такої сторінки легітимно додає /Font до вузла Pages. У тесті MovePage вище вибір колишньої сторінки 2 додає запис Helvetica до вузла 3 — це коректна поведінка, а не витік. Саме тому перевірка GetObjectToString(3) іде перед SelectPage(1); поміняйте їх місцями, і тест падає на полагодженій збірці

Те правило водночас окреслює, що v3.539.36 свідомо лишає в спокої. Запис ресурсу на сторінку, яка успадковує свій словник Resources, пишеться в словник предка, і кожен сусід бачить новий запис. Це успадкування, що працює за специфікацією, а не спільне володіння екземпляром, і воно нешкідливе, бо додавання імені шрифту чи зображення до спільного словника не змінює, як рендеряться інші сторінки. Якщо потрібна сторінка, що перестає успадковувати, дайте їй спершу власний словник Resources

Чекліст для коду об'єктної моделі PDF

Уроки узагальнюються на будь-яку об'єктну модель PDF, збудовану на пулі та вказівникових контейнерах, у Delphi чи де завгодно:

  • Матеріалізуючи успадковані атрибути за ISO 32000-1 §7.7.3.4, глибоко копіюйте прямі значення і лишайте непрямі посилання новими посиланнями на той самий об'єкт
  • Ніколи не робіть Add наявного екземпляра в другий контейнер, якщо спільне володіння не задумане і не задокументоване; власність пула означає, що виконуване середовище ніколи не скаржитиметься
  • Редагуйте на місці лише те, чим поточний вузол володіє як прямим об'єктом; замінюйте непрямі чи успадковані значення свіжим прямим об'єктом (copy-on-write)
  • Усталені значення, похідні від іншого запису, як-от CropBox від MediaBox, потребують власного екземпляра
  • Тестуйте аліасинг послідовностями «мутація, потім інспекція» на іншому тримачі, і перевіряйте порядок викликів, які між тим можуть легітимно писати
  • Порівняння збереженого виходу тут нічого не доводить: спільні та скопійовані значення серіалізуються однаково до першого редагування
  • На PDFlibPas оновіться до v3.539.36 чи новішої, якщо викликаєте MovePage, CollateDocumentsEx, BalancePageTree чи CopyPageRanges, а потім редагуєте бокси сторінок чи малюєте на сторінках

PDFlibPas відкриває редагування дерева сторінок, міждокументне копіювання та керування боксами сторінок через один клас TPDFlib для Delphi, C++Builder і Free Pascal. Видання, платформи і повний довідник API — на сторінці продукту PDFlibPas Delphi PDF library