Article technique

Dégradés COLRv1 repeat/reflect en shadings PDF en Delphi

HotPDF Component peint les dégradés COLRv1 repeat et reflect en shadings PDF vectoriels au lieu de rasteriser le glyphe. La géométrie est étirée pour couvrir un nombre entier de cycles de ligne de couleur, chaque cycle reçoit son propre shading axial ou radial avec Extend coupé, les couleurs sont interpolées en CalRGB lumière linéaire, et les modes de composition in/out de Porter-Duff sexpriment avec des soft masks de luminosité

Quand nous avons couvert comment HotPDF dessine les polices COLR v1 et emoji couleur SVG en Delphi, les modes dextension repeat et reflect figuraient sur la liste de ce que le chemin natif némittait pas, et de tels glyphes partaient en repli raster. Les versions récentes dHotPDF les dessinent en vecteurs. Y arriver a pris plusieurs faux départs, dont la plupart causés par une hypothèse sur le PDF dapparence raisonnable qui sest révélée fausse dans au moins une visionneuse très répandue. Ce billet passe en revue ces hypothèses, parce que quiconque mappe des dégradés CSS, SVG ou OpenType sur le PDF rencontrera les mêmes

Pourquoi une visionneuse PDF ne peut-elle pas répéter le dégradé pour vous ?

Un shading PDF na ni mode repeat ni mode reflect, donc le producteur doit mettre en page chaque cycle lui-même. ISO 32000-1 §8.7.4.5.3 et §8.7.4.5.4 définissent /Extend pour les shadings axiaux et radiaux, et tout ce quil fait est continuer la couleur à t0 ou t1 au-delà de la fin de laxe. Cest COLRv1 EXTEND_PAD, et rien dautre. OpenType COLR v1 définit aussi EXTEND_REPEAT, où la ligne de couleur repart à zéro chaque fois que le paramètre franchit un entier, et EXTEND_REFLECT, où un cycle sur deux tourne à lenvers

Correspondance COLRv1 vers shading PDF chez HotPDF en Delphi : EXTEND_PAD correspond au drapeau de continuation /Extend des shadings axiaux et radiaux, tandis que EXTEND_REPEAT et EXTEND_REFLECT nont pas déquivalent PDF et doivent être construits depuis une géométrie de shading par cycle
Les shadings PDF ne font que prolonger les couleurs à t0 et t1, si bien quun dégradé COLRv1 repeat ou reflect doit être mis en page cycle par cycle par le producteur, pas activé pour la visionneuse

La première tentative dHotPDF a essayé de faire faire le travail périodique à la fonction. Elle enregistrait une fonction échantillonnée Type 0 (§7.10.2) avec un domaine large, environ [-12.73, 12.73] pour une envergure de 25 cycles, la remplissait déchantillons repliés, et activait Extend. À lintérieur du premier cycle, un fichier de test minimal se rendait correctement ; en dehors, MuPDF peignait une seule couleur moyennée et Poppler ne valait pas mieux. Les deux visionneuses évaluent la fonction le long du paramètre de shading et prolongent au-delà de laxe, et aucune ne retourne interroger une fonction périodique dans la région étendue. Le domaine large était aussi une mauvaise lecture de la norme : le paramètre de shading t est passé tel quel à la fonction, et daprès §7.10.1 une entrée hors du domaine de la fonction est rognée, si bien quune fonction dont le domaine ne correspond pas au /Domain du shading (défaut [0 1]) ne voit quune partie de ses échantillons lus

La conception qui marche a trois parties, toutes vérifiées contre des rendus MuPDF et Poppler. La géométrie du shading couvre physiquement un nombre entier N de cycles, si bien quaucun pixel ne dépend de Extend. La fonction échantillonnée garde le domaine par défaut [0 1], correspondant à ce que la visionneuse passe réellement. Et la séquence déchantillons elle-même est repliée par cycle, avec 256 intervalles par cycle, si bien que la fonction contient les données de couleur repeat ou reflect au lieu de compter sur une quelconque fonctionnalité de visionneuse

