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

PDF version preflight у Delphi: правила Measure RL проти GEO

PDFlibPas (PDF Library for Delphi) звіряє кожен об'єкт із таблицею правил версій PDF, перш ніж записати файл, і донедавна той PDF-version-preflight плутав звичайні CAD-словники вимірювань із геопросторовими. Односторінковий CAD-кресленик завантажувався нормально, а потім SaveToFile повертав 0 з LastErrorCode 602 і вимагав 1.7 ExtensionLevel 3. Виправлені правила трактують прямолінійні словники /Measure (/Subtype /RL) як звичайний PDF 1.6 і лишають extension-гейт для справжніх геопросторових маркерів

Файл прийшов через приймання корпусу: одна сторінка, одна група optional content, два прямолінійні вимірювальні viewport-и — той тип виводу, який пише архітектурний CAD-пакет, щоб переглядач міг зчитувати відстані з плану поверху. Нічого екзотичного — і саме тому відмова мала значення. Preflight, який блокує валідний файл, гірший за повільний, бо викликач отримує солідний на вигляд діагноз, що вказує на фічу, якої в документі немає. Виправлення складалося з двох частин: перечитування специфікації за одним правилом і усвідомлення, що правило не могло розрізнити два типи словників на тому рівні, на який дивилося

Як працює version-preflight збереження в PDFlibPas?

Гейт збереження PrepareAndCheckSaveVersion звіряє кожен непрямий об'єкт із PDFFeatureRules і падає на першому правилі, яке і збігається, і вимагає більшого, ніж дозволяє ціль. Ціль — це версія документа (або версія, закрита LockSaveVersion) плюс рівень розширення Adobe, оголошений під /Extensions /ADBE. Кожен запис TPDFFeatureRule несе MinVersion, MinExtensionLevel, MatchKind на кшталт fmkDictKey чи fmkDictSubtype, рядок Match, зрозумілу людині назву Feature і опційний callback. AddRule реєструє звичайне правило версії; AddExtensionRule завжди закріплює MinVersion на 17 і додає рівень розширення зверху, тож extension-правило може бути задоволене лише PDF 1.7 плюс правильним записом /Extensions. Коли гейт спрацьовує, потрібна версія і назва фічі зберігаються для викликача, і ключі 311, 312 і 313 GetInformation їх віддають

Version-preflight збереження в PDFlibPas: PrepareAndCheckSaveVersion звіряє кожен об'єкт із записами PDFFeatureRules, що несуть MinVersion, рівень розширення і рід збігу, AddExtensionRule закріплює вимогу на PDF 1.7 плюс рівень розширення, а перший збіг, який ціль не може задовольнити, зупиняє збереження з помилкою 602
Ключі 311, 312 і 313 GetInformation перетворюють відмову на діагноз, звітучи потрібну версію, фічу, що її спричинила, і закриту ціль, тож викликач може полагодити файл чи правило, а не вгадувати
var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('floor-plan.pdf', '') <> 1 then
      raise Exception.Create('load failed');
    if Pdf.SaveToFile('floor-plan-out.pdf') <> 1 then
      if Pdf.LastErrorCode = PDFLIB_ERROR_VERSION_COMPLIANCE then
        // 311: потрібна версія, 312: фіча, що її спричинила,
        // 313: версія, на якій закрита ціль збереження ('' коли відкрито)
        Writeln('Needs ', Pdf.GetInformation(311),
          ' for ', Pdf.GetInformation(312),
          ', locked at [', Pdf.GetInformation(313), ']');
  finally
    Pdf.Free;
  end;
end;

Чому звичайний CAD-кресленик падав із помилкою 602?

У таблиці правил сиділо AddExtensionRule(3, fmkDictKey, 'Measure', '/Measure geospatial dictionary', Nil), яке спрацьовувало на будь-якому словнику, що просто мав ключ /Measure, а такий ключ є в кожного вимірювального viewport-а. Масив /VP сторінки тримає словники viewport-ів, кожен viewport вказує на свій словник вимірювань через /Measure, і збіг за присутністю ключа там і зупинявся, не дивлячись, чим словник вимірювань насправді був. Скан фіч під час завантаження міг підняти номер версії документа до 1.7, але він ніколи не пише оголошення /Extensions від імені вхідного файлу, тож гейт збереження бачив PDF 1.7 на рівні розширення 0 і звітував 1.7 ExtensionLevel 3. Ця відмова вигадувати оголошення розширення — навмисна: бібліотека не тихо підвищує вхідний файл, щоб замазати правило, яке неправильне

