dxpdf maakt van .docx-bestanden PDF's. Geen Microsoft Office, geen headless LibreOffice, geen cloud-API — één Rust-binary die de OOXML rechtstreeks ontleedt en het resultaat via Googles tekent. We bouwden het omdat de alternatieven ons allemaal lieten kiezen tussen getrouwheid, snelheid en klantdocumenten niet naar een derde sturen — en omdat degene die we werkelijk draaiden, headless LibreOffice, een desktopapplicatie bleek te zijn waar we in productie op pasten.

Versie 0.5.0 kwam op 11 augustus uit. Het is de release waarin de converter ophield om, op een stuk of tien kleine plekken, aan te nemen dat het document dat hij krijgt in het Engels is geschreven.

Kernpunten

  • 0.5.0 is een internationalisatierelease. Regelafbreking volgens UAX #14 (inclusief Thai, Lao, Khmer en Birmees), bidirectionele tekst volgens UAX #9 met spiegeling volgens regel L4, en getallen en datums uit CLDR die de eigen w:lang van het document volgen.
  • De eerste bijdrage van buiten landde@ikashapov voegde Russische nummeringsformaten toe, het ontleden van opmerkingverwijzingen, en drie oplossingen voor lijstlabels.
  • Taalregiodata wordt als één uitgedunde blob meegeleverd, geladen via icu_provider_blob in plaats van via de ingebouwde compiled_data van elke ICU4X-crate, want "correct in elke taalregio" hoort niet "en de binary is nu enorm" te betekenen.
  • Lettertypen oplossen bepaalt wat een conversie kost, niet de documentgrootte — een document van 9 pagina's en 1,3 MB converteert in 55 ms terwijl een van 3 pagina's en 34 KB er 170 ms over doet, en het hele verschil zit in hoe hun lettertypen oplossen.
  • De dekking van ISO 29500 staat nu op 74 functies volledig geïmplementeerd, 11 gedeeltelijk, 12 nog niet ondersteund — en die laatste kolom is de eerlijkste routekaart die we kunnen publiceren.
  • Het verving een pijplijn met headless LibreOffice — een desktopsuite achter een wachtrij, een waakhond en een profielmap per werker. Wat dat kostte staat hieronder.
  • dxpdf valt onder de MIT-licentie en is open source, staat op crates.io en PyPI, en levert nu ook een .deb.

Dank aan onze eerste bijdrager van buiten

0.5.0 is de eerste dxpdf-release met een sectie New Contributors, en het is een goede om mee te beginnen. @ikashapov landde #118: ontleding van w:commentReference, de nummeringsformaten russianUpper en russianLower, en oplossingen voor drie afzonderlijke defecten in de lay-out en nummering van lijstlabels.

Dat is geen typefoutje in het voorbijgaan. Nummeringsformaten zijn precies het soort functie dat alleen wordt gevonden door iemand wiens documenten ze werkelijk gebruiken — een Russischtalig contract met een lijst а)/б)/в) duikt niet op in een set fixtures die in het Engels is samengesteld, en de drie labeldefecten die het blootlegde waren echte bugs in het lay-outpad voor iedereen met een document van diezelfde vorm. Dank je wel.

Lees je dit en verminkt dxpdf iets in jouw documenten, dan is dat de bijdrage die we het liefst willen. Meer daarover aan het eind.

Wat we hiervoor draaiden: headless LibreOffice

Vóór dxpdf was er soffice --headless --convert-to pdf, verpakt in een wachtrij en een herhaalpoging, zoals de meeste documentpijplijnen. Het werkt, en het was lang de juiste keuze — niets anders converteert DOCX met die getrouwheid voor de prijs van een apt install. Wat het niet is, is een component die je in een verzoekpad kunt zetten en daarna kunt vergeten:

  • Het is een desktopapplicatie in serverkostuum. Er is geen bibliotheek-API en geen aanroep binnen het proces: je start een binary, leest een exitcode, en hoopt — van buitenaf zien een mislukte conversie en een gecrasht proces er sterk op elkaar lijken. De eerste conversie in een vers proces betaalt bovendien het starten van een hele kantoorsuite.
  • Eén proces, één profiel. soffice serialiseert rond zijn gebruikersprofielmap, dus elke gelijktijdige werker heeft er een eigen nodig, anders botsen ze op dezelfde vergrendelingsbestanden. Opschalen is procesbeheer, geen threadpool.
  • Het hangt. Een ongebruikelijk of misvormd document kan soffice eeuwig laten wachten, dus een productieopstelling groeit een time-out, een waakhond en een opruimer voor verweesde processen. Elk team dat dit draait schrijft uiteindelijk dezelfde oppascode.
  • Geheugen en imagegrootte. Honderden megabytes resident per instantie, en een container-image met een volledige kantoorsuite — plus de lettertypen, die geïnstalleerd moeten worden of de metriek vervangt stilletjes en de lay-out schuift.
  • De getrouwheid drijft mee met de versie. Hetzelfde document geconverteerd op een andere LibreOffice-release kan anders pagineren, waardoor een basisimage ophogen verandert wat de klant ontvangt. Dat van de klant horen is de verkeerde manier om het te horen.