Replier une ligne de couleur en échantillons Type 0

Replier signifie échantillonner la ligne de couleur une fois par cycle et refléter la séquence sur les cycles impairs pour le mode reflect. Le helper ci-dessous est du Delphi simple, pas une API HotPDF, et il fait deux choses que la version naïve rate. Il interpole en lumière linéaire au lieu dinterpoler sur les octets sRGB stockés, et il résout les arrêts durs (deux arrêts au même décalage) vers la couleur du côté lointain, ce qui est la sémantique OpenType et CSS

uses
  System.SysUtils, System.Math;

type
  TColorStop = record
    Offset: Single;  // 0..1 dans un cycle, trié croissant
    R, G, B: Byte;   // octets sRGB style CPAL
  end;

function SRGBToLinear(V: Byte): Double;
var
  C: Double;
begin
  C := V / 255;
  if C <= 0.04045 then
    Result := C / 12.92
  else
    Result := Power((C + 0.055) / 1.055, 2.4);
end;

function LinearToSRGB(L: Double): Byte;
var
  C: Double;
begin
  L := EnsureRange(L, 0, 1);
  if L <= 0.0031308 then
    C := L * 12.92
  else
    C := 1.055 * Power(L, 1 / 2.4) - 0.055;
  Result := Round(C * 255);
end;

// Un cycle dune ligne de couleur en triplets RGB 8 bits. Reverse = True
// reflète le cycle, ce que fait le mode reflect sur les cycles impairs
function SampleCycle(const Stops: array of TColorStop; Count: Integer;
  Reverse: Boolean): TBytes;
var
  I, S: Integer;
  T, F, Span: Double;
begin
  SetLength(Result, Count * 3);
  for I := 0 to Count - 1 do
  begin
    T := I / (Count - 1);
    if Reverse then
      T := 1 - T;
    S := 0;
    // >= choisit le côté aval dun arrêt dur
    while (S < High(Stops) - 1) and (T >= Stops[S + 1].Offset) do
      Inc(S);
    Span := Stops[S + 1].Offset - Stops[S].Offset;
    if Span > 0 then
      F := EnsureRange((T - Stops[S].Offset) / Span, 0, 1)
    else
      F := 1;
    Result[I * 3] := LinearToSRGB(SRGBToLinear(Stops[S].R) +
      (SRGBToLinear(Stops[S + 1].R) - SRGBToLinear(Stops[S].R)) * F);
    Result[I * 3 + 1] := LinearToSRGB(SRGBToLinear(Stops[S].G) +
      (SRGBToLinear(Stops[S + 1].G) - SRGBToLinear(Stops[S].G)) * F);
    Result[I * 3 + 2] := LinearToSRGB(SRGBToLinear(Stops[S].B) +
      (SRGBToLinear(Stops[S + 1].B) - SRGBToLinear(Stops[S].B)) * F);
  end;
end;

Le moteur de rendu COLRv1 dans HotPDF va plus loin que ce croquis. Il écrit des échantillons linéaires 16 bits big-endian dans une fonction CalRGB et stocke lalpha comme un échantillon gris 16 bits séparé, tandis que les helpers publics montrés ensuite prennent des triplets DeviceRGB 8 bits, ce pourquoi le croquis reconvertit en octets sRGB. Pour la théorie de ce quun lecteur fait de ces échantillons, larticle sur les fonctions échantillonnées Type 0 comme LUT de couleur 3D couvre le modèle dinterpolation en détail

Pourquoi chaque cycle a-t-il besoin de son propre shading ?

