Teknisk artikel

CSS multi-column-layout i HTML-till-PDF med HotPDF Delphi

HotPDF Component lägger ut CSS multi-column-text när THPDFHTMLImporter.RenderHTML5 konverterar HTML till PDF i Delphi. Den sidade HTML-motorn löser column-count och column-width mot innehållsrutan, balanserar sista fragmentet av varje multicol-container med en binärsökning, fyller tidigare sidor kolumn för kolumn till sidbotten, och stödjer column-fill, column-span: all och column-rule

Det vanliga skälet att vilja det här är tråkigt och verkligt: en tvåkolumners produktbulletin, en villkorsbilaga satt i tre smala kolumner, eller en release notes-PDF som ska se ut som det tryckta nyhetsbrev marknadsteamet redan designat i HTML. Utan kolumnstöd staplar importern varje stycke i en enda bred bredd, radlängden fördubblas, och dokumentet läses som en loggfil. Med det driver samma stilmall som driver webbläsarens förhandsvisning PDF:en, och den svåra delen blir sidbrytning, vilket webbläsare mest aldrig behöver tänka på på skärmen

Hur avgör HotPDF hur många kolumner som ska användas?

HotPDF löser kolumnantalet med samma regler som pseudoalgoritmen i CSS Multi-column Layout Module Level 1 §3.4, tillämpad på innehållsrutans bredd W efter att padding och ramar tagits bort. Ett deklarerat column-count tas som det är. När bara column-width ges beräknar motorn N = Trunc((W + gap) / (column-width + gap)) med ett golv på en kolumn. När båda finns är resultatet det mindre av de två, så det deklarerade antalet kan kapka det härledda antalet men aldrig blåsa upp det

Ett konkret fall: en innehållsruta på 483 pt (A4 med 56 pt sidmarginaler, avrundat) och column-width: 140pt; column-gap: 12pt ger Trunc(495 / 152) = 3 kolumner, var och en (483 - 2 * 12) / 3 = 153pt bred. Att lägga till column-count: 2 i samma regel sänker den till två kolumner, eftersom minimum vinner. Nyckelordet normal för gap löser sig till 1em av containerens typstorlek, och den upplösningen sker vid layouttid i stället för vid parstid, så en font-size deklarerad efter column-gap i samma regel ger ändå rätt gap

Upplösning av CSS multi-column-antal i HotPDF för HTML till PDF i Delphi: column-count används som deklarerat, column-width härleder N = Trunc((W + gap) / (column-width + gap)), det mindre vinner när båda är satta, och varje kolumn under 12pt kollapsar till en enda kolumn
Motorn tillämpar CSS-pseudoalgoritmen på innehållsrutan, kapar det härledda antalet med ett deklarerat, och lägger till en golvskydd på 12pt så att icke betrodda mallar inte kan producera tusentals mikroskopiska kolumner

Ett skydd finns inte i specifikationen, och det är där med flit. Efter att kolumnbredden beräknats kollapsar varje värde under 12 pt containern tillbaka till en enda kolumn. En stilmall som säger column-count: 9999, vare sig av misstag eller för att den kom från en icke betrodd mall, skulle annars producera tusentals kolumner med negativa eller mikroskopiska bredder och en radbrytare som försöker få plats med en glyf per rad. Att falla tillbaka till en kolumn håller utdata läsbart och layoutkostnaden avgränsad. Noll eller negativa antal kommer aldrig så långt: valideraren accepterar bara auto eller ett positivt heltal för column-count, och column-width och column-gap tar auto respektive normal, eller en längd i punkter eller em

Att rendera en multi-column-HTML-sektion till PDF i Delphi

Multi-column-utdata behöver ingen omkopplare på importern. THPDFHTMLImporter skapas med det THotPDF-dokument den ritar in i, BeginDoc måste redan ha körts, och kolumnegenskaperna anländer via den normala kaskaden, antingen i markeringen eller i författarstilmallen som skickas som andra argument till RenderHTML5

uses
  SysUtils, HPDFDoc, HPDFHTMLImport;

procedure BuildBulletin(const FileName: string);
var
  Pdf: THotPDF;
  Importer: THPDFHTMLImporter;
  Html, Css: WideString;