Специфікація щодо прямолінійного випадку однозначна. Словники Measure з'явилися в PDF 1.6, і ISO 32000-1 §12.9 дає /Subtype усталене значення RL — прямолінійну систему координат, описану власним набором записів: масштаб, формати чисел X і Y, відстань і площа. Геопросторові вимірювання — пізніше доповнення з Adobe Extension Level 3 поверх PDF 1.7, розпізнаване за /Subtype /GEO і несе масиви географічних точок, словники систем координат і одиниці показу — структури, які розібрано в читанні viewport-ів GeoPDF і масивів GPTS та LPTS у Delphi. Обидва словники висять на тому самому ключі /Measure, тож будь-яке правило, що зупиняється на ключі, не може бути правильним для обох. Розрізнювальна інформація лежить рівнем нижче — у самому словнику вимірювань

Один ключ /Measure, два словники в PDFlibPas: прямолінійні вимірювання — з /RL чи без субтипу — потребують лише PDF 1.6, тоді як геопросторовий словник потребує 1.7 ExtensionLevel 3, тож CB_GeospatialDictionary вирішує за вмістом там, де старе правило за ключем їх не розрізняло
Preflight, який блокує валідний файл, гірший за повільний, бо викликач отримує солідний діагноз про фічу, якої в документі ніколи не було, — тому розрізнювальну перевірку пересунуто рівнем нижче

Що виправлений набір правил досі вимагає?

Виправлення видаляє безумовне правило за ключем і лишає гейти, що описують справжні вимоги версій. Сторінка з /VP чи /UserUnit досі потребує PDF 1.6 через CB_PagePDF16Entries, ключ /PtData досі потребує рівня розширення 3, а CB_GeospatialDictionary вирішує, чи словник вимірювань геопросторовий, за його вмістом, а не за ключем, яким він дістався

// Видалено: кожен словник із ключем /Measure рахувався геопросторовим
// AddExtensionRule(3, fmkDictKey, 'Measure', '/Measure geospatial dictionary', Nil);

AddRule(16, fmkCustom, '', 'Page PDF 1.6 entry /UserUnit /VP', CB_PagePDF16Entries);
AddExtensionRule(3, fmkDictKey, 'PtData', '/PtData geospatial dictionary', Nil);
AddExtensionRule(3, fmkCustom, '', 'geospatial measure dictionary', CB_GeospatialDictionary);

function CB_GeospatialDictionary(Obj: TPDFObject; const Ctx: TPDFRuleContext): Boolean;
var
  Dict: TPDFDictionary;
begin
  Result := False;
  if not (Obj is TPDFDictionary) then
    Exit;
  Dict := TPDFDictionary(Obj);
  Result := (Dict.StringValue('Subtype') = 'GEO') or
    (Dict.FindIndexByKeyName('GCS') >= 0) or (Dict.FindIndexByKeyName('DCS') >= 0) or
    (Dict.FindIndexByKeyName('GPTS') >= 0) or (Dict.FindIndexByKeyName('LPTS') >= 0) or
    (Dict.FindIndexByKeyName('PDU') >= 0);
end;

Спільні Delphi- та FPC-регреси закріплюють ту межу з обох боків. Viewport, чий словник вимірювань пропускає /Subtype, і той, що виписує /RL, обидва проходять на PDF 1.6, та сама сторінка досі відкидається на PDF 1.5, і детекція фіч більше не звітує для неї розширення. Додавання масиву /GPTS перекидає вердикт назад на 1.7 ExtensionLevel 3, який проходить щойно рівень розширення оголошено, а голий словник /Subtype /GEO без нього відмовляється. Callback свідомо консервативний: прямолінійний словник, що теж несе зайвий ключ /GCS чи /PDU, трактується як геопросторовий, бо в RL-моделі ті ключі сенсу не мають

LockSaveVersion — місце, де ця зміна стає видимою викликачам. TPDFlib.LockSaveVersion приймає '1.0' до '1.7', повертає 0 на всьому іншому, закріплює версію документа і не дає викликам на боці запису тихо її підняти, але гейт збереження все одно звіряється із закритим значенням. З виправленими правилами CAD-файл, закритий на 1.6, зберігається чисто. Справжній GeoPDF, закритий на 1.6, досі отримує 602 — це правильна відповідь, — а геопросторові авторські виклики на кшталт SetMeasureDictCoordinateSystem самі оголошують рівень розширення 3, коли ви будуєте такий вміст через API

