Kapitel 2 · Teil I · Grundlagen
Architektur von Postext
Technische Architektur der Layout-Engine Postext
Kurz gesagt
Diese Seite erklärt, wie Postext im Inneren arbeitet, und richtet sich an Programmiererinnen und Programmierer. Zuerst misst Postext jedes Wort, ohne etwas auf dem Bildschirm zu zeichnen, und ist dadurch sehr schnell. Dann berechnet es, wohin jede Zeile, jedes Bild und jede Spalte gehört, und behält diesen Plan im Speicher. Es wiederholt die Arbeit, bis sich nichts mehr bewegt. Zuletzt zeichnet es die fertigen Seiten als Webseite, als Canvas-Bild oder als PDF-Datei. Wenn Sie ein Wort ändern, setzt es die Seiten neu und verwendet die Messungen der Absätze wieder, die sich nicht geändert haben.
Wenn Sie schon einmal mit React gearbeitet haben, kennen Sie den Kerngedanken bereits. React baut ein virtuelles DOM im Speicher auf, vergleicht es und fasst erst dann das echte DOM des Browsers an. Postext macht dasselbe – nur baut es statt UI-Komponenten einen Baum aus Seiten, Spalten, Textblöcken und Begrenzungsrahmen. Die gesamte Geometrie eines mehrseitigen, mehrspaltigen Dokuments, berechnet, bevor ein einziges Pixel gezeichnet wird. Jeder Absatz, jede Überschrift, jedes Bild, jede Fußnote und jedes herausgestellte Zitat steht an genauen Koordinaten und folgt jahrhundertealten typografischen Regeln, die CSS schlicht nicht ausdrücken kann.
Möglich ist all das dank @chenglou/pretext, einer Bibliothek zur Textmessung ohne DOM, die 300- bis 600-mal schneller ist als ein Reflow im Browser. Die Geschichte hinter dem Projekt (ein Jahrzehnt gescheiterter Versuche, der Engpass, an dem sie alle hängen blieben, und die Bibliothek, die ihn endlich beseitigte) erzählt die Einführung.
#Der Kerngedanke
Denken Sie an ein zweispaltiges Dokument mit 74 Seiten. Einen Geschäftsbericht vielleicht oder ein dicht bebildertes Lehrbuch. Sie übergeben es Postext, und die Engine baut das gesamte Layout im Speicher auf – jede Seite, jede Spalte, die genaue Position und die Pixelmaße jedes Absatzes. Sie wollen wissen, was auf Seite 72 in Spalte 2 steht? Die Antwort liegt schon bereit. Ohne zu rendern. Die Engine hat bereits entschieden, wo jeder Absatz umbricht, wo jedes Bild steht, wie Witwen und Waisen vermieden werden und wie die Grundlinien benachbarter Spalten fluchten.
Und darum ist das so wichtig.
Typografische Regeln hängen tief und zum Verzweifeln voneinander ab. Sie beheben eine Witwe auf Seite 5 – eine einsame letzte Zeile, die unten in einer Spalte gestrandet ist –, indem Sie sie in die vorige Spalte zurückholen. Gut. Aber diese Änderung verkürzt die Spalte auf Seite 5, das schiebt Inhalt nach vorn, und das erzeugt womöglich auf Seite 6 eine Waise: eine einzelne erste Zeile, in eine neue Spalte geschoben und von ihrem Absatz getrennt. Um überhaupt zu erkennen, dass Sie ein neues Problem geschaffen haben, muss das gesamte Layout des Dokuments zur Prüfung vorliegen. Und um es zu beheben, ohne woanders noch ein Problem zu erzeugen, müssen Sie das Ganze anpassen, neu messen und neu prüfen können.
Das ist die Philosophie „erst alles berechnen, dann rendern“. Sie ist kein Leistungstrick. Sie ist der einzige Weg, die Dutzenden miteinander verflochtenen typografischen Regeln anzuwenden, nach denen professionelle Setzer seit Jahrhunderten arbeiten.
#Grundbegriffe
Ein kurzes Glossar. Der Rest des Dokuments setzt diese Begriffe voraus – kommen Sie hierher zurück, wenn einer unscharf geworden ist.
| Begriff | Definition |
|---|---|
| VDT | Virtual Document Tree, der virtuelle Dokumentbaum. Die veränderliche, an Ort und Stelle bearbeitete Datenstruktur, die das gesamte Dokument darstellt: Seiten, Spalten, Blöcke, Inline-Segmente und Begrenzungsrahmen. Vergleichbar mit einem virtuellen DOM, aber für die Geometrie des Dokumentlayouts. |
| Seite | Eine rechteckige Fläche fester Größe. Die Engine kennt Seiten von Anfang an. Ein Dokument ist eine geordnete Folge von Seiten. |
| Spalte | Eine senkrechte Unterteilung einer Seite. Spalten haben eine feste Breite und eine maximale Höhe. Text fließt von einer Spalte in die nächste und dann auf die nächste Seite. |
| Block | Eine Inhaltseinheit, die senkrechten Raum in einer Spalte einnimmt: Absatz, Überschrift, Bild, Tabelle, Zitatblock, herausgestelltes Zitat oder Fußnotenbereich. |
| Zeile | Eine gemessene Textzeile innerhalb eines Blocks, erzeugt von Pretext. Jede Zeile hat einen Begrenzungsrahmen und eine Grundlinienposition. |
| Begrenzungsrahmen | x, y, width, height in Pixeln, relativ zum Ursprung der Seite. Jeder Knoten im VDT trägt einen. |
| Ressource | Ein Element, das kein Text ist und über eine ID mit dem Fließtext verbunden ist: eine Bitmap, ein SVG, eine Tabelle oder ein Video (Resource, mit kind: 'bitmap' | 'svg' | 'table' | 'video'). Ressourcen werden beim ersten Verweis je Typ nummeriert (Abbildung 1, Tabelle 2.1 …) und in ein Band oben oder unten auf einer Seite in der Nähe dieses Verweises gleiten gelassen. |
| Anmerkung | Eine Fußnote oder eine Anmerkung am Kapitelende, im Markdown als Marke [^id] und Definition [^id]: geschrieben (siehe Fußnoten). Marginalien und die Liste PostextNote, die das Inhaltsmodell annimmt, sind nicht implementiert: Die Engine liest notes nicht. |
| Grundlinienraster | Das senkrechte Raster, abgeleitet aus dem Zeilenabstand des Fließtextes (z. B. 24 px bei 16 px/1,5). Jede Grundlinie des Fließtextes soll auf einem Vielfachen davon landen, damit die Zeilen über Spalten und gegenüberliegende Seiten hinweg fluchten. |
| Badness | Wie weit die Wortzwischenräume einer Zeile im Blocksatz von ihrer natürlichen Breite abweichen – das quadrierte Anpassungsverhältnis, gesättigt bei 10000. Die Grundkosten beim Zeilenumbruch nach Knuth-Plass. |
| Demerits | Die Gesamtkosten eines möglichen Zeilenumbruchs nach Knuth-Plass: Badness plus Strafen (Silbentrennung, eine zu kurze Ausgangszeile, unpassende Fitness-Klassen). Der Algorithmus wählt die Menge der Umbrüche mit den niedrigsten Gesamt-Demerits. Die Aufteilung eines Absatzes auf zwei Spalten wird genauso gewählt, nach der niedrigsten Summe aus Witwen-, Waisen- und Leerraumkosten. |
| Fitness-Klasse | Eine grobe Einteilung, wie eng oder locker eine Zeile ist. Benachbarte Zeilen in sehr unterschiedlichen Klassen (eine enge Zeile neben einer sehr lockeren) kosten zusätzliche Demerits, was das Satzbild des Absatzes glättet. |
| Leerraum | Zeilen, die am Fuß einer Spalte leer bleiben, wenn ein Absatz dort geteilt oder als Ganzes weitergeschoben wird. Quadrierte Leerraumkosten (gewichtet mit slackWeight) werden gegen die Witwen- und Waisenstrafen abgewogen, wenn die Engine die Teilung wählt. |
| Backend | Ein Ausgabeziel, das den fertigen VDT verarbeitet. Vier gibt es heute: Canvas (Bitmap-Vorschau), HTML (Lesen am Bildschirm über das DOM), PDF (druckfertige Ausgabe über postext-pdf) und EPUB (postext-epub, das Buch als E-Book). Alle vier rendern aus derselben Messung (dem Messmodul über Pretext). |
| Platzierungsdurchlauf | Ein Durchgang durch den Inhalt, vom ersten bis zum letzten Block, der einen ganzen VDT aufbaut: Er parst, misst über den Cache, platziert Text, Gleitobjekte und Kästen und wendet die Umbruch- und Zusammenhalteregeln an. |
| Runde | Eine Wiederholung des Platzierungsdurchlaufs mit Hinweisen aus dem vorigen: eine Bandgrenze, Hebel für den Spaltenausgleich, feststehende Seitenbezeichnungen oder Anmerkungsnummern. Jede Art von Runde hat ihre eigene Grenze (siehe Der Durchlaufsteuerer). |
#Systemarchitektur
So durchläuft Ihr Inhalt die Engine:
- Der Parser liest Ihr angereichertes Markdown und macht daraus eine flache Liste typisierter Inhaltsblöcke: Inhalt und Struktur, noch ohne Positionen
- Ein Platzierungsdurchlauf geht diese Liste einmal durch und baut den VDT auf. Er misst jeden Block über Pretext in der Breite der Spalte, in die der Block gleich kommt, platziert Text, Gleitobjekte und Kästen in Seiten und Spalten und wendet dabei die Umbruch- und Zusammenhalteregeln an
- Der Steuerer (
buildDocument) wiederholt den Durchlauf, wenn eine Entscheidung das Ergebnis eines ganzen Layouts braucht: ein Inhaltsverzeichnis mit Seitenzahlen, ein Kasten über die ganze Seitenbreite, der die Spalten über sich auf gleiche Höhe bringen muss, eine Spalte, die zu kurz endete. Jede Wiederholung erhält Hinweise aus dem Durchlauf davor, und jede Art von Wiederholung hat ihre eigene Grenze - Der fertige VDT ist die vollständige Layoutgeometrie: Jedes Element kennt seine Seitenzahl, seine Spalte, seine Position und seinen Begrenzungsrahmen. Das Dokument ist vollständig „gesetzt“, bevor irgendetwas gerendert wird
- Ein Backend geht den fertigen VDT durch und rendert ihn im Zielformat: eine gerasterte Canvas-Bitmap, ein DOM-Baum aus positionierten HTML-Elementen, ein PDF-Dokument mit eingebetteten Schriften oder ein EPUB. Derselbe VDT speist alle vier; die Wahl eines Backends ist allein eine Frage der Ausgabe
#Eingabeschicht
#Inhaltsmodell
Das Inhaltsmodell ist ebenso eine Philosophie wie eine Datenstruktur. Sie beschreiben, was gesagt werden soll, nicht, wie es gesetzt wird. Die Layoutentscheidungen trifft die Engine.
// packages/postext/src/types.ts
interface PostextContent {
markdown: string; // enriched markdown with :ref / ::resource markers
metadata?: DocumentMetadata; // title, subtitle, author, publishDate, …
resources?: Resource[]; // bitmaps, SVGs, tables, videos — referenced by id
notes?: PostextNote[]; // not read: footnotes are written as [^id] in the markdown
}
interface Resource {
id: string; // stable id, referenced by inline :refs
typeId: string; // ResourceType this resource belongs to ('figure', 'table', …)
kind: 'bitmap' | 'svg' | 'table' | 'video';
caption?: string; // the type prefix + number are computed, not written here
altText?: string;
createdAt: number;
updatedAt: number;
// Exactly one kind-specific payload:
bitmap?: { fileId: string; format: string; width: number; height: number };
svg?: { fileId: string; width?: number; height?: number };
table?: { model: TableModel };
video?: ResourceVideo; // where it plays from; drawn on the page as its poster
placement?: ResourcePlacement; // optional per-resource float override
safeArea?: ResourceSafeArea; // bitmap / svg: { x, y, width, height } in fractions, the part always shown
}Ressourcen tragen die visuellen Daten: Legenden, Alternativtext und eine Nutzlast, die von ihrer Art abhängt. Binäre Nutzlasten (Bitmaps, SVGs) liegen außerhalb – die Ressource speichert nur eine fileId, und der Renderer löst sie beim Zeichnen auf (die Sandbox hält die Bytes in IndexedDB). Tabellenressourcen sind die Ausnahme: Ihr TableModel (ein Raster aus Zellen mit Spannen und Ausrichtung) reist mit, weil es strukturierte Daten sind und keine Bytes.
Anmerkungen werden im Markdown geschrieben: eine Marke [^id] dort, wo die Anmerkung zitiert wird, und eine Definition [^id]:, ein eigener Absatz an beliebiger Stelle im Kapitel. Die Engine nummeriert die Anmerkungen in der Reihenfolge, in der sie zuerst zitiert werden, und setzt jede an den Fuß der Spalte, die die zitierende Zeile enthält, unter einer kurzen Linie, oder mit footnotes.placement: 'chapterEnd' hinter den letzten Block des Kapitels (siehe Fußnoten und Konfiguration → Fußnoten). Das Inhaltsmodell nimmt weiterhin ein Array notes an, aber keine Stufe der Pipeline liest es.
Diese Trennung ist eine bewusste Gestaltungsentscheidung, und sie wiegt schwerer, als es aussieht. Das Markdown bestimmt die Lesereihenfolge und die semantische Struktur – was zuerst kommt, was eine Überschrift ist, wo eine Fußnote zitiert wird und was sie sagt. Das Ressourcen-Array trägt die visuellen Daten – Bildmaße, Text der Legende, Tabellenzellen. Weil beides getrennt bleibt, lässt sich dasselbe Markdown allein durch eine andere Konfiguration völlig unterschiedlich setzen. Ein zweispaltiges wissenschaftliches Layout und ein einspaltiger Blogbeitrag können denselben Quellinhalt teilen. Und die Engine kann Platzierungsentscheidungen treffen – etwa ein Bild in die nächste Spalte zu verschieben, weil es hier nicht passt –, ohne je Ihren Quellinhalt anzurühren.
// Example: a simple article with a referenced figure
const content: PostextContent = {
markdown: `
# The Art of Typography
The history of typography begins with Gutenberg's
movable type, shown in :ref{id="printing-press"}.
His invention transformed the production of books.
The technique spread rapidly across Europe, reaching
Italy by 1465 and France by 1470.
`,
resources: [
{
id: 'printing-press',
typeId: 'figure',
kind: 'bitmap',
caption: 'A reconstruction of the original press.',
altText: "Reconstruction of Gutenberg's printing press",
createdAt: 1765379100000,
updatedAt: 1765379100000,
bitmap: { fileId: 'press-photo', format: 'jpeg', width: 600, height: 400 },
},
],
};Ressourcen sind über eine ID mit dem Fließtext verbunden, und ein Verweis genügt, um eine Ressource aufzunehmen. Das Inline-:ref{id="printing-press"} erledigt zwei Aufgaben auf einmal: Es setzt die berechnete Nummer der Ressource in den Fließtext („Abb. 1“), und – beim ersten Verweis in Lesereihenfolge – lässt es die Ressource auf die Seite gleiten, indem es oben oder unten in der Nähe dieses Verweises ein Band reserviert, genau wie ein Setzer im Druck es täte. Sie platzieren die Abbildung nie ein zweites Mal. Für die gelegentliche Ressource, die an einer genauen Stelle im Fluss stehen muss, ist ::resource{id="…"} auf einer eigenen Zeile eine optionale Blockeinbettung: Sie wird nur dann an Ort und Stelle gerendert, wenn die aufgelöste placement.position der Ressource 'here' ist (was sie vom Gleiten ausnimmt); bei einer gleitenden Ressource gilt sie einfach als weiterer Verweis. Die Autorin oder der Autor muss nie über die Platzierung nachdenken. Das übernimmt die Engine.
#Konfiguration
Jeder Aspekt der Layout-Pipeline wird über PostextConfig gesteuert. Der vollständige Überblick über die Abschnitte (Seite, Layout, Fließtext, Überschriften, Listen, Mathematik, Kopf- und Fußzeilen …) steht auf der Seite Konfiguration; die für dieses Dokument wichtigsten sind:
| Konfiguration | Steuert | Verwendet in |
|---|---|---|
bodyText / headings | Strafen für Witwen, Waisen und zu kurze Ausgangszeilen je Feld, Zusammenhalteregeln, Grenzen des Blocksatzes, Silbentrennung – siehe Konfiguration → Fließtext | Messung, Umbruch- und Zusammenhalteregeln, Spaltenausgleich |
tableStyle | Zelltypografie, Rahmenlinien, Eckenradius, Füllungen für Kopf und Rumpf von Ressourcen mit kind: 'table', dazu die benannten Varianten aus tableStyles, die eine Tabelle mit table.styleId wählt – siehe Konfiguration → Tabellenstil | Platzierung von Ressourcen |
captionStyle | Typografie der Legenden von Ressourcen: die nummerierte Kennung und der Beschreibungstext – siehe Konfiguration → Legendenstil | Platzierung von Ressourcen |
diagramStyle | singleInk + inkColor – färbt SVG-Diagramme in Tönungen einer einzigen Druckfarbe um, abgebildet nach Helligkeit, sodass Abbildungen beim Druck mit einer einzigen Sonderfarbe getreu wiedergegeben werden – siehe Konfiguration → Diagrammstil | Backends |
resourceTypes | Nummerierung von Ressourcen nach Typ: Vorlagen, Zählerformate, Rücksetzbereiche, standardmäßige Gleitplatzierung – siehe Konfiguration → Ressourcentypen | Strukturierung des Inhalts, Platzierung von Ressourcen |
#Parsing-Strategie
Das Parsen ist mit Absicht der einfachste Schritt der Pipeline. Markdown geht hinein und kommt als flache Liste typisierter Inhaltsblöcke heraus (ContentBlock, in packages/postext/src/parse/): eine Überschrift, ein Absatz mit seinen Inline-Spannen, ein Listenpunkt, eine Direktive. Das Ergebnis wird memoisiert (parseMarkdownMemo), sodass die Durchläufe eines Builds nur einmal parsen und ein späterer Build desselben Textes ebenso. Ressourcenverweise werden zu Beginn jedes Platzierungsdurchlaufs über die ID gegen das Array resources[] aufgelöst. Fußnotendefinitionen (Absätze, die mit [^id]: beginnen) verlassen dort ebenfalls den Fluss und werden nach ID für die Marken aufbewahrt, die sie zitieren; das Array notes[] wird nicht gelesen.
Stellen Sie es sich als Manifest vor: „Da ist eine Überschrift, dann ein Absatz mit 200 Wörtern, dann ein Abbildungsverweis, dann ein weiterer Absatz.“ Keine Messungen. Keine Positionierung. Überhaupt keine Layoutentscheidungen. Ein VDTBlock existiert erst, wenn der Platzierungsdurchlauf einen Block gemessen und ihm einen Platz gegeben hat.
#Virtueller Dokumentbaum (VDT)
Angenommen, Sie bitten einen professionellen Setzer, ein ganzes Buch zu setzen, und statt gedruckter Seiten gibt er Ihnen eine Tabelle zurück. Jede Zeile ist ein Element. Jede Zelle ist ein genaues Maß: „Die Überschrift steht bei (40, 30), der erste Absatz beginnt bei (40, 78) und ist 144 px hoch, das Bild kommt oben in Spalte 2 auf Seite 3 …“ Diese Tabelle ist der VDT.
Der virtuelle Dokumentbaum ist die zentrale Datenstruktur von Postext – ein veränderlicher, an Ort und Stelle bearbeiteter Baum, der jede Seite, jede Spalte, jeden Block und jede Zeile darstellt, jeweils mit einem genauen Begrenzungsrahmen. Sobald buildDocument zurückkehrt, ist der VDT die Antwort. Sie können fragen „Was steht auf Seite 72 in Spalte 2?“, ohne ein einziges Pixel zu rendern.
#Warum veränderlich
Das ist derselbe Ansatz wie in den Render-Pipelines von Spiele-Engines, wo ein gemeinsamer, veränderlicher Weltzustand in einer engen Schleife von aufeinanderfolgenden Systemen aktualisiert wird. Und zwar aus demselben Grund.
Ein Platzierungsdurchlauf füllt den Baum an Ort und Stelle. Er öffnet eine Seite, hängt ihr Spalten an, zieht die Höhe eines Blocks von der availableHeight der Spalte ab, in der der Block landet, und schließt die Spalten eines Bandes, wo ein Kasten über die ganze Seitenbreite es abschneidet. Ein Buch hat Tausende von Blöcken, und ein unveränderlicher Baum (wie das virtuelle DOM von React) würde für jeden dieser Schritte ein neues Objekt anlegen. Deshalb sind die Knoten einfache Objekte, und der Durchlauf schreibt in sie hinein.
Ein abgeschlossener Durchlauf wird nicht nachgebessert. Braucht buildDocument einen weiteren Versuch (eine Bandgrenze, einen Ausgleichshebel), wiederholt es den Durchlauf vom Inhalt aus und erhält einen neuen Baum. Der Baum des vorigen Durchlaufs bleibt, wie er war, sodass der Steuerer beide vergleichen, den besseren behalten oder von jedem einige Seiten nehmen kann. Der Messcache macht die Wiederholung bezahlbar: Ein Absatz, dessen Text, Schriften und Breite sich nicht geändert haben, wird aus dem Cache gelesen, statt neu gemessen zu werden.
#Struktur
#Typdefinitionen
Die folgenden Formen sind zur Erklärung vereinfacht – die echten Definitionen in packages/postext/src/vdt.ts enthalten viele weitere Felder für das Rendern (Schriftangaben, Farben, Aufzählungszeichen, gerenderte Formeln, Gestaltungsplätze). Hier kommt es auf die Struktur an:
// Simplified — see packages/postext/src/vdt.ts for the full definitions
// The root of the Virtual Document Tree
interface VDTDocument {
pages: VDTPage[];
blocks: VDTBlock[]; // flat view of the same block objects
config: ResolvedConfig; // every sub-config resolved to non-optional
baselineGrid: number; // baseline increment in px (e.g. 24 for 16px/1.5)
converged: boolean; // every balancing segment ended flush or out of levers
iterationCount: number; // placement passes behind this layout (see The Pass Driver)
metadata: DocumentMetadata;
}
// A physical page
interface VDTPage {
index: number;
width: number;
height: number;
columns: VDTColumn[];
header?: VDTDesignSlot; // running header (design slot)
footer?: VDTDesignSlot; // running footer / page number
floats?: VDTBlock[]; // resource bands floated to the top/bottom of this page
pageNumberValue: number;
pageLabel: string; // rendered label ('iv', '7', 'A', …)
}
// A column within a page
interface VDTColumn {
index: number;
bbox: BoundingBox; // position within the page
blocks: VDTBlock[];
availableHeight: number; // remaining vertical space
baselineOffset: number; // current baseline y-position
band?: number; // column band (0 unless a span block split the page)
kind?: 'text' | 'span' | 'side'; // 'span' = full-width column holding a page-span block,
// 'side' = the float-only side column of a one-and-a-half layout
}
// A content block (paragraph, heading, resource, etc.)
type VDTBlockType =
| 'paragraph' | 'heading' | 'resource' | 'blockquote'
| 'listItem' | 'footnoteRef' | 'mathDisplay' | 'callout' | 'code';
interface VDTBlock {
id: string;
type: VDTBlockType;
bbox: BoundingBox;
lines: VDTLine[]; // for text blocks: the measured lines
resourceBlock?: ResolvedResourceBlock; // for resource blocks
pageIndex: number;
columnIndex: number;
dirty: boolean; // not used: cleared when the block is placed, read by nothing
snappedToGrid: boolean; // the flow went back to the baseline grid under this block
balancing?: VDTBalancing; // the column-balancing levers applied to this block
}
// A measured line of text
interface VDTLine {
text: string;
bbox: BoundingBox; // natural width: a justified line is painted to its block's right edge
baseline: number; // y-position of the text baseline
hyphenated: boolean; // line ends inside a word, or after a closed dash ("say—" | "that’s")
hardHyphen?: boolean; // …after a hyphen the text carries ("well-" | "known"): nothing added
repeatedHyphen?: boolean; // opens with that hyphen repeated ("vencer-" | "-se"), not in the source
segments?: VDTLineSegment[]; // word/space/math runs for justified rendering
isLastLine?: boolean; // last line of its paragraph
justifiedSpaceRatio?: number; // width every space is painted at ÷ the regular face's space
justifiedSpaceLimitRatio?: number; // the spacing the limits bound, when the line's own spaces are not regular (lineWordSpacing reads both)
sourceStart?: number; // markdown source map (char offsets; a line opening with `\$` starts at the backslash)
sourceEnd?: number;
plainStart?: number; // plain-text source map
plainEnd?: number;
}
// A measured, placement-ready resource embed (bitmap / svg / table / video)
interface ResolvedResourceBlock {
resource: Resource;
kind: 'bitmap' | 'svg' | 'table' | 'video';
number: string; // computed number, e.g. "1.7"
captionPrefix: string; // e.g. "Figure"
bodyRect: BoundingBox; // the image / table area
bodySource?: ResourceSafeArea; // the part of the picture shown in bodyRect when cropped to its safe area
bodyFlex?: { shrink: number; grow: number; delta: number }; // px the body can still shrink / grow, px applied
fileId?: string; // out-of-band binary (bitmap / svg)
captionLines: VDTLine[]; // measured caption, prefix + number included
table?: VDTResourceTableLayout; // cell geometry for table resources
}
// Bounding box — all values in px, relative to page origin
interface BoundingBox {
x: number;
y: number;
width: number;
height: number;
}Einige dieser Felder verdienen eine Anmerkung:
isLastLinesteuert das Rendern im Blocksatz: Letzte Zeilen werden im Flattersatz gezeichnet, auch wenn der Absatz im Blocksatz steht – außer überfüllten letzten Zeilen, deren Wortzwischenräume sich zusammenziehen, damit sie in die Satzbreite passen (die Semantik des Glue-Setting in TeX).sourceStart/sourceEndundplainStart/plainEndsind Source-Maps von jeder Zeile zurück zum ursprünglichen Markdown und zum reinen Text des Blocks – darauf beruht die Synchronisierung von Cursor und Auswahl in Editor-Integrationen.VDTResourceTableLayout(mit seinen EinträgenVDTResourceTableCell) trägt die vollständige gesetzte Geometrie einer Tabellenressource: x-Kanten der Spalten, y-Kanten der Zeilen und Rechtecke je Zelle mit ihren gemessenen Inhaltszeilen, sodass jedes Backend dieselbe Tabelle zeichnet.dirtyundsnappedToGridwerden beim Platzieren eines Blocks geschrieben und von keiner Stufe und keinem Backend gelesen.dirtybeginnt bei einem neuen Block mittrueund wird beim Platzieren des Blocks auffalsegesetzt; die Engine verfolgt keine Änderungen am Baum, sie wiederholt den Platzierungsdurchlauf (siehe Der Durchlaufsteuerer).snappedToGridisttruebeim ersten Teil eines Textblocks, nach dem der Fluss auf das Raster zurückkehrt (siehe System des vertikalen Rhythmus).balancinggibt an, welche Hebel des Spaltenausgleichs den Block verschoben oder gedehnt haben; ein Block, den der Ausgleich nicht angerührt hat, hat keinen (siehe Spaltenausgleich).computePageTextExtent(page)ist eine kleine öffentliche Hilfsfunktion, die die senkrechte Ausdehnung zurückgibt, die auf einer Seite tatsächlich von Text bedeckt ist (einschließlich der Legenden von Gleitobjekten). Debug-Overlays nutzen sie, damit die Linien des Grundlinienrasters nur echten Text überspannen und nicht den leeren Seitenfuß.
#Layout-Pipeline
Ein Platzierungsdurchlauf, so oft ausgeführt, wie das Layout es braucht. Das ist die ganze Layout-Pipeline.
Der Durchlauf (placeDocumentPass, in packages/postext/src/pipeline/build.ts) geht den Inhalt vom ersten bis zum letzten Block durch. Für jeden Block tut er alles, was zu seinem Satz nötig ist: ihn in der Breite der Spalte messen, in die er gleich kommt, prüfen, ob er passt, ihn nach den Umbruch- und Zusammenhalteregeln teilen oder weiterschieben, den Gleitobjekten, die er zitiert, einen Platz anbieten und den Fluss zurück auf das Grundlinienraster bringen (siehe System des vertikalen Rhythmus). Gleitobjekte, Witwen und das Raster haben keinen eigenen Durchlauf: Sie werden geregelt, während jeder Block landet.
Manche Entscheidungen lassen sich nicht innerhalb eines Durchgangs treffen, weil sie davon abhängen, wo der Durchgang endet: die Seitenzahlen, die ein Inhaltsverzeichnis druckt, die Höhe, in der ein Kasten über die ganze Seitenbreite die Spalten über sich abschneidet, die Zeilen, die einer kurzen Spalte noch fehlen. Dafür wiederholt buildDocument den Durchlauf mit Hinweisen aus dem Ergebnis des vorigen Durchlaufs. Die fünf Stufen unten begleiten einen Block durch den Durchlauf; Der Durchlaufsteuerer und Spaltenausgleich behandeln die Wiederholungen.
#Stufe 1: Strukturierung des Inhalts
- Eingabe: Rohes
PostextContent - Aktion: Das Markdown in Inhaltsblöcke parsen, Ressourcenverweise per ID gegen
resources[]auflösen, die Fußnotendefinitionen ([^id]:) aus dem Fluss nehmen und ihre Marken in Zitierreihenfolge nummerieren, die Überschriften nummerieren und die Gleitobjekte planen: welche Ressourcen gleiten und nach welchem Block (notes[]wird nicht gelesen) - Ausgabe: Eine flache Liste von Inhaltsblöcken (typisiert und mit Inhalt gefüllt, aber ohne Zuordnung zu Seite oder Spalte) und der Gleitplan
- Läuft: zu Beginn jedes Platzierungsdurchlaufs. Das Parsen wird nach dem ersten Durchlauf aus seinem Memo gelesen; der Rest wird neu berechnet
#Stufe 2: Textmessung
- Eingabe: Ein Inhaltsblock mit Text und die Breite der Spalte, in die er gleich kommt
- Aktion: Die Zeilen des Blocks in dieser Breite über das eigene Messmodul (
packages/postext/src/measure/) messen, das Silbentrennung, Blocksatz, formatierte Inline-Läufe und den Zeilenumbruch nach Knuth-Plass über Pretext legt. Das Ergebnis sind dieVDTLine[]des Blocks und seine Gesamthöhe - Wichtiges Detail: Die Messung wird gecacht.
cachedMeasureBlock/cachedMeasureRichBlock(inmeasure/cache.ts) verwenden als Schlüssel den Text, die Schriften, die Breite und jede Option, die das Layout beeinflusst, sodass das erneute Messen eines unveränderten Absatzes eine Map-Abfrage ist - Ausgabe: Die Zeilen des Blocks mit genauen Pixelmaßen
- Läuft: wenn der Durchgang den Block erreicht, nicht vorher, weil die Breite davon abhängt, wo der Block landet: eine Spalte neben einer Seitenspalte, die Zeilen neben einer umflossenen Abbildung, die Unterspalten einer Gruppe
:::columns. Ein Block, der in eine Spalte anderer Breite weiterrückt, wird in dieser Breite neu gemessen
Das Modul ist sauber nach Zuständigkeiten aufgeteilt: plain.ts misst einfache Läufe, rich.ts misst gemischte Spannen aus Fett, Kursiv und Formeln, font.ts baut Schriftangaben und verwaltet den Lebenszyklus des Caches, und canvas.ts kapselt die rohen Primitive der Canvas zur Messung der Textbreite. Ein Detail des Lebenszyklus zählt in der Praxis: Glyphenbreiten, die mit einer Ersatzschrift gemessen wurden, dürfen das Eintreffen der echten Schrift nicht überdauern. canvas.ts hält seine Breiten je Schriftangabe und eine Messgeneration; evictFontFamilies (aufgerufen, wenn ein Build feststellt, dass sich die Schnitte einer Familie geändert haben, von watchFonts, prepareFonts und loadBundleFonts) verwirft die Breiten dieser Familien und leert den Cache von Pretext, und ein MeasurementCache, der unter einer älteren Generation gefüllt wurde, verwirft bei seiner nächsten Abfrage seine Blöcke in diesen Familien. clearMeasurementCache() tut dasselbe für jede Familie. Die Schnitte selbst lädt fonts/prepare.ts (prepareFonts) vor dem Layout, und fonts/faces.ts liest für die Warnungen fontFallback zurück, welche Schnitte ein gesetztes Dokument verwendet hat.
Chinesische, japanische und koreanische Absätze nehmen einen anderen Weg durch dasselbe Modul. Ein Absatz mit mehr CJK-Zeichen als Wortzwischenräumen geht an das CJK-Satzmodul (cjkCompose.ts). Es zerlegt den Text in Einheiten (ein Zeichen, ein Lauf lateinischen Textes, ein zwei Geviert langer Strich oder eine Auslassung, eine atomare Box wie ein Chip oder eine Anmerkungsmarke), misst jede Einheit einmal und füllt die Zeilen nach dem First-Fit-Verfahren unter den Regeln für Zeilenanfang und Zeilenende aus cjkClasses.ts: Eine Zeile nimmt ein Zeichen, das die nächste Zeile nicht eröffnen darf, noch auf, indem sie Weißraum der Satzzeichen abgibt (cjkPunctuation.ts), bevor sie ein Zeichen in die nächste Zeile schiebt. Eine Zeile im Blocksatz wird dann zwischen ihren Zeichen ausgetrieben. Die Ausgabe sind gewöhnliche VDTLines, deren Segmente das tragen, was die Renderer brauchen, um sie genau so zu zeichnen, wie sie gemessen wurden: tracking (Pixel nach jedem Zeichen, bereits in der Breite des Segments enthalten), inkOffset für ein Satzzeichen, das Weißraum abgegeben hat, hangs, die Abstände zwischen Han- und lateinischer Schrift als Segmente autospace sowie die Zeichen, Ruby-Lesungen und Warichu-Zeilen chinesischer Annotationen. In einem japanischen Dokument folgt dasselbe Satzmodul JLReq: Die Kinsoku-Klassen der japanischen Stufen werden beim Umbruch an den Randzeichen jeder Einheit abgelesen, die Weißräume der Satzzeichen werden in der Reihenfolge von JLReq gestaucht und zurückgegeben, Furigana setzt rubyJis.ts (Verteilung 1:2:1, Überhang auf Kana, Jukugo-Wörter), und Kanbun-Zeichen verbreitern die Einheit ihres Zeichens. Der Aufwand wächst linear mit der Länge des Absatzes: Kapitel 1 von 紅樓夢 (6.949 Zeichen) wird gesetzt, indem 1.298 Zeichen gemessen werden, jedes unterschiedliche Zeichen einmal, wo das Messen von Präfixen des Absatzes 636.948 brauchte. Siehe Chinesischer Satz.
Darunter zeigt Pretext, was es kann. Der Aufruf prepare() ist der teure Teil – er analysiert den Text mit der Schrift-Engine der Canvas und cacht das Ergebnis. Aber der Aufruf layout()? Reine Arithmetik, nahezu kostenlos. Auf dieser Aufteilung beruht alles. Ist ein Text einmal vorbereitet, kann die Engine ihn zu vernachlässigbaren Kosten neu umbrechen: in einer anderen Breite, für einen Absatz, der in einer schmaleren Spalte weitergeht, oder um eine Zeile länger, für einen Absatz, den der Spaltenausgleich lockert. Einmal vorbereiten, so oft setzen wie nötig.
// Simplified: how the measure module uses pretext internally
const prepared = prepare(paragraphText, '16px/1.5 Inter');
const { height } = layout(prepared, columnWidth, 24); // 24px line-height
// => "This paragraph is 168px tall at 320px column width — that's 7 lines."#Stufe 3: Platzierung auf Seiten und Spalten
- Eingabe: Ein gemessener Block
- Aktion: Den Block in die aktuelle Spalte setzen. Knoten
VDTPageundVDTColumnanlegen, wie der Fluss sie braucht.availableHeightje Spalte nachführen. Passt ein Block nicht, ihn teilen (Stufe 5) oder zur nächsten Spalte oder Seite weitergehen - Strategie: Gierige Platzierung nach dem First-Fit-Verfahren. Spalten- und Seitenumbrüche folgen der einfachsten gültigen Zuordnung
- Ausgabe: Dem Block sind
pageIndex,columnIndexundbboxzugewiesen
In diesem Moment wird der VDT zu einem echten Dokument. Vor dieser Stufe ist ein Block ein Eintrag in einer flachen Liste, ohne Adresse. Die Platzierung ordnet jeden einer Seite und einer Spalte zu, wie Wasser, das in ein Raster von Behältern gegossen wird: Spalte 1 füllen, bis sie überläuft, in Spalte 2 weiterfließen, und wenn die Seite voll ist, eine neue beginnen.
Bevor Inhaltsblöcke auf einer Seite landen, reserviert der Durchlauf Platz für strukturelle Elemente – Kolumnentitel in Kopf- und Fußzeile (gesetzt als Gestaltungsplätze aus config.header / config.footer) und alle Gleitbänder, die für die frisch geöffnete Seite bereits anstehen. Diese Reservierungen verringern die availableHeight jeder Spalte, sodass die Engine, wenn die Inhaltsblöcke einzufließen beginnen, bereits genau weiß, wie viel Platz zur Verfügung steht.
Spaltenbänder und Spannspalten. page.columns ist ein flaches Array in Lesereihenfolge, aber eine Seite ist nicht immer eine einzige Reihe von Spalten. In einem mehrspaltigen Layout zerschneidet ein Inline-Block über die ganze Seitenbreite die Seite in übereinanderliegende Bänder. Vier Dinge zählen als solcher Block: ein :::callout, dessen Format oder Direktivenblock span: 'page' setzt, eine Gruppe :::columns{span="page"} im Fließtext, ein Inline-::resource, dessen Platzierung zu { position: 'here', span: 'page' } aufgelöst wird, und ein Comicstreifen, der dort, wo er geschrieben steht, über die Seite gesetzt wird (:::strip{span=page}). Die letzten drei werden vor der Platzierung in einen rahmenlosen Kasten über die ganze Seitenbreite gehüllt (pipeline/flowColumns.ts, pipeline/spanEmbeds.ts), sodass ein einziger Mechanismus alle vier setzt. Am Schnitt werden die Textspalten des aktuellen Bandes an der Schnittlinie geschlossen (ihre Höhe begrenzt, availableHeight null), der Block erhält eine eigene Spalte über die volle Breite mit kind: 'span', und darunter wird ein neues Band von Textspalten angehängt (band + 1, gleiches x und gleiche Breite, gleicher Fuß wie das Band, das es ersetzt). Spalten werden immer nur angehängt, sodass columnIndex weiterhin page.columns[i] adressiert und die Renderer kein besonderes Zeichnen brauchen: Jede Spalte wird auf ihre eigene bbox zugeschnitten (erweitert durch columnClipRect – 2 pt für die Farbe der Glyphen plus so weit, wie die Gestaltungsüberlagerungen der Spalte, etwa ein Überschriftenreiter oder ein Kastenabzeichen, über ihre Seiten hinausragen, und so weit, wie die Gestaltung einer Überschrift über ihren Kopf hinausreicht; ihr Fuß bleibt die Kante – im Canvas- und im PDF-Backend dasselbe Rechteck), und die Spaltenlinie wird je Band zwischen benachbarten Textspalten gezeichnet, beginnend unter dem Band, das eine Überschrift über die ganze Seitenbreite am Seitenkopf einnimmt (columnRuleSegments), in der eigenen Linie der Seite, wenn ein gestalteter Abschnitt eine setzt (VDTPage.columnRule, gelesen über pageColumnRule). Der Spaltenausgleich übergeht Spannspalten, Seitenspalten und Bänder ohne Höhe. Ein Spannblock schneidet direkt, wo das Band auf gleicher Höhe steht (Seitenkopf, direkt nach einer Auftaktüberschrift, einem anderen Spannblock oder einem oberen Gleitband); trifft er auf ein ungleiches Band, schlägt er stattdessen eine Bandgrenze vor (packages/postext/src/pipeline/bandCaps.ts) – die Spalten des Bandes, das mit einem bestimmten Inhaltsblock beginnt, werden auf ceil(Σ used / N / grid) Zeilen gekürzt –, und buildDocument wiederholt den Platzierungsdurchlauf mit der Grenze (und erhöht sie jeweils um eine Zeile, wenn das begrenzte Band überläuft, bis zu MAX_BAND_CAP_RETRIES = 3 Mal, danach wird sie verworfen, sodass der Block an den Kopf der nächsten Seite zurückfällt), sodass der Text die gekürzten Spalten unter allen Platzierungsregeln füllt, am Schnitt auf gleicher Höhe endet und die geschlossenen Spalten den verbleibenden Leerraum als availableHeight behalten, den der Spaltenausgleich aufnehmen kann. Derselbe Mechanismus gleicht das schließende Band eines Kapitels und des Dokuments aus (headings.balancing.trailing): Ein Kapitelauftakt, ein :::part, ein Kasten mit placement: 'fixed', der ein Kapitel schließt, oder das Ende des Dokuments, das erreicht wird, während die Spalten des aktuellen Bandes ungleich sind, schlägt eine Grenze kind: 'trailing' vor, geschlüsselt nach dem Grenzblock; weil eine Grenze nach dem Block geschlüsselt ist, der ihr Band eröffnet – und dieser Block wandert, sobald eine frühere Seite zusätzliche Ausgleichszeilen aufnimmt –, werden schließende Grenzen erst nach dem Abschluss des Spaltenausgleichs aufgelöst, mit eingefrorenen Ausgleichshinweisen, und eine kurze Nachbesserungsrunde lässt die Hebel dann füllen, was der Schnitt zu kurz gelassen hat. Kästen mit placement: 'fixed' verlassen den Fluss: Der Kasten wird am Inhaltsbereich der Seite, an der Endformat-Box oder an der Anschnitt-Box verankert, die Textspalten, die er überdeckt, geben diese Zone ab (unten oder oben abgeschnitten wie bei einem Gleitband, bei einem Konflikt auf die nächste Seite verschoben), und Rahmen samt Kindern kommen nach page.floats.
Senkrechte Seiten. Mit layout.writingMode: 'vertical-rl' setzt der Durchlauf eine waagerechte Seite, die um eine Vierteldrehung im Uhrzeigersinn gedreht ist. Auf einer solchen Seite liegen Inhaltsbereich, Spalten, Blöcke, Zeilen, Gleitobjekte und Fußnotenbereiche in Flusskoordinaten, und VDTPage.flow trägt die Drehung, die sie auf das Blatt bringt: Ein Flusspunkt (x, y) landet bei (Seitenbreite − y, x). Nichts weiter hinten in der Kette muss davon wissen: Umbruch, Gleitobjekte, Zusammenhalteregeln und Spaltenausgleich arbeiten im Flussrahmen wie auf jeder Seite, eine Spalte des Flusses ist auf dem Blatt eine Zeilenreihe, und jedes Backend wendet die Drehung beim Zeichnen an (flowToPage und pageToFlow bilden Punkte in beide Richtungen ab). Kolumnentitel, Paginae, Schnittmarken und der Hintergrund bleiben in Blattkoordinaten. Abbildungen und Tabellen werden als aufrechte Blöcke gesetzt, die innerhalb des Rahmens zurückgedreht werden, und Zeichen, die aufrecht stehen, werden beim Zeichnen einzeln zurückgedreht.
#Stufe 4: Platzierung von Ressourcen
- Eingabe: Der Gleitplan aus Stufe 1 und die bisher platzierten Seiten
- Aktion: Jede referenzierte Ressource in den ersten freien Platz nach ihrem ersten Verweis gleiten lassen – den Fuß der verweisenden Spalte, Kopf oder Fuß der nächsten leeren Spalte oder ein Band der nächsten Seite (
packages/postext/src/pipeline/floatPlacement.tsplant die Gleitobjekte,pipeline/floatSlots.tszählt die Plätze auf und misst sie; der Platzierungsdurchlauf reserviert die Bänder auf seinem Weg) - Läuft: sobald der Block mit dem ersten Verweis gelandet ist, innerhalb desselben Durchgangs; der danach platzierte Text fließt am Band vorbei
- Auflösung der Platzierung: Je Ressource löst die Engine
resource.placement→ dasresourceType.defaultPlacementdes Typs → den eingebauten Standard{ position: 'auto', span: 'column' }auf
| Platzierungsfeld | Verhalten |
|---|---|
position: 'auto' | Die Ressource nimmt den ersten freien Platz nach ihrem Verweis, oben oder unten – der Standard |
position: 'top' | Nur obere Plätze: ein Band am Kopf der nächsten leeren Spalte oder Seite, das den Spalteninhalt darunter schiebt |
position: 'bottom' | Nur untere Plätze: ein Band am Fuß einer Spalte oder Seite, das die Spalte darüber verkürzt |
position: 'here' | Verzichtet auf das Gleiten: Die Ressource wird an ihrer Direktive ::resource eingebettet, genau dort, wo sie im Fluss steht |
span: 'column' | Das Band belegt eine einzige Spalte (auf einer frisch geöffneten Seite wählt die Engine die Spalte mit dem meisten freien Platz) |
span: 'page' | Das Band erstreckt sich über die volle Inhaltsbreite und alle Spalten und unterbricht den Spaltenfluss – Bänder über die volle Breite werden zuerst reserviert, sodass spaltenbreite Gleitobjekte sich in den verbleibenden Raum einfügen |
span: 'side' | Die Ressource wird in die Seitenspalte eines anderthalbspaltigen Layouts gesetzt, das diese Spalte für Gleitobjekte freihält (layout.sideColumnRole: 'floats'), gestapelt neben dem Absatz, der sie zuerst zitiert. Auf einer Seite ohne eine solche Spalte verhält sie sich wie 'column' |
ResourcePlacement hat mehr Felder als diese beiden: rotate, width, align, columns, captionSide, shrink, minScale, captionMeasure, wrap, wrapGap und citingPage. Konfiguration → Ressourcentypen und Dokumentformat › Platzierung erklären, was jedes davon tut.
- Aufgeschobene Platzierung: Ein Gleitobjekt, das in keinen Platz der aktuellen Seite passt, wartet auf die nächste Seite, die der Fluss öffnet, und wird an einer Kapitelgrenze auf Seiten untergebracht, die vor der Grenze geöffnet werden. Ein Bild behält seine Größe, es sei denn,
placement.shrink(oderlayout.floatShrinkdes Dokuments) erlaubt ihm, sich auf den Platz zu verkleinern, den ein Platz bietet. Eine Tabelle, die für eine frische Seite zu hoch ist, wird zwischen Zeilen geteilt und auf den folgenden Seiten mit wiederholten Kopfzeilen fortgesetzt (tableStyle.overflow: 'split', der Standard) - Ausgabe: Ressourcen, positioniert in Seitenbändern (
page.floats), wobei die Höhe der betroffenen Spalten verringert ist, sodass der Text um die Bänder fließt
Bei der Platzierung von Ressourcen wird es interessant, denn Gleitobjekte belegen nicht nur Raum – sie formen den Raum um sich herum neu. Ist der Block mit einem Verweis gelandet, werden dem anstehenden Gleitobjekt die freien Plätze der aktuellen Seite danach angeboten – der Fuß dieser Spalte, dann Kopf und Fuß der nächsten leeren Spalte (bei einem Gleitobjekt über die ganze Seitenbreite: der Seitenfuß, wenn jede Spalte noch Platz hat); was nirgends passt, wartet auf die nächste Seite, wo anstehende Gleitobjekte ihre Bänder beanspruchen, bevor Text einfließt. Die Spalten schrumpfen, damit sie zwischen die Bänder passen, der Text fließt ohne Unterbrechung durch die verengten Spalten, und der Leser sieht die Abbildung in der Nähe der Stelle (aber nicht genau dort), an der sie erwähnt wird. Das ist gängige Praxis im professionellen Satz; Bücher tun es ständig.
Platzierungsregeln. Über die Verteilung der Platzierung hinaus folgen Gleitobjekte strengen redaktionellen Vorgaben:
- Regel „nach dem Verweis“. Ein Gleitobjekt landet im ersten freien Platz nach seinem ersten Verweis im Text, nie davor. Der Leser trifft zuerst auf den Verweis und sieht dann die Ressource. Passt ein Gleitobjekt nicht, wird es nach vorn auf einen späteren Platz oder eine spätere Seite verschoben, nie zurück.
- Verweisreihenfolge innerhalb einer Folge. Anstehenden Gleitobjekten wird jeder Platz in der Reihenfolge ihres ersten Verweises angeboten, und eines, das nirgends passt, hält die hinter ihm in seiner Nummernfolge auf: Tabelle 3 landet nie nach Tabelle 4, Abbildung 12 nie vor Abbildung 11. Die Folgen halten einander nicht auf – eine wartende Tabelle lässt eine spätere Abbildung durch. Damit eine lange Tabelle nicht auf eine frische Seite warten muss, wird eine, der der Kopf einer leeren Spalte angeboten wird, auf diese Spalte zugeschnitten und im nächsten Platz fortgesetzt (der Spalte daneben oder den Bändern der nächsten Seite), mit wiederholten Kopfzeilen.
- Kapitelgrenze. Gleitobjekte entkommen nie ihrem Kapitel. An einem Kapitelauftakt (einer Überschriftenebene mit
breakBeforeoderspan: 'page'), einem:::part, einem Kastenformat mitfloatBarrier: trueund am Ende des Dokuments wird jedes anstehende Gleitobjekt zuerst platziert – in den freien Plätzen der Seite, dann auf Seiten, die vor der Grenze geöffnet werden (jede erzwingt die Platzierung mindestens eines Gleitobjekts) –, vor dem eigenen Seitenumbruch der Grenze. Bevor eine solche Seite geöffnet wird, werden einer noch anstehenden Abbildung oder Tabelle die freien Plätze der aktuellen Seite noch einmal angeboten, unabhängig von ihrerposition– ein Gleitobjekt für den Seitenkopf, das auf der letzten Seite eines Kapitels zitiert wird, nimmt den Fuß dieser Seite unter den ausgeglichenen Spalten statt einer eigenen Seite (gleitende Kästen behalten ihre Platzierung). Ein:::pagebreakschickt anstehende Gleitobjekte auf die Seite, die ihm folgt, nach etwaigen Ausgleichsseiten für die Parität. - Mindestplatz für Text. Auf einer frisch geöffneten Seite wird ein Band nur reserviert, wenn in den betroffenen Spalten noch mindestens 3 Zeilen Fließtext passen – mit einer Ausnahme: Ein übergroßes Gleitobjekt darf in ein Band gezwungen werden, das noch ganz aus Text besteht, damit eine alles beherrschende Abbildung die Warteschlange nicht für immer blockiert. Ein Platz auf der aktuellen Seite muss in die verbleibende Höhe der Spalte passen; die Drei-Zeilen-Regel gilt dort nur neben einem anderen Gleitband.
- Luft. Ein Abstand von einer Zeilenhöhe des Fließtextes trennt ein Band vom Text daneben.
- Ausrichtung am Grundlinienraster. Obere Bänder werden auf ein Vielfaches des Grundlinienrasters aufgerundet (der Abstand unter dem Gleitobjekt wächst), sodass jede verschobene Zeile weiterhin auf dem Raster landet. Untere Gleitobjekte werden so verankert, dass die letzte Grundlinie der Legende auf dem Raster steht – die Legende teilt ihre Grundlinie mit der letzten Textzeile der benachbarten Spalten, und die Seiten enden über Spalten und gegenüberliegende Seiten hinweg auf derselben Höhe.
Nummerierung nach Typ beim ersten Verweis. Ressourcen werden nicht im Markdown nummeriert. pipeline/resourceNumbering.ts weist jeder Ressource ihre Nummer zu, wenn sie zum ersten Mal in Lesereihenfolge referenziert wird, anhand ihres ResourceType: Die numberingTemplate verbindet den Zähler {n} des Typs mit den Überschriftenzählern {h1}..{h6}, die beim Verweis gelten (z. B. '{h1}.{n}' → „1.7“), resetOn steuert, wann der Zähler neu beginnt ('never' oder auf einer beliebigen Überschriftenebene), und counterFormat wählt dezimale, römische oder alphabetische Zahlen. Die eingebauten Typen Abbildung und Tabelle stammen aus defaultResourceTypes(locale), lokalisiert in die Sprache des Dokuments. Weil die Nummerierung der Reihenfolge der ersten Verweise folgt, nummeriert das Einfügen einer neuen Abbildung mitten im Dokument alles danach automatisch neu – ohne Änderungen an der Quelle.
#Stufe 5: Umbrüche und Zusammenhalteregeln
Diese Stufe unterscheidet eine Layout-Engine von einem Textabwurf. Sie setzt die redaktionellen Qualitätsregeln durch, die professionelle Setzer seit Jahrhunderten von Hand anwenden – und die naives Textrendering vollständig ignoriert.
Drei Mechanismen teilen sich die Arbeit, jeder dort, wo die Information vorliegt, die er braucht: eine Strafe für zu kurze Ausgangszeilen im Zeilenumbruch, eine bewertete Teilung dort, wo ein Absatz auf das Ende einer Spalte trifft, und strukturelle Zusammenhalteregeln zwischen Blöcken.
Witwen, Waisen und zu kurze Ausgangszeilen
Witwen und Waisen sind die sichtbarsten Zeichen laienhaften Satzes:
- Eine Witwe ist eine einzelne Zeile eines Absatzes, die allein am Fuß einer Spalte zurückbleibt. Der Absatz geht in der nächsten Spalte weiter, aber diese einsame Zeile wirkt gestrandet (als ob die Spalte zu früh endete).
- Eine Waise ist eine einzelne Zeile eines Absatzes, die am Kopf einer Spalte gestrandet ist. Der größte Teil des Absatzes steht in der vorigen Spalte, aber eine Zeile ist hinübergeschwappt (sie wirkt von ihrem Zusammenhang abgeschnitten).
- Eine zu kurze Ausgangszeile ist die letzte Zeile eines Absatzes, die nur aus einem einzigen kurzen Wort (oder zweien) besteht – optisch viel zu kurz, um wie eine richtige Textzeile zu wirken. Strukturell weniger schwerwiegend als eine Witwe, aber für aufmerksame Leser ebenso störend.
Zu kurze Ausgangszeilen werden im Zeilenumbruch geregelt. Eine zu kurze Ausgangszeile gehört allein zum Absatz: Seine letzte Zeile ist gleich kurz, wo auch immer der Absatz landet. Deshalb ist sie die einzige der drei Regeln, die der Zeilenumbruch nach Knuth-Plass sieht. Würde eine Menge von Umbrüchen den Absatz mit einer Zeile beenden, deren Inhalt schmaler ist als runtMinCharacters × normalSpaceWidth (Standard runtMinCharacters 20: Inhalt von zwanzig Wortzwischenräumen, etwa 8 bis 12 Buchstaben), addiert der Umbruch runtPenalty (Standard 1000) zur Badness dieser Zeile innerhalb der quadrierten Demerit-Formel, sodass sie auf derselben Skala konkurriert wie die Badness der Zeilen (die bei 10000 sättigt), statt von ihr erdrückt zu werden. Die Strafe steht neben den üblichen Demerits (Badness, Kosten der Silbentrennung, unpassende Fitness-Klassen) in einer einzigen globalen Optimierung, und der Umbruch findet fast immer eine Menge von Umbrüchen ohne zu kurze Ausgangszeile. Wo nicht, setzt bodyText.tightenRunts den Absatz um eine Zeile kürzer, wenn seine Wortzwischenräume und eine leicht negative Laufweite den Unterschied aufnehmen können. Das Messmodul gibt die Strafe an den Umbruch weiter (measure/plain.ts, measure/rich.ts), und knuthPlass/breakpoints.ts wendet sie an.
Witwen und Waisen werden am Spaltenende geregelt. Der Zeilenumbruch bricht einen Absatz für eine Breite um. Er weiß nicht, wo auf der Seite der Absatz stehen wird, also kann er nicht wissen, welche seiner Zeilen eine Spalte schließen wird. Der Platzierungsdurchlauf weiß es. Passen nicht alle Zeilen eines Absatzes in das, was von der Spalte übrig ist, probiert chooseParagraphSplit (in pipeline/orphanWidow.ts) jede Teilung k der bereits umbrochenen Zeilen aus, von 0 bis zu der Zahl, die passt: k Zeilen bleiben, und der Rest eröffnet die nächste Spalte. Jeder Kandidat wird bewertet:
slackWeight · slack²für die Zeilen, die er am Fuß der Spalte leer lässt, wobeislackdie passenden Zeilen minusksind (StandardslackWeight10);- plus
widowPenalty(Standard 1000), wenn er weniger alswidowMinLinesZeilen am Fuß der Spalte lässt; - plus
orphanPenalty(Standard 1000), wenn er weniger alsorphanMinLinesZeilen an den Kopf der nächsten trägt.
Die Teilung mit dem niedrigsten Wert gewinnt, und k = 0 verschiebt den Absatz als Ganzes. Die Regeln sind weich, und die Zahlen sagen, wie weich: Eine Strafe von 1000 entspricht den Leerraumkosten von zehn leeren Zeilen (10 · 10² = 1000), mit den Standardwerten gewinnt also eine Teilung, die beide Regeln einhält, immer dann, wenn sie weniger als zehn Zeilen der Spalte leer lässt. Darüber hinaus kann eine Witwe oder eine Waise die günstigere Wahl sein. Die Teilung wählt unter den Zeilen, die der Zeilenumbruch bereits gebildet hat; sie bricht den Absatz nicht neu um. Listenpunkte durchlaufen dieselbe Teilung über avoidOrphansInLists und avoidWidowsInLists und erhalten die Strafe für zu kurze Ausgangszeilen über avoidRuntsInLists (alle standardmäßig true).
All das lässt sich in BodyTextConfig einstellen – siehe Konfiguration → Waisen, Witwen, zu kurze Ausgangszeilen und Regeln zum Zusammenhalten. Wird eine beliebige *Penalty auf 0 gesetzt, ist die betreffende Regel praktisch abgeschaltet.
Strukturelle Zusammenhalteregeln
Manche Gruppierungen sind größer als ein einzelner Absatz – sie erstrecken sich über benachbarte Blöcke und lassen sich nicht allein durch den Zeilenumbruch behandeln. Der Platzierungsdurchlauf setzt sie durch, während jeder Block landet, und schiebt ganze Gruppen nach vorn, wenn sie sonst durch einen Spalten- oder Seitenumbruch getrennt würden:
- Überschrift mit ihrem ersten Absatz. Eine Überschrift darf nie am Fuß einer Spalte stehen, wenn der Absatz, den sie einleitet, in der nächsten Spalte beginnen würde. Durchgesetzt durch
headings.keepWithNext(Standardtrue): Ist nicht genug Platz für die Überschrift plus das Witwenminimum des Fließtextes (bodyText.widowMinLines, Standard2) des nächsten Blocks – oder nur eine Zeile, wennavoidWidowsaus ist –, wird die Überschrift nach vorn geschoben und wandert mit ihrem Text. - Aufeinanderfolgende Überschriften. Stehen mehrere Überschriften hintereinander (z. B. eine h2, gefolgt von einer h3 und dann einem Absatz), muss die ganze Gruppe zusammenbleiben. Keine der Überschriften darf ohne den Inhalt, den sie einleitet, am Fuß einer Spalte zurückbleiben.
- Mit Doppelpunkt eingeleitete Listen. Endet ein Absatz mit einem Doppelpunkt, der direkt eine Liste einleitet, muss die Zeile mit dem Doppelpunkt beim Anfang der Liste bleiben. Durchgesetzt durch
bodyText.keepColonWithList(Standardtrue): Würde das Platzieren des Absatzes keinen Platz lassen, damit der erste Listenpunkt beginnen kann (den ganzen Punkt, wenn die Waisen- und Witwenregeln für Listen ihn zusammenhalten; eine Zeile mitbodyText.colonListRoom: 'line', wie bis postext 1.4), wandert die letzte Zeile mit dem Doppelpunkt (oder der ganze Absatz, wenn er einzeilig ist) zusammen mit der Liste nach vorn. Muss diese Regel den ganzen Absatz schieben und steht direkt davor in der Spalte eine Folge von Überschriften, werden diese Überschriften ebenfalls nach vorn gezogen, damitkeepWithNextnicht stillschweigend verletzt wird; die einzige Ausnahme ist eine Spalte, die nur die Überschrift(en) enthält, die eine frühere Verschiebung bereits nach vorn gebracht hat – dann hält die Engine den Absatz bei der Überschrift und nimmt die weichere Trennung von Doppelpunkt und Liste in Kauf, um eine Endlosschleife zu vermeiden. - Abbildung mit ihrer Legende. Eine Abbildung und ihre Legende sind eine untrennbare Einheit. Sie wandern immer zusammen.
Wird ein Verstoß gegen eine Zusammenhalteregel erkannt, schiebt die Engine die ganze Gruppe in die nächste Spalte oder auf die nächste Seite. Die verlassene Spalte endet dann zu kurz, um die Zeilen, die die Gruppe eingenommen hätte. Sie zu füllen ist die Aufgabe des Spaltenausgleichs.
Gut gemacht sind diese Korrekturen unsichtbar (ein Leser sollte sie nie bemerken). Ihr Fehlen aber fällt allen, die aufmerksam lesen, sofort auf: diese unbeholfene einzelne Zeile am Kopf einer Spalte, diese ungleichmäßigen Lücken, wo die Engine es aufgegeben hat, den Text passend zu machen. Professionelle Verlage haben ganze Stilhandbücher darüber, genau diese Probleme zu verhindern. Postext automatisiert sie.
#Der Durchlaufsteuerer
buildDocument ist ein Steuerer um den Platzierungsdurchlauf (buildDocumentGen und buildDocumentBalanced, in pipeline/build.ts). Er führt den Durchlauf einmal aus. Dann liest er, was der Durchlauf gemeldet hat, und wo eine Entscheidung einen zweiten Blick braucht, wiederholt er den Durchlauf vollständig mit Hinweisen (PassHints): den Grenzen, zusätzlichen Zeilen und Nummern, die der nächste Durchlauf anwenden soll. Tritt keine der Bedingungen aus der folgenden Tabelle ein, ist dieser erste Durchlauf der ganze Build.
Die Wiederholungen kommen in Runden, jede für eine Art von Entscheidung, in dieser Reihenfolge:
| Runde | Läuft, wenn | Der nächste Durchlauf erhält | Grenze |
|---|---|---|---|
| 1. Inhaltsverzeichnis | Das Dokument druckt Seitenzahlen, die der Host nicht geliefert hat: ein :::toc oder ein :::index ohne outline im Inhalt oder ein Verweis, der die Seite eines Ankers druckt | Die Gliederung mit den Seitenbezeichnungen des vorigen Builds | MAX_TOC_ROUNDS: 3 Builds über den ersten hinaus |
| 2. Anmerkungsnummern | footnotes.numbering ist 'page', 'column' oder 'spread', und die Anmerkungen stehen am Fuß der Spalte oder der Doppelseite, sodass die Nummer einer Anmerkung davon abhängt, wo das Layout sie setzt | Die Nummer, die jede Anmerkung im vorigen Build erhielt (footnoteNumbers) | MAX_FOOTNOTE_ROUNDS: 3 Builds über den ersten hinaus |
| 3. Seitliche Legendeen | Ein seitlicher Kasten oder ein seitliches Gleitobjekt hat die Legende überdeckt, die eine Abbildung in die Seitenspalte gesetzt hat (placement.captionSide) | Diese Legendeen kommen unter ihre Abbildungen (captionUnder) | 3 Durchläufe |
| 4. Gleitobjekte auf der zitierenden Seite | Ein Gleitobjekt mit placement.citingPage ist auf einer Seite oder in einer Spalte nach der Zeile gelandet, die es zitiert | Diese Seite oder Spalte beginnt mit dem Gleitobjekt an ihrem Kopf (citingPageFloats) | MAX_CITING_PAGE_ROUNDS: 3 Runden mit Vorschlägen, dann ein Durchlauf für jede Gruppe von Gleitobjekten, die ein Durchlauf dort nicht halten konnte |
| 5. Bandgrenzen | Ein Block über die ganze Seitenbreite traf auf ein Band mit ungleichen Spalten (siehe Stufe 3) | Die Höhe, auf der dieses Band geschnitten wird (bandCaps) | MAX_BAND_PASSES: 4 Durchläufe mit Vorschlägen und Wiederholungen. Eine Grenze wächst bis zu 3 Mal um eine Zeile (MAX_BAND_CAP_RETRIES), dann wird sie verworfen |
| 6. Spaltenausgleich | headings.balancing.enabled ist aktiv, und eine ausgleichbare Spalte endet zu kurz | Zusätzliche Rasterzeilen je Block und die Absätze, die um eine Zeile länger umbrochen werden (balanceExtraPx, balanceLooseness) | MAX_BALANCING_PASSES: 10 Versuche je Segment. MAX_BALANCING_PASSES_PER_DOCUMENT: 30 Durchläufe je Dokument |
| 7. Schließende Bänder | Der Ausgleich ist mit headings.balancing.trailing aktiv, und ein Kapitel oder das Dokument endet in einem Band mit ungleichen Spalten | Eine schließende Grenze für dieses Band, mit eingefrorenen Ausgleichshinweisen | MAX_BAND_PASSES: 4 Durchläufe mit Vorschlägen und Wiederholungen, jede Grenze mit ihren 3 Wiederholungen |
| 8. Nachbesserung | Runde 7 hat eine schließende Grenze behalten | Noch einmal Spaltenausgleich, auf dem begrenzten Layout, mit neuen Segmenten | 10 Versuche je Segment und 30 Durchläufe, neu gezählt |
Die Runden 3 bis 8 laufen innerhalb eines Builds nacheinander (buildDocumentBalanced). Die Runden 1 und 2 liegen außerhalb: Eine Inhaltsverzeichnis- oder Anmerkungsnummern-Runde ist ein ganzer Build mit eigenen Runden 3 bis 8. Für einen Build als Ganzes gibt es keine Grenze, nur diese.
Jede Wiederholung ist eine vollständige Platzierung. Ein Hinweis bessert den Baum des vorigen Durchlaufs nicht nach. Der Durchlauf platziert das Dokument mit dem geltenden Hinweis neu, sodass eine Überschrift mit zwei zusätzlichen Zeilen darüber oder ein sechs Zeilen höher geschnittenes Band dieselben Umbruch- und Zusammenhalteregeln durchläuft wie alles andere. Der Steuerer prüft dann das Ergebnis: Eine Bandgrenze, deren Kasten nicht auf dem Schnitt gelandet ist, wird erhöht oder verworfen, und ein Ausgleichsdurchlauf, der ein Segment nicht verbessert hat, wird für dieses Segment verworfen. Was der Steuerer zurückgibt, besteht aus Seiten, die ein Durchlauf platziert hat: der ganze Ertrag eines Durchlaufs oder, nach dem Spaltenausgleich, die Seiten jedes Segments aus dem besten Durchlauf, den dieses Segment hatte.
Was das Dokument meldet. doc.iterationCount zählt die Platzierungsdurchläufe hinter dem zurückgegebenen Layout: den ersten und die der Runden für Bandgrenzen, Ausgleich und schließende Bänder. Die Durchläufe für seitliche Legendeen und zitierende Seiten sind nicht enthalten, ebenso wenig die früheren Builds einer Inhaltsverzeichnis- oder Anmerkungsnummern-Runde; BuildDocumentOptions.onPass wird für jeden Durchlauf aufgerufen, mit seiner tatsächlichen Laufzeit. doc.converged betrifft allein den Spaltenausgleich. Es ist true, wenn jedes Segment ohne leere Zeile in seinen ausgleichbaren Spalten oder ohne weiteren vorschlagbaren Hebel endete, und false, wenn ein Segment bei einem Durchlauf stehen blieb, der es nicht verbesserte, oder keine Versuche mehr hatte, während noch eine Lücke offen war. Bei abgeschaltetem Ausgleich ist es true. Ein Dokument mit converged: false ist vollständig: Es enthält das beste Layout, das der Steuerer gefunden hat, und eine seiner Spalten endet zu kurz.
#Spaltenausgleich
- Läuft: als Runden 6 und 8 des Steuerers (standardmäßig aktiv;
headings.balancing.enabled: falseschaltet ihn ab, und in senkrechtem Text und auf einem Zeichenraster ist er standardmäßig aus, siehe Konfiguration → Spaltenausgleich) - Eingabe: Das bisher beste Layout und die leeren Rasterzeilen am Fuß jeder ausgleichbaren Spalte (
collectColumnGaps, inpipeline/columnBalancing.ts) - Aktion: Hebel vorschlagen, die jede kurze Spalte an Ort und Stelle füllen, den Platzierungsdurchlauf damit ausführen und das Ergebnis für ein Segment behalten, wenn sein Lückenwert gesunken ist
- Ausgabe: Spalten, die auf der letzten Rasterzeile der Seite enden, wobei jeder Hebel an dem Block vermerkt ist, auf den er angewendet wurde (
VDTBlock.balancing)
Unausgeglichene Spalten fallen sofort auf. Umbruchregeln hinterlassen sie ständig: eine Überschrift, die weitergeschoben wurde, um bei ihrem Text zu bleiben, ein Absatz, der als Ganzes verschoben wurde, um eine Witwe zu vermeiden, eine Abbildung, die nicht passte. Eine Spalte, die zwei Zeilen über ihrer Nachbarin aufhört, wirkt unfertig – als hätte das Layout auf halbem Weg aufgegeben.
Kein Block wechselt die Spalte, um das zu beheben. Ein Spaltenumbruch ist an das Element gebunden, das nicht passte: Der Block, der die nächste Spalte eröffnet, war für den verbleibenden Platz zu hoch, und für weniger Platz ist er erst recht zu hoch. Eine kurze Spalte lässt sich also von innen füllen, um höchstens ihre Lücke, und nichts wandert in die nächste Spalte. Die Anpassung ist lokal, und genau so arbeitet ein Setzer von Hand: eine Zeile Abstand dort einfügen, wo das Auge sie hinnimmt. Eine Spalte ist ausgleichbar, wenn Inhalt an ihr vorbei in eine spätere Spalte derselben Seite geflossen ist oder, bei der letzten Spalte einer Seite, wenn die Seite in die nächste weiterfließt. Die letzte Seite eines Kapitels darf zu kurz enden; ihr schließendes Band wird stattdessen von einer schließenden Grenze auf gleiche Höhe geschnitten (siehe Stufe 3).
Für jede Lücke werden die Hebel in redaktioneller Rangfolge vorgeschlagen, in derselben Reihenfolge, in der Konfiguration → Spaltenausgleich sie nennt:
- Ein Kasten, der die Spalte schließt, nimmt den Raum unter seinem Fuß als Abstand über sich auf, sodass sein Fuß auf der letzten Rasterzeile der Seite landet, auf gleicher Höhe mit der letzten Zeile der Nachbarspalte. Vermerkt als
trailingCallout. - Ein Bild mit Sicherheitsbereich (
Resource.safeArea), inline in die Spalte gesetzt oder an ihren Kopf oder Fuß über genau dieser Spalte gleiten gelassen, wächst um ganze Rasterzeilen, beschnitten innerhalb seines Sicherheitsbereichs. Ein höheres Bild hinterlässt kein Loch auf der Seite, deshalb kommt dieser Hebel vor jedem zusätzlichen Abstand. Vermerkt alsflexFigure. - Überschriften nehmen ganze Rasterzeilen über sich auf, abwechselnd jeweils eine Zeile, die wichtigste Überschrift (die niedrigste Ebene) zuerst, sodass sie den größten Anteil erhält. Eine Überschrift ganz oben in einer Spalte nimmt nichts auf, damit Spalten weiterhin am Seitenkopf beginnen. Eine Überschrift nimmt auf diese Weise nur ihre ersten Zeilen auf, bevor die Hebel unten versucht werden, und mehr, bis zu ihrer Obergrenze, wenn diese nichts mehr hergeben. Vermerkt als
heading. - Listenenden nehmen eine Rasterzeile dort auf, wo eine Liste endet. Vermerkt als
listEnd. - Formeln, Kästen und obere Gleitobjekte nehmen eine Rasterzeile unter sich auf: unter einer abgesetzten Formel oder einem Kasten, nach dem der Text weitergeht (
afterDisplay), und zwischen einer Abbildung oder Tabelle am Kopf der Spalte und dem Text darunter (afterFloat). - Lockere Absätze sind das letzte Mittel. Absätze der Spalte, die längsten zuerst, werden jeweils um eine Zeile länger umbrochen (
\looseness=+1aus TeX, über die Optionloosenessdes Knuth-Plass-Umbruchs), und der längere Satz wird nur angenommen, solange jede Zeile innerhalb vonbodyText.maxWordSpacingbleibt. Kann der Wortabstand allein die Zeile nicht gewinnen, darf der Absatz zusätzlich die kleinste positive Laufweite annehmen, die es schafft, unter einer Obergrenze. Vermerkt alslooseParagraph.
Die Obergrenzen, die Schalter und die Ausnahmen (ein Zeichenraster, die Schlussseiten, ein Gleitobjekt über mehrere Spalten) sind Schlüssel von headings.balancing. Die Seite Konfiguration führt sie auf und wird mit ihnen aktuell gehalten; diese Seite wiederholt sie nicht.
Ein Vorschlag wird geprüft, indem er platziert wird. Der Durchlauf, der die Hebel anwendet, ist eine vollständige Platzierung, und hin und wieder verschiebt ein Hebel doch Inhalt über einen Spaltenumbruch: ein geteilter Absatz, dessen Anfang nicht mehr passt, ein Gleitobjekt, das seinen Platz verloren hat. Die Seiten nach dieser Stelle fließen anders, und anderswo öffnen sich Lücken. Der Steuerer sieht das am Wert, verwirft den Durchlauf für dieses Segment und sperrt die Hebel, die in der ersten veränderten Spalte neu angewendet wurden, sodass der nächste Vorschlag die Hebel behält, die funktioniert haben, und den weglässt, der die Kettenreaktion ausgelöst hat.
Der Ausgleich konvergiert je Segment. Die Seiten zwischen ausdrücklichen Umbrüchen – einem Kapitelauftakt, einem :::pagebreak – werden unabhängig voneinander gesetzt: Nichts fließt über einen solchen Umbruch, also kann ein Ausgleichshebel in einer Folge von Seiten nie eine Zeile einer anderen verschieben. Die Schleife des Spaltenausgleichs beurteilt deshalb jedes solche Segment für sich. Sein Lückenwert ist die Zahl der leeren Rasterzeilen am Fuß seiner ausgleichbaren Spalten. Ein Durchlauf platziert weiterhin das ganze Dokument, aber jedes Segment behält oder verwirft seinen Anteil der Hebel nach seinem eigenen Lückenwert, sperrt seine eigenen Kettenreaktionen, stagniert für sich und verbraucht sein eigenes Budget an Versuchen; ein Segment, dessen Wiederholung sich verschlechtert hat, erhält seine Seiten aus dem besten Durchlauf zurück, den es hatte, während die anderen weitermachen. Ein Buch mit dreißig Kapiteln gleicht sich so genau so aus, wie seine Kapitel es einzeln täten – das PDF des ganzen Buches und das PDF eines einzelnen Kapitels sind für dasselbe Kapitel identisch –, statt dass eine einzige Kettenreaktion irgendwo jede Seite des Buches einen Durchlauf kostet.
Die Grenzen sind ein Sicherheitsventil. Manche Spalten lassen sich nicht schließen: Eine, die nur kurze Absätze enthält, hat keinen Hebel, an dem sich ziehen ließe, und eine, deren Hebel jeweils einen Block über den Spaltenumbruch schieben, hat keinen, der sich behalten ließe. Die Engine behält das beste Layout, das sie gefunden hat, und lässt diese Spalte zu kurz. Weil die Hebel an den Blöcken vermerkt sind, kann ein Korrekturabzug oder ein Test die Spalten auflisten, die nichts geschlossen hat (siehe Konfiguration → Welcher Hebel gegriffen hat).
#System des vertikalen Rhythmus
Halten Sie ein gut gesetztes Buch gegen das Licht. Die Zeilen der linken Seite decken sich mit den Zeilen der rechten. Die Grundlinie von Zeile 5 in Spalte 1 steht auf genau derselben Höhe wie die Grundlinie von Zeile 5 in Spalte 2. Das ist vertikaler Rhythmus, und er gehört zu den ersten Dingen, die ein geschultes Auge bei der Beurteilung typografischer Qualität prüft. Er ist auch eines der wesentlichen Unterscheidungsmerkmale von Postext.
Enthalten beide Spalten nur Fließtext in derselben Größe, ist die Ausrichtung trivial – jede Zeile ist gleich hoch, also stimmen die Grundlinien von selbst überein. Die Schwierigkeit beginnt in dem Moment, in dem eine Spalte eine Überschrift mit größerer Schrift enthält, ein Bild mit beliebiger Pixelhöhe oder zusätzlichen Abstand um ein Zitat. Diese Elemente „brechen“ das Raster: Der Inhalt darunter verschiebt sich um einen Betrag, der kein Vielfaches des Grundlinienschritts ist, und plötzlich laufen die Grundlinien dieser Spalte aus dem Takt mit der Nachbarspalte. Die optische Harmonie ist dahin.
Das Ziel ist, sie zurückzugewinnen: Die Grundlinien des Fließtextes in benachbarten Spalten müssen waagerecht fluchten, auch wenn Überschriften, Bilder oder andere Elemente mit abweichender Höhe in der einen Spalte stehen und in der anderen nicht.
#Grundlinienraster
Alles hängt an einer einzigen Zahl. Das Dokument legt einen Wert baselineGrid fest, abgeleitet vom Zeilenabstand des Fließtextes – zum Beispiel ergibt Fließtext in 16px mit einer line-height von 1.5 ein Grundlinienraster von 24px. Jede Grundlinie des Fließtextes soll auf ein Vielfaches dieses Wertes fallen. Das ist die Abmachung.
#Rasterbrechende Elemente
Manche Elemente brechen das Raster zwangsläufig, weil ihre Höhe kein Vielfaches von baselineGrid ist:
- Überschriften (größere Schrift, anderer Zeilenabstand)
- Bilder (beliebige Pixelhöhe)
- Tabellen (variable Höhe)
- Zitatblöcke (eventuell mit anderer Schriftgröße oder anderem Innenabstand)
- Fußnotenbereiche (die Anmerkungen am Fuß einer Spalte und ihre Linie: Der Textbereich der Spalte endet darüber)
#Wie der Fluss zum Raster zurückkehrt
Das Raster wird beim Platzieren gehalten, Block für Block. Kein späterer Durchlauf geht die Spalten auf der Suche nach Abweichungen durch. Fließtextzeilen sind eine Rasterzeile hoch, also bleibt eine Folge von Fließtext, die auf dem Raster beginnt, darauf. Hat der Platzierungsdurchlauf einen Block gesetzt, der das Raster verlassen kann, rundet er die in der Spalte belegte Höhe auf die nächste Rasterzeile auf, bevor der nächste Block beginnt. Die Blöcke, die auf diese Weise einrasten, sind:
- die letzte Überschrift einer Folge von Überschriften (aufeinanderfolgende Überschriften folgen ohne Einrasten aufeinander, und die letzte richtet den Text danach neu aus);
- der letzte Punkt einer Liste, unabhängig vom Abstand zwischen den Punkten;
- eine abgesetzte Formel;
- ein Kasten, ein Codelisting und eine inline eingebettete Abbildung oder Tabelle;
- der letzte Absatz eines Containers
:::paragraphsund ein Gedicht:::verse, deren Zeilenabstand ihr eigener ist.
Der Abstand unter dem Block (marginBottom) wird vor dem Runden gezählt, der Rand ist also ein Minimum, und das Raster entscheidet über den Rest. In der Abbildung enthält Spalte 1 eine 36 px hohe Überschrift auf einem 24-px-Raster, ohne Rand darunter. Ihr Fuß liegt 12 px hinter einer Rasterzeile, das Einrasten fügt 12 px hinzu, und die nächste Fließtextzeile beginnt auf dem Raster, auf gleicher Höhe mit der Zeile daneben in Spalte 2. Mit einem marginBottom von 16 px läge der Fuß bei 52 px, und das Einrasten würde auf 72 px aufrunden.
Gleitbänder halten das Raster auf ihre eigene Weise, beschrieben unter Stufe 4: Ein oberes Band wird auf ganze Rasterzeilen aufgerundet, und ein unteres Gleitobjekt wird so verankert, dass die letzte Grundlinie seiner Legende auf dem Raster steht. Der Spaltenausgleich fügt seinen Abstand in ganzen Rasterzeilen hinzu und hält das Raster damit ebenfalls.
Nichts vergleicht eine Spalte mit einer anderen. Jede Spalte beginnt auf dem Raster, und jedes Einrasten kehrt zu ihm zurück, sodass sich die Zeilen benachbarter Spalten und gegenüberliegender Seiten ohne zweiten Durchlauf treffen.
Abwählen. snapToGrid: false behält stattdessen den genauen Abstand unter einem Block. Es lässt sich an headings setzen (für jede Ebene, für eine Ebene oder für ein Überschriftenformat), an einem Absatzformat, an einem Kastenformat und an codeStyle. Der Text nach einem solchen Block kann neben dem Raster liegen, bis zum nächsten Block, der einrastet. Der Text in einem Kasten folgt dem eigenen Zeilenabstand des Kastens und hat kein Raster.
#Backend-Schnittstelle
Es gibt genau eine Quelle der Wahrheit für die Textmessung – das Messmodul über den Canvas-Schriftmetriken von Pretext –, und jedes Backend rendert aus demselben fertigen VDT, den es erzeugt hat.
Das ist eine bewusste Entscheidung, und sie hat einen entscheidenden Grund: Wie Sie Text messen, muss genau dem entsprechen, wie Sie ihn rendern. Angenommen, die Messung verwendete Canvas-Schriftmetriken, ein Backend aber eine PDF-Bibliothek mit leicht abweichenden Kerning-Tabellen. Das Layout würde nicht zur Ausgabe passen. Zeilen, die die Engine als passend für 320 px gemessen hat, könnten beim Rendern über- oder unterlaufen. Jedes Pixel Abweichung ist eine Lüge. Weil einmal gemessen und überall aus der daraus entstandenen Geometrie gerendert wird, können die Backends nicht voneinander abweichen: Zeilenumbrüche, Spaltenhöhen und die Platzierung von Ressourcen sind im VDT festgeschrieben, bevor irgendein Backend läuft.
Deshalb misst etwa das PDF-Backend den Text nicht neu: Es verarbeitet einen fertigen VDT und übersetzt dessen Pixelkoordinaten in PDF-Punkte. Die Canvas-Metriken sind die Quelle der Wahrheit; PDF ist ein Transportweg. Wer renderToPdf (aus dem Paket postext-pdf) nutzt, übergibt denselben VDT, den er an renderToCanvas oder renderToHtml übergeben würde, und die drei Ausgaben stimmen garantiert überein.
#API-Oberfläche
Die Backends sind einfache Funktionen über VDTDocument, keine Klassenhierarchie. Es gibt keine Schnittstelle PostextBackend – nur die Render-Einstiegspunkte jeder Ausgabe und die Hilfsfunktionen, die sie braucht:
// Canvas (from 'postext')
renderToCanvas(doc, options?): HTMLCanvasElement[]; // one canvas per page
renderPage(page, doc, options?): HTMLCanvasElement; // a single page
renderPageToCanvas(page, doc, canvas, options?): void; // draw into an existing canvas
// Canvas resource-image registry — decoded images keyed by Resource fileId
registerResourceImage(fileId, image, options?): void; // options: { vector?, singleInk? }
unregisterResourceImage(fileId): void;
clearResourceImages(): void;
// HTML (from 'postext')
renderToHtml(doc, options?): string;
renderToHtmlIndexed(doc, options?): HtmlRenderIndex; // per-page / per-block breakdown
// PDF (from 'postext-pdf')
renderToPdf(doc, options): Promise<Uint8Array>;
// EPUB (from 'postext-epub'), a book's chapters in order
renderToEpub(docs, options): Promise<Uint8Array>;Weil binäre Nutzlasten der Ressourcen außerhalb liegen, löst jedes Backend fileIds auf seine eigene Weise auf. Das Canvas-Backend führt ein Register dekodierter CanvasImageSources – die Host-Anwendung registriert jede Bitmap und jedes SVG einmal mit registerResourceImage(fileId, image), und der Renderer schlägt sie beim Zeichnen nach. Das HTML-Backend nimmt in seinen Optionen einen Auflöser resourceImageUrl(fileId) an und gibt <img>-Tags aus, die auf die URLs zeigen, die der Host zurückgibt (Object-URLs, Data-URIs, CDN-Pfade). Das PDF-Backend erhält einen Anbieter resourceBytes und bettet die tatsächlichen Bytes ein; der EPUB-Schreiber nimmt ebenfalls einen an und speichert jedes Bild einmal als Datei des Buches. Eine Videoressource wird als ihr Vorschaubild gezeichnet, eine Bitmap wie jede andere; das HTML-Backend nimmt zusätzlich einen Auflöser resourceVideoUrl(fileId) für die Videodateien an, die es abspielt, und der EPUB-Schreiber legt diese Dateien unter media/ ab. Das PDF-Backend zeichnet das Abspielsymbol und den QR-Code als Vektorpfade über das Vorschaubild und legt, wenn video.linkPoster aktiv ist, eine URI-Link-Annotation darüber, die video.link öffnet; in einem getaggten PDF schließt sich der Link der Figure des Vorschaubilds an, deren Alternativtext mit dem Namen des Typs beginnt (Video: …). Tabellenressourcen brauchen nichts davon – ihr Modell reist mit, und jedes Backend zeichnet die Zellen aus der Tabellengeometrie des VDT.
renderToHtmlIndexed verdient eine Anmerkung: Neben dem vollständigen HTML-String liefert es eine Aufschlüsselung je Seite und je Block (HtmlRenderIndex), sodass Aufrufer gegen ein früheres Rendering vergleichen und nur die DOM-Teilbäume aktualisieren können, deren HTML sich tatsächlich geändert hat – der Weg der Live-Vorschau in der Sandbox.
#Backends
| Backend | Messung | Rendering | Status |
|---|---|---|---|
| Canvas | Pretext (Canvas-Schriftmetriken) | Bitmap-Zeichnung auf einem HTMLCanvasElement (renderToCanvas, renderPage, renderPageToCanvas) | Verfügbar |
| HTML | Pretext (dieselben Metriken wie Canvas) | Absolut positionierte DOM-Knoten mit redaktionellem CSS (renderToHtml, renderToHtmlIndexed) | Verfügbar |
| Verarbeitet den bereits mit Pretext gemessenen VDT | Aufbau der PDF-Seiten über pdf-lib mit Schrifteinbettung je Schriftstärke (renderToPdf in postext-pdf) | Verfügbar | |
| EPUB | Verarbeitet den bereits mit Pretext gemessenen VDT | EPUB-3-Datei: ein XHTML-Dokument je gedruckter Seite (festes Layout) oder je Kapitel (umfließend), Schriften eingebettet (renderToEpub in postext-epub) | Verfügbar |
| Kommandozeile | Pretext über Skia (@napi-rs/canvas) statt der Canvas des Browsers | Die Schreiber für PDF, HTML und EPUB, Seitenbilder und Word, ausgeführt in einem Terminal ohne Browser (postext-cli) | Verfügbar |
Alle vier verfügbaren Backends verarbeiten dasselbe VDTDocument. Die Aufteilung zwischen postext (das die Canvas- und HTML-Backends exportiert) und postext-pdf (das das PDF-Backend exportiert) hat allein mit Abhängigkeiten zu tun: Der PDF-Weg zieht pdf-lib und @pdf-lib/fontkit nach sich, und die meisten Web-Integrationen brauchen sie nicht. Installieren Sie postext-pdf nur, wenn Sie tatsächlich PDF-Bytes erzeugen wollen. postext-epub ist aus demselben Grund ein eigenes Paket und wird nur für E-Books gebraucht. Die Kommandozeile ist kein fünftes Backend: postext-cli führt dieselben Schreiber dort aus, wo es keinen Browser gibt.
Wo das Layout läuft: Die Engine misst Text auf einer Canvas, läuft also überall, wo sie eine bekommt. Im Browser läuft die Pipeline entweder im Hauptthread (buildDocument) oder in einem eigenen Web Worker (createLayoutWorker aus postext/worker) – der Worker-Weg ist die empfohlene Integration für Anwendungen mit Benutzeroberfläche, weil er Messung und Platzierungsdurchläufe vom Hauptthread fernhält, Abbruch nach dem Prinzip „der letzte gewinnt“ über AbortSignal unterstützt und seinen eigenen Messcache und Raster-Cache für Formeln besitzt, sodass aufeinanderfolgende Neuaufbauten günstig bleiben. Das vollständige Integrationsmuster beschreibt Konfiguration → Layout in einem Web Worker ausführen. Außerhalb des Browsers führt die Kommandozeile dieselbe Pipeline aus, wobei Skia die Canvas des Browsers vertritt, sodass ein Buch in einem Terminal, einem Skript oder auf einem Build-Server in dieselben Seiten umbricht.
#Leistungsstrategie
Zwischen einem schwerfälligen Werkzeug und einem, das wie von selbst arbeitet, liegt etwa der Faktor 10. Ein Layout von 500 ms bedeutet, dass die Nutzerin oder der Nutzer bei jeder Änderung eines Wortes ein sichtbares Ruckeln sieht. Ein Layout von 50 ms wirkt augenblicklich – als wäre das Dokument schon immer da gewesen. Dieser Faktor lässt sich nicht nachträglich hineinflicken. Er muss vom ersten Tag an eingeplant sein.
Bedenken Sie, womit es die Engine zu tun hat: Tausende von Textblöcken auf Hunderten von Seiten, bei jeder Bearbeitung neu gesetzt, und mehrmals, wenn das Layout seine Runden braucht. Spiele-Engines stehen vor derselben Art von Problem – Tausende von Objekten (Geometrie, Physik, Beleuchtung, KI) 60-mal pro Sekunde zu verarbeiten –, und sie lösen es, indem sie bereits getane Arbeit nicht wiederholen. Postext wendet das auf das an, was es am meisten kostet: das Messen von Text.
#Grundsätze
-
Berechnung im Speicher. Der gesamte VDT passt in den Speicher. Während des Layouts wird das DOM nicht gelesen. Das DOM wird erst ganz am Ende angefasst, beim Rendern.
-
Ganze Durchläufe über gecachten Messungen. Eine Wiederholung des Platzierungsdurchlaufs sucht nicht nach dem, was sich geändert hat: Sie platziert das Dokument vom ersten Block an neu. Das ist bezahlbar, weil das Messen, der teure Teil, je Absatz gecacht wird (Grundsatz 5). Ein Durchlauf, der an Text, Schriften oder Breite eines Absatzes nichts ändert, liest dessen Zeilen aus dem Cache.
-
Begrenzte Runden. Jede Art von Wiederholung hat eine feste Grenze (siehe Der Durchlaufsteuerer), und der Spaltenausgleich hat eine je Dokument und eine je Segment, sodass ein Buch mit vielen Kapiteln nicht endlos Durchläufe verbrauchen kann. Die Kosten eines Builds lassen sich Durchlauf für Durchlauf messen:
BuildDocumentOptions.onPassmeldet jeden Durchlauf und seine tatsächliche Laufzeit. -
Geschwindigkeit von Pretext. Textmessung mit dem 300- bis 600-Fachen der DOM-Geschwindigkeit bedeutet, dass die Engine es sich leisten kann, Text auf Verdacht neu zu messen (verschiedene Spaltenbreiten, Trennstellen, Anpassungen der Laufweite ausprobieren), ohne den Hauptthread zu blockieren.
-
Geschichtetes Caching der Messungen. Das Messmodul (
packages/postext/src/measure/) führt einen ausdrücklichen Messcache, geschlüsselt nach Text, Schriften, Breite und jeder Option, die das Layout beeinflusst, zusätzlich zum eigenen Cache vonprepare()in Pretext und einem rohen Cache für Textbreiten. Das erneute Messen eines unveränderten Absatzes kostet eine Map-Abfrage. Ändern sich die Schnitte einer Familie, verwirft die Engine die Breiten dieser Familie und den Cache von Pretext, und ein Messcache verwirft bei seiner nächsten Abfrage die in ihr gesetzten Blöcke (er trägt die Generation, unter der er gefüllt wurde), sodass die Glyphenbreiten nach dem Laden der Schriften korrekt sind, ohne dass der Host etwas aufrufen muss;clearMeasurementCache()verwirft jede Familie. -
Zeilenumbruch auf dem heißen Pfad. Die Behandlung der aktiven Knoten in Knuth-Plass wurde für Geschwindigkeit neu geschrieben: Die aktive Menge wird an Ort und Stelle verdichtet, während Knoten ausscheiden, und Kandidaten werden je (Zeile, Fitness-Klasse) dedupliziert, sodass je Schlüssel nur der Knoten mit den niedrigsten Demerits überlebt. Die algorithmischen Ergebnisse sind identisch – dieselben Umbruchmengen, nur schneller berechnet.
-
Builds außerhalb des Hauptthreads. Der Einstiegspunkt
postext/workerführt die gesamte Pipeline in einem eigenen Web Worker aus. Der Hauptthread sendet{ content, config }und einAbortSignal; der Worker registriert die Schriften (übertragen alsArrayBuffers), führt die Platzierungsdurchläufe aus und sendet das fertigeVDTDocumentzurück. Ein neuerer Aufruf vonbuild()bricht den vorigen kooperativ ab – der Worker führtbuildDocumentAsyncaus, das einmal je Block und zwischen den Durchläufen einen Abbruch-Hook abfragt undBuildCancelledErrorwirft, sodass jemand, der in einen Editor tippt, nie auf ein überholtes Layout wartet. Der Worker hält außerdem seinen eigenen dauerhaften Messcache und einen nach Inhalt geschlüsselten Raster-Cache für Formeln, sodass per Structured Clone übertragene ObjekteMathRenderNeuaufbauten überdauern, ohne neu gerastert zu werden. -
VDT mit doppeltem Zugriff. Der Baum (
pages > columns > blocks) bietet hierarchischen Zugriff für Arbeit, die Seite für Seite oder Spalte für Spalte vorgeht (der Spaltenausgleich misst die Lücke am Fuß jeder Spalte). Ein paralleles flaches Arrayblocks[]bietet direkten Zugriff auf jeden Block, wo auch immer er steht (die Anmerkungsnummern-Runde liest daraus die Marke, die jede Anmerkung gedruckt hat). Beide Sichten verweisen auf dieselben Blockobjekte (es gibt keine Verdopplung, nur zwei Wege, dieselben Daten zu durchlaufen).
#Neu setzen
Es gibt keinen inkrementellen Einstiegspunkt: Kein Aufruf nimmt einen fertigen VDT und aktualisiert ihn. Ändern sich Inhalt oder Konfiguration (ein Tastendruck, ein anderes Seitenformat), ruft der Host buildDocument erneut auf und erhält ein neues Dokument.
Was von einem Build zum nächsten erhalten bleibt, ist das Messen. Legen Sie einmal mit createMeasurementCache() einen MeasurementCache an und übergeben Sie ihn als drittes Argument bei jedem Aufruf von buildDocument. Ein Absatz, dessen Text, Schriften, Breite und Umbruchoptionen sich nicht geändert haben, wird daraus gelesen, sodass ein Editor, der das Dokument bei jedem Tastendruck setzt, nur den Absatz misst, in dem getippt wird. Eine Änderung des Seitenformats oder der Spaltenzahl ändert die Breiten, und diese Absätze werden neu gemessen; die Ergebnisse von prepare() in Pretext gelten weiterhin, da sie von Schrift und Text abhängen und nicht von der Breite, sodass nur die günstige Arithmetik von layout() läuft. Auch das Parsen wird memoisiert.
Für einen Live-Editor erledigt createLayoutWorker (aus postext/worker) all das außerhalb des Hauptthreads: Der Worker besitzt einen Messcache, der so lange lebt wie er selbst, und ein neuerer Aufruf von build() bricht den laufenden ab. Siehe Konfiguration → Mess-Cache und Layout in einem Web Worker ausführen.
#Benchmarks
Zwei Benchmarks liegen in packages/postext/src/__bench__/. pnpm test überspringt sie; pnpm bench, ausgeführt in packages/postext, führt sie aus. chapter.bench.test.ts setzt ein Kapitel im Umfang eines Lehrbuchkapitels (vierzig Abschnitte aus Überschriften, Absätzen und Listen) fünfmal mit leerem und fünfmal mit gefülltem Messcache und meldet beide Mediane und die Zahl der Durchläufe. cjkChapter.bench.test.ts setzt Kapitel 1 von 红楼梦 und zählt die an measureText gesendeten Zeichen, um das CJK-Satzmodul auf Kosten zu halten, die linear mit der Länge des Absatzes wachsen.
// From packages/postext/src/__bench__/chapter.bench.test.ts
const cold: number[] = [];
for (let i = 0; i < ROUNDS; i++) cold.push(time(() => buildDocument({ markdown }, config, createMeasurementCache())));
const warmCache = createMeasurementCache();
buildDocument({ markdown }, config, warmCache);
const warm: number[] = [];
for (let i = 0; i < ROUNDS; i++) warm.push(time(() => buildDocument({ markdown }, config, warmCache)));
const passes: BuildPassInfo[] = [];
const doc = buildDocument({ markdown }, config, createMeasurementCache(), { onPass: (p) => passes.push(p) });
// [bench] chapter: … pages, … passes (… ms), iterationCount=…; cold median … ms, warm median … ms (5 rounds)Der Ehrlichkeit halber eine Einschränkung: In Node gibt es keine Canvas, deshalb ersetzen beide Benchmarks die Textmessung durch eine Attrappe, und ihre absoluten Zeiten sind nicht die des Browsers. Das Verhältnis von kaltem zu warmem Cache und die Zahl der Durchläufe sind es aber, und eine Verschlechterung bei einem von beiden zeigt sich hier, bevor sie sich in der Sandbox zeigt. Der Kapitel-Benchmark meldet Zahlen und lässt den Build nicht scheitern; der chinesische prüft, dass die gemessenen Zeichen unter dem Dreifachen der Länge des längsten Absatzes bleiben. Keiner von beiden läuft in der CI: Verschlechterungen werden erkannt, indem die Zahlen über mehrere Läufe verglichen werden, wenn sich ein heißer Pfad ändert (so geschehen bei der Neufassung der aktiven Knoten in Knuth-Plass). Leistung ist ein Feature, keine Hoffnung – aber durchgesetzt wird sie heute durch Messung und Review, nicht durch eine fehlschlagende Pipeline.
#Datenfluss
#Nicht-Ziele
Jede dieser Grenzen ist eine bewusste Entscheidung – die Engine ist für sich schon komplex genug, und Aufgaben zu übernehmen, die anderswo hingehören, wäre der schnellste Weg, nie fertig zu werden.
- WYSIWYG-Bearbeitung. Postext ist eine Layout-Engine, kein Editor. Inhalt hinein, Geometrie heraus. Eine interaktive Bearbeitungsoberfläche zu bauen – Cursorverwaltung, Auswahl, Rückgängig/Wiederholen, Eingabeverarbeitung – ist ein völlig anderes Problem. Postext kann als Rendering-Backend für einen Editor dienen, bietet aber selbst keine Bearbeitungsfunktionen.
- Hülle um CSS column-count. Postext ersetzt das mehrspaltige Layout von CSS; es kapselt es nicht. Es berechnet genaue, positionierte Geometrie von Grund auf, weil dem Spaltenalgorithmus des Browsers die Kontrolle über die Platzierung von Ressourcen, die Vermeidung von Witwen und Waisen und spaltenübergreifende typografische Regeln fehlt. Genau darum geht es.
- Verwaltung responsiver Breakpoints. Postext berechnet das Layout für ein gegebenes Seitenformat. Wann neu gesetzt wird (bei einer Größenänderung des Viewports, bei einem Wechsel der Ausrichtung), entscheidet die nutzende Anwendung. Postext verwaltet keine Breakpoints, Media Queries oder Entscheidungen des responsiven Designs. Das ist Ihre Aufgabe.
- Gemeinsame Bearbeitung in Echtzeit. Postext ist eine zustandslose Layoutberechnung – Inhalt hinein, Geometrie heraus –, kein kollaboratives Dokumentsystem mit Konfliktauflösung, Operational Transforms oder Wissen um mehrere Nutzer.
- Schriftdateien. Das Paket
postextlädt Schriften; es bringt keine mit.prepareFontslädt vor dem ersten Build jeden Schnitt, den eine Konfiguration und ihr Text verlangen, und ein Schnitt, den der Schriftbestand nicht liefern konnte, wird in den Warnungen des Dokuments alsfontFallbackaufgeführt. Die Dateien selbst kommen vom Host: eine Regel@font-faceauf der Seite oder eine Funktionresolve, die angibt, wo jeder Schnitt liegt. Siehe Konfiguration → Schriften vor dem Layout laden.
#Anhang: Beziehung zu den vorhandenen Typen
So bilden sich die wichtigsten in packages/postext/src/types.ts definierten Typen auf die oben beschriebene Architektur ab:
| Typ | Rolle in der Architektur |
|---|---|
PostextContent | Einstiegspunkt: die Eingabe der Engine (Strukturierung des Inhalts) |
PostextConfig | Steuert jede Stufe des Platzierungsdurchlaufs und die Runden um ihn herum |
Resource | Eine typisierte Ressource aus Bitmap, SVG, Tabelle oder Video. Wird bei der Messung zu einem ResolvedResourceBlock und dann entweder zu einem Inline-VDTBlock vom Typ 'resource' (Platzierung 'here') oder zu einem Gleitobjekt in einem Seitenband (Platzierung von Ressourcen) |
ResourceType | Steuert die Nummerierung nach Typ, die Präfixe der Legenden, die Verweisbezeichnungen und die standardmäßige Gleitplatzierung (Strukturierung des Inhalts, Platzierung von Ressourcen) |
ResourcePlacement | Überschreibt das Gleiten je Ressource: position ('auto' / 'top' / 'bottom' / 'here') und span ('column' / 'page' / 'side'), dazu rotate, width, align, columns, captionSide, shrink, minScale, captionMeasure, wrap, wrapGap und citingPage; jedes Feld fällt auf das defaultPlacement des Typs zurück und wird bei der Platzierung von Ressourcen aufgelöst |
PostextNote | Wird von der Engine nicht gelesen. Fußnoten werden im Markdown geschrieben ([^id]) und am Fuß der zitierenden Spalte oder nach dem Kapitel gesetzt; Marginalien sind nicht implementiert |
PostextResource | Veraltet. Die Ressource des alten Inhaltsmodells, nur so lange beibehalten, bis der letzte Verweis in einem Renderer (VDTBlock.resource) auf das Modell Resource umgestellt ist |
#Wo die VDT-Typen liegen
Die VDT-Typen (VDTDocument, VDTPage, VDTColumn, VDTBlock, VDTLine, VDTLineSegment, ResolvedResourceBlock, VDTResourceTableLayout, BoundingBox und verwandte) liegen in packages/postext/src/vdt.ts, zusammen mit den Fabrikfunktionen (createVDTDocument, createVDTPage, createVDTBlock …) und computePageTextExtent. Ein eigenes Modul für eine Backend-Schnittstelle gibt es nicht – die in Backend-Schnittstelle beschriebenen Render-Einstiegspunkte werden direkt aus postext (Canvas, HTML), postext-pdf (PDF) und postext-epub (EPUB) exportiert.