Chaque cycle a besoin de son propre shading parce que les lecteurs névaluent pas exactement une fonction échantillonnée ; ils précalculent une table de couleurs, et la table interpole à travers la boucle de la dernière couleur dun cycle à la première couleur du suivant. MuPDF stocke la fonction unidimensionnelle dun shading comme une table de 256 entrées. Entassez 11 cycles dans une seule fonction de 2816 échantillons et chaque échantillon sauvegardé peut être exact tandis que MuPDF montre encore 12 à 14 niveaux derreur de canal, parce que les 256 entrées de table sont étalées sur toute lenvergure et la couture dure du repeat se fait lisser en rampe. Poppler et linterpolation analytique du même fichier étaient tous deux corrects, donc cest une limite de précision de lecteur, pas une erreur de fichier, et ajouter des échantillons naide pas

Diagramme des coutures du dégradé repeat COLRv1 HotPDF pour Delphi : une seule fonction échantillonnée couvrant 11 cycles laisse la table de couleurs MuPDF à 256 entrées lisser chaque couture de repeat en rampe, tandis quun shading axial par cycle garde chaque table à lintérieur dun seul cycle et préserve le saut de couleur dur
Les lecteurs précalculent une table de couleurs fixe par shading, si bien que la correction est géométrique : un shading par cycle avec Extend coupé déplace la couture de repeat sur une frontière quaucune table ninterpole jamais

La correction est de garder chaque table de lecteur à lintérieur dun cycle. HotPDF émet les dégradés linéaires périodiques comme un shading axial indépendant par cycle dorigine, avec les deux drapeaux Extend à faux, si bien que le saut de couleur fin-début tombe sur la frontière entre deux shadings au lieu de tomber dans une table. Pour reflect, le test dimpairité emploie le numéro de cycle signé dorigine, cycles négatifs avant le début de la ligne de couleur compris, et la direction du shading est échangée plutôt que les échantillons recalculés. Tous les cycles sont dessinés dans une surface isolée en lumière linéaire et lalpha de peinture est appliqué une fois à la fin, si bien quun dégradé translucide ne double pas son opacité le long des coutures

Les méthodes publiques THotPDF.RegisterSampledAxialGradient et THotPDF.RegisterSampledRadialGradient exposent la brique de shading échantillonné pour votre propre dessin. Toutes deux prennent (..., DomainT0, DomainT1: Single; const Samples: TBytes; SampleCount, Cycles: Integer) après la géométrie, exigent PDF 1.3, lèvent une exception sauf si Samples contient exactement SampleCount * 3 octets, et renvoient un nom de pattern pour THPDFPage.SetFillPattern. Elles posent Extend aux deux bouts et enregistrent Cycles comme une clé de pattern personnalisée /Cycles que les visionneuses ignorent. Pour suivre la règle dun cycle par shading avec elles, enregistrez un pattern par cycle et laissez un rectangle de bande le clipper

// Dégradé repeat ou reflect horizontal fait dun shading par cycle.
// Les coordonnées du pattern sont en espace PDF par défaut ; un axe
// horizontal garde cet exemple indépendant de la convention Y haut-bas de la page
procedure PaintPeriodicBands(Pdf: THotPDF; const Stops: array of TColorStop;
  X, Y, Width, Height, CycleLength: Single; Reflect: Boolean);
const
  SamplesPerCycle = 257;  // 256 intervalles par cycle
var
  Cycle, CycleCount: Integer;
  X0, X1: Single;
  Samples: TBytes;
  PatternName: AnsiString;
begin
  CycleCount := Ceil(Width / CycleLength);
  for Cycle := 0 to CycleCount - 1 do
  begin
    X0 := X + Cycle * CycleLength;
    X1 := X0 + CycleLength;
    Samples := SampleCycle(Stops, SamplesPerCycle, Reflect and Odd(Cycle));
    // Domaine [0, 1] : le paramètre de shading atteint la fonction inchangé
    PatternName := Pdf.RegisterSampledAxialGradient(X0, 0, X1, 0, 0, 1,
      Samples, SamplesPerCycle, 1);
    if PatternName = '' then
      Exit;  // version de document verrouillée sous PDF 1.3
    Pdf.CurrentPage.SetFillPattern(PatternName);
    // La bande clippe le shading à exactement un cycle, donc Extend napparaît jamais
    Pdf.CurrentPage.Rectangle(X0, Y, Math.Min(X1, X + Width) - X0, Height);
    Pdf.CurrentPage.Fill;
  end;