if Pdf.LockSaveVersion('1.6') <> 1 then
  raise Exception.Create('unsupported version string');
if Pdf.SaveToFile('floor-plan-16.pdf') <> 1 then
begin
  if Pdf.LastErrorCode = PDFLIB_ERROR_VERSION_COMPLIANCE then
    // Справжній вміст вище 1.6, наприклад GEO-словник вимірювань
    raise Exception.CreateFmt('Locked at 1.6 but %s needs %s',
      [string(Pdf.GetInformation(312)), string(Pdf.GetInformation(311))]);
end;
Pdf.UnlockSaveVersion;

Чому скан правил версій був повільнішим, ніж міг бути?

Скан копіював кожен TPDFFeatureRule у локальний запис перед тестуванням, а оскільки запис тримає два поля AnsiString, кожна копія коригувала два лічильники посилань і вивільняла попередні значення. Preflight відвідує кожен вузол кожного дерева об'єктів, включно зі скалярами, тож та ціна множила кількість об'єктів на кількість правил, і правила, які взагалі не стосувалися цільової версії, спершу копіювалися, а потім пропускалися. Оскільки PDFFeatureRules заповнюється один раз при ініціалізації юніта і трактується як read-only, v3.539.17 передає записи таблиці прямо в MatchSingleRule і RuleExceedsTarget, чиї параметри const Rule беруть посилання, не торкаючись рядків

Прискорення скану правил у PDFlibPas: preflight раніше копіював кожен запис TPDFFeatureRule перед тестуванням, коригуючи лічильники посилань AnsiString для кожного відвіданого об'єкта, тоді як const-параметри тепер читають read-only таблицю на місці, зрізавши медіану раунду збігів правил із 0.711 с до 0.203 с
Виграш справжній, але вузький: повне збереження також платить за відкладену детекцію фіч, декодування об'єктів і серіалізацію, тож виміряне відношення належить шляху збігів правил, а не всьому часу збереження
// Раніше: керована копія запису на правило, на відвіданий об'єкт
Rule := PDFFeatureRules[X];
if not RuleExceedsTarget(Rule, TargetVersion, TargetExtensionLevel) then
  Continue;

// Тепер: const-параметри читають незмінний запис таблиці на місці
if not RuleExceedsTarget(PDFFeatureRules[X], TargetVersion, TargetExtensionLevel) then
  Continue;
if MatchSingleRule(Obj, Ctx, PDFFeatureRules[X]) then
begin
  RequiredVersion := RequiredVersionString(PDFFeatureRules[X]);
  FeatureName := PDFFeatureRules[X].Feature;
  Result := False;
  Exit;
end;

Виміряний ефект вузький, і цитувати його треба саме так. Бенчмарк звіряє масив із 20 000 числових об'єктів із ціллю PDF 1.4 по десять разів на раунд; збірка FPC Win64 з -O2 дала медіану п'яти раундів, що впала з 0.711 с до 0.203 с, а прогін двох збірок у зворотному порядку — 0.459 с проти 0.150 с. Це приблизно трикратний виграш лише на шляху збігів правил. Реальне збереження також платить за відкладену детекцію фіч, декодування об'єктів і серіалізацію, тож відношення не переноситься на весь час збереження. Порядок правил, callback'и, пороги версій і діагностика першого збою не змінилися, і жодне правило не кешувалося між збереженнями і не пропускалося заради цього

Що перевіряти, коли завантажений PDF провалює version-preflight?

Читайте ключі 311 і 312, перш ніж торкатися версії. Якщо фіча називає геопросторовий словник, а файл лише малює прямолінійні вимірювання, — це був той самий false positive, і актуальна збірка зберігає файл без змін. Якщо фіча справжня, або оголосіть розширення, або закрийтеся на версію, яка чесно містить цей вміст; підняти версію лише щоб заткнути гейт — значить сховати питання, чи зможуть споживачі нижче по течії прочитати те, що ви поставляєте. Той самий принцип обмежених, підкріплених доказами перевірок веде author-mode-preflight PDF/E-1 для інженерних документів, де CAD-кресленики зустрічаються зі стандартом відповідності, а не з номером версії

Перевірки відповідності версіям, словники вимірювань і геопросторові словники та закриття версії збереження — усе це частина PDF Library for Delphi, тулкіта PDFlibPas для розробників на Delphi, C++Builder і Lazarus