Niets hiervan is een bugmelding tegen LibreOffice. Het is een kantoorsuite en het is daar heel goed in; dit is een uitspraak over wat er gebeurt wanneer een grafische applicatie het dragende deel van een serverpijplijn wordt. dxpdf kwam voort uit de wens om één bibliotheekaanroep, een voorspelbaar geheugenprofiel, en uitvoer die alleen verandert wanneer wij hem veranderen.

Het probleem: een lay-outmotor die maar één alfabet kende

De premisse van dxpdf is altijd getrouwheid geweest. Een DOCX-naar-PDF-converter is alleen nuttig als de pagina-einden landen waar Word ze zet — een factuur waarvan de totaalregel naar pagina twee schuift is erger dan helemaal geen PDF. Alles in de architectuur van het project dient dat: de OOXML in een onveranderlijk model ontleden, de stijlcascade oplossen, en meten en uitlijnen voordat er iets wordt getekend.

Wat we tegen 0.4.0 hadden gebouwd was een lay-outmotor die dat heel goed deed voor tekst van spaties en Latijnse letters. Elke aanname eronder was onzichtbaar tot ze brak:

  • Regels braken bij witruimte. Dat is geen regelafbrekingsalgoritme, het is een vuistregel die toevallig voor het Engels werkt. Thai, Lao, Khmer en Birmees worden zonder spaties tussen woorden geschreven — onder een witruimteregel is een Thaise alinea één onbreekbaar blok dat van de pagina afloopt.
  • Tekst werd altijd van links naar rechts uitgelijnd. Een Arabische of Hebreeuwse alinea kwam er als visuele onzin uit: juiste tekens, verkeerde volgorde, ongespiegelde haakjes.
  • Getallen en datums werden op zijn Amerikaans opgemaakt. Een decimale tabstop lijnde uit op . terwijl een Duits document , bedoelde. Een DATE-veld met een vertaalde notatie — TT.MM.JJJJ in plaats van dd.MM.yyyy — werd helemaal niet begrepen.
  • Voluit geschreven lijstnummering kende alleen Engels. cardinalText in een Duits document leverde One op, niet Eins.
  • Letterafstand werkte op codepunten. Voeg 2 pt spatiëring toe aan een tekenreeks met een combinerend teken en dat teken drijft weg van de letter waar het bij hoort.

Niets hiervan is exotisch. Het is wat er gebeurt de eerste keer dat een converter die door Engelssprekenden is geschreven een document ontmoet dat dat niet was.

De oplossing: echte specificaties, geen extra vuistregels

De rode draad van 0.5.0 is dat elk van die dingen is vervangen door het werkelijke Unicode- of OOXML-algoritme in plaats van door een betere gok.

Regelafbreking is nu UAX #14, via ICU4X, opgelost per alinea in plaats van per run — zodat een woord dat door een verdwaalde opmaakwijziging over <w:r>-grenzen is gesplitst nog steeds breekt waar het algoritme zegt, en niet waar de XML toevallig is doorgeknipt. De vier schriften die UAX #14 uitdrukkelijk aan "complexe contextanalyse" overdraagt, krijgen woordgrenzen bepaald door een LSTM. Een blok dat volgens geen enkele regel gebroken mag worden, wordt op de rand van de container afgekapt in plaats van eroverheen te lopen, en dat is wat Word doet en wat een smalle tabelcel nodig heeft.

Bidirectionele tekst is nu UAX #9 — inbeddingsniveaus per alinea opgelost, per regel herordend, met spiegeling volgens regel L4 zodat haakjes de juiste kant op wijzen. w:jc-uitlijning en w:ind-inspringing lossen op tegen de basisrichting van de alinea in plaats van tegen "links".