begin
  Pdf := THotPDF.Create(nil);
  Importer := THPDFHTMLImporter.Create(Pdf);
  try
    Pdf.FileName := FileName;
    Pdf.BeginDoc;
    Importer.Margin := 56;
    Importer.BaseFontName := 'Arial';
    Importer.BaseFontSize := 10;

    Css := 'section.news { column-width: 140pt; column-gap: 12pt; ' +
           '  column-rule: 0.5pt solid #999999 } ' +
           'section.news h2 { column-span: all }';

    Html := '<section class="news">' +
            '<p>Release 4.2 ships the new export pipeline...</p>' +
            '<p>Licensing changes take effect on 1 March...</p>' +
            '<h2>Known issues</h2>' +
            '<p>Import of legacy archives &amp; templates...</p>' +
            '</section>';

    if not Importer.RenderHTML5(Html, Css) then
      raise Exception.Create('Nothing was emitted');
    Pdf.EndDoc;
  finally
    Importer.Free;
    Pdf.Free;
  end;
end;

Notera den dubbla eskaperingen i sista stycket: &amp; inne i Pascal-strängen är HTML-entiteten för ett et-tecken, som importern avkodar tillbaka till ett enda tecken. Resten av kaskaden, budgeter och resurs-callbacks beter sig exakt som beskrivs i översikten av HotPDF HTML5 paged media-import; kolumner är bara ett layoutläge till inne i samma profil

Att balansera kolumner med en binärsökning

HotPDF balanserar kolumner genom att söka den minsta kolumnhöjd vid vilken en girig fyllning fortfarande placerar varje kvarvarande rad. Det giriga varvet är enkelt: gå raderna in i kolumn ett tills nästa rad skulle överskrida kandidathöjden, fortsätt sedan i kolumn två, och så vidare. Sökningen börjar med den högsta enskilda raden som nedre gräns och summan av alla radhöjder som övre gräns, halverar intervallet varje runda, och stannar när gränserna ligger inom 0,05 pt

Balansering av multi-column i HotPDF i Delphi: en binärsökning mellan den högsta enskilda raden och summan av alla radhöjder hittar den minsta kolumnhöjd vars giriga fyllning placerar varje rad, med gränser konvergerande inom 0,05pt
Predikatet varje-rad-får-plats är monont i kolumnhöjd, så att halvera intervallet konvergerar mot det kortaste balanserade bandet och den slutliga giriga fyllningen konsumerar varje rad utan att lämna enstaka änkor

En binärsökning fungerar bara om predikaten är monont, och här är det: en högre kolumn kan aldrig hålla färre rader än en lägre, så "varje rad får plats" vänder från falskt till sant exakt en gång när höjden växer. Den egenskapen är det som garanterar konvergens och garanterar att den slutliga giriga fyllningen vid den funna höjden konsumerar varje rad, i stället för att lämna en änkrad som skulle utlösa en fejkad extra sida. Den förklarar också en detalj i resultatet: det balanserade bandet är så kort som radrutorna tillåter, inte nödvändigtvis lika höga kolumner, eftersom rader är odelbara och sista kolumnen kan sluta en rad eller två högre än de andra

Vad händer när kolumner korsar en sidbrytning?

Kolumner som inte får plats på den aktuella sidan fylls sekventiellt till sidbotten, och bara sista sidfragmentet balanseras. Det här är beteendet CSS Multi-column Layout Level 1 §7.1 föreskriver för column-fill: balance i fragmenterade sammanhang, och det är hur ett tryckt nyhetsbrev ser ut: fulla kolumner på varje sida utom den sista, där resttexten delas jämnt i stället för att lämna en hög kolumn bredvid en tom

Varje sidfragment går igenom samma fragmenterare oberoende. Får de kvarvarande raderna plats i N kolumner av tillgänglig höjd balanseras fragmentet; annars fyller varje kolumn till sidbotten och loopen startar en ny sida. Containerns naturliga höjd, som motorn använder för break-inside: avoid-beslut och utsträckningsmätning, är den balanserade bandhöjden med obegränsad kapacitet, så en container som skulle få plats på en färsk sida som balanserat block flyttas dit intakt i stället för att skivas. När en enda rad är högre än en hel sida släpper motorn på kravet, placerar raden ändå överst på en sida, och rapporterar en hdkPaginationConstraint-diagnostik med subjektet multi-column