end;

Les envergures très longues demandent une autre astuce, parce quun shading par cycle signifie une ressource par cycle. Pour les dégradés linéaires COLRv1 périodiques, HotPDF passe à un schéma de doublement : une forme feuille détient une primitive repeat à un cycle ou reflect à deux cycles, et chaque niveau de Form XObject dessine le niveau précédent deux fois, décalé de sa largeur. Le nombre de formes croît logarithmiquement avec lenvergure, tandis que la visionneuse peint toujours chaque cycle visible, si bien que la couverture après alignement de phase est plafonnée à 65536 unités de cycle. Une version antérieure qui plaçait la primitive dans un pattern de tuilage Type 1 a été abandonnée, parce que les lecteurs ajustent lespacement des tuiles à la grille du périphérique et que Poppler dérivait en phase sur un cycle qui nétait pas un nombre entier de pixels

Résoudre la plage de paramètres pour les familles de cercles radiaux

Un dégradé radial ne couvre le glyphe que si la plage de cycles est calculée pour la bonne famille de cercles, et les peintures radiales COLRv1 tombent en trois familles aux mathématiques différentes. Avec D = c1 - c0 et dr = r1 - r0, une famille strictement imbriquée a |dr| > |D|, un cône non imbriqué a |D|² > dr², et une famille tangente a |D| = |dr|. HotPDF mesure le paramètre depuis le point de rayon nul de la famille dorigine et garde le centre et le rayon de chaque cercle émis liés au même paramètre dorigine, si bien que les rayons négatifs remappés ne sont jamais bornés indépendamment

Arbre de décision du dégradé radial COLRv1 HotPDF pour Delphi : comparer dr avec D sépare les familles de cercles imbriquées résolues par bissection, les cônes résolus avec de courts shadings radiaux par cycle, et les familles tangentes réduites à la racine linéaire q = |W|^2 / (2(W*V))
Linégalité entre |dr| et |D| décide des mathématiques de cycles : les familles imbriquées bissectent pour le premier cercle touchant, les cônes reçoivent un court shading par cycle, et les lignes tangentes laissent une bande singulière transparente
  • Les familles imbriquées intersectent la boîte de remplissage de façon monotone, si bien que la plage de paramètres vient des quatre coins plus une bissection pour le premier cercle qui touche la boîte. Jusquà 64 cycles sont échantillonnés dans un shading à 256 échantillons par cycle, plafonné à 4096 échantillons ; les envergures plus profondes passent aux tranches par cycle
  • Les cônes et les bandes à rayon égal emploient de courts shadings radiaux séparés aux frontières de cycles dorigine, chacun avec 257 échantillons et Extend coupé, composés dans lordre des paramètres dorigine à lintérieur dun groupe isolé non knockout, si bien que le plus grand paramètre valide lemporte là où les cercles se chevauchent
  • Les familles tangentes réduisent léquation du cercle à une équation linéaire. Relativement au point de tangence partagé, la racine est q = |W|² / (2·(W·V)), et la région couverte est le demi-plan ouvert devant ce point

Le cas tangent a une vraie singularité. Quand la ligne tangente traverse la boîte de remplissage, le nombre de cycles nécessaires pour atteindre les pixels près de cette ligne est non borné, si bien quaucun nombre fini de shadings ne la couvre. HotPDF dépense un budget de 64 cycles depuis le point de tangence et laisse la mince bande restante transparente. Quand les quatre coins de la boîte se trouvent derrière le point de tangence, la peinture est transparente dans son ensemble et le reste du glyphe se dessine quand même. Le chemin de cône fini accepte jusquà 4096 cycles requis, avec une couverture plafonnée à 1024 tranches et tout ce qui dépasse le plafond laissé non peint ; un glyphe à 1024 tranches arrive à environ 1,8 Mo