Schriften met positievormen worden via HarfBuzz geshapet. Arabisch, Syrisch, N'Ko, Mongools, Adlam en verwanten hebben verbindende vormen nodig om überhaupt leesbaar te zijn, dus een run waarvan het schrift positievormen kent gaat door de HarfBuzz van Skia; al het andere houdt het bestaande, goedkopere cmap-pad.

Getallen en datums volgen w:lang. Decimaalscheidingstekens komen uit CLDR en zijn regiobewust — de-CH en de-DE zijn het oneens, en nu is dxpdf het correct met ze oneens. DATE- en TIME-velden worden geëvalueerd met vertaalde notatienamen volgens §17.16.4.2. Getallen worden voluit geschreven in het Engels, Duits, Frans en Spaans (Eins, Vingt et un, Veintiuno, 1.º), met cijfers als terugval voor al het andere.

Letterafstand en uitvullen werken op grafeemclusters, volgens UAX #29 — zodat spatiëring volgens §17.3.2.35 nooit een combinerend teken van zijn basis scheidt, en distribute-uitlijning volgens §17.3.1.13 overtollige breedte tussen clusters verdeelt in plaats van binnen één.

Naast het internationalisatiewerk repareerde 0.5.0 ook de paginering aan de randen die meteen opvalt: een doorlopend sectie-einde wordt tot pagina-einde gepromoveerd wanneer de pagina-instelling werkelijk verschilt, een alinea met "bij volgende houden" blijft onderaan zijn pagina vóór een expliciet einde, en alinea's die alleen een einde bevatten krijgen de regelhoogte die ze horen bij te dragen. Plus lettertypesynthese voor vet en cursief wanneer een letterontwerp geen echte vette variant heeft, Windows in de CI-matrix, en Debian-pakketten.

Drie besluiten die uitleg verdienen

Eenheden zijn types, geen getallen

OOXML meet dingen in twips, EMU's, halve punten, achtste punten en duizendsten van een procent, soms drie daarvan in hetzelfde element. De voor de hand liggende aanpak is alles bij de parsergrens naar f64 om te zetten en door te gaan. dxpdf doet het omgekeerde: elke OOXML-eenheid is een eigen type op basis van i64 in model::dimension, ze stromen ongeconverteerd door parse en resolve zodat ze verliesvrij heen en terug gaan, de lay-out werkt uitsluitend in Pt, en kale f32 verschijnt pas op de grens met Skia.

DOCX (ZIP) → Parse → Document Model → Resolve → Layout → Subset → Paint → PDF
             Twips/Emu/HalfPoints        ←──── Pt throughout ────→      Skia

Het rendement is dat een twipwaarde bij een halvepuntwaarde optellen een compileerfout is in plaats van een document dat in één marge een factor tien afwijkt. Deze klasse bugs is ellendig om met het oog te vinden — de uitvoer ziet er nog steeds als een plausibel document uit, alleen subtiel verkeerd — en ze verdwijnt volledig zodra de compiler weigert de eenheden te laten mengen. Meetkundetypes zijn om dezelfde reden generiek over de eenheid, met de Pt-specifieke tegenhangers in render::geometry, zodat de modellaag helemaal geen Skia-afhankelijkheid draagt.

Taalregiodata is één uitgedunde blob, geen ingebouwde standaarden

ICU4X-crates komen met een functie compiled_data die de volledige CLDR-dataset in de binary bakt. Het is de makkelijke weg en hij is enorm — je krijgt elke taalregio, elke kalender, elke valuta, of het document ze nu noemt of niet.

dxpdf bouwt in plaats daarvan één uitgedunde datablob met alleen de taalregio's die het werkelijk ondersteunt en laadt die via icu_provider_blob. Meer buildinfrastructuur, één artefact om synchroon te houden, en een binary die een CLI-gebruiker ook werkelijk installeert. Dezelfde instelling zie je ernaast bij het shapingbesluit: unicode-joining-type wordt als predicaat gebruikt — het ding waarvan het enige werk is HarfBuzz van Latijnse tekst weg te houden — zodat het dure pad draait voor de schriften die het nodig hebben en verder niets. Beide zijn dezelfde ruil: betaal voor correctheid waar die vereist is, niet overal even hard.

De lay-out kreeg een speculatieve scope

Sectievrijloop (§17.6.22) kan niet vooruitkijkend worden beslist. Of een doorlopend sectie-einde op de huidige pagina blijft, hangt af van wat erna komt, dus moet de lay-out een plaatsing proberen, vooruitkijken, en de poging terugdraaien als het antwoord verkeerd terugkomt.

Dat is onmogelijk wanneer je lay-outstaat een &mut is die je de hele weg naar beneden hebt zitten muteren. #111 voegde een speculatieve BuildState-scope toe: een gebied lay-outwerk dat als geheel bevestigd of weggegooid kan worden. Het is een mooie illustratie van een patroon dat door dit project heen terugkeert — de specificatie vertelt je niet alleen wat je moet bouwen, ze vertelt je welke vorm je architectuur moet hebben. De lay-out van Word kijkt vooruit, dus een converter die de pagina-einden van Word wil, heeft ergens plek nodig voor een weggegooide poging.

De cijfers

Gemeten op een Apple M3 Max met hyperfine (30 runs, 5 opwarmronden) bij v0.5.0, tegen fixtures die in de repo staan zodat de cijfers reproduceerbaar zijn:

FixturePagina'sInvoerConversietijdPiek-RSS
sample-docx-files-sample3334 KB170 ms55 MB
sample-docx-files-sample-4710 KB170 ms52 MB
sample-docx-files-sample191,3 MB55 ms42 MB
sample-docx-files-sample417114 MB420 ms159 MB

De interessante rij is de derde. Een document van 9 pagina's met veertig keer zoveel invoer converteert in een derde van de tijd die dat van 3 pagina's nodig heeft — omdat lettertypen oplossen bepaalt wat een conversie kost, niet de documentgrootte. Het lettertyperegister wordt in lagen en lui opgebouwd: een document waarvan de lettertypen ingesloten zijn of al op de host staan bereikt de dure laag nooit en besteedt daar ongeveer 4 ms, terwijl een document dat moet terugvallen op de metadata-index van de host en op PostScript- en stijlnamen moet matchen 120 tot 185 ms betaalt, eenmalig. Bij de fixture van 3 pagina's is die opzoekactie ruwweg vijf keer parsen, lay-out, uitdunnen en tekenen bij elkaar.

Wie een batchwerklast dimensioneert, herformuleert daarmee de vraag volledig. Het is niet hoe groot zijn de documenten — het is noemen ze lettertypen die de host al heeft. Documenten die door Word zijn geschreven doen dat meestal.

Dekking en gebruik, op het moment van schrijven:

  • 74 OOXML-functies volledig geïmplementeerd, 11 gedeeltelijk, 12 nog niet ondersteund, getoetst aan ISO 29500. De volledige matrix staat in de README, gaten inbegrepen.
  • Ongeveer 6.000 downloads op crates.io over 38 gepubliceerde versies, plus PyPI-wheels voor macOS, Linux en Windows op Python 3.8+.
  • 29 sterren, 6 forks, MIT-licentie, vijf maanden oud.
  • In productie gebruikt door nerdy.pro en formtastic.de.

En de eerlijke andere kant van dat grootboek: geen herordening van Indische schriften, geen terugval per glyph naar een ander lettertype, geen automatisch afbreken, geen bijgehouden wijzigingen of opmerkingen, geen SmartArt of grafieken, en strakke en doorlopende beeldomloop benaderd met het omhullende kader in plaats van een veelhoek. Het staat allemaal, met ernaast, in dezelfde tabel als de successen.

Installeer het

cargo install dxpdf            # CLI
pip install dxpdf              # Python
curl -LO https://github.com/nerdy-pro/dxpdf/releases/download/v0.5.0/dxpdf_0.5.0-1_amd64.deb
sudo apt install ./dxpdf_0.5.0-1_amd64.deb

Bijdragen zijn werkelijk welkom

dxpdf is geschreven in Rust en de bijdrageluslus is kort. Twee dingen zijn meer waard dan ze lijken:

Een DOCX die verkeerd rendert is net zoveel waard als een patch. Het project wordt door fixtures gedreven — een document dat een defect reproduceert, samen met de oplossing vastgelegd, is hoe elke functie in die dekkingstabel is verankerd. Verminkt dxpdf een document dat van jou is, open dan een issue met het bestand, of met de kleinste versie ervan die je kunt delen.

De -kolom is de routekaart. Automatisch afbreken, terugval per glyph naar een ander lettertype, gespiegelde tabstops onder w:bidi, chineseCounting en de andere telformaten, SmartArt — elk daarvan is een netjes afgebakend stuk werk met een specificatiesectie eraan vast. De eerste PR van @ikashapov begon precies daar.

Open een issue vóór een grote PR, en draai wat CI draait voordat je pusht:

cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo test --all

De projectafspraken staan in AGENTS.md.

Veelgestelde vragen

0.5.0 is een internationalisatierelease: regelafbreking volgens UAX #14 via ICU4X (inclusief Thai, Lao, Khmer en Birmees), bidirectionele tekst volgens UAX #9 met spiegeling volgens regel L4, HarfBuzz-shaping voor verbindende schriften, regiobewuste decimaalscheidingstekens en vertaalde notaties voor DATE- en TIME-velden gestuurd door de w:lang van het document, en getallen voluit geschreven in het Engels, Duits, Frans en Spaans. Het herstelt daarnaast enkele randgevallen in de paginering, voegt lettertypesynthese voor vet en cursief toe, zet Windows in de CI-matrix, en levert een .deb voor Debian en Ubuntu.
LibreOffice in --headless-modus is het gebruikelijke antwoord en converteert DOCX met goede getrouwheid, maar het is een desktopsuite die aan de serverkant draait. Het serialiseert op zijn gebruikersprofielmap, dus elke gelijktijdige werker heeft er een eigen nodig; het kan hangen op ongebruikelijke documenten en vraagt om een time-out, een waakhond en een opruimer; het draagt honderden megabytes resident geheugen en een container-image van dienovereenkomstige omvang; en de uitvoer kan veranderen wanneer de LibreOffice-versie verandert. dxpdf is één Rust-binary met een bibliotheek-API, een voorspelbaar geheugenprofiel, en uitvoer die alleen verandert wanneer de converter dat doet.
Nee. dxpdf is een zelfstandige Rust-binary die DOCX-bestanden rechtstreeks leest en PDF-uitvoer via Skia rendert. Er is geen Office-installatie, geen headless LibreOffice-proces en geen externe dienst — wat ook betekent dat documenten de machine die de conversie doet nooit verlaten.
Op een Apple M3 Max converteren de meegeleverde fixtures in 55 tot 170 ms, en een document van 171 pagina's en 14 MB in ongeveer 420 ms. Documentgrootte telt minder dan lettertypen oplossen: een document waarvan de lettertypen ingesloten zijn of al op de host staan besteedt daar ongeveer 4 ms aan, terwijl een document dat terugvalt op de metadata-index van de host eenmalig 120 tot 185 ms betaalt.
Vanaf 0.5.0: regelafbreking volgens UAX #14 voor alle schriften inclusief de spatieloze (Thai, Lao, Khmer, Birmees), bidirectionele tekst volgens UAX #9 voor Arabisch en Hebreeuws met spiegeling, en HarfBuzz-shaping voor schriften met positievormen zoals Arabisch, Syrisch, N'Ko, Mongools en Adlam. Herordening van Indische schriften wordt nog niet ondersteund, en er is geen terugval per glyph naar een ander lettertype — een document moet een lettertype noemen dat de tekens dekt die het gebruikt, zoals Word ook schrijft.
Ja. Installeer het met pip install dxpdf en roep dxpdf.convert(bytes) of dxpdf.convert_file("input.docx", "output.pdf") aan. Er zijn wheels gepubliceerd voor macOS, Linux en Windows op Python 3.8 en nieuwer, dus er is geen Rust-toolchain nodig om het te gebruiken.
Open een issue met een DOCX die verkeerd rendert — het project wordt door fixtures gedreven, dus een document dat het probleem reproduceert is net zo nuttig als een patch. Voor code vormen de niet-ondersteunde regels in de functiematrix van de README de routekaart, elk met een sectie uit ISO 29500 eraan vast. Open een issue vóór een grote PR, en draai cargo fmt, cargo clippy en cargo test voordat je pusht.

Converteer je documenten op schaal?

dxpdf valt onder de MIT-licentie en is open source — gebruik het, fork het, of vertel ons waar het bij jouw documenten tekortschiet. Heb je documentverwerking nodig die in een product is ingebouwd in plaats van erop geschroefd, neem dan contact op; deze converter bestaat omdat we er telkens een nodig hadden voor klantwerk en hem liever in het openbaar onderhouden dan hem elke keer privé opnieuw te bouwen.


Ilya Nixan is oprichter en lead developer bij Nerdy Production, een Flutter-eerst bureau dat ook de infrastructuurtooling bouwt en onderhoudt — zoals dxpdf en Orosu — waarop het eigen opleverwerk draait.