Paginering av multi-column i HotPDF för HTML till PDF i Delphi: fragment före sista sidan fyller varje kolumn till sidbotten, det sista fragmentet balanseras över kolumner, och column-span all delar en container i separat balanserade band kring ett helbreddselement
Bara det sista fragmentet av en multicol-container balanseras; tidigare sidor fyller sekventiellt, och varje fragment upprepar balansen, strecken och pagineringen oberoende

column-fill: auto och rutor med fast höjd

Med column-fill: auto och en definit height fyller HotPDF kolumnerna i ordning, och varje kolumnhöjd är det mindre av kvarvarande sidutrymme och containerns höjd minus dess padding och ramar. Minimum spelar större roll än det ser ut. Att använda bara kvarvarande sidutrymme skulle låta första kolumnen svälja varje rad närhelst sidan har mer utrymme kvar än rutan. Utan definit höjd faller column-fill: auto tillbaka till balansering, som webbläsare gör, eftersom en obegränsad multicol-container inte har någon kolumnhöjd att fylla till; den första implementationen missade den reserven, och en av de 27 regressionskontrollerna fångade den

column-span: all och nästlade stycken

HotPDF stödjer column-span: all på direkta barn av en multi-column-container, enligt CSS Multi-column Layout Level 1 §6.1. Ett spannande barn delar containern i segment: barnen före det bildar ett kolumnband, det spannande elementet renderas över hela bredden, och barnen efter det startar ett nytt band. Varje band balanseras, fylls, streckas och pagineras för sig, så en rubrik mitt i en tre-kolumners sektion ger två balanserade band med en helbreddsrubrik mellan dem, vilket är den layout folk faktiskt ritar i mockups

Att komma dit krävde att först fixa ett mindre synligt problem. En container som <div style="column-count:2"> med flera <p>-barn renderade förr varje stycke i en enda staplad kolumn, eftersom varje stycke lades ut som sitt eget textblock och kolumn-deklarationen på föräldern aldrig applicerades. HotPDF samlar nu in texten hos nästlade blockbarn till ett flöde för containern. Insamlaren måste sätta in en hård brytning vid varje blockgräns medan den gör det; att skippa den konkatenerar sista ordet i ett stycke med första ordet i nästa på samma rad, en bugg som är lätt att missa när testtexten råkar sluta med skiljetecken

Det sammanslagna flödet har en medveten räckvidd. Det används bara när hela subtree är ren textström. En container som håller formulärkontroller, bilder, tabeller, listor eller <hr> var som helst inuti behåller den vanliga blockvägen och renderas i en enda kolumn, eftersom den sammanslagna textsamlaren inte kan uttrycka deras geometri. Tabeller har i synnerhet egna pagineringsregler, täckta i artikeln om rowspan-hantering och upprepade tabellrubriker i HotPDF, och att blanda dem i ett kolumnband skulle bryta de garantier

Var kolumnstrecket hamnar, och hur man testar det

HotPDF ritar column-rule på mitlinjen av varje gap, vid en kolumns högerkant plus halva gapet, från toppen till botten av bandet på varje sidfragment, som CSS Multi-column Layout Level 1 §4 beskriver. Bredden är som standard 1 pt. Färgen är som standard elementets textfärg när den inte är deklarerad eller är currentColor. Shorthanden column-rule utan stilnyckelord behandlas som solid, matchande hur shorthanden border vanligen läses, medan none och hidden undertrycker strecket

Kolumngeometrin är observerbar utan att parsa PDF:en. Händelsen OnLineLayout på THPDFHTMLImporter avfyras en gång per ritad rad med en THPDFHTMLLineLayout-post, och eftersom motorn ritar varje kolumn separat är fältet X på varje rad dess kolumns vänsterkant. Gränskolumner skiljer sig med exakt kolumnbredd plus gapet, vilket gör det lätt att skilja ett 12 pt-gap från ett 20 pt-gap i ett regressionstest

type
  TColumnProbe = class
  public
    Lines: TStringList;
    constructor Create;
    destructor Destroy; override;
    procedure LineDrawn(Sender: TObject; const Line: THPDFHTMLLineLayout);
  end;

constructor TColumnProbe.Create;
begin
  inherited Create;
  Lines := TStringList.Create;
end;

destructor TColumnProbe.Destroy;
begin
  Lines.Free;
  inherited;
end;

procedure TColumnProbe.LineDrawn(Sender: TObject;
  const Line: THPDFHTMLLineLayout);