Couleur en lumière linéaire et le piège des deux décimales

COLRv1 interpole et compose en lumière linéaire, si bien que HotPDF décode chaque octet CPAL avec la fonction de transfert inverse sRGB et peint aplats, shadings et groupes internes dans un espace CalRGB unique avec blanc D65, primaires sRGB et /Gamma [1 1 1] (§8.6.5.3). Seule la surface racine finie se convertit vers lespace de fusion de lappelant. Interpoler les octets sRGB stockés produit à la place des points milieux visiblement plus sombres, lerreur gamma classique que fait tout moteur de dégradés une fois

Le passage à la lumière linéaire a exposé un vieux bug de formateur. Une valeur de canal CPAL sombre de 12 devient environ 0.0036765 en lumière linéaire, et le formateur de nombres partagé derrière scn ne gardait que deux décimales, si bien que cette composante était écrite 0 avant que la moindre visionneuse touche le fichier. Le symptôme ressemblait exactement à une quantification de lecteur, et le premier réflexe fut daccuser les tampons de groupe 8 bits de MuPDF. Les composantes de remplissage et de trait passent désormais par un formateur décimal sans exposant qui garde les petites valeurs et ignore la locale du processus. La leçon se généralise : quand une couleur sombre disparaît, videz le flux de contenu et vérifiez lopérande écrit avant denquêter sur la visionneuse

La visionneuse compte toujours après cette correction. MuPDF 1.28.2 perd les canaux linéaires proches de zéro quand il compose un groupe de transparence isolé qui porte un espace de couleur de fusion explicite, et Poppler 26.07.0 Splash ignore carrément les espaces de fusion de groupe. La propriété opt-in THotPDF.COLRv1DeviceSpaceGroupBlend (défaut False) émet les groupes COLRv1 sans lespace de couleur de fusion, si bien quils se composent dans lespace périphérique hérité tandis que les couleurs de contenu, shadings et fonctions restent en CalRGB lumière linéaire. Le compromis est que les couches translucides qui se chevauchent fusionnent alors en espace périphérique dans les visionneuses qui honorent les espaces de couleur de groupe

Quels modes de composition COLRv1 le PDF peut-il exprimer exactement ?

HotPDF rend les modes de composition COLRv1 5 à 8 (SRC_IN, DEST_IN, SRC_OUT, DEST_OUT) nativement, et les modes 9 à 12 (SRC_ATOP, DEST_ATOP, XOR, PLUS) partent vers OnColorGlyphRasterize. Les modes in et out sont des produits dune surface par lalpha de lautre, ce qui est exactement ce que calcule un soft mask (§11.6.5). Lopérande survivante est capturée comme une forme ordinaire ; lopérande masquée est capturée lors dun passage alpha comme contenu plat à lintérieur dun groupe DeviceGray isolé et sert de masque /Luminosity avec /BC [0]. Les modes out écrivent 1 − α pour chaque alpha et placent un couvert blanc opaque sous le contenu du masque, si bien que la survivante reste visible là où lautre opérande na jamais peint

Les quatre autres exigent une addition prémultipliée de deux surfaces. Dans le modèle source-over du PDF, le terme de fond est toujours atténué par le (1 − α) de la source, et aucune primitive nadditionne deux surfaces ni ne met à léchelle le fond par la seule couverture de la source, si bien que atop, xor et plus ne peuvent pas sécrire en vecteurs. Une restriction de plus venait des PDF de test nus : les deux visionneuses calculent mal les groupes de soft mask dont le contenu imbrique un Do de forme, si bien que lopérande masquée doit être plate. Les couleurs unies, les dégradés pad et un wrapper PaintGlyph autour deux qualifient ; les couches, transformations et dégradés périodiques, qui produisent tous des formes internes, envoient le glyphe en repli