begin
  // PageIndex är nollbaserad; X är vänsterkanten av radens kolumn
  Lines.Add(Format('page=%d x=%.1f y=%.1f %s',
    [Line.PageIndex, Line.X, Line.Y, string(Line.Text)]));
end;

// Kabeldragning, före RenderHTML5:
//   Probe := TColumnProbe.Create;
//   Importer.OnLineLayout := Probe.LineDrawn;

Två praktiska noteringar från att skriva tester mot den här händelsen. PageIndex är nollbaserad, vilket är det första ett handskrivet assertion tenderar att få fel. Och X inkluderar justeringsskiften, så för centrerad eller högerjusterad text, jämför kolumnpositioner på vänsterjusterade fixturer. Strecket i sig syns inte i händelsen; för att asserta dess position, skanna den okomprimerade innehållsströmmen efter de vertikala operatorerna m och l

Gränser och fallgropar värda att känna till

Ett fåtal beteenden är approximationer, och de är bättre kända före en granskning av layouten än efter. Styckavstånd inne i ett sammanslaget kolumnflöde approximeras med en rad av containerens radhöjd vid varje blockgräns, så per-stycke-marginaler inne i ett kolumnband återges inte exakt. När column-span: all delar en container upprepar varje segment containerns padding och bakgrundsbox, vilket håller segmenten visuellt sammanhängande men inte är en enda box i strikt CSS-mening. Varje column-rule-style-värde utöver none och hidden passerar valideringen, men strecket stryks som en solid linje

Icke stödda värden misslyckas inte tyst. Att sätta StrictCSS till True gör varje diagnostik till ett EHPDFHTMLUnsupportedCSS-undantag, medan OnDiagnostic låter ett batchjobb logga dem och fortsätta. Notera att strikt läge gäller pagineringsdiagnostiken också, så ett dokument med en rad högre än en sida kastar under StrictCSS i stället för att tyst släppa på kravet

procedure TReportJob.HtmlDiagnostic(Sender: TObject;
  const Diagnostic: THPDFHTMLDiagnostic);
begin
  case Diagnostic.Kind of
    hdkUnsupportedProperty, hdkUnsupportedValue:
      FLog.Add('CSS ' + Diagnostic.Subject + ': ' + Diagnostic.MessageText);
    hdkPaginationConstraint:
      FLog.Add('Layout relaxed (' + Diagnostic.Subject + '): ' +
        Diagnostic.MessageText);
  else
    FLog.Add(Diagnostic.MessageText);
  end;
end;

// Mallförfattande: fall snabbt på allt motorn skulle ignorera
Importer.StrictCSS := FValidatingTemplate;
Importer.OnDiagnostic := HtmlDiagnostic;

Strikt läge är rätt inställning medan en mall skrivs, eftersom en typo som column-span: both avvisas omedelbart i stället för att tyst låta rubriken flyta inne i kolumnerna. I produktion är loggning via OnDiagnostic oftast bättre handel, eftersom en märklig deklaration i kundlevererad HTML inte ska kosta hela dokumentet. Identiska diagnostiker (samma sort, subjekt och värde) rapporteras en gång medan de får plats i MaxDiagnostics-listan, som är standard 256 poster, så en deklaration kaskaden besöker för hundratals element översvämmar inte loggen

FAQ

Stödjer HotPDF shorthanden columns?

Ja. Shorthanden columns accepterar som mest ett positivt heltalsantal och en kolumnlängd, i valfri ordning, och auto nollställer båda longhands. De lösta värdena följer samma antals- och breddsregler som de enskilda egenskaperna

Kan en multi-column-container hålla bilder eller tabeller?

Den kan hålla dem, men HotPDF renderar då containern i en enda kolumn. Kolumnflödet appliceras bara när varje ättling är ren textström, eftersom bilder, tabeller, listor och formulärkontroller behöver sin egen boxgeometri och egna pagineringsregler

Varför visar sista sidan balanserade kolumner medan tidigare sidor är fulla?

Det är CSS-regeln för column-fill: balance i sidad media: bara sista fragmentet balanseras. Tidigare sidor fyller varje kolumn till sidbotten så att inget utrymme slösas mitt i dokumentet

Multi-column-layout, HTML5 paged media-profilen och resten av HTML-till-PDF-pipelinen är del av HotPDF Component for Delphi and C++Builder, som också täcker direkt PDF-ritning, formulär, kryptering och textextraktion