var
  Pdf: THotPDF;
  Fallback: TEmojiFallback;  // classe gestionnaire avec la signature dévénement ci-dessous
begin
  Pdf := THotPDF.Create(nil);
  Fallback := TEmojiFallback.Create;
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'periodic-icons.pdf';
    // Opt-in : composition des groupes en espace périphérique pour MuPDF et Poppler
    Pdf.COLRv1DeviceSpaceGroupBlend := True;
    // Reçoit les glyphes atop/xor/plus et tout le reste hors du sous-ensemble :
    // (Sender; Format; const FontBytes, AssetData: TBytes; GlyphID: Word;
    //  PaletteIndex, PixelSize: Integer; out Width, Height: Integer;
    //  out RGBA: TBytes; out Handled: Boolean)
    Pdf.OnColorGlyphRasterize := Fallback.Rasterize;
    Pdf.BeginDoc;
    Pdf.RegisterUnicodeTTF('Fonts\GradientIcons.ttf');
    if not Pdf.DrawRegisteredColorGlyph(Pdf.CurrentPage, $E001,
      72, 144, 'Gradient Icons', 48) then
      Writeln('U+E001 was neither drawn natively nor rasterized');
    Pdf.EndDoc;
  finally
    Fallback.Free;
    Pdf.Free;
  end;
end;

Où le chemin vectoriel reste encore en deçà

La sortie vectorielle nest pas identique à des pixels identiques dans chaque visionneuse, et les enregistrements dacceptation HotPDF le disent franchement. Les échantillons à un ou deux pas dune frontière de cycle sont lissés par linterpolation du lecteur, si bien que les contrôles de pixels échantillonnent loin des coutures et que les flux de fonctions sont vérifiés en les décodant à la place. Interpoler des échantillons RGB droits et alpha stockés séparément est une approximation près des extrémités à faible alpha et des arrêts durs ; lerreur mesurée est du second ordre à 256 échantillons par cycle, mais ce nest pas exact. Poppler Splash échantillonne au bord gauche dun pixel plutôt quà son centre, ce qui se voit comme de petites différences sur les rampes les plus raides

Les budgets sont de vraies limites aussi. Les familles radiales imbriquées déclinent au-delà denvergures de 4096 cycles, les cônes et familles tangentes au-delà de 4096 cycles requis, et la bande tangente près de la ligne singulière reste vide. Un alpha variable des arrêts exige la transparence PDF 1.4, donc ce nest pas activé sous PDF/A-1, PDF/X-1a ou PDF/X-3. Si un glyphe tombe hors de ces limites, DrawRegisteredColorGlyph décline le graphe entier avant décrire un opérateur, ce qui est le même comportement tout-ou-rien quauparavant

FAQ

HotPDF rasterise-t-il les dégradés COLRv1 repeat et reflect ?

Non. Les versions récentes dHotPDF les émettent comme des shadings axiaux et radiaux vectoriels couvrant des cycles entiers. Seuls les graphes hors du sous-ensemble pris en charge, comme les compositions atop, xor et plus, partent vers OnColorGlyphRasterize

Pourquoi ne pas employer une seule fonction échantillonnée pour tous les cycles ?

Les lecteurs précalculent une table de couleurs depuis la fonction, et la table de MuPDF a 256 entrées. Étalée sur de nombreux cycles, elle interpole à travers chaque couture de repeat. Un shading par cycle garde chaque table à lintérieur dun cycle unique

Quand activer COLRv1DeviceSpaceGroupBlend ?

Activez-le quand des canaux quasi noirs des glyphes COLRv1 tombent à zéro dans MuPDF. Laissez-le coupé quand des couches translucides qui se chevauchent doivent fusionner en lumière linéaire dans des visionneuses qui honorent les espaces de couleur de groupe

Dégradés COLRv1 natifs, emoji couleur, shadings échantillonnés et groupes de transparence font tous partie de HotPDF Component for Delphi and C++Builder, qui génère des documents PDF directement depuis du code Delphi et C